Files
zeekay eb64196297 feat(browser): 1.8.4 โ€” /v1/iam, Ctrl-default + recordable shortcuts, hardened parseTabId
/api/ โ†’ /v1/iam (Hanzo path convention)
  ----------------------------------------
  Per Hanzo convention every service uses `/v1/<service>/<endpoint>` โ€”
  never `/api/`. The IAM extension calls were still hitting Casdoor's
  upstream `/api/get-account`, `/api/userinfo`, `/api/get-organizations`,
  `/api/update-user`, which the gateway no longer rewrites โ€” every prod
  request was returning the SPA HTML and silently failing.

  - shared/config.ts: new `IAM_API_PATH = '/v1/iam'` constant.
  - shared/auth.ts: fetchUserInfo uses `${iamApiUrl}${IAM_API_PATH}/...`.
  - background.ts + background-firefox.ts: `IAM_V1` shorthand.
  - shared/kms.ts: KMS still on upstream `/api/v3` (Infisical fork; no
    gateway rewrite yet) โ€” centralised in `KMS_PATH_PREFIX` so the day
    we cut over it's a one-line change. Header comment documents this.

  Default inspect-modifier: Alt โ†’ Ctrl
  ------------------------------------
  Alt/Option on macOS is the system character-compose modifier
  (Option-c โ†’ รง). Holding Alt while clicking elements silently swallows
  every text input on every page. New default is Ctrl (matches DevTools
  Inspect Element). The chord is fully configurable via a new in-popup
  recorder.

  - shared/shortcut.ts: Shortcut type, matchesShortcut, shortcutFromEvent,
    formatShortcut, loadInspectShortcut, saveInspectShortcut.
  - content-script.ts: imports + uses matchesShortcut. chrome.storage
    onChanged listener picks up new bindings without page reload.
  - popup.html: kbd display + Record / Reset buttons in Settings.
  - popup.ts: 10-second-timed recorder using shortcutFromEvent.
  - cli.ts + build.js: docs reference Ctrl+Click everywhere.

  parseTabId security hardening
  -----------------------------
  The previous regex `(?:^|tab-)(\d+)$` accepted "https://x.com/?tab-123"
  as tabId 123 (URL-borne tab-injection) and treated empty string as
  tab 0 (Number('') === 0). Both fixed: fully-anchored
  `^(?:tab-)?(\d+)$` plus explicit empty-string reject.

  Tests
  -----
  tests/shared-tab-id.test.ts (28 cases) covers every parseTabId edge,
  tabTarget merging, and unwrapEvaluateResult for every shape Chrome /
  Firefox / hanzo-tools can hand back. tests/shared-shortcut.test.ts
  (19 cases) covers DEFAULT_INSPECT_SHORTCUT, matchesShortcut required-
  AND-forbidden modifier semantics, shortcutFromEvent recorder, the
  formatShortcut renderer (Mac and non-Mac), and load/save round-trip.

  63/63 new tests pass. Existing 134 tests still pass.

  Bumps 1.8.3 โ†’ 1.8.4 across package.json, manifest.json,
  manifest-firefox.json.
2026-05-07 17:52:58 -07:00

178 lines
6.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
parseTabId,
tabTarget,
unwrapEvaluateResult,
} from '../src/shared/tab-id.js';
describe('parseTabId', () => {
it('accepts a finite positive number', () => {
expect(parseTabId(123)).toBe(123);
expect(parseTabId(0)).toBe(0);
expect(parseTabId(1888868904)).toBe(1888868904);
});
it('accepts negative numbers (some test fixtures use them)', () => {
expect(parseTabId(-7)).toBe(-7);
});
it('rejects non-finite numbers (NaN / Infinity)', () => {
expect(parseTabId(NaN)).toBeNull();
expect(parseTabId(Infinity)).toBeNull();
expect(parseTabId(-Infinity)).toBeNull();
});
it('parses "tab-NNN" format from Target.getTargets', () => {
expect(parseTabId('tab-1888868904')).toBe(1888868904);
expect(parseTabId('tab-0')).toBe(0);
expect(parseTabId('tab-42')).toBe(42);
});
it('parses bare numeric strings', () => {
expect(parseTabId('123')).toBe(123);
expect(parseTabId('0')).toBe(0);
expect(parseTabId('1888868904')).toBe(1888868904);
});
it('returns null for malformed strings', () => {
expect(parseTabId('')).toBeNull();
expect(parseTabId('abc')).toBeNull();
expect(parseTabId('tab-')).toBeNull();
expect(parseTabId('tab-abc')).toBeNull();
expect(parseTabId('foo-123')).toBeNull(); // not "tab-" prefix
expect(parseTabId('123abc')).toBeNull();
expect(parseTabId('1.5e10abc')).toBeNull();
});
it('returns null for null / undefined / non-string non-number', () => {
expect(parseTabId(null)).toBeNull();
expect(parseTabId(undefined)).toBeNull();
expect(parseTabId({})).toBeNull();
expect(parseTabId([])).toBeNull();
expect(parseTabId(true)).toBeNull();
});
it('does NOT match "tab-NN" embedded mid-string (security)', () => {
// The regex anchors on (start | "tab-") and \d+$, so something like
// "https://x.com/?tab-123" must NOT be parsed as tabId 123.
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
// But pure "tab-123" still works.
expect(parseTabId('tab-123')).toBe(123);
});
});
describe('tabTarget', () => {
it('returns empty object when nothing supplied', () => {
expect(tabTarget(undefined)).toEqual({});
expect(tabTarget({})).toEqual({});
});
it('forwards extra params unchanged', () => {
expect(tabTarget({}, { url: 'https://example.com' }))
.toEqual({ url: 'https://example.com' });
});
it('forwards tabId when present', () => {
expect(tabTarget({ tabId: 42 })).toEqual({ tabId: 42 });
expect(tabTarget({ tabId: 'tab-42' })).toEqual({ tabId: 'tab-42' });
});
it('forwards tabIndex when present', () => {
expect(tabTarget({ tabIndex: 0 })).toEqual({ tabIndex: 0 });
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
});
it('forwards both tabId and tabIndex', () => {
expect(tabTarget({ tabId: 5, tabIndex: 2 }))
.toEqual({ tabId: 5, tabIndex: 2 });
});
it('falls back to targetId when tabId absent', () => {
expect(tabTarget({ targetId: 'tab-99' }))
.toEqual({ tabId: 'tab-99' });
});
it('prefers tabId over targetId when both present', () => {
expect(tabTarget({ tabId: 1, targetId: 'tab-2' }))
.toEqual({ tabId: 1 });
});
it('does NOT forward null / undefined fields', () => {
expect(tabTarget({ tabId: null, tabIndex: undefined } as any))
.toEqual({});
});
it('extra params merged with tab fields, tab fields win on key collision', () => {
const out = tabTarget({ tabId: 7 }, { tabId: 99, url: 'x' });
// tabTarget assigns from `extra` first, then overlays the resolved
// tab targeting โ€” caller's tab id is authoritative.
expect(out.tabId).toBe(7);
expect(out.url).toBe('x');
});
});
describe('unwrapEvaluateResult', () => {
it('returns null/undefined unchanged', () => {
expect(unwrapEvaluateResult(null)).toBeNull();
expect(unwrapEvaluateResult(undefined)).toBeUndefined();
});
it('passes through bare primitives', () => {
expect(unwrapEvaluateResult(42)).toBe(42);
expect(unwrapEvaluateResult('hello')).toBe('hello');
expect(unwrapEvaluateResult(true)).toBe(true);
expect(unwrapEvaluateResult(false)).toBe(false);
expect(unwrapEvaluateResult(0)).toBe(0);
});
it('unwraps the standard Chrome / CDP shape {result: {value: X}}', () => {
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
expect(unwrapEvaluateResult({ result: { value: 'ok' } })).toBe('ok');
expect(unwrapEvaluateResult({ result: { value: null } })).toBeNull();
expect(unwrapEvaluateResult({ result: { value: 0 } })).toBe(0);
});
it('unwraps full-CDP shape {result: {value, type}}', () => {
expect(unwrapEvaluateResult({ result: { value: 'hi', type: 'string' } }))
.toBe('hi');
});
it('unwraps legacy {result: bare} shape', () => {
expect(unwrapEvaluateResult({ result: 7 })).toBe(7);
});
it('unwraps hanzo-tools {value: bare} shape', () => {
expect(unwrapEvaluateResult({ value: 5 })).toBe(5);
});
it('returns undefined for empty {} (MV3 Promise drop)', () => {
// The exact bug 1.8.3 fixes: executeScript serializes a Promise as {}
// and the caller saw "result: {}" with no way to tell whether the
// page returned an actual empty object or the bridge swallowed a
// Promise. Empty plain-object โ†’ undefined sentinel.
expect(unwrapEvaluateResult({})).toBeUndefined();
});
it('preserves a non-empty object that has none of the known keys', () => {
const arbitrary = { foo: 1, bar: 2 };
expect(unwrapEvaluateResult(arbitrary)).toEqual(arbitrary);
});
it('does NOT collapse arrays to undefined', () => {
expect(unwrapEvaluateResult([])).toEqual([]);
expect(unwrapEvaluateResult([1, 2, 3])).toEqual([1, 2, 3]);
});
it('handles deeply-nested shape: {result: {value: {result: {value: X}}}}', () => {
// Only unwraps ONE layer โ€” recursive unwrap would be wrong because
// the page might legitimately have evaluated to {result: {value: X}}.
expect(unwrapEvaluateResult({ result: { value: { result: { value: 9 } } } }))
.toEqual({ result: { value: 9 } });
});
it('regression: 1+1 case (the bug from 1.8.1)', () => {
// Sanity: the path that broke in 1.8.1 (returned {}) now returns 2.
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
});
});