1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, SyncMessage } from "../shared/sync-types";3import type {4 SyncSession,5 SyncEndpoint,6} from "../shared/sync-transport";7import type { SyncVaultAdapter } from "./sync-coordinator";8import type { SyncRunnerDeps } from "./sync-runner";910type StorageShape = Record<string, unknown>;1112function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {13 const area = (store: StorageShape) => ({14 get: vi.fn(async (key?: string | string[]) => {15 if (!key) return store;16 if (typeof key === "string") return { [key]: store[key] };17 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});18 }),19 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),20 remove: vi.fn(async (keys: string | string[]) => {21 const list = Array.isArray(keys) ? keys : [keys];22 for (const k of list) delete store[k];23 }),24 });25 return {26 storage: { local: area(local), sync: area(sync) },27 alarms: {28 clear: vi.fn(),29 create: vi.fn(),30 onAlarm: { addListener: vi.fn() },31 },32 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },33 runtime: { sendMessage: vi.fn(async () => undefined) },34 } as unknown as typeof chrome;35}3637const REMOTE_PUBKEY_HEX = "ab".repeat(32);3839function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {40 return {41 deviceId: "pixel-xyz",42 deviceName: "Pixel",43 deviceType: "android",44 longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45 rendezvousSecretHex: "11".repeat(32),46 wsPort: 49142,47 pairedAt: 1700000000000,48 ...overrides,49 };50}5152interface FakePeer {53 send(msg: SyncMessage): Promise<void>;54 receive(): Promise<SyncMessage>;55}5657585960616263function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {64 const inboundToSession: Uint8Array[] = [];65 const outboundFromSession: Uint8Array[] = [];66 const sessionReaders: Array<(v: Uint8Array) => void> = [];67 const peerReaders: Array<(v: Uint8Array) => void> = [];68 const closed = { v: false };6970 const session: SyncSession = {71 send(bytes) {72 const r = peerReaders.shift();73 if (r) r(bytes); else outboundFromSession.push(bytes);74 },75 receive(): Promise<Uint8Array> {76 const v = inboundToSession.shift();77 if (v) return Promise.resolve(v);78 return new Promise<Uint8Array>((res) => sessionReaders.push(res));79 },80 async close() { closed.v = true; },81 remoteStaticPublicKey: new Uint8Array(32),82 };83 const peer: FakePeer = {84 async send(msg) {85 const bytes = new TextEncoder().encode(JSON.stringify(msg));86 const r = sessionReaders.shift();87 if (r) r(bytes); else inboundToSession.push(bytes);88 },89 async receive() {90 const v = outboundFromSession.shift();91 const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));92 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;93 },94 };95 return { session, peer, closed };96}979899100101102103104105async function runPeer(106 peer: FakePeer,107 opts: {108 vaultId?: string;109 peerEntriesB64?: Record<string, string>;110 wantFromUsIds?: string[];111 } = {},112): Promise<void> {113 const hello = await peer.receive();114 if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");115 await peer.send({116 type: "SYNC_HELLO",117 vaultId: opts.vaultId ?? hello.vaultId,118 deviceId: "peer-device",119 version: 0,120 deviceTime: Date.now(),121 });122 const manifest = await peer.receive();123 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");124125 126 const peerEntryManifest: Record<string, number> = {};127 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {128 peerEntryManifest[id] = Date.now();129 }130 await peer.send({131 type: "SYNC_MANIFEST",132 entries: peerEntryManifest,133 tombstones: {},134 });135136 const ourRequest = await peer.receive();137 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");138139 140 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });141142 const ourEntries = await peer.receive();143 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");144145 146 147 if (Object.keys(peerEntryManifest).length > 0) {148 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });149 }150151 const ourTombstones = await peer.receive();152 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");153 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });154155 const done = await peer.receive();156 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");157 await peer.send({ type: "SYNC_DONE" });158}159160function emptyAdapter(): SyncVaultAdapter {161 return {162 async computeVaultId() { return "vault-abc"; },163 async getOurDeviceId() { return "device-init"; },164 165 166 async getLocalVersion() { return 5; },167 async buildEntryManifest() { return {}; },168 async buildTombstoneManifest() { return {}; },169 async getEncryptedEntries() { return {}; },170 async applyEntries() { },171 async applyTombstones() { },172 };173}174175describe("productionSyncRunner", () => {176 let local: StorageShape;177 let sync: StorageShape;178179 beforeEach(() => {180 vi.resetModules();181 local = {};182 sync = {};183 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);184 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));185 });186187 async function bootVault(): Promise<typeof import("./vault")> {188 const vault = await import("./vault");189 await vault.createVault("correct horse battery staple");190 return vault;191 }192193 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {194 const vault = await bootVault();195 vault.lock();196 const { runWithDeps } = await import("./sync-runner");197 const deps: SyncRunnerDeps = {198 openSession: vi.fn(),199 probeSubnet: vi.fn(),200 getSubnet: vi.fn(async () => "192.168.1"),201 createAdapter: emptyAdapter,202 };203 const result = await runWithDeps(makeDevice(), deps);204 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205 expect(deps.openSession).not.toHaveBeenCalled();206 });207208 it("throws when the device has no longTermPublicKeyHex", async () => {209 await bootVault();210 const { runWithDeps } = await import("./sync-runner");211 const deps: SyncRunnerDeps = {212 openSession: vi.fn(),213 probeSubnet: vi.fn(),214 getSubnet: vi.fn(),215 createAdapter: emptyAdapter,216 };217 await expect(218 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),219 ).rejects.toThrow("paired_device_missing_pubkey");220 });221222 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {223 await bootVault();224 const { runWithDeps } = await import("./sync-runner");225 const deps: SyncRunnerDeps = {226 openSession: vi.fn(),227 probeSubnet: vi.fn(),228 getSubnet: vi.fn(),229 createAdapter: emptyAdapter,230 };231 await expect(232 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),233 ).rejects.toThrow("paired_device_missing_rendezvous_secret");234 });235236 it("uses the cached last-known IP first and skips the subnet probe", async () => {237 await bootVault();238 const { setLastKnownIp } = await import("./paired-devices");239 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");240241 const { session, peer } = inMemoryPair();242 const openSession = vi.fn(async (_e: SyncEndpoint) => session);243 const probeSubnet = vi.fn();244 const deps: SyncRunnerDeps = {245 openSession,246 probeSubnet,247 getSubnet: vi.fn(async () => "192.168.1"),248 createAdapter: emptyAdapter,249 };250251 const { runWithDeps } = await import("./sync-runner");252 const runPromise = runWithDeps(makeDevice(), deps);253 await runPeer(peer);254 const result = await runPromise;255 expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });256 expect(openSession).toHaveBeenCalledTimes(1);257 expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });258 expect(probeSubnet).not.toHaveBeenCalled();259 });260261 it("falls back to subnet probe when cached IP fails to connect", async () => {262 await bootVault();263 const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");264 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");265266 const { session, peer } = inMemoryPair();267 const openSession = vi.fn()268 .mockRejectedValueOnce(new Error("ECONNREFUSED"))269 .mockRejectedValueOnce(new Error("ECONNREFUSED"))270 .mockRejectedValueOnce(new Error("ECONNREFUSED"))271 .mockResolvedValueOnce(session);272 const probeSubnet = vi.fn(async (273 ...args: Parameters<SyncRunnerDeps["probeSubnet"]>274 ) => ({275 ip: "192.168.1.99",276 port: 49142,277 rendezvousId: args[4][2],278 }));279 const deps: SyncRunnerDeps = {280 openSession,281 probeSubnet,282 getSubnet: vi.fn(async () => "192.168.1"),283 createAdapter: emptyAdapter,284 };285286 const { runWithDeps } = await import("./sync-runner");287 const runPromise = runWithDeps(makeDevice(), deps);288 await runPeer(peer);289 await runPromise;290 expect(openSession).toHaveBeenCalledTimes(4);291 expect(openSession.mock.calls[3][0]).toEqual({ ip: "192.168.1.99", port: 49142 });292 expect(openSession.mock.calls[3][3]).toBe(probeSubnet.mock.calls[0][4][2]);293 expect(probeSubnet).toHaveBeenCalledTimes(1);294 expect(probeSubnet.mock.calls[0][4]).toHaveLength(3);295 expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");296 });297298 it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {299 await bootVault();300 const { setLastKnownIp } = await import("./paired-devices");301 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");302303 const deps: SyncRunnerDeps = {304 openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),305 probeSubnet: vi.fn(async () => null),306 getSubnet: vi.fn(async () => "192.168.1"),307 createAdapter: emptyAdapter,308 };309 const { runWithDeps } = await import("./sync-runner");310 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");311 });312313 it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {314 await bootVault();315 const deps: SyncRunnerDeps = {316 openSession: vi.fn(),317 probeSubnet: vi.fn(),318 getSubnet: vi.fn(async () => null),319 createAdapter: emptyAdapter,320 };321 const { runWithDeps } = await import("./sync-runner");322 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");323 });324325 it("falls back to the configured relay after LAN discovery fails", async () => {326 await bootVault();327 const { session, peer } = inMemoryPair();328 const openRelaySession = vi.fn()329 .mockRejectedValueOnce(new Error("sync_handshake_timeout"))330 .mockResolvedValueOnce(session);331 const deps: SyncRunnerDeps = {332 openSession: vi.fn(),333 probeSubnet: vi.fn(),334 openRelaySession,335 getRelaySettings: vi.fn(async () => ({336 relayUrl: "wss://relay.example.test",337 lanOnly: false,338 })),339 getSubnet: vi.fn(async () => null),340 createAdapter: emptyAdapter,341 };342343 const { runWithDeps } = await import("./sync-runner");344 const runPromise = runWithDeps(345 makeDevice({ relayUrl: "wss://paired-relay.example.test" }),346 deps,347 );348 await runPeer(peer);349 await expect(runPromise).resolves.toEqual({350 ok: true,351 entriesReceived: 0,352 entriesSent: 0,353 });354 expect(openRelaySession).toHaveBeenCalledTimes(2);355 expect(openRelaySession.mock.calls[0][0]).toBe(356 "wss://paired-relay.example.test",357 );358 expect(openRelaySession.mock.calls[0][3]).not.toBe(359 openRelaySession.mock.calls[1][3],360 );361 });362363 it("never opens a relay session in LAN-only mode", async () => {364 await bootVault();365 const openRelaySession = vi.fn();366 const deps: SyncRunnerDeps = {367 openSession: vi.fn(),368 probeSubnet: vi.fn(),369 openRelaySession,370 getRelaySettings: vi.fn(async () => ({371 relayUrl: "wss://relay.example.test",372 lanOnly: true,373 })),374 getSubnet: vi.fn(async () => null),375 createAdapter: emptyAdapter,376 };377378 const { runWithDeps } = await import("./sync-runner");379 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow(380 "subnet_unknown",381 );382 expect(openRelaySession).not.toHaveBeenCalled();383 });384385 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {386 await bootVault();387 const { session, peer } = inMemoryPair();388 const openSession = vi.fn(async () => session);389 const deps: SyncRunnerDeps = {390 openSession,391 probeSubnet: vi.fn(async () => ({392 ip: "192.168.1.1",393 port: 49142,394 rendezvousId: "A".repeat(43),395 })),396 getSubnet: vi.fn(async () => "192.168.1"),397 createAdapter: emptyAdapter,398 };399 const { runWithDeps } = await import("./sync-runner");400 const runPromise = runWithDeps(makeDevice(), deps);401 await runPeer(peer, {402 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },403 });404 const result = await runPromise;405 expect(result.ok).toBe(true);406 if (result.ok) {407 expect(result.entriesReceived).toBe(3);408 expect(result.entriesSent).toBe(0);409 }410 });411412 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {413 await bootVault();414 const { session, peer } = inMemoryPair();415 const adapter: SyncVaultAdapter = {416 ...emptyAdapter(),417 async getEncryptedEntries(ids) {418 const out: Record<string, string> = {};419 for (const id of ids) out[id] = "ENVELOPE_" + id;420 return out;421 },422 };423 const deps: SyncRunnerDeps = {424 openSession: vi.fn(async () => session),425 probeSubnet: vi.fn(async () => ({426 ip: "192.168.1.1",427 port: 49142,428 rendezvousId: "A".repeat(43),429 })),430 getSubnet: vi.fn(async () => "192.168.1"),431 createAdapter: () => adapter,432 };433 const { runWithDeps } = await import("./sync-runner");434 const runPromise = runWithDeps(makeDevice(), deps);435 await runPeer(peer, { wantFromUsIds: ["a", "b"] });436 const result = await runPromise;437 expect(result.ok).toBe(true);438 if (result.ok) expect(result.entriesSent).toBe(2);439 });440441 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {442 await bootVault();443 const { session, peer } = inMemoryPair();444 const deps: SyncRunnerDeps = {445 openSession: vi.fn(async () => session),446 probeSubnet: vi.fn(async () => ({447 ip: "192.168.1.1",448 port: 49142,449 rendezvousId: "A".repeat(43),450 })),451 getSubnet: vi.fn(async () => "192.168.1"),452 createAdapter: emptyAdapter,453 };454 const { runWithDeps } = await import("./sync-runner");455 const runPromise = runWithDeps(makeDevice(), deps);456 457 458 (async () => {459 const hello = await peer.receive();460 if (hello.type === "SYNC_HELLO") {461 await peer.send({462 type: "SYNC_HELLO",463 vaultId: "wrong-vault-id",464 deviceId: "peer-device",465 version: 0,466 deviceTime: Date.now(),467 });468 }469 })().catch(() => undefined);470 await expect(runPromise).rejects.toThrow("vault_id_mismatch");471 });472});473474describe("sync-trigger persistence", () => {475 let local: StorageShape;476 let sync: StorageShape;477478 beforeEach(() => {479 vi.resetModules();480 local = {};481 sync = {};482 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);483 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));484 });485486 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {487 const trigger = await import("./sync-trigger");488 const paired = await import("./paired-devices");489 trigger._resetSyncTriggerForTest();490 await paired.upsertPairedDevice({491 deviceId: "P",492 deviceName: "Pixel",493 deviceType: "android",494 longTermPublicKeyHex: "a".repeat(64),495 trustSource: "direct",496 pairedAt: 1700000000000,497 });498 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));499 await trigger.triggerSyncIfReachable({ reason: "manual" });500 expect(typeof local.peach_last_sync_at).toBe("number");501 expect(local.peach_last_sync_at).toBeGreaterThan(0);502 });503504 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {505 local.peach_last_sync_at = 1_700_005_000_000;506 const trigger = await import("./sync-trigger");507 trigger._resetSyncTriggerForTest();508 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();509 await trigger.hydratePersistedSyncState();510 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);511 });512});