import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --- Mocks (must be declared before `route` is imported so the router's // `import * as vault` / `import * as session` resolve to these doubles) --- // Partial mock: we override only the vault calls the new tests care about // (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings) // and let the real implementations of listItems / findByHostname / etc. // continue to run for the other tests that don't need mocks. vi.mock('../../vault', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchAndDecryptItem: vi.fn(), fetchAndDecryptSettings: vi.fn(), encryptAndWriteSettings: vi.fn(), encryptAndWriteItem: vi.fn(), encryptAndWriteManifest: vi.fn(), }; }); vi.mock('../../session', () => ({ setCurrent: vi.fn(), getCurrent: vi.fn(), clearCurrent: vi.fn(), requireCurrent: vi.fn(), })); vi.mock('../../devices', () => ({ readDevices: vi.fn(), writeDevices: vi.fn(), addDevice: vi.fn().mockResolvedValue(undefined), revokeDevice: vi.fn(), })); import { route, type RouterState } from '../index'; import type { Request } from '../../../shared/messages'; import type { Item } from '../../../shared/types'; import * as vault from '../../vault'; import * as session from '../../session'; import * as devices from '../../devices'; // --- chrome.* shim --- // @ts-expect-error test harness globalThis.chrome = { runtime: { id: 'relicario-test-id', getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`, }, storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } }, tabs: { get: vi.fn(), sendMessage: vi.fn() }, }; function makePopupSender(): chrome.runtime.MessageSender { return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' }; } function makeSetupSender(): chrome.runtime.MessageSender { return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' }; } function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender { return { tab: { id: 42, url: pageUrl } as chrome.tabs.Tab, frameId: 0, id: 'relicario-test-id', }; } function makeVaultSender(): chrome.runtime.MessageSender { return { url: `chrome-extension://relicario-test-id/vault.html`, id: 'relicario-test-id' }; } function makeExternalSender(): chrome.runtime.MessageSender { return { url: 'https://evil.example/', id: 'some-other-extension' }; } function makeState(): RouterState { return { manifest: { schema_version: 2, items: {} }, gitHost: null, wasm: { // Stubs sufficient for the message types exercised by tests: new_item_id: () => 'fakeitemid0000ab', generate_password: () => 'PASSWORD', rate_passphrase: () => ({ score: 4, guesses_log10: 15 }), }, }; } // --- Sender-check matrix --- describe('router sender dispatch', () => { let state: RouterState; beforeEach(() => { state = makeState(); }); const popupOnlyMsgs: Request[] = [ { type: 'is_unlocked' }, { type: 'lock' }, { type: 'list_items' }, { type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } }, { type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' }, { type: 'get_blacklist' }, ]; for (const msg of popupOnlyMsgs) { it(`accepts popup-only "${msg.type}" from popup`, async () => { const res = await route(msg, state, makePopupSender()); expect(res).toMatchObject({ ok: true }); }); it(`accepts popup-only "${msg.type}" from vault tab`, async () => { const res = await route(msg, state, makeVaultSender()); expect(res).toMatchObject({ ok: true }); }); it(`rejects popup-only "${msg.type}" from content`, async () => { const res = await route(msg, state, makeContentSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it(`rejects popup-only "${msg.type}" from external`, async () => { const res = await route(msg, state, makeExternalSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); } it('accepts save_setup from popup', async () => { const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; const res = await route(msg, state, makePopupSender()); expect(res).toMatchObject({ ok: true }); }); it('accepts save_setup from setup tab', async () => { const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; const res = await route(msg, state, makeSetupSender()); expect(res).toMatchObject({ ok: true }); }); it('rejects save_setup from content', async () => { const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; const res = await route(msg, state, makeContentSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); const contentMsgs: Request[] = [ { type: 'get_autofill_candidates' }, { type: 'blacklist_site' }, ]; for (const msg of contentMsgs) { it(`accepts content "${msg.type}" from top-frame content`, async () => { const res = await route(msg, state, makeContentSender()); expect(res.ok).toBe(true); }); it(`rejects content "${msg.type}" from popup`, async () => { const res = await route(msg, state, makePopupSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it(`rejects content "${msg.type}" from subframe`, async () => { const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 }; const res = await route(msg, state, sender); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it(`rejects content "${msg.type}" from external`, async () => { const res = await route(msg, state, makeExternalSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); } it('rejects unknown message type', async () => { // @ts-expect-error intentional invalid type const res = await route({ type: 'nonsense' }, state, makePopupSender()); expect(res).toEqual({ ok: false, error: 'unknown_message_type' }); }); }); // --- Origin-bound autofill --- describe('get_autofill_candidates uses sender.tab.url', () => { it('derives hostname from sender, not message', async () => { const state: RouterState = makeState(); state.manifest = { schema_version: 2, items: { 'aaaaaaaaaaaaaaaa': { id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub', tags: [], favorite: false, icon_hint: 'github.com', modified: 0, attachment_summaries: [], }, 'bbbbbbbbbbbbbbbb': { id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example', tags: [], favorite: false, icon_hint: 'example.com', modified: 0, attachment_summaries: [], }, }, }; const res = await route( { type: 'get_autofill_candidates' }, state, makeContentSender('https://example.com/login'), ); expect(res.ok).toBe(true); if (res.ok) { const data = res.data as { candidates: Array<[string, { title: string }]> }; expect(data.candidates).toHaveLength(1); expect(data.candidates[0][1].title).toBe('Example'); } }); }); // --- fill_credentials TOCTOU + origin verification --- describe('fill_credentials captured-tab verification', () => { const FAKE_ITEM_ID = 'cccccccccccccccc'; function loginItem(url: string): Item { return { id: FAKE_ITEM_ID, title: 'Example', type: 'login', tags: [], favorite: false, created: 0, modified: 0, core: { type: 'login', username: 'alice', password: 'hunter2', url }, sections: [], attachments: [], field_history: {}, }; } function primeUnlocked(state: RouterState): void { // Provide a fake handle + githost so the handler's "vault_locked" guard // passes — values don't matter because vault is mocked. vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); state.gitHost = {} as never; } beforeEach(() => { vi.mocked(session.getCurrent).mockReset(); vi.mocked(vault.fetchAndDecryptItem).mockReset(); (chrome.tabs.get as ReturnType).mockReset(); (chrome.tabs.sendMessage as ReturnType).mockReset(); }); it('returns tab_navigated when captured tab hostname differs from current', async () => { const state = makeState(); primeUnlocked(state); // chrome.tabs.get returns a tab that has navigated to a DIFFERENT host. (chrome.tabs.get as ReturnType).mockResolvedValue({ id: 42, url: 'https://evil.example/landing', }); const res = await route( { type: 'fill_credentials', id: FAKE_ITEM_ID, capturedTabId: 42, capturedUrl: 'https://example.com/login', }, state, makePopupSender(), ); expect(res).toEqual({ ok: false, error: 'tab_navigated' }); // We must NOT have attempted to deliver credentials. expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); }); it('returns origin_mismatch when item hostname differs from current tab', async () => { const state = makeState(); primeUnlocked(state); // Tab is still on example.com (matches capturedUrl) … (chrome.tabs.get as ReturnType).mockResolvedValue({ id: 42, url: 'https://example.com/login', }); // … but the item we'd fill belongs to github.com. vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( loginItem('https://github.com/login'), ); const res = await route( { type: 'fill_credentials', id: FAKE_ITEM_ID, capturedTabId: 42, capturedUrl: 'https://example.com/login', }, state, makePopupSender(), ); expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); }); it('forwards fill_credentials with expectedHost when all checks pass', async () => { const state = makeState(); primeUnlocked(state); (chrome.tabs.get as ReturnType).mockResolvedValue({ id: 42, url: 'https://example.com/login', }); vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( loginItem('https://example.com/login'), ); (chrome.tabs.sendMessage as ReturnType).mockResolvedValue({ ok: true }); const res = await route( { type: 'fill_credentials', id: FAKE_ITEM_ID, capturedTabId: 42, capturedUrl: 'https://example.com/login', }, state, makePopupSender(), ); expect(res).toEqual({ ok: true }); expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, { type: 'fill_credentials', username: 'alice', password: 'hunter2', expectedHost: 'example.com', }); }); }); // --- setup-tab exception scope --- // // Setup is allowed a narrow subset of popup-only messages: // - save_setup (final wire-up) // - rate_passphrase (zxcvbn meter during passphrase entry) // - is_unlocked (step-4 extension detection) // Everything else popup-only must be rejected from setup. describe('setup tab exception scope', () => { it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => { const state = makeState(); const res = await route( { type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' }, state, makeSetupSender(), ); expect(res).toMatchObject({ ok: true }); }); it('accepts is_unlocked from the setup tab (step-4 detection)', async () => { const state = makeState(); const res = await route({ type: 'is_unlocked' }, state, makeSetupSender()); expect(res).toMatchObject({ ok: true }); }); it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => { const state = makeState(); const res = await route( { type: 'fill_credentials', id: 'cccccccccccccccc', capturedTabId: 42, capturedUrl: 'https://example.com/', }, state, makeSetupSender(), ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('rejects unlock from the setup tab (outside the allowlist)', async () => { const state = makeState(); const res = await route( { type: 'unlock', passphrase: 'hunter2' }, state, makeSetupSender(), ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- register_this_device: wasm returns a JS object, not a JSON string --- // // The #[wasm_bindgen] binding for `register_device` uses `serde-wasm-bindgen` // and returns a plain JsValue (object), not a JSON string. Calling // JSON.parse on it would throw `SyntaxError: "[object Object]" is not // valid JSON`. This regression test pins that contract. describe('register_this_device', () => { it('treats register_device() return value as an object, not a JSON string', async () => { const state = makeState(); state.gitHost = {} as never; state.wasm.register_device = () => ({ signing_public_key: 'aa'.repeat(32), deploy_public_key: 'bb'.repeat(32), }); vi.mocked(devices.addDevice).mockClear(); const res = await route( { type: 'register_this_device', name: 'Test Browser' }, state, makePopupSender(), ); expect(res).toEqual({ ok: true }); expect(devices.addDevice).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ name: 'Test Browser', public_key: 'aa'.repeat(32) }), ); }); }); // --- isContent rejects unknown sender.id --- describe('isContent sender.id guard', () => { it('rejects content-shaped sender whose id is not the extension id', async () => { const state = makeState(); const sender: chrome.runtime.MessageSender = { tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab, frameId: 0, id: 'some-other-extension', // NOT chrome.runtime.id }; const res = await route({ type: 'get_autofill_candidates' }, state, sender); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- capture_save_login (content-callable, origin-bound) --- describe('capture_save_login', () => { const EXISTING_ID = 'dddddddddddddddd'; function loginItem(url: string, username: string, password: string): Item { return { id: EXISTING_ID, title: 'Example', type: 'login', tags: [], favorite: false, created: 0, modified: 0, core: { type: 'login', username, password, url }, sections: [], attachments: [], field_history: {}, }; } function primeUnlocked(state: RouterState): void { vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); state.gitHost = {} as never; } beforeEach(() => { vi.mocked(session.getCurrent).mockReset(); vi.mocked(vault.fetchAndDecryptItem).mockReset(); vi.mocked(vault.encryptAndWriteItem).mockReset(); vi.mocked(vault.encryptAndWriteManifest).mockReset(); vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined); vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined); }); it('accepts capture_save_login from top-frame content', async () => { const state = makeState(); primeUnlocked(state); const res = await route( { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, state, makeContentSender('https://example.com/login'), ); expect(res.ok).toBe(true); }); it('rejects capture_save_login from popup', async () => { const state = makeState(); primeUnlocked(state); const res = await route( { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, state, makePopupSender(), ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('update path: existing (host, username) match rotates the password', async () => { const state = makeState(); primeUnlocked(state); // Seed manifest with a login for example.com. state.manifest = { schema_version: 2, items: { [EXISTING_ID]: { id: EXISTING_ID, type: 'login', title: 'Example', tags: [], favorite: false, icon_hint: 'example.com', modified: 0, attachment_summaries: [], }, }, }; vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( loginItem('https://example.com/', 'alice', 'oldpass'), ); const res = await route( { type: 'capture_save_login', username: 'alice', password: 'newpass' }, state, makeContentSender('https://example.com/login'), ); expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } }); // Verify write was invoked with a core whose password is the new one. expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; expect(writtenItem.id).toBe(EXISTING_ID); if (writtenItem.core.type !== 'login') throw new Error('expected login core'); expect(writtenItem.core.password).toBe('newpass'); expect(writtenItem.core.username).toBe('alice'); }); it('add path: no match creates a new item bound to senderHost', async () => { const state = makeState(); primeUnlocked(state); // Empty manifest — no candidates. state.manifest = { schema_version: 2, items: {} }; const res = await route( { type: 'capture_save_login', username: 'bob', password: 's3cret' }, state, makeContentSender('https://example.com/signup'), ); expect(res.ok).toBe(true); if (res.ok) { const data = res.data as { action: string; id: string }; expect(data.action).toBe('added'); expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id() } expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; expect(newItem.title).toBe('example.com'); if (newItem.core.type !== 'login') throw new Error('expected login core'); expect(newItem.core.url).toBe('https://example.com'); expect(newItem.core.username).toBe('bob'); expect(newItem.core.password).toBe('s3cret'); // Manifest entry should have been added too. expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined(); }); it('origin_mismatch when existing item for same username has a different host', async () => { const state = makeState(); primeUnlocked(state); // Manifest says there's a match for example.com (icon_hint), but the // underlying item actually belongs to github.com — defense-in-depth // check should reject. state.manifest = { schema_version: 2, items: { [EXISTING_ID]: { id: EXISTING_ID, type: 'login', title: 'Example', tags: [], favorite: false, icon_hint: 'example.com', modified: 0, attachment_summaries: [], }, }, }; vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( loginItem('https://github.com/', 'alice', 'oldpass'), ); const res = await route( { type: 'capture_save_login', username: 'alice', password: 'newpass' }, state, makeContentSender('https://example.com/login'), ); expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); }); }); // --- get_totp covers both Login.totp and Totp.config --- describe('get_totp handler covers both Login.totp and Totp.config', () => { function primeUnlocked(state: RouterState): void { vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); state.gitHost = {} as never; } function withTotpWasm(state: RouterState): void { state.wasm = { ...state.wasm, totp_compute: (_json: string, _now: bigint) => ({ code: '123456', expires_at: 1_700_000_030, }), }; } beforeEach(() => { vi.mocked(session.getCurrent).mockReset(); vi.mocked(vault.fetchAndDecryptItem).mockReset(); }); it('returns a code for an item with core.type === "totp"', async () => { const state = makeState(); primeUnlocked(state); withTotpWasm(state); const totpItem: Item = { id: 'totp0000000000aa', title: 'GitHub TOTP', type: 'totp', tags: [], favorite: false, created: 0, modified: 0, core: { type: 'totp', config: { secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], // "Hello" in bytes algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp', }, issuer: 'GitHub', }, sections: [], attachments: [], field_history: {}, }; vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(totpItem); const res = await route( { type: 'get_totp', id: 'totp0000000000aa' }, state, makePopupSender(), ); expect(res.ok).toBe(true); if (res.ok) { const d = res.data as { code: string; expires_at: number }; expect(d.code).toMatch(/^\d{6}$/); expect(d.expires_at).toBeGreaterThan(0); } }); it('still returns a code for Login items with a totp subfield', async () => { const state = makeState(); primeUnlocked(state); withTotpWasm(state); const loginItem: Item = { id: 'login0000000000a', title: 'Example', type: 'login', tags: [], favorite: false, created: 0, modified: 0, core: { type: 'login', username: 'alice', password: 'hunter2', url: 'https://example.com', totp: { secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp', }, }, sections: [], attachments: [], field_history: {}, }; vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(loginItem); const res = await route( { type: 'get_totp', id: 'login0000000000a' }, state, makePopupSender(), ); expect(res.ok).toBe(true); }); it('rejects items without any TOTP config', async () => { const state = makeState(); primeUnlocked(state); withTotpWasm(state); const identityItem: Item = { id: 'id0000000000aaaa', title: 'Identity', type: 'identity', tags: [], favorite: false, created: 0, modified: 0, core: { type: 'identity' }, sections: [], attachments: [], field_history: {}, }; vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(identityItem); const res = await route( { type: 'get_totp', id: 'id0000000000aaaa' }, state, makePopupSender(), ); expect(res).toEqual({ ok: false, error: 'no_totp' }); }); }); // --- get_vault_settings / update_vault_settings (β₂ Slice 3) --- describe('get_vault_settings / update_vault_settings', () => { function primeUnlocked(state: RouterState): void { vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); state.gitHost = {} as never; } beforeEach(() => { vi.mocked(session.getCurrent).mockReset(); vi.mocked(vault.fetchAndDecryptSettings).mockReset(); vi.mocked(vault.encryptAndWriteSettings).mockReset(); }); it('get_vault_settings accepted from popup; returns VaultSettings', async () => { const state = makeState(); primeUnlocked(state); const mockSettings = { trash_retention: { kind: 'days', value: 30 }, field_history_retention: { kind: 'forever' }, generator_defaults: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' }, }, attachment_caps: {}, autofill_origin_acks: { 'github.com': 1000 }, }; vi.mocked(vault.fetchAndDecryptSettings).mockResolvedValueOnce(mockSettings as never); const res = await route({ type: 'get_vault_settings' }, state, makePopupSender()); expect(res).toMatchObject({ ok: true }); if (res.ok) { const d = res.data as { settings: typeof mockSettings }; expect(d.settings).toEqual(mockSettings); } }); it('get_vault_settings rejected from content', async () => { const state = makeState(); const res = await route({ type: 'get_vault_settings' }, state, makeContentSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('update_vault_settings accepted from popup; calls encryptAndWriteSettings', async () => { const state = makeState(); primeUnlocked(state); vi.mocked(vault.encryptAndWriteSettings).mockResolvedValueOnce(undefined); const newSettings = { trash_retention: { kind: 'forever' }, field_history_retention: { kind: 'last_n', value: 5 }, generator_defaults: { kind: 'bip39', word_count: 6, separator: '-', capitalization: 'lower', }, attachment_caps: {}, autofill_origin_acks: {}, }; const res = await route( { type: 'update_vault_settings', settings: newSettings as never }, state, makePopupSender(), ); expect(res).toMatchObject({ ok: true }); expect(vault.encryptAndWriteSettings).toHaveBeenCalledWith( expect.anything(), expect.anything(), newSettings, expect.any(String), ); }); it('update_vault_settings rejected from setup tab (not in SETUP_ALLOWED)', async () => { const state = makeState(); const res = await route( { type: 'update_vault_settings', settings: {} as never }, state, makeSetupSender(), ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- upload_attachment / download_attachment (γ₁ Task 6) --- describe('upload_attachment / download_attachment', () => { it('upload_attachment accepted from popup', async () => { const state = makeState(); const result = await route( { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, state, makePopupSender(), ); // The handler may return ok: false (vault_locked) since session is not primed, // but the router MUST reach the handler — i.e. not return unauthorized_sender. expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('upload_attachment rejected from content script', async () => { const state = makeState(); const result = await route( { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, state, makeContentSender(), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('download_attachment accepted from popup', async () => { const state = makeState(); const result = await route( { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, state, makePopupSender(), ); expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('download_attachment rejected from content script', async () => { const state = makeState(); const result = await route( { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, state, makeContentSender(), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- export_backup / restore_backup sender check --- describe('export_backup / restore_backup sender check', () => { it('accepts vault tab for export_backup', async () => { const state = makeState(); const result = await route( { type: 'export_backup', passphrase: 'p', includeImage: false }, state, makeVaultSender(), ); // The handler may return ok: false (vault_locked / missing state) but the // router must NOT reject it as unauthorized_sender. expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('accepts popup for export_backup', async () => { const state = makeState(); const result = await route( { type: 'export_backup', passphrase: 'p', includeImage: false }, state, makePopupSender(), ); expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('rejects setup tab for export_backup', async () => { const state = makeState(); const result = await route( { type: 'export_backup', passphrase: 'p', includeImage: false }, state, makeSetupSender(), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('rejects content top frame for restore_backup', async () => { const state = makeState(); const result = await route( { type: 'restore_backup', bytes: new ArrayBuffer(8), passphrase: 'p', newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' }, }, state, makeContentSender('https://example.com'), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- parse_lastpass_csv / import_lastpass_commit sender check --- describe('parse_lastpass_csv / import_lastpass_commit sender check', () => { it('accepts vault tab for parse_lastpass_csv', async () => { const state = makeState(); const result = await route( { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, state, makeVaultSender(), ); expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('accepts popup for parse_lastpass_csv', async () => { const state = makeState(); const result = await route( { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, state, makePopupSender(), ); expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('rejects setup tab for parse_lastpass_csv', async () => { const state = makeState(); const result = await route( { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, state, makeSetupSender(), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); it('rejects content top frame for import_lastpass_commit', async () => { const state = makeState(); const result = await route( { type: 'import_lastpass_commit', items: [] }, state, makeContentSender('https://example.com'), ); expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); // --- get_active_tab_url --- describe('get_active_tab_url', () => { let originalChrome: any; beforeEach(() => { originalChrome = (globalThis as any).chrome; }); afterEach(() => { (globalThis as any).chrome = originalChrome; }); it('get_active_tab_url returns active tab url + title', async () => { // happy-dom does not provide chrome.tabs; stub it. (globalThis as any).chrome = { ...((globalThis as any).chrome ?? {}), tabs: { query: (q: any, cb: (tabs: any[]) => void) => { cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]); }, }, }; const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender()); expect(resp.ok).toBe(true); expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' }); }); it('get_active_tab_url returns null for chrome:// pages', async () => { (globalThis as any).chrome = { ...((globalThis as any).chrome ?? {}), tabs: { query: (q: any, cb: (tabs: any[]) => void) => { cb([{ url: 'chrome://newtab/', title: 'New Tab' }]); }, }, }; const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender()); expect(resp.ok).toBe(true); expect(resp.data).toBeNull(); }); it('get_active_tab_url returns null for view-source: URLs', async () => { (globalThis as any).chrome = { ...((globalThis as any).chrome ?? {}), tabs: { query: (q: any, cb: (tabs: any[]) => void) => { cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]); }, }, }; const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender()); expect(resp.ok).toBe(true); expect(resp.data).toBeNull(); }); }); // --- list_groups --- describe('list_groups', () => { it('list_groups returns deduplicated sorted groups from manifest', async () => { const state = makeState(); state.manifest = { schema_version: 2, items: { a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group }, }; const resp = await route({ type: 'list_groups' } as any, state, makePopupSender()); expect(resp.ok).toBe(true); expect(resp.data).toEqual({ groups: ['personal', 'work'] }); }); it('list_groups returns empty array when manifest is null', async () => { const state = makeState(); state.manifest = null; const resp = await route({ type: 'list_groups' } as any, state, makePopupSender()); expect(resp.ok).toBe(true); expect(resp.data).toEqual({ groups: [] }); }); }); // --- preview_totp_from_secret --- describe('preview_totp_from_secret', () => { let originalChrome: any; beforeEach(() => { originalChrome = (globalThis as any).chrome; }); afterEach(() => { (globalThis as any).chrome = originalChrome; }); it('returns code for valid base32', async () => { const state = makeState(); state.wasm = { totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }), }; const resp = await route( { type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any, state, makePopupSender(), ); expect(resp.ok).toBe(true); expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 }); // Verify a transient TotpConfig was passed (sha1, 6 digits, 30s) const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]); expect(cfgArg.algorithm).toBe('sha1'); expect(cfgArg.digits).toBe(6); expect(cfgArg.period_seconds).toBe(30); }); it('rejects invalid base32', async () => { const state = makeState(); state.wasm = { totp_compute: vi.fn() }; const resp = await route( { type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any, state, makePopupSender(), ); expect(resp.ok).toBe(false); expect(resp.error).toMatch(/invalid/i); expect(state.wasm.totp_compute).not.toHaveBeenCalled(); }); });