The router migrated from generate_device_keypair → register_device (returns signing_public_key + deploy_public_key with private keys staying internal to WASM). Test still mocked the old function under the old return shape (public_key_hex / private_key_base64), so the router's state.wasm.register_device() call failed with "is not a function". Updates the mock function name, response shape, and assertion to the current contract. Test intent (treat the WASM return as a JS object, not a JSON string) is preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1041 lines
35 KiB
TypeScript
1041 lines
35 KiB
TypeScript
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<typeof import('../../vault')>();
|
|
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<typeof vi.fn>).mockReset();
|
|
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
|
id: 42,
|
|
url: 'https://example.com/login',
|
|
});
|
|
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
|
loginItem('https://example.com/login'),
|
|
);
|
|
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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();
|
|
});
|
|
});
|