1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45function makeChromeMock(local: StorageShape, sync: StorageShape, session: 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), session: area(session) },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;30 let session: StorageShape;3132 beforeEach(() => {33 vi.resetModules();34 local = {};35 session = {};36 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {}, session);37 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));38 });3940 it("retains only opaque envelopes and transport identity after a full lock", async () => {41 const vault = await import("./vault");42 await vault.createVault("correct horse battery staple");43 const entry = await vault.addEntry({ type: "login", title: "Top Secret", password: "swordfish" });44 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");45 await refreshLockedSyncSpool();46 vault.lock();4748 const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");49 const adapter = await createLockedSyncVaultAdapter();50 const identity = await getLockedSyncIdentity();51 expect(vault.isUnlocked()).toBe(false);52 53 54 expect(identity).not.toBeNull();55 expect(identity?.longTermPrivateKey).toHaveLength(32);56 57 expect(local.peach_locked_sync_spool_v1).toBeUndefined();58 expect(JSON.stringify(local)).not.toContain("longTermPrivateKeyHex");59 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");60 expect(session.peach_locked_sync_spool_v1).toBeDefined();61 expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });62 const envelopes = await adapter?.getEncryptedEntries([entry.id]);63 expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);64 expect(JSON.stringify(local)).not.toContain("Top Secret");65 expect(JSON.stringify(local)).not.toContain("swordfish");66 });6768 it("stages bounded inbound ciphertext without promoting it to the outbound manifest", async () => {69 const vault = await import("./vault");70 await vault.createVault("correct horse battery staple");71 const entry = await vault.addEntry({ type: "login", title: "Cached", password: "secret" });72 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");73 await refreshLockedSyncSpool();74 vault.lock();7576 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");77 const adapter = (await createLockedSyncVaultAdapter())!;78 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });7980 expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });81 const stored = session.peach_locked_sync_spool_v1 as {82 inboxEntries: Record<string, { ciphertext: string }>;83 entries: Record<string, unknown>;84 };85 expect(stored.inboxEntries.remote?.ciphertext).toBe("AA==");86 expect(stored.entries.remote).toBeUndefined();87 });8889 it("bounds the cumulative locked inbox across repeated sync sessions", async () => {90 const vault = await import("./vault");91 await vault.createVault("correct horse battery staple");92 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");93 await refreshLockedSyncSpool();94 vault.lock();9596 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");97 const adapter = (await createLockedSyncVaultAdapter())!;98 const stored = session.peach_locked_sync_spool_v1 as {99 inboxEntries: Record<string, { modifiedAt: number; ciphertext: string }>;100 inboxTombstones: Record<string, number>;101 };102 for (let index = 0; index < 100_000; index += 1) {103 stored.inboxEntries[`entry-${index}`] = { modifiedAt: 1, ciphertext: "AA==" };104 stored.inboxTombstones[`tombstone-${index}`] = 1;105 }106107 await expect(adapter.applyEntries({ overflow: "AA==" }, { overflow: 2 }))108 .rejects.toThrow("sync_entry_count_exceeded");109 await expect(adapter.applyTombstones({ overflow: 2 }))110 .rejects.toThrow("sync_tombstone_count_exceeded");111 });112113 114 115 116 it("scrubs a legacy disk-backed spool and refuses to adopt it", async () => {117 local.peach_locked_sync_spool_v1 = {118 version: 1,119 vaultId: "a".repeat(64),120 deviceId: "device",121 deviceName: "Browser",122 deviceType: "extension",123 localVersion: 1,124 longTermPublicKeyHex: "b".repeat(64),125 longTermPrivateKeyHex: "c".repeat(64),126 entries: {},127 tombstones: {},128 inboxEntries: { queued: { modifiedAt: 1, ciphertext: "AA==" } },129 inboxTombstones: {},130 };131 const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");132 expect(await getLockedSyncIdentity()).toBeNull();133 expect(await createLockedSyncVaultAdapter()).toBeNull();134 135 expect(local.peach_locked_sync_spool_v1).toBeUndefined();136 });137138 it("fails closed after a worker restart because the private key is not persisted", async () => {139 const vault = await import("./vault");140 await vault.createVault("correct horse battery staple");141 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");142 await refreshLockedSyncSpool();143 vault.lock();144145 vi.resetModules();146 const { getLockedSyncIdentity } = await import("./locked-sync-spool");147 expect(await getLockedSyncIdentity()).toBeNull();148 expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("PrivateKey");149 expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("longTermPrivateKey");150151 for (const key of Object.keys(session)) delete session[key];152 vi.resetModules();153 const afterRestart = await import("./locked-sync-spool");154 expect(await afterRestart.getLockedSyncIdentity()).toBeNull();155 });156157 it("drains staged envelopes through the live decrypt boundary after unlock", async () => {158 const vault = await import("./vault");159 const syncAdapter = await import("./sync-vault-adapter");160 vault.setLockedSyncHooks({161 refresh: syncAdapter.refreshLockedSyncSpool,162 afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,163 });164 await vault.createVault("correct horse battery staple");165 const entry = await vault.addEntry({ type: "login", title: "Local", password: "secret" });166 vault.lock();167168 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");169 const adapter = (await createLockedSyncVaultAdapter())!;170 await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });171172 expect(await vault.unlock("correct horse battery staple")).toBe(true);173 const stored = session.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };174 expect(stored.inboxEntries).toEqual({});175 expect(vault.getEntryById(entry.id)?.title).toBe("Local");176 });177});