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).toBeNull();51 expect(JSON.stringify(local)).not.toContain("longTermPrivateKeyHex");52 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");53 expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });54 const envelopes = await adapter?.getEncryptedEntries([entry.id]);55 expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);56 expect(JSON.stringify(local)).not.toContain("Top Secret");57 expect(JSON.stringify(local)).not.toContain("swordfish");58 });5960 it("stages bounded inbound ciphertext without promoting it to the outbound manifest", async () => {61 const vault = await import("./vault");62 await vault.createVault("correct horse battery staple");63 const entry = await vault.addEntry({ type: "login", title: "Cached", password: "secret" });64 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");65 await refreshLockedSyncSpool();66 vault.lock();6768 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");69 const adapter = (await createLockedSyncVaultAdapter())!;70 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });7172 expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });73 const stored = local.peach_locked_sync_spool_v1 as {74 inboxEntries: Record<string, { ciphertext: string }>;75 entries: Record<string, unknown>;76 };77 expect(stored.inboxEntries.remote?.ciphertext).toBe("AA==");78 expect(stored.entries.remote).toBeUndefined();79 });8081 it("bounds the cumulative locked inbox across repeated sync sessions", async () => {82 const vault = await import("./vault");83 await vault.createVault("correct horse battery staple");84 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");85 await refreshLockedSyncSpool();86 vault.lock();8788 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");89 const adapter = (await createLockedSyncVaultAdapter())!;90 const stored = local.peach_locked_sync_spool_v1 as {91 inboxEntries: Record<string, { modifiedAt: number; ciphertext: string }>;92 inboxTombstones: Record<string, number>;93 };94 for (let index = 0; index < 100_000; index += 1) {95 stored.inboxEntries[`entry-${index}`] = { modifiedAt: 1, ciphertext: "AA==" };96 stored.inboxTombstones[`tombstone-${index}`] = 1;97 }9899 await expect(adapter.applyEntries({ overflow: "AA==" }, { overflow: 2 }))100 .rejects.toThrow("sync_entry_count_exceeded");101 await expect(adapter.applyTombstones({ overflow: 2 }))102 .rejects.toThrow("sync_tombstone_count_exceeded");103 });104105 it("fails closed on a legacy private-key-bearing record until unlock refreshes it", async () => {106 local.peach_locked_sync_spool_v1 = {107 version: 1,108 vaultId: "a".repeat(64),109 deviceId: "device",110 deviceName: "Browser",111 deviceType: "extension",112 localVersion: 1,113 longTermPublicKeyHex: "b".repeat(64),114 longTermPrivateKeyHex: "c".repeat(64),115 entries: {},116 tombstones: {},117 inboxEntries: { queued: { modifiedAt: 1, ciphertext: "AA==" } },118 inboxTombstones: {},119 };120 const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");121 expect(await getLockedSyncIdentity()).toBeNull();122 expect(await createLockedSyncVaultAdapter()).toBeNull();123 expect((local.peach_locked_sync_spool_v1 as { inboxEntries: object }).inboxEntries).toEqual({ queued: { modifiedAt: 1, ciphertext: "AA==" } });124 });125126 it("drains staged envelopes through the live decrypt boundary after unlock", async () => {127 const vault = await import("./vault");128 const syncAdapter = await import("./sync-vault-adapter");129 vault.setLockedSyncHooks({130 refresh: syncAdapter.refreshLockedSyncSpool,131 afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,132 });133 await vault.createVault("correct horse battery staple");134 const entry = await vault.addEntry({ type: "login", title: "Local", password: "secret" });135 vault.lock();136137 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");138 const adapter = (await createLockedSyncVaultAdapter())!;139 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });140141 expect(await vault.unlock("correct horse battery staple")).toBe(true);142 const stored = local.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };143 expect(stored.inboxEntries).toEqual({});144 expect(vault.getEntryById(entry.id)?.title).toBe("Local");145 });146});