locked-sync-spool.test.ts

5.6 KB · raw

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});