diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index fa49416..03848de 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -654,3 +654,81 @@ describe('get_totp handler covers both Login.totp and Totp.config', () => { 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' }); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 14b89cc..7903de9 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -171,6 +171,23 @@ export async function handle( return { ok: true }; } + case 'get_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + return { ok: true, data: { settings } }; + } + + case 'update_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteSettings( + state.gitHost, handle, msg.settings, + 'settings: update vault-level config', + ); + return { ok: true }; + } + case 'get_blacklist': return { ok: true, data: { blacklist: await loadBlacklist() } }; diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 3001b9f..a79e126 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,6 +1,6 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, - DeviceSettings, GeneratorRequest, + DeviceSettings, GeneratorRequest, VaultSettings, } from './types'; // --- Messages a popup (or setup page) may send --- @@ -24,6 +24,8 @@ export type PopupMessage = | { type: 'ack_autofill_origin'; hostname: string } | { type: 'get_settings' } | { type: 'update_settings'; settings: Partial } + | { type: 'get_vault_settings' } + | { type: 'update_vault_settings'; settings: VaultSettings } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string }; @@ -88,13 +90,18 @@ export interface RatePassphraseResponse extends Extract data: { score: number; guesses_log10: number }; } +export interface VaultSettingsResponse extends Extract { + data: { settings: VaultSettings }; +} + // --- Capability sets (consumed by the router) --- export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state', 'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials', - 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist', + 'ack_autofill_origin', 'get_settings', 'update_settings', + 'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'remove_blacklist', ] as PopupMessage['type'][]);