locked-sync-spool.test.ts

8.1 KB · raw

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    // PB-26-104: locked sync is not allowed to die as a side effect of53    // protecting the key — the identity has to still be usable.54    expect(identity).not.toBeNull();55    expect(identity?.longTermPrivateKey).toHaveLength(32);56    // And no part of the spool, private key included, is on disk.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  // PB-26-104: profiles upgraded from the disk-backed spool still hold a114  // cleartext private key in chrome.storage.local. It has to go on first use,115  // not on the next unlock, and the stale record must never be adopted.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    // The cleartext key is gone from disk rather than left until unlock.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});