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(12 local: StorageShape,13 sync: StorageShape,14 session: StorageShape = {},15): typeof chrome {16 const area = (store: StorageShape) => ({17 get: vi.fn(async (key?: string | string[]) => {18 if (!key) return store;19 if (typeof key === "string") return { [key]: store[key] };20 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});21 }),22 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),23 remove: vi.fn(async (keys: string | string[]) => {24 const list = Array.isArray(keys) ? keys : [keys];25 for (const k of list) delete store[k];26 }),27 });28 return {29 storage: { local: area(local), sync: area(sync), session: area(session) },30 alarms: {31 clear: vi.fn(),32 create: vi.fn(),33 onAlarm: { addListener: vi.fn() },34 },35 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },36 runtime: { sendMessage: vi.fn(async () => undefined) },37 } as unknown as typeof chrome;38}3940const REMOTE_PUBKEY_HEX = "ab".repeat(32);4142function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {43 return {44 deviceId: "pixel-xyz",45 deviceName: "Pixel",46 deviceType: "android",47 longTermPublicKeyHex: REMOTE_PUBKEY_HEX,48 rendezvousSecretHex: "11".repeat(32),49 wsPort: 49142,50 pairedAt: 1700000000000,51 ...overrides,52 };53}5455interface FakePeer {56 send(msg: SyncMessage): Promise<void>;57 receive(): Promise<SyncMessage>;58}5960616263646566function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {67 const inboundToSession: Uint8Array[] = [];68 const outboundFromSession: Uint8Array[] = [];69 const sessionReaders: Array<(v: Uint8Array) => void> = [];70 const peerReaders: Array<(v: Uint8Array) => void> = [];71 const closed = { v: false };7273 const session: SyncSession = {74 send(bytes) {75 const r = peerReaders.shift();76 if (r) r(bytes); else outboundFromSession.push(bytes);77 },78 receive(): Promise<Uint8Array> {79 const v = inboundToSession.shift();80 if (v) return Promise.resolve(v);81 return new Promise<Uint8Array>((res) => sessionReaders.push(res));82 },83 async close() { closed.v = true; },84 remoteStaticPublicKey: new Uint8Array(32),85 };86 const peer: FakePeer = {87 async send(msg) {88 const bytes = new TextEncoder().encode(JSON.stringify(msg));89 const r = sessionReaders.shift();90 if (r) r(bytes); else inboundToSession.push(bytes);91 },92 async receive() {93 const v = outboundFromSession.shift();94 const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));95 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;96 },97 };98 return { session, peer, closed };99}100101102103104105106107108async function runPeer(109 peer: FakePeer,110 opts: {111 vaultId?: string;112 peerEntriesB64?: Record<string, string>;113 wantFromUsIds?: string[];114 } = {},115): Promise<void> {116 const hello = await peer.receive();117 if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");118 await peer.send({119 type: "SYNC_HELLO",120 vaultId: opts.vaultId ?? hello.vaultId,121 deviceId: "peer-device",122 version: 0,123 deviceTime: Date.now(),124 });125 const manifest = await peer.receive();126 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");127128 129 const peerEntryManifest: Record<string, number> = {};130 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {131 peerEntryManifest[id] = Date.now();132 }133 await peer.send({134 type: "SYNC_MANIFEST",135 entries: peerEntryManifest,136 tombstones: {},137 });138139 const ourRequest = await peer.receive();140 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");141142 143 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });144145 const ourEntries = await peer.receive();146 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");147148 149 150 if (Object.keys(peerEntryManifest).length > 0) {151 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });152 }153154 const ourTombstones = await peer.receive();155 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");156 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });157158 const done = await peer.receive();159 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");160 await peer.send({ type: "SYNC_DONE" });161}162163function emptyAdapter(): SyncVaultAdapter {164 return {165 async computeVaultId() { return "vault-abc"; },166 async getOurDeviceId() { return "device-init"; },167 168 169 async getLocalVersion() { return 5; },170 async buildEntryManifest() { return {}; },171 async buildTombstoneManifest() { return {}; },172 async getEncryptedEntries() { return {}; },173 async applyEntries() { },174 async applyTombstones() { },175 };176}177178const relaySettings = async () => ({179 mode: "public" as const,180 relayUrl: "wss://relay.peachpasswords.com",181});182183describe("productionSyncRunner", () => {184 let local: StorageShape;185 let sync: StorageShape;186 let sessionStore: StorageShape;187188 beforeEach(() => {189 vi.resetModules();190 local = {};191 sync = {};192 sessionStore = {};193 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore);194 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));195 });196197 async function bootVault(): Promise<typeof import("./vault")> {198 const vault = await import("./vault");199 await vault.createVault("correct horse battery staple");200 return vault;201 }202203 204 205 async function seedPairSecret(secret = "11".repeat(32)): Promise<void> {206 const vault = await import("./vault");207 const rootHex = vault.getRootKeyHex();208 if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets");209 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));210 try {211 const { setPairSecret } = await import("./pair-secrets");212 await setPairSecret("pixel-xyz", secret, root);213 } finally {214 root.fill(0);215 }216 }217218 219 220 221 it("completes a sync round while locked, using the session-scoped spool", async () => {222 const vault = await bootVault();223 await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });224 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");225 await refreshLockedSyncSpool();226 await seedPairSecret();227 vault.lock();228 expect(vault.isUnlocked()).toBe(false);229230 const { session, peer } = inMemoryPair();231 const deps: SyncRunnerDeps = {232 openRelaySession: vi.fn(async () => session),233 getSyncSettings: relaySettings,234 createAdapter: emptyAdapter,235 };236 const { runWithDeps } = await import("./sync-runner");237 const runPromise = runWithDeps(makeDevice(), deps);238 await runPeer(peer, {});239 const result = await runPromise;240241 expect(result.ok).toBe(true);242 243 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");244 expect(local.peach_locked_sync_spool_v1).toBeUndefined();245 });246247 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {248 const vault = await bootVault();249 vault.lock();250 const { runWithDeps } = await import("./sync-runner");251 const deps: SyncRunnerDeps = {252 openRelaySession: vi.fn(),253 getSyncSettings: relaySettings,254 createAdapter: emptyAdapter,255 };256 const result = await runWithDeps(makeDevice(), deps);257 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });258 expect(deps.openRelaySession).not.toHaveBeenCalled();259 });260261 it("throws when the device has no longTermPublicKeyHex", async () => {262 await bootVault();263 const { runWithDeps } = await import("./sync-runner");264 const deps: SyncRunnerDeps = {265 openRelaySession: vi.fn(),266 getSyncSettings: relaySettings,267 createAdapter: emptyAdapter,268 };269 await expect(270 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),271 ).rejects.toThrow("paired_device_missing_pubkey");272 });273274 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {275 await bootVault();276 const { runWithDeps } = await import("./sync-runner");277 const deps: SyncRunnerDeps = {278 openRelaySession: vi.fn(),279 getSyncSettings: relaySettings,280 createAdapter: emptyAdapter,281 };282 await expect(283 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),284 ).rejects.toThrow("paired_device_missing_rendezvous_secret");285 });286287 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {288 await bootVault();289 await seedPairSecret();290 const { session, peer } = inMemoryPair();291 const openRelaySession = vi.fn(async () => session);292 const deps: SyncRunnerDeps = {293 openRelaySession,294 getSyncSettings: relaySettings,295 createAdapter: emptyAdapter,296 };297 const { runWithDeps } = await import("./sync-runner");298 const runPromise = runWithDeps(makeDevice(), deps);299 await runPeer(peer, {300 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },301 });302 const result = await runPromise;303 expect(result.ok).toBe(true);304 if (result.ok) {305 expect(result.entriesReceived).toBe(3);306 expect(result.entriesSent).toBe(0);307 }308 });309310 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {311 await bootVault();312 await seedPairSecret();313 const { session, peer } = inMemoryPair();314 const adapter: SyncVaultAdapter = {315 ...emptyAdapter(),316 async getEncryptedEntries(ids) {317 const out: Record<string, string> = {};318 for (const id of ids) out[id] = "ENVELOPE_" + id;319 return out;320 },321 };322 const deps: SyncRunnerDeps = {323 openRelaySession: vi.fn(async () => session),324 getSyncSettings: relaySettings,325 createAdapter: () => adapter,326 };327 const { runWithDeps } = await import("./sync-runner");328 const runPromise = runWithDeps(makeDevice(), deps);329 await runPeer(peer, { wantFromUsIds: ["a", "b"] });330 const result = await runPromise;331 expect(result.ok).toBe(true);332 if (result.ok) expect(result.entriesSent).toBe(2);333 });334335 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {336 await bootVault();337 await seedPairSecret();338 const { session, peer } = inMemoryPair();339 const deps: SyncRunnerDeps = {340 openRelaySession: vi.fn(async () => session),341 getSyncSettings: relaySettings,342 createAdapter: emptyAdapter,343 };344 const { runWithDeps } = await import("./sync-runner");345 const runPromise = runWithDeps(makeDevice(), deps);346 347 348 (async () => {349 const hello = await peer.receive();350 if (hello.type === "SYNC_HELLO") {351 await peer.send({352 type: "SYNC_HELLO",353 vaultId: "wrong-vault-id",354 deviceId: "peer-device",355 version: 0,356 deviceTime: Date.now(),357 });358 }359 })().catch(() => undefined);360 await expect(runPromise).rejects.toThrow("vault_id_mismatch");361 });362});363364describe("sync-trigger persistence", () => {365 let local: StorageShape;366 let sync: StorageShape;367368 beforeEach(() => {369 vi.resetModules();370 local = {};371 sync = {};372 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);373 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));374 });375376 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {377 const trigger = await import("./sync-trigger");378 const paired = await import("./paired-devices");379 trigger._resetSyncTriggerForTest();380 await paired.upsertPairedDevice({381 deviceId: "P",382 deviceName: "Pixel",383 deviceType: "android",384 longTermPublicKeyHex: "a".repeat(64),385 trustSource: "direct",386 pairedAt: 1700000000000,387 });388 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));389 await trigger.triggerSyncIfReachable({ reason: "manual" });390 expect(typeof local.peach_last_sync_at).toBe("number");391 expect(local.peach_last_sync_at).toBeGreaterThan(0);392 });393394 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {395 local.peach_last_sync_at = 1_700_005_000_000;396 const trigger = await import("./sync-trigger");397 trigger._resetSyncTriggerForTest();398 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();399 await trigger.hydratePersistedSyncState();400 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);401 });402});