import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PairedDevice, SyncMessage } from "../shared/sync-types"; import type { SyncSession, } from "../shared/sync-transport"; import type { SyncVaultAdapter } from "./sync-coordinator"; import type { SyncRunnerDeps } from "./sync-runner"; type StorageShape = Record; function makeChromeMock( local: StorageShape, sync: StorageShape, session: StorageShape = {}, ): typeof chrome { const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((acc, k) => { acc[k] = store[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete store[k]; }), }); return { storage: { local: area(local), sync: area(sync), session: area(session) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { sendMessage: vi.fn(async () => undefined) }, } as unknown as typeof chrome; } const REMOTE_PUBKEY_HEX = "ab".repeat(32); function makeDevice(overrides: Partial = {}): PairedDevice { return { deviceId: "pixel-xyz", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: REMOTE_PUBKEY_HEX, rendezvousSecretHex: "11".repeat(32), wsPort: 49142, pairedAt: 1700000000000, ...overrides, }; } interface FakePeer { send(msg: SyncMessage): Promise; receive(): Promise; } /** * In-memory SyncSession ↔ FakePeer pair. The session is what the * runner passes to runSyncInitiator; the peer is the other end of the * wire, controlled by the test body. Mirrors the pattern in * sync-coordinator.test.ts. */ function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } { const inboundToSession: Uint8Array[] = []; const outboundFromSession: Uint8Array[] = []; const sessionReaders: Array<(v: Uint8Array) => void> = []; const peerReaders: Array<(v: Uint8Array) => void> = []; const closed = { v: false }; const session: SyncSession = { send(bytes) { const r = peerReaders.shift(); if (r) r(bytes); else outboundFromSession.push(bytes); }, receive(): Promise { const v = inboundToSession.shift(); if (v) return Promise.resolve(v); return new Promise((res) => sessionReaders.push(res)); }, async close() { closed.v = true; }, remoteStaticPublicKey: new Uint8Array(32), }; const peer: FakePeer = { async send(msg) { const bytes = new TextEncoder().encode(JSON.stringify(msg)); const r = sessionReaders.shift(); if (r) r(bytes); else inboundToSession.push(bytes); }, async receive() { const v = outboundFromSession.shift(); const bytes = v ?? await new Promise((res) => peerReaders.push(res)); return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage; }, }; return { session, peer, closed }; } /** * Drive a happy-path SYNC_DONE handshake on the peer side, matching the * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any * size for the entriesReceived assertion; `wantFromUs` requests N entries * from our side (drives entriesSent). */ async function runPeer( peer: FakePeer, opts: { vaultId?: string; peerEntriesB64?: Record; wantFromUsIds?: string[]; } = {}, ): Promise { const hello = await peer.receive(); if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator"); await peer.send({ type: "SYNC_HELLO", vaultId: opts.vaultId ?? hello.vaultId, deviceId: "peer-device", version: 0, deviceTime: Date.now(), protocolVersion: 2, }); const manifest = await peer.receive(); if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST"); // Peer manifest declares the entries it will ship in SYNC_ENTRIES. const peerEntryManifest: Record = {}; for (const id of Object.keys(opts.peerEntriesB64 ?? {})) { peerEntryManifest[id] = Date.now(); } await peer.send({ type: "SYNC_MANIFEST", entries: peerEntryManifest, tombstones: {}, }); const ourRequest = await peer.receive(); if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator"); // Peer asks for the ids it wants from us. await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] }); const ourEntries = await peer.receive(); if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator"); // Only ship SYNC_ENTRIES when the initiator actually requested any. The // coordinator only awaits this frame when wantFromPeer.length > 0. if (Object.keys(peerEntryManifest).length > 0) { await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} }); } const ourTombstones = await peer.receive(); if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES"); await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} }); const done = await peer.receive(); if (done.type !== "SYNC_DONE") throw new Error("expected DONE"); await peer.send({ type: "SYNC_DONE" }); } function emptyAdapter(): SyncVaultAdapter { return { async computeVaultId() { return "vault-abc"; }, async getOurDeviceId() { return "device-init"; }, // Non-zero so the equal-version short circuit doesn't fire in runner tests // that exercise the full manifest-diff flow. async getLocalVersion() { return 5; }, async buildEntryManifest() { return {}; }, async buildTombstoneManifest() { return {}; }, async getEncryptedEntries() { return {}; }, async applyEntries() { /* no-op */ }, async applyTombstones() { /* no-op */ }, }; } const relaySettings = async () => ({ mode: "public" as const, relayUrl: "wss://relay.peachpasswords.com", }); describe("productionSyncRunner", () => { let local: StorageShape; let sync: StorageShape; let sessionStore: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; sync = {}; sessionStore = {}; (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); async function bootVault(): Promise { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); return vault; } // PB-26-213: device rows carry no reusable secrets; the runner resolves // them from the encrypted pair-secret record / session copy. async function seedPairSecret(secret = "11".repeat(32)): Promise { const vault = await import("./vault"); const rootHex = vault.getRootKeyHex(); if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets"); const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16))); try { const { setPairSecret } = await import("./pair-secrets"); await setPairSecret("pixel-xyz", secret, root); } finally { root.fill(0); } } // PB-26-104: locked sync has to keep working with the spool moved to // chrome.storage.session. The previous fix removed the private key from the // spool outright, which silently turned every locked round into VAULT_LOCKED. it("completes a sync round while locked, using the session-scoped spool", async () => { const vault = await bootVault(); await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" }); const { refreshLockedSyncSpool } = await import("./sync-vault-adapter"); await refreshLockedSyncSpool(); await seedPairSecret(); vault.lock(); expect(vault.isUnlocked()).toBe(false); const { session, peer } = inMemoryPair(); const deps: SyncRunnerDeps = { openRelaySession: vi.fn(async () => session), getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; const { runWithDeps } = await import("./sync-runner"); const runPromise = runWithDeps(makeDevice(), deps); await runPeer(peer, {}); const result = await runPromise; expect(result.ok).toBe(true); // Nothing about the spool reached disk-backed storage. expect(JSON.stringify(local)).not.toContain("longTermPrivateKey"); expect(local.peach_locked_sync_spool_v1).toBeUndefined(); }); it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => { const vault = await bootVault(); vault.lock(); const { runWithDeps } = await import("./sync-runner"); const deps: SyncRunnerDeps = { openRelaySession: vi.fn(), getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; const result = await runWithDeps(makeDevice(), deps); expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" }); expect(deps.openRelaySession).not.toHaveBeenCalled(); }); it("throws when the device has no longTermPublicKeyHex", async () => { await bootVault(); const { runWithDeps } = await import("./sync-runner"); const deps: SyncRunnerDeps = { openRelaySession: vi.fn(), getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; await expect( runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps), ).rejects.toThrow("paired_device_missing_pubkey"); }); it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => { await bootVault(); const { runWithDeps } = await import("./sync-runner"); const deps: SyncRunnerDeps = { openRelaySession: vi.fn(), getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; await expect( runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps), ).rejects.toThrow("paired_device_missing_rendezvous_secret"); }); it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => { await bootVault(); await seedPairSecret(); const { session, peer } = inMemoryPair(); const openRelaySession = vi.fn(async () => session); const deps: SyncRunnerDeps = { openRelaySession, getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; const { runWithDeps } = await import("./sync-runner"); const runPromise = runWithDeps(makeDevice(), deps); await runPeer(peer, { peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" }, }); const result = await runPromise; expect(result.ok).toBe(true); if (result.ok) { expect(result.entriesReceived).toBe(3); expect(result.entriesSent).toBe(0); } }); it("surfaces entriesSent from the adapter's encrypted-entry response", async () => { await bootVault(); await seedPairSecret(); const { session, peer } = inMemoryPair(); const adapter: SyncVaultAdapter = { ...emptyAdapter(), async getEncryptedEntries(ids) { const out: Record = {}; for (const id of ids) out[id] = "ENVELOPE_" + id; return out; }, }; const deps: SyncRunnerDeps = { openRelaySession: vi.fn(async () => session), getSyncSettings: relaySettings, createAdapter: () => adapter, }; const { runWithDeps } = await import("./sync-runner"); const runPromise = runWithDeps(makeDevice(), deps); await runPeer(peer, { wantFromUsIds: ["a", "b"] }); const result = await runPromise; expect(result.ok).toBe(true); if (result.ok) expect(result.entriesSent).toBe(2); }); it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => { await bootVault(); await seedPairSecret(); const { session, peer } = inMemoryPair(); const deps: SyncRunnerDeps = { openRelaySession: vi.fn(async () => session), getSyncSettings: relaySettings, createAdapter: emptyAdapter, }; const { runWithDeps } = await import("./sync-runner"); const runPromise = runWithDeps(makeDevice(), deps); // Peer responds with a vault_id that does NOT match ours, forcing the // coordinator to send SYNC_ERROR and return status:"vault_mismatch". (async () => { const hello = await peer.receive(); if (hello.type === "SYNC_HELLO") { await peer.send({ type: "SYNC_HELLO", vaultId: "wrong-vault-id", deviceId: "peer-device", version: 0, deviceTime: Date.now(), protocolVersion: 2, }); } })().catch(() => undefined); await expect(runPromise).rejects.toThrow("vault_id_mismatch"); }); }); describe("sync-trigger persistence", () => { let local: StorageShape; let sync: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; sync = {}; (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => { const trigger = await import("./sync-trigger"); const paired = await import("./paired-devices"); trigger._resetSyncTriggerForTest(); await paired.upsertPairedDevice({ deviceId: "P", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), trustSource: "direct", pairedAt: 1700000000000, }); trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 })); await trigger.triggerSyncIfReachable({ reason: "manual" }); expect(typeof local.peach_last_sync_at).toBe("number"); expect(local.peach_last_sync_at).toBeGreaterThan(0); }); it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => { local.peach_last_sync_at = 1_700_005_000_000; const trigger = await import("./sync-trigger"); trigger._resetSyncTriggerForTest(); expect(trigger.getSyncState().lastSyncAt).toBeUndefined(); await trigger.hydratePersistedSyncState(); expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000); }); });