1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, SyncMessage } from "../shared/sync-types";3import type {4 SyncSession,5} from "../shared/sync-transport";6import type { SyncVaultAdapter } from "./sync-coordinator";7import type { SyncRunnerDeps } from "./sync-runner";89type StorageShape = Record<string, unknown>;1011function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {12 const area = (store: StorageShape) => ({13 get: vi.fn(async (key?: string | string[]) => {14 if (!key) return store;15 if (typeof key === "string") return { [key]: store[key] };16 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});17 }),18 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),19 remove: vi.fn(async (keys: string | string[]) => {20 const list = Array.isArray(keys) ? keys : [keys];21 for (const k of list) delete store[k];22 }),23 });24 return {25 storage: { local: area(local), sync: area(sync) },26 alarms: {27 clear: vi.fn(),28 create: vi.fn(),29 onAlarm: { addListener: vi.fn() },30 },31 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },32 runtime: { sendMessage: vi.fn(async () => undefined) },33 } as unknown as typeof chrome;34}3536const REMOTE_PUBKEY_HEX = "ab".repeat(32);3738function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {39 return {40 deviceId: "pixel-xyz",41 deviceName: "Pixel",42 deviceType: "android",43 longTermPublicKeyHex: REMOTE_PUBKEY_HEX,44 rendezvousSecretHex: "11".repeat(32),45 wsPort: 49142,46 pairedAt: 1700000000000,47 ...overrides,48 };49}5051interface FakePeer {52 send(msg: SyncMessage): Promise<void>;53 receive(): Promise<SyncMessage>;54}5556575859606162function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {63 const inboundToSession: Uint8Array[] = [];64 const outboundFromSession: Uint8Array[] = [];65 const sessionReaders: Array<(v: Uint8Array) => void> = [];66 const peerReaders: Array<(v: Uint8Array) => void> = [];67 const closed = { v: false };6869 const session: SyncSession = {70 send(bytes) {71 const r = peerReaders.shift();72 if (r) r(bytes); else outboundFromSession.push(bytes);73 },74 receive(): Promise<Uint8Array> {75 const v = inboundToSession.shift();76 if (v) return Promise.resolve(v);77 return new Promise<Uint8Array>((res) => sessionReaders.push(res));78 },79 async close() { closed.v = true; },80 remoteStaticPublicKey: new Uint8Array(32),81 };82 const peer: FakePeer = {83 async send(msg) {84 const bytes = new TextEncoder().encode(JSON.stringify(msg));85 const r = sessionReaders.shift();86 if (r) r(bytes); else inboundToSession.push(bytes);87 },88 async receive() {89 const v = outboundFromSession.shift();90 const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));91 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;92 },93 };94 return { session, peer, closed };95}96979899100101102103104async function runPeer(105 peer: FakePeer,106 opts: {107 vaultId?: string;108 peerEntriesB64?: Record<string, string>;109 wantFromUsIds?: string[];110 } = {},111): Promise<void> {112 const hello = await peer.receive();113 if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");114 await peer.send({115 type: "SYNC_HELLO",116 vaultId: opts.vaultId ?? hello.vaultId,117 deviceId: "peer-device",118 version: 0,119 deviceTime: Date.now(),120 });121 const manifest = await peer.receive();122 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");123124 125 const peerEntryManifest: Record<string, number> = {};126 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {127 peerEntryManifest[id] = Date.now();128 }129 await peer.send({130 type: "SYNC_MANIFEST",131 entries: peerEntryManifest,132 tombstones: {},133 });134135 const ourRequest = await peer.receive();136 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");137138 139 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });140141 const ourEntries = await peer.receive();142 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");143144 145 146 if (Object.keys(peerEntryManifest).length > 0) {147 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });148 }149150 const ourTombstones = await peer.receive();151 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");152 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });153154 const done = await peer.receive();155 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");156 await peer.send({ type: "SYNC_DONE" });157}158159function emptyAdapter(): SyncVaultAdapter {160 return {161 async computeVaultId() { return "vault-abc"; },162 async getOurDeviceId() { return "device-init"; },163 164 165 async getLocalVersion() { return 5; },166 async buildEntryManifest() { return {}; },167 async buildTombstoneManifest() { return {}; },168 async getEncryptedEntries() { return {}; },169 async applyEntries() { },170 async applyTombstones() { },171 };172}173174const relaySettings = async () => ({175 mode: "public" as const,176 relayUrl: "wss://relay.peachpasswords.com",177});178179describe("productionSyncRunner", () => {180 let local: StorageShape;181 let sync: StorageShape;182183 beforeEach(() => {184 vi.resetModules();185 local = {};186 sync = {};187 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);188 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));189 });190191 async function bootVault(): Promise<typeof import("./vault")> {192 const vault = await import("./vault");193 await vault.createVault("correct horse battery staple");194 return vault;195 }196197 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {198 const vault = await bootVault();199 vault.lock();200 const { runWithDeps } = await import("./sync-runner");201 const deps: SyncRunnerDeps = {202 openRelaySession: vi.fn(),203 getSyncSettings: relaySettings,204 createAdapter: emptyAdapter,205 };206 const result = await runWithDeps(makeDevice(), deps);207 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });208 expect(deps.openRelaySession).not.toHaveBeenCalled();209 });210211 it("throws when the device has no longTermPublicKeyHex", async () => {212 await bootVault();213 const { runWithDeps } = await import("./sync-runner");214 const deps: SyncRunnerDeps = {215 openRelaySession: vi.fn(),216 getSyncSettings: relaySettings,217 createAdapter: emptyAdapter,218 };219 await expect(220 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),221 ).rejects.toThrow("paired_device_missing_pubkey");222 });223224 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {225 await bootVault();226 const { runWithDeps } = await import("./sync-runner");227 const deps: SyncRunnerDeps = {228 openRelaySession: vi.fn(),229 getSyncSettings: relaySettings,230 createAdapter: emptyAdapter,231 };232 await expect(233 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),234 ).rejects.toThrow("paired_device_missing_rendezvous_secret");235 });236237 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {238 await bootVault();239 const { session, peer } = inMemoryPair();240 const openRelaySession = vi.fn(async () => session);241 const deps: SyncRunnerDeps = {242 openRelaySession,243 getSyncSettings: relaySettings,244 createAdapter: emptyAdapter,245 };246 const { runWithDeps } = await import("./sync-runner");247 const runPromise = runWithDeps(makeDevice(), deps);248 await runPeer(peer, {249 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },250 });251 const result = await runPromise;252 expect(result.ok).toBe(true);253 if (result.ok) {254 expect(result.entriesReceived).toBe(3);255 expect(result.entriesSent).toBe(0);256 }257 });258259 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {260 await bootVault();261 const { session, peer } = inMemoryPair();262 const adapter: SyncVaultAdapter = {263 ...emptyAdapter(),264 async getEncryptedEntries(ids) {265 const out: Record<string, string> = {};266 for (const id of ids) out[id] = "ENVELOPE_" + id;267 return out;268 },269 };270 const deps: SyncRunnerDeps = {271 openRelaySession: vi.fn(async () => session),272 getSyncSettings: relaySettings,273 createAdapter: () => adapter,274 };275 const { runWithDeps } = await import("./sync-runner");276 const runPromise = runWithDeps(makeDevice(), deps);277 await runPeer(peer, { wantFromUsIds: ["a", "b"] });278 const result = await runPromise;279 expect(result.ok).toBe(true);280 if (result.ok) expect(result.entriesSent).toBe(2);281 });282283 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {284 await bootVault();285 const { session, peer } = inMemoryPair();286 const deps: SyncRunnerDeps = {287 openRelaySession: vi.fn(async () => session),288 getSyncSettings: relaySettings,289 createAdapter: emptyAdapter,290 };291 const { runWithDeps } = await import("./sync-runner");292 const runPromise = runWithDeps(makeDevice(), deps);293 294 295 (async () => {296 const hello = await peer.receive();297 if (hello.type === "SYNC_HELLO") {298 await peer.send({299 type: "SYNC_HELLO",300 vaultId: "wrong-vault-id",301 deviceId: "peer-device",302 version: 0,303 deviceTime: Date.now(),304 });305 }306 })().catch(() => undefined);307 await expect(runPromise).rejects.toThrow("vault_id_mismatch");308 });309});310311describe("sync-trigger persistence", () => {312 let local: StorageShape;313 let sync: StorageShape;314315 beforeEach(() => {316 vi.resetModules();317 local = {};318 sync = {};319 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);320 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));321 });322323 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {324 const trigger = await import("./sync-trigger");325 const paired = await import("./paired-devices");326 trigger._resetSyncTriggerForTest();327 await paired.upsertPairedDevice({328 deviceId: "P",329 deviceName: "Pixel",330 deviceType: "android",331 longTermPublicKeyHex: "a".repeat(64),332 trustSource: "direct",333 pairedAt: 1700000000000,334 });335 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));336 await trigger.triggerSyncIfReachable({ reason: "manual" });337 expect(typeof local.peach_last_sync_at).toBe("number");338 expect(local.peach_last_sync_at).toBeGreaterThan(0);339 });340341 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {342 local.peach_last_sync_at = 1_700_005_000_000;343 const trigger = await import("./sync-trigger");344 trigger._resetSyncTriggerForTest();345 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();346 await trigger.hydratePersistedSyncState();347 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);348 });349});