import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; function makeChromeMock(local: StorageShape, sync: StorageShape, session: StorageShape): typeof chrome { const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((out, item) => { out[item] = store[item]; return out; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const key of Array.isArray(keys) ? keys : [keys]) delete store[key]; }), }); return { storage: { local: area(local), sync: area(sync), session: area(session) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { sendMessage: vi.fn(async () => undefined) }, } as unknown as typeof chrome; } describe("locked sync spool", () => { let local: StorageShape; let session: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; session = {}; (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {}, session); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); it("retains only opaque envelopes and transport identity after a full lock", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ type: "login", title: "Top Secret", password: "swordfish" }); const { refreshLockedSyncSpool } = await import("./sync-vault-adapter"); await refreshLockedSyncSpool(); vault.lock(); const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool"); const adapter = await createLockedSyncVaultAdapter(); const identity = await getLockedSyncIdentity(); expect(vault.isUnlocked()).toBe(false); // PB-26-104: locked sync is not allowed to die as a side effect of // protecting the key — the identity has to still be usable. expect(identity).not.toBeNull(); expect(identity?.longTermPrivateKey).toHaveLength(32); // And no part of the spool, private key included, is on disk. expect(local.peach_locked_sync_spool_v1).toBeUndefined(); expect(JSON.stringify(local)).not.toContain("longTermPrivateKeyHex"); expect(JSON.stringify(local)).not.toContain("longTermPrivateKey"); expect(session.peach_locked_sync_spool_v1).toBeDefined(); expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt }); const envelopes = await adapter?.getEncryptedEntries([entry.id]); expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/); expect(JSON.stringify(local)).not.toContain("Top Secret"); expect(JSON.stringify(local)).not.toContain("swordfish"); }); it("stages bounded inbound ciphertext without promoting it to the outbound manifest", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ type: "login", title: "Cached", password: "secret" }); const { refreshLockedSyncSpool } = await import("./sync-vault-adapter"); await refreshLockedSyncSpool(); vault.lock(); const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool"); const adapter = (await createLockedSyncVaultAdapter())!; await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 }); expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt }); const stored = session.peach_locked_sync_spool_v1 as { inboxEntries: Record; entries: Record; }; expect(stored.inboxEntries.remote?.ciphertext).toBe("AA=="); expect(stored.entries.remote).toBeUndefined(); }); it("bounds the cumulative locked inbox across repeated sync sessions", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const { refreshLockedSyncSpool } = await import("./sync-vault-adapter"); await refreshLockedSyncSpool(); vault.lock(); const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool"); const adapter = (await createLockedSyncVaultAdapter())!; const stored = session.peach_locked_sync_spool_v1 as { inboxEntries: Record; inboxTombstones: Record; }; for (let index = 0; index < 100_000; index += 1) { stored.inboxEntries[`entry-${index}`] = { modifiedAt: 1, ciphertext: "AA==" }; stored.inboxTombstones[`tombstone-${index}`] = 1; } await expect(adapter.applyEntries({ overflow: "AA==" }, { overflow: 2 })) .rejects.toThrow("sync_entry_count_exceeded"); await expect(adapter.applyTombstones({ overflow: 2 })) .rejects.toThrow("sync_tombstone_count_exceeded"); }); // PB-26-104: profiles upgraded from the disk-backed spool still hold a // cleartext private key in chrome.storage.local. It has to go on first use, // not on the next unlock, and the stale record must never be adopted. it("scrubs a legacy disk-backed spool and refuses to adopt it", async () => { local.peach_locked_sync_spool_v1 = { version: 1, vaultId: "a".repeat(64), deviceId: "device", deviceName: "Browser", deviceType: "extension", localVersion: 1, longTermPublicKeyHex: "b".repeat(64), longTermPrivateKeyHex: "c".repeat(64), entries: {}, tombstones: {}, inboxEntries: { queued: { modifiedAt: 1, ciphertext: "AA==" } }, inboxTombstones: {}, }; const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool"); expect(await getLockedSyncIdentity()).toBeNull(); expect(await createLockedSyncVaultAdapter()).toBeNull(); // The cleartext key is gone from disk rather than left until unlock. expect(local.peach_locked_sync_spool_v1).toBeUndefined(); }); // chrome.storage.session is memory-backed but outlives the MV3 service // worker, which is what makes it the right home for the spool: locked sync // keeps working across worker death without the key ever reaching disk. it("the spool survives a service-worker restart but not a new browser session", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const { refreshLockedSyncSpool } = await import("./sync-vault-adapter"); await refreshLockedSyncSpool(); vault.lock(); vi.resetModules(); const { getLockedSyncIdentity } = await import("./locked-sync-spool"); expect(await getLockedSyncIdentity()).not.toBeNull(); // Browser restart: the session area is cleared for us. for (const key of Object.keys(session)) delete session[key]; vi.resetModules(); const afterRestart = await import("./locked-sync-spool"); expect(await afterRestart.getLockedSyncIdentity()).toBeNull(); }); it("drains staged envelopes through the live decrypt boundary after unlock", async () => { const vault = await import("./vault"); const syncAdapter = await import("./sync-vault-adapter"); vault.setLockedSyncHooks({ refresh: syncAdapter.refreshLockedSyncSpool, afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool, }); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ type: "login", title: "Local", password: "secret" }); vault.lock(); const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool"); const adapter = (await createLockedSyncVaultAdapter())!; await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 }); expect(await vault.unlock("correct horse battery staple")).toBe(true); const stored = session.peach_locked_sync_spool_v1 as { inboxEntries: Record }; expect(stored.inboxEntries).toEqual({}); expect(vault.getEntryById(entry.id)?.title).toBe("Local"); }); });