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("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {326 await bootVault();327 const { session, peer } = inMemoryPair();328 const openSession = vi.fn(async () => session);329 const deps: SyncRunnerDeps = {330 openSession,331 probeSubnet: vi.fn(async () => ({332 ip: "192.168.1.1",333 port: 49142,334 rendezvousId: "A".repeat(43),335 })),336 getSubnet: vi.fn(async () => "192.168.1"),337 createAdapter: emptyAdapter,338 };339 const { runWithDeps } = await import("./sync-runner");340 const runPromise = runWithDeps(makeDevice(), deps);341 await runPeer(peer, {342 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },343 });344 const result = await runPromise;345 expect(result.ok).toBe(true);346 if (result.ok) {347 expect(result.entriesReceived).toBe(3);348 expect(result.entriesSent).toBe(0);349 }350 });351352 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {353 await bootVault();354 const { session, peer } = inMemoryPair();355 const adapter: SyncVaultAdapter = {356 ...emptyAdapter(),357 async getEncryptedEntries(ids) {358 const out: Record<string, string> = {};359 for (const id of ids) out[id] = "ENVELOPE_" + id;360 return out;361 },362 };363 const deps: SyncRunnerDeps = {364 openSession: vi.fn(async () => session),365 probeSubnet: vi.fn(async () => ({366 ip: "192.168.1.1",367 port: 49142,368 rendezvousId: "A".repeat(43),369 })),370 getSubnet: vi.fn(async () => "192.168.1"),371 createAdapter: () => adapter,372 };373 const { runWithDeps } = await import("./sync-runner");374 const runPromise = runWithDeps(makeDevice(), deps);375 await runPeer(peer, { wantFromUsIds: ["a", "b"] });376 const result = await runPromise;377 expect(result.ok).toBe(true);378 if (result.ok) expect(result.entriesSent).toBe(2);379 });380381 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {382 await bootVault();383 const { session, peer } = inMemoryPair();384 const deps: SyncRunnerDeps = {385 openSession: vi.fn(async () => session),386 probeSubnet: vi.fn(async () => ({387 ip: "192.168.1.1",388 port: 49142,389 rendezvousId: "A".repeat(43),390 })),391 getSubnet: vi.fn(async () => "192.168.1"),392 createAdapter: emptyAdapter,393 };394 const { runWithDeps } = await import("./sync-runner");395 const runPromise = runWithDeps(makeDevice(), deps);396 397 398 (async () => {399 const hello = await peer.receive();400 if (hello.type === "SYNC_HELLO") {401 await peer.send({402 type: "SYNC_HELLO",403 vaultId: "wrong-vault-id",404 deviceId: "peer-device",405 version: 0,406 deviceTime: Date.now(),407 });408 }409 })().catch(() => undefined);410 await expect(runPromise).rejects.toThrow("vault_id_mismatch");411 });412});413414describe("sync-trigger persistence", () => {415 let local: StorageShape;416 let sync: StorageShape;417418 beforeEach(() => {419 vi.resetModules();420 local = {};421 sync = {};422 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);423 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));424 });425426 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {427 const trigger = await import("./sync-trigger");428 const paired = await import("./paired-devices");429 trigger._resetSyncTriggerForTest();430 await paired.upsertPairedDevice({431 deviceId: "P",432 deviceName: "Pixel",433 deviceType: "android",434 longTermPublicKeyHex: "a".repeat(64),435 trustSource: "direct",436 pairedAt: 1700000000000,437 });438 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));439 await trigger.triggerSyncIfReachable({ reason: "manual" });440 expect(typeof local.peach_last_sync_at).toBe("number");441 expect(local.peach_last_sync_at).toBeGreaterThan(0);442 });443444 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {445 local.peach_last_sync_at = 1_700_005_000_000;446 const trigger = await import("./sync-trigger");447 trigger._resetSyncTriggerForTest();448 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();449 await trigger.hydratePersistedSyncState();450 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);451 });452});