locked-sync-spool.test.ts

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