2b121cc936d6

vault-history + blob-sync coordinator tests — 281/281 vitest, 30/30 cargo (v2.14.7)

time
2026-04-30 20:55 UTC · 6 months ago
machine
koh2
parent
d957bb317130
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+293 −0src/background/vault-history.test.ts+221 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.6",4+  "version": "2.14.7",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.6",4+  "version": "2.14.7",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.14.6",3+  "version": "2.14.7",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+293 −0
@@ -0,0 +1,293 @@1+/**2+ * Blob sync session tests.3+ *4+ * Covers the v2.14.x rewrite that replaced per-entry SYNC_HELLO/MANIFEST/5+ * REQUEST/ENTRIES/DONE with a single bidirectional blob exchange. Last-6+ * write-wins is enforced via `sent_at` versus `peach_last_sync_at`.7+ */8+9+import { beforeEach, describe, expect, it, vi } from "vitest";10+import { encodeSyncMessage, decodeSyncMessage } from "../shared/sync-wire";11+import type { SyncSessionTransport } from "./sync-coordinator";12+13+type StorageShape = Record<string, unknown>;14+15+interface MockTransport extends SyncSessionTransport {16+  sentFrames: string[];17+  pushIncoming: (frame: string) => void;18+  pushClose: () => void;19+  pushError: (msg: string) => void;20+}21+22+function makeMockTransport(): MockTransport {23+  const sentFrames: string[] = [];24+  const frameHandlers: Array<(frame: string) => void> = [];25+  const errorHandlers: Array<(msg: string) => void> = [];26+  const closeHandlers: Array<() => void> = [];27+28+  return {29+    sentFrames,30+    send: (frame: string) => {31+      sentFrames.push(frame);32+    },33+    onFrame: (handler) => {34+      frameHandlers.push(handler);35+      return () => {36+        const i = frameHandlers.indexOf(handler);37+        if (i >= 0) frameHandlers.splice(i, 1);38+      };39+    },40+    onError: (handler) => {41+      errorHandlers.push(handler);42+      return () => {43+        const i = errorHandlers.indexOf(handler);44+        if (i >= 0) errorHandlers.splice(i, 1);45+      };46+    },47+    onClose: (handler) => {48+      closeHandlers.push(handler);49+      return () => {50+        const i = closeHandlers.indexOf(handler);51+        if (i >= 0) closeHandlers.splice(i, 1);52+      };53+    },54+    close: () => {55+      // no-op for the mock — finish() in the coordinator will already56+      // have called us inside finish().57+    },58+    pushIncoming: (frame) => {59+      for (const h of frameHandlers) h(frame);60+    },61+    pushClose: () => {62+      for (const h of closeHandlers) h();63+    },64+    pushError: (msg) => {65+      for (const h of errorHandlers) h(msg);66+    },67+  };68+}69+70+describe("runBlobSyncSession + applyIncomingVault", () => {71+  let storage: StorageShape;72+73+  beforeEach(() => {74+    vi.resetModules();75+    storage = {};76+77+    const chromeMock = {78+      storage: {79+        local: {80+          get: vi.fn(async (key?: string | string[]) => {81+            if (!key) return { ...storage };82+            if (typeof key === "string") return { [key]: storage[key] };83+            return key.reduce<Record<string, unknown>>((acc, k) => {84+              acc[k] = storage[k];85+              return acc;86+            }, {});87+          }),88+          set: vi.fn(async (next: Record<string, unknown>) => {89+            Object.assign(storage, next);90+          }),91+          remove: vi.fn(async (keys: string | string[]) => {92+            for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];93+          }),94+        },95+      },96+      alarms: {97+        clear: vi.fn(),98+        create: vi.fn(),99+        onAlarm: { addListener: vi.fn() },100+      },101+      idle: {102+        setDetectionInterval: vi.fn(),103+        onStateChanged: { addListener: vi.fn() },104+      },105+    } as unknown as typeof chrome;106+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;107+  });108+109+  it("applyIncomingVault writes new blobs when sent_at beats lastSyncAt", async () => {110+    const { applyIncomingVault } = await import("./sync-coordinator");111+    storage.peach_last_sync_at = "100";112+    storage.peach_vault_a = "old-a";113+114+    const verdict = await applyIncomingVault("new-a", "new-b", 200);115+116+    expect(verdict).toBe("applied");117+    expect(storage.peach_vault_a).toBe("new-a");118+    expect(storage.peach_vault_b).toBe("new-b");119+    expect(storage.peach_last_sync_at).toBe("200");120+  });121+122+  it("applyIncomingVault ignores blobs whose sent_at is older", async () => {123+    const { applyIncomingVault } = await import("./sync-coordinator");124+    storage.peach_last_sync_at = "500";125+    storage.peach_vault_a = "current-a";126+127+    const verdict = await applyIncomingVault("stale-a", "stale-b", 100);128+129+    expect(verdict).toBe("ignored");130+    expect(storage.peach_vault_a).toBe("current-a");131+    expect(storage.peach_last_sync_at).toBe("500");132+  });133+134+  it("applyIncomingVault snapshots BEFORE writing with reason 'sync_received'", async () => {135+    // Pre-populate storage so takeVaultSnapshot has a slotA to snapshot.136+    const priorSlot = {137+      version: 1,138+      salt: "00".repeat(16),139+      nonce: "00".repeat(12),140+      ciphertext: "00".repeat(32),141+      tag: "00".repeat(16),142+    };143+    storage.peach_vault_a = priorSlot;144+    storage.peach_vault_b = priorSlot;145+    const priorJson = JSON.stringify(priorSlot);146+147+    const { applyIncomingVault } = await import("./sync-coordinator");148+    await applyIncomingVault("incoming-a", "incoming-b", Date.now() + 1000);149+150+    const history = storage.peach_vault_history as151+      | { snapshots: Array<{ reason: string; vault_a: string }> }152+      | undefined;153+    expect(history?.snapshots[0]?.reason).toBe("sync_received");154+    // History captures the old blob (JSON-stringified) before the sync write155+    // overwrote slotA with the incoming string.156+    expect(history?.snapshots[0]?.vault_a).toBe(priorJson);157+    // After apply, slotA holds the incoming bytes.158+    expect(storage.peach_vault_a).toBe("incoming-a");159+  });160+161+  it("initiator: pro_required when isPro returns false", async () => {162+    const { runBlobSyncSession } = await import("./sync-coordinator");163+    const transport = makeMockTransport();164+    const result = await runBlobSyncSession("initiator", transport, {165+      isPro: async () => false,166+      isUnlocked: () => true,167+      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),168+    });169+170+    expect(result.status).toBe("errored");171+    expect(result.error).toBe("pro_required");172+    expect(transport.sentFrames).toHaveLength(1);173+    const decoded = decodeSyncMessage(transport.sentFrames[0]!);174+    expect(decoded.type).toBe("SYNC_ERROR");175+    if (decoded.type === "SYNC_ERROR") {176+      expect(decoded.reason).toBe("pro_required");177+    }178+  });179+180+  it("initiator: vault_locked when isUnlocked returns false", async () => {181+    const { runBlobSyncSession } = await import("./sync-coordinator");182+    const transport = makeMockTransport();183+    const result = await runBlobSyncSession("initiator", transport, {184+      isPro: async () => true,185+      isUnlocked: () => false,186+      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),187+    });188+189+    expect(result.status).toBe("errored");190+    expect(result.error).toBe("vault_locked");191+  });192+193+  it("initiator sends SYNC_VAULT_SEND first and completes on peer ACK", async () => {194+    const { runBlobSyncSession } = await import("./sync-coordinator");195+    const transport = makeMockTransport();196+    const sessionPromise = runBlobSyncSession("initiator", transport, {197+      isPro: async () => true,198+      isUnlocked: () => true,199+      getVaultBlobs: async () => ({ vault_a: "blob-a", vault_b: "blob-b" }),200+    });201+202+    // Wait a microtask so the initiator's first send fires.203+    await Promise.resolve();204+    await Promise.resolve();205+206+    expect(transport.sentFrames).toHaveLength(1);207+    const sent = decodeSyncMessage(transport.sentFrames[0]!);208+    expect(sent.type).toBe("SYNC_VAULT_SEND");209+210+    transport.pushIncoming(211+      encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),212+    );213+214+    const result = await sessionPromise;215+    expect(result.status).toBe("completed");216+  });217+218+  it("responder applies incoming vault if newer and sends ACK + its own SYNC_VAULT_SEND", async () => {219+    const { runBlobSyncSession } = await import("./sync-coordinator");220+    const transport = makeMockTransport();221+222+    const sessionPromise = runBlobSyncSession("responder", transport, {223+      isPro: async () => true,224+      isUnlocked: () => true,225+      getVaultBlobs: async () => ({ vault_a: "responder-a", vault_b: "responder-b" }),226+    });227+228+    await Promise.resolve();229+    transport.pushIncoming(230+      encodeSyncMessage({231+        type: "SYNC_VAULT_SEND",232+        vault_a: "incoming-a",233+        vault_b: "incoming-b",234+        sent_at: Date.now() + 1000,235+      }),236+    );237+238+    // Settle the async work inside the frame handler.239+    await new Promise((r) => setTimeout(r, 5));240+241+    // Responder should have sent ACK + its own SEND.242+    expect(transport.sentFrames).toHaveLength(2);243+    const ack = decodeSyncMessage(transport.sentFrames[0]!);244+    const send = decodeSyncMessage(transport.sentFrames[1]!);245+    expect(ack.type).toBe("SYNC_VAULT_ACK");246+    expect(send.type).toBe("SYNC_VAULT_SEND");247+248+    // Now the initiator-side ACK arrives — session completes.249+    transport.pushIncoming(250+      encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),251+    );252+253+    const result = await sessionPromise;254+    expect(result.status).toBe("completed");255+    expect(storage.peach_vault_a).toBe("incoming-a");256+  });257+258+  it("SYNC_ERROR from peer propagates as session error", async () => {259+    const { runBlobSyncSession } = await import("./sync-coordinator");260+    const transport = makeMockTransport();261+    const sessionPromise = runBlobSyncSession("initiator", transport, {262+      isPro: async () => true,263+      isUnlocked: () => true,264+      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),265+    });266+267+    await Promise.resolve();268+    transport.pushIncoming(269+      encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),270+    );271+272+    const result = await sessionPromise;273+    expect(result.status).toBe("errored");274+    expect(result.error).toBe("vault_locked");275+  });276+277+  it("invalid frame triggers SYNC_ERROR + invalid_frame", async () => {278+    const { runBlobSyncSession } = await import("./sync-coordinator");279+    const transport = makeMockTransport();280+    const sessionPromise = runBlobSyncSession("responder", transport, {281+      isPro: async () => true,282+      isUnlocked: () => true,283+      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),284+    });285+286+    await Promise.resolve();287+    transport.pushIncoming("@@@invalid-base64@@@");288+289+    const result = await sessionPromise;290+    expect(result.status).toBe("errored");291+    expect(result.error).toBe("invalid_frame");292+  });293+});

view file

src/background/vault-history.test.ts+221 −0
@@ -0,0 +1,221 @@1+/**2+ * Vault history tests.3+ *4+ * These cover the new `peach_vault_history` system that snapshots the5+ * about-to-be-overwritten encrypted blob BEFORE every real-mode vault write.6+ * The old `peach_vault_snapshots` system was retired in v2.14.0 — see7+ * Projects.md for the rationale.8+ */9+10+import { beforeEach, describe, expect, it, vi } from "vitest";11+import type { VaultHistory } from "../shared/types";12+13+type StorageShape = Record<string, unknown>;14+15+describe("vault history", () => {16+  let storage: StorageShape;17+18+  beforeEach(() => {19+    vi.resetModules();20+    storage = {};21+22+    const chromeMock = {23+      storage: {24+        local: {25+          get: vi.fn(async (key?: string | string[]) => {26+            if (!key) return { ...storage };27+            if (typeof key === "string") return { [key]: storage[key] };28+            return key.reduce<Record<string, unknown>>((acc, k) => {29+              acc[k] = storage[k];30+              return acc;31+            }, {});32+          }),33+          set: vi.fn(async (next: Record<string, unknown>) => {34+            Object.assign(storage, next);35+          }),36+          remove: vi.fn(async (keys: string | string[]) => {37+            for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];38+          }),39+        },40+      },41+      alarms: {42+        clear: vi.fn(),43+        create: vi.fn(),44+        onAlarm: { addListener: vi.fn() },45+      },46+      idle: {47+        setDetectionInterval: vi.fn(),48+        onStateChanged: { addListener: vi.fn() },49+      },50+    } as unknown as typeof chrome;51+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;52+53+    vi.stubGlobal("fetch", vi.fn(async () => {54+      throw new Error("network disabled in tests");55+    }));56+  });57+58+  function readHistory(): VaultHistory {59+    const raw = storage.peach_vault_history as VaultHistory | undefined;60+    return raw ?? { snapshots: [] };61+  }62+63+  it("first vault write produces no snapshot — nothing to lose yet", async () => {64+    const vault = await import("./vault");65+    await vault.createVault("history-pass-12345");66+    expect(readHistory().snapshots).toHaveLength(0);67+  });68+69+  it("second write snapshots the first state with reason 'local_save'", async () => {70+    const vault = await import("./vault");71+    await vault.createVault("history-pass-12345");72+    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });73+74+    const history = readHistory();75+    expect(history.snapshots).toHaveLength(1);76+    expect(history.snapshots[0]!.reason).toBe("local_save");77+    expect(history.snapshots[0]!.id).toBeTruthy();78+    expect(history.snapshots[0]!.savedAt).toBeGreaterThan(0);79+  });80+81+  it("snapshots are newest-first", async () => {82+    const vault = await import("./vault");83+    await vault.createVault("history-pass-67890");84+    await vault.addEntry({ title: "First", username: "", password: "p1", url: "" });85+    await vault.addEntry({ title: "Second", username: "", password: "p2", url: "" });86+87+    const snaps = readHistory().snapshots;88+    expect(snaps).toHaveLength(2);89+    expect(snaps[0]!.savedAt).toBeGreaterThanOrEqual(snaps[1]!.savedAt);90+  });91+92+  it("history is capped at 10 — oldest dropped", async () => {93+    const vault = await import("./vault");94+    await vault.createVault("cap-pass-abcde");95+    for (let i = 0; i < 15; i += 1) {96+      await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" });97+    }98+    expect(readHistory().snapshots).toHaveLength(10);99+  });100+101+  it("entryCount reflects current in-memory active count at snapshot time", async () => {102+    const vault = await import("./vault");103+    await vault.createVault("count-pass-12345");104+    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });105+    // Most recent snapshot was taken before the addEntry write fired its106+    // persist, capturing the pre-add state of slotA but counting the107+    // post-add in-memory entries — see spec Part 1.1.108+    await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });109+110+    const snaps = readHistory().snapshots;111+    expect(snaps).toHaveLength(2);112+    // Newest snapshot was taken at the persistSlotA call inside the second113+    // addEntry, when in-memory has 2 active entries.114+    expect(snaps[0]!.entryCount).toBe(2);115+    expect(snaps[1]!.entryCount).toBe(1);116+  });117+118+  it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => {119+    const vault = await import("./vault");120+    await vault.createVault("dedup-pass-12345");121+    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });122+123+    // The first explicit takeVaultSnapshot captures the post-addEntry blob124+    // (which differs from the snapshot already in history, which captured125+    // the pre-addEntry blob). The SECOND call sees an identical blob to the126+    // one we just captured and should dedup.127+    await vault.takeVaultSnapshot("local_save");128+    const afterFirst = readHistory().snapshots.length;129+    await vault.takeVaultSnapshot("local_save");130+    expect(readHistory().snapshots.length).toBe(afterFirst);131+  });132+133+  it("getVaultHistory returns the stored snapshots in array form", async () => {134+    const vault = await import("./vault");135+    await vault.createVault("get-pass-12345");136+    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });137+    const list = await vault.getVaultHistory();138+    expect(Array.isArray(list)).toBe(true);139+    expect(list).toHaveLength(1);140+  });141+142+  it("restoreVaultSnapshot rolls slot A back to a saved blob and locks the vault", async () => {143+    const vault = await import("./vault");144+    await vault.createVault("restore-pass-12345");145+    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });146+    const slotABeforeSecond = JSON.stringify(storage.peach_vault_a);147+    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });148+149+    const list = await vault.getVaultHistory();150+    // Most recent snapshot captures the slotA right before the "second" write.151+    const target = list[0]!;152+    expect(target.vault_a).toBe(slotABeforeSecond);153+154+    expect(vault.isUnlocked()).toBe(true);155+    const result = await vault.restoreVaultSnapshot(target.id);156+    expect(result).toEqual({ ok: true });157+    expect(vault.isUnlocked()).toBe(false);158+    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABeforeSecond);159+  });160+161+  it("restore is itself snapshotted with reason 'restore' so it is reversible", async () => {162+    const vault = await import("./vault");163+    await vault.createVault("undo-pass-12345");164+    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });165+    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });166+167+    const list = await vault.getVaultHistory();168+    const target = list[0]!;169+170+    const slotABeforeRestore = JSON.stringify(storage.peach_vault_a);171+    await vault.restoreVaultSnapshot(target.id);172+173+    const after = await vault.getVaultHistory();174+    const restoreEntry = after.find((s) => s.reason === "restore");175+    expect(restoreEntry).toBeTruthy();176+    expect(restoreEntry!.vault_a).toBe(slotABeforeRestore);177+  });178+179+  it("restoreVaultSnapshot returns snapshot_not_found for unknown id", async () => {180+    const vault = await import("./vault");181+    await vault.createVault("unknown-pass-12345");182+    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });183+    const result = await vault.restoreVaultSnapshot("does-not-exist");184+    expect(result).toEqual({ ok: false, error: "snapshot_not_found" });185+  });186+187+  it("clearVaultHistory wipes the array", async () => {188+    const vault = await import("./vault");189+    await vault.createVault("clear-pass-12345");190+    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });191+    expect(readHistory().snapshots).toHaveLength(1);192+193+    await vault.clearVaultHistory();194+    expect(readHistory().snapshots).toHaveLength(0);195+  });196+197+  it("import flow uses reason 'import' on its snapshot", async () => {198+    const vault = await import("./vault");199+    await vault.createVault("import-pass-12345");200+    await vault.addEntry({ title: "existing", username: "", password: "p1", url: "" });201+202+    await vault.restoreEntries(203+      [204+        {205+          id: "imported-1",206+          type: "login" as const,207+          title: "imported",208+          username: "u",209+          password: "ip",210+          url: "https://imported.test",211+          createdAt: 100,212+          modifiedAt: 100,213+        },214+      ],215+      "merge",216+    );217+218+    const list = await vault.getVaultHistory();219+    expect(list[0]!.reason).toBe("import");220+  });221+});

view file