test(ext/router): add fill_credentials + save_setup exception tests

Three new describe blocks cover the gaps flagged during Slice 4 review:

1. fill_credentials captured-tab verification — three cases:
   - tab_navigated: chrome.tabs.get returns a tab whose hostname differs
     from capturedUrl → handler must return { ok: false, tab_navigated }
     and not call chrome.tabs.sendMessage.
   - origin_mismatch: tab matches capturedUrl but the item's
     LoginCore.url hostname differs → same refusal, no delivery.
   - happy path: verify the forwarded message is exactly
     { type: 'fill_credentials', username, password, expectedHost }.

2. save_setup exception scope: the setup tab gets a narrow exception
   to POST save_setup, but nothing else. Prove fill_credentials from
   the setup tab is rejected with unauthorized_sender.

3. isContent sender.id guard: a content-shaped sender with a bogus
   sender.id (≠ chrome.runtime.id) must be rejected.

Vault/session modules are partial-mocked via vi.mock + importOriginal so
the existing tests continue to exercise real listItems/findByHostname.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-20 20:39:49 -04:00
parent eed11acba2
commit 1d5ad5e59e

View File

@@ -1,6 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { 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(),
};
});
vi.mock('../../session', () => ({
setCurrent: vi.fn(),
getCurrent: vi.fn(),
clearCurrent: vi.fn(),
requireCurrent: vi.fn(),
}));
import { route, type RouterState } from '../index'; import { route, type RouterState } from '../index';
import type { Request } from '../../../shared/messages'; import type { Request } from '../../../shared/messages';
import type { Item } from '../../../shared/types';
import * as vault from '../../vault';
import * as session from '../../session';
// --- chrome.* shim --- // --- chrome.* shim ---
@@ -160,3 +188,155 @@ describe('get_autofill_candidates uses sender.tab.url', () => {
} }
}); });
}); });
// --- 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',
});
});
});
// --- save_setup exception scope: setup tab is ONLY allowed save_setup ---
describe('save_setup exception scope', () => {
it('rejects fill_credentials from the setup tab (setup can only save_setup)', 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' });
});
});
// --- 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' });
});
});