diff --git a/extension/src/service-worker/__tests__/storage.test.ts b/extension/src/service-worker/__tests__/storage.test.ts new file mode 100644 index 0000000..261c44f --- /dev/null +++ b/extension/src/service-worker/__tests__/storage.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } + from '../storage'; + +function mockChromeStorage(initial: Record = {}) { + const store: Record = { ...initial }; + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of arr) if (k in store) out[k] = store[k]; + return Promise.resolve(out); + }), + set: vi.fn((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }), + }, + }, + } as never; + return store; +} + +describe('service-worker/storage', () => { + beforeEach(() => { mockChromeStorage(); }); + + it('loadDeviceSettings returns default when storage is empty', async () => { + const s = await loadDeviceSettings(); + expect(s.captureEnabled).toBe(false); + expect(s.captureStyle).toBe('bar'); + }); + + it('loadDeviceSettings returns stored value', async () => { + mockChromeStorage({ relicarioSettings: { captureEnabled: true, captureStyle: 'toast' } }); + const s = await loadDeviceSettings(); + expect(s.captureEnabled).toBe(true); + expect(s.captureStyle).toBe('toast'); + }); + + it('saveDeviceSettings persists', async () => { + const store = mockChromeStorage(); + await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' }); + expect(store.relicarioSettings).toEqual({ captureEnabled: true, captureStyle: 'bar' }); + }); + + it('loadBlacklist returns empty array by default', async () => { + expect(await loadBlacklist()).toEqual([]); + }); + + it('saveBlacklist / loadBlacklist round-trips', async () => { + await saveBlacklist(['example.com', 'evil.test']); + expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']); + }); +}); diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts index 73143b5..79587b0 100644 --- a/extension/src/service-worker/router/content-callable.ts +++ b/extension/src/service-worker/router/content-callable.ts @@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages'; import type { Item, Manifest } from '../../shared/types'; import type { GitHost } from '../git-host'; import * as vault from '../vault'; +import { itemToManifestEntry } from '../vault'; import * as session from '../session'; +import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; export interface ContentState { manifest: Manifest | null; @@ -164,41 +166,6 @@ export async function handle( } } -// --- Manifest entry derivation (duplicated from popup-only for self-containment) --- - -function itemToManifestEntry(item: Item) { - return { - id: item.id, - type: item.type, - title: item.title, - tags: item.tags, - favorite: item.favorite, - group: item.group, - icon_hint: (item.core.type === 'login' && item.core.url) - ? safeHostname(item.core.url) : undefined, - modified: item.modified, - trashed_at: item.trashed_at, - attachment_summaries: item.attachments.map((a) => ({ - id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, - })), - }; -} - -async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> { - const r = await chrome.storage.local.get('relicarioSettings'); - return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' }) - ?? { captureEnabled: false, captureStyle: 'bar' }; -} - -async function loadBlacklist(): Promise { - const r = await chrome.storage.local.get('captureBlacklist'); - return (r.captureBlacklist as string[]) ?? []; -} - -async function saveBlacklist(list: string[]): Promise { - await chrome.storage.local.set({ captureBlacklist: list }); -} - function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } } diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 6686f44..febff18 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -4,15 +4,16 @@ /// via sender.url === popup.html (or setup.html for save_setup). import type { PopupMessage, Response } from '../../shared/messages'; -import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types'; -import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types'; import { base32Decode } from '../../shared/base32'; import type { GitHost } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host'; import * as vault from '../vault'; +import { itemToManifestEntry } from '../vault'; import * as session from '../session'; import * as devices from '../devices'; import * as sessionTimer from '../session-timer'; +import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; // --- Shared ambient state owned by the SW module --- // @@ -684,44 +685,6 @@ async function loadSetupState(): Promise { return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; } -async function loadDeviceSettings(): Promise { - const r = await chrome.storage.local.get('relicarioSettings'); - return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; -} - -async function saveDeviceSettings(s: DeviceSettings): Promise { - await chrome.storage.local.set({ relicarioSettings: s }); -} - -async function loadBlacklist(): Promise { - const r = await chrome.storage.local.get('captureBlacklist'); - return (r.captureBlacklist as string[]) ?? []; -} - -async function saveBlacklist(list: string[]): Promise { - await chrome.storage.local.set({ captureBlacklist: list }); -} - -// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) --- - -function itemToManifestEntry(item: Item) { - return { - id: item.id, - type: item.type, - title: item.title, - tags: item.tags, - favorite: item.favorite, - group: item.group, - icon_hint: (item.core.type === 'login' && item.core.url) - ? safeHostname(item.core.url) : undefined, - modified: item.modified, - trashed_at: item.trashed_at, - attachment_summaries: item.attachments.map((a) => ({ - id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, - })), - }; -} - function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } } diff --git a/extension/src/service-worker/storage.ts b/extension/src/service-worker/storage.ts new file mode 100644 index 0000000..133d26b --- /dev/null +++ b/extension/src/service-worker/storage.ts @@ -0,0 +1,25 @@ +/// Single home for chrome.storage.local reads/writes done by the service +/// worker. Both router files (popup-only.ts and content-callable.ts) import +/// from here — the duplicated definitions in those files were lifted out as +/// part of Plan C Phase 2 (P1.9). + +import type { DeviceSettings } from '../shared/types'; +import { DEFAULT_DEVICE_SETTINGS } from '../shared/types'; + +export async function loadDeviceSettings(): Promise { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; +} + +export async function saveDeviceSettings(s: DeviceSettings): Promise { + await chrome.storage.local.set({ relicarioSettings: s }); +} + +export async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +export async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index a252554..c10f9bb 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -395,3 +395,33 @@ export async function removeAttachmentsFromItem( await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); return removed; } + +// --- Manifest entry derivation --- + +/** + * Project a decrypted Item into its ManifestEntry shape for browse-without- + * decrypt views. Both router files use this; defined here (the SW's + * vault-orchestration home) instead of duplicated in each router. Moved out + * of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9). + */ +export function itemToManifestEntry(item: Item): ManifestEntry { + return { + id: item.id, + type: item.type, + title: item.title, + tags: item.tags, + favorite: item.favorite, + group: item.group, + icon_hint: (item.core.type === 'login' && item.core.url) + ? safeHostname(item.core.url) : undefined, + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })), + }; +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +}