chirp-sync-vault-adapter.test.ts

9.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>>((acc, k) => { acc[k] = store[k]; return acc; }, {});11    }),12    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),13    remove: vi.fn(async (keys: string | string[]) => {14      const list = Array.isArray(keys) ? keys : [keys];15      for (const k of list) delete store[k];16    }),17  });18  return {19    storage: { local: area(local), sync: area(sync) },20    alarms: {21      clear: vi.fn(),22      create: vi.fn(),23      onAlarm: { addListener: vi.fn() },24    },25    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },26    runtime: { sendMessage: vi.fn(async () => undefined) },27  } as unknown as typeof chrome;28}2930describe("ChirpSyncVaultAdapter", () => {31  let local: StorageShape;32  let sync: StorageShape;3334  beforeEach(() => {35    vi.resetModules();36    local = {};37    sync = {};38    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);39    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));40  });4142  async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./chirp-sync-coordinator").ChirpSyncVaultAdapter }> {43    const vault = await import("./vault");44    await vault.createVault("correct horse battery staple");45    const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");46    const adapter = createChirpSyncVaultAdapter();47    return { vault, adapter };48  }4950  it("computeVaultId returns a stable 16-char hex string for the same masterKey", async () => {51    const { adapter } = await setupVault();52    const id1 = await adapter.computeVaultId();53    const id2 = await adapter.computeVaultId();54    expect(id1).toBe(id2);55    expect(id1).toMatch(/^[0-9a-f]{16}$/);56  });5758  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {59    const { adapter } = await setupVault();60    const id1 = await adapter.getOurDeviceId();61    const id2 = await adapter.getOurDeviceId();62    expect(id1).toBe(id2);63    expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);64  });6566  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {67    const { vault, adapter } = await setupVault();68    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });69    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });70    await vault.deleteEntry(b.id);71    const m = await adapter.buildEntryManifest();72    expect(Object.keys(m).sort()).toEqual([a.id]);73    expect(m[a.id]).toBe(a.modifiedAt);74  });7576  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {77    const { vault, adapter } = await setupVault();78    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });79    await vault.deleteEntry(a.id);80    const t = await adapter.buildTombstoneManifest();81    expect(Object.keys(t)).toEqual([a.id]);82    expect(typeof t[a.id]).toBe("number");83    expect(t[a.id]).toBeGreaterThan(0);84  });8586  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {87    const { vault, adapter } = await setupVault();88    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });89    const env = await adapter.getEncryptedEntries([a.id]);90    expect(Object.keys(env)).toEqual([a.id]);91    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);92    // Plaintext title must not be visible in the envelope.93    expect(env[a.id]).not.toContain("Alpha");94  });9596  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {97    const { adapter } = await setupVault();98    const env = await adapter.getEncryptedEntries(["does-not-exist"]);99    expect(env).toEqual({});100  });101102  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {103    const { vault, adapter } = await setupVault();104    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });105    const e1 = await adapter.getEncryptedEntries([a.id]);106    const e2 = await adapter.getEncryptedEntries([a.id]);107    expect(e1[a.id]).not.toBe(e2[a.id]);108  });109110  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {111    const { vault, adapter } = await setupVault();112    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });113    const envelopes = await adapter.getEncryptedEntries([a.id]);114    // Simulate a remote vault by bumping modifiedAt on the envelope's source115    // and then applying it back — we can't easily fork the module's storage,116    // so we just verify decrypt path by overwriting modifiedAt locally.117    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries118    // will re-add it because the local copy is tombstoned with an OLDER ts (since119    // we set modifiedAt forward by 1s in the new envelope payload).120    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.121    vault.lock();122    await vault.unlock("correct horse battery staple");123    const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");124    const adapter2 = createChirpSyncVaultAdapter();125    // applyEntries should be a no-op when local mtime >= envelope mtime126    await adapter2.applyEntries(envelopes);127    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");128  });129130  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {131    const { vault, adapter } = await setupVault();132    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });133    // Manually produce a fresher remote envelope by encrypting an updated entry shape.134    // We can't easily synthesise this without the entry key — so we use a second135    // adapter instance backed by the same vault, after mutating the underlying entry.136    await vault.updateEntry(local.id, { title: "Updated" });137    const newer = vault.getEntries().find((e) => e.id === local.id);138    expect(newer?.title).toBe("Updated");139    const envelopes = await adapter.getEncryptedEntries([local.id]);140    // Now revert the local entry to an older mtime by re-creating from scratch via141    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the142    // decrypt+merge path is that applyEntries on a fresher envelope keeps the143    // newer state, and on an older envelope is a no-op. We've covered the no-op144    // path above; for the apply path, simulate by replacing the in-memory entry145    // with an older copy and re-running applyEntries.146    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };147    await vault.restoreEntries([older], "replace");148    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");149    await adapter.applyEntries(envelopes);150    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");151  });152153  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {154    const { vault, adapter } = await setupVault();155    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });156    const env = await adapter.getEncryptedEntries([a.id]);157    // Re-key the envelope under a different id — adapter must refuse to merge it.158    const swapped: Record<string, string> = { "wrong-id": env[a.id] };159    await adapter.applyEntries(swapped);160    // Local entry remains unchanged; no new entry under "wrong-id"161    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);162  });163164  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {165    const { vault, adapter } = await setupVault();166    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });167    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });168    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();169    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();170  });171172  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {173    const { vault, adapter } = await setupVault();174    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });175    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });176    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();177  });178179  it("methods throw VaultLockedError when the vault is locked", async () => {180    const { vault, adapter } = await setupVault();181    vault.lock();182    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });183    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });184    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });185    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });186    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });187    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });188  });189});