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
↓ peach-browser-2b121cc9.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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": {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+});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+});