1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6 const area = (store: StorageShape) => ({7 get: vi.fn(async (key?: string | string[]) => {8 if (!key) return store;9 if (typeof key === "string") return { [key]: store[key] };10 return key.reduce<Record<string, unknown>>((out, item) => {11 out[item] = store[item];12 return out;13 }, {});14 }),15 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),16 remove: vi.fn(async (keys: string | string[]) => {17 for (const key of Array.isArray(keys) ? keys : [keys]) delete store[key];18 }),19 });20 return {21 storage: { local: area(local), sync: area(sync) },22 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },23 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },24 runtime: { sendMessage: vi.fn(async () => undefined) },25 } as unknown as typeof chrome;26}2728describe("locked sync spool", () => {29 let local: StorageShape;3031 beforeEach(() => {32 vi.resetModules();33 local = {};34 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {});35 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));36 });3738 it("retains only opaque envelopes and transport identity after a full lock", async () => {39 const vault = await import("./vault");40 await vault.createVault("correct horse battery staple");41 const entry = await vault.addEntry({ type: "login", title: "Top Secret", password: "swordfish" });42 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");43 await refreshLockedSyncSpool();44 vault.lock();4546 const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");47 const adapter = await createLockedSyncVaultAdapter();48 const identity = await getLockedSyncIdentity();49 expect(vault.isUnlocked()).toBe(false);50 expect(identity?.longTermPrivateKey).toHaveLength(32);51 expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });52 const envelopes = await adapter?.getEncryptedEntries([entry.id]);53 expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);54 expect(JSON.stringify(local)).not.toContain("Top Secret");55 expect(JSON.stringify(local)).not.toContain("swordfish");56 });5758 it("stages bounded inbound ciphertext without promoting it to the outbound manifest", async () => {59 const vault = await import("./vault");60 await vault.createVault("correct horse battery staple");61 const entry = await vault.addEntry({ type: "login", title: "Cached", password: "secret" });62 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");63 await refreshLockedSyncSpool();64 vault.lock();6566 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");67 const adapter = (await createLockedSyncVaultAdapter())!;68 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });6970 expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });71 const stored = local.peach_locked_sync_spool_v1 as {72 inboxEntries: Record<string, { ciphertext: string }>;73 entries: Record<string, unknown>;74 };75 expect(stored.inboxEntries.remote?.ciphertext).toBe("AA==");76 expect(stored.entries.remote).toBeUndefined();77 });7879 it("bounds the cumulative locked inbox across repeated sync sessions", async () => {80 const vault = await import("./vault");81 await vault.createVault("correct horse battery staple");82 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");83 await refreshLockedSyncSpool();84 vault.lock();8586 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");87 const adapter = (await createLockedSyncVaultAdapter())!;88 const stored = local.peach_locked_sync_spool_v1 as {89 inboxEntries: Record<string, { modifiedAt: number; ciphertext: string }>;90 inboxTombstones: Record<string, number>;91 };92 for (let index = 0; index < 100_000; index += 1) {93 stored.inboxEntries[`entry-${index}`] = { modifiedAt: 1, ciphertext: "AA==" };94 stored.inboxTombstones[`tombstone-${index}`] = 1;95 }9697 await expect(adapter.applyEntries({ overflow: "AA==" }, { overflow: 2 }))98 .rejects.toThrow("sync_entry_count_exceeded");99 await expect(adapter.applyTombstones({ overflow: 2 }))100 .rejects.toThrow("sync_tombstone_count_exceeded");101 });102103 it("drains staged envelopes through the live decrypt boundary after unlock", async () => {104 const vault = await import("./vault");105 const syncAdapter = await import("./sync-vault-adapter");106 vault.setLockedSyncHooks({107 refresh: syncAdapter.refreshLockedSyncSpool,108 afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,109 });110 await vault.createVault("correct horse battery staple");111 const entry = await vault.addEntry({ type: "login", title: "Local", password: "secret" });112 vault.lock();113114 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");115 const adapter = (await createLockedSyncVaultAdapter())!;116 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });117118 expect(await vault.unlock("correct horse battery staple")).toBe(true);119 const stored = local.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };120 expect(stored.inboxEntries).toEqual({});121 expect(vault.getEntryById(entry.id)?.title).toBe("Local");122 });123});