1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, ChirpSyncMessage } from "../shared/sync-types";3import type {4 ChirpSyncSession,5 SyncEndpoint,6} from "../shared/chirp-sync-transport";7import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";8import type { SyncRunnerDeps } from "./chirp-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 wsPort: 8765,46 pairedAt: 1700000000000,47 ...overrides,48 };49}5051interface FakePeer {52 send(msg: ChirpSyncMessage): Promise<void>;53 receive(): Promise<ChirpSyncMessage>;54}5556575859606162function inMemoryPair(): { session: ChirpSyncSession; 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: ChirpSyncSession = {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 ChirpSyncMessage;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 });119 const manifest = await peer.receive();120 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");121122 123 const peerEntryManifest: Record<string, number> = {};124 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {125 peerEntryManifest[id] = Date.now();126 }127 await peer.send({128 type: "SYNC_MANIFEST",129 entries: peerEntryManifest,130 tombstones: {},131 });132133 const ourRequest = await peer.receive();134 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");135136 137 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });138139 const ourEntries = await peer.receive();140 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");141142 143 144 if (Object.keys(peerEntryManifest).length > 0) {145 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });146 }147148 const ourTombstones = await peer.receive();149 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");150 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });151152 const done = await peer.receive();153 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");154 await peer.send({ type: "SYNC_DONE" });155}156157function emptyAdapter(): ChirpSyncVaultAdapter {158 return {159 async computeVaultId() { return "vault-abc"; },160 async getOurDeviceId() { return "device-init"; },161 async buildEntryManifest() { return {}; },162 async buildTombstoneManifest() { return {}; },163 async getEncryptedEntries() { return {}; },164 async applyEntries() { },165 async applyTombstones() { },166 };167}168169describe("productionSyncRunner", () => {170 let local: StorageShape;171 let sync: StorageShape;172173 beforeEach(() => {174 vi.resetModules();175 local = {};176 sync = {};177 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);178 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));179 });180181 async function bootVault(): Promise<typeof import("./vault")> {182 const vault = await import("./vault");183 await vault.createVault("correct horse battery staple");184 return vault;185 }186187 it("throws VaultLockedError when the vault is locked", async () => {188 const vault = await bootVault();189 vault.lock();190 const { runWithDeps } = await import("./chirp-sync-runner");191 const deps: SyncRunnerDeps = {192 openSession: vi.fn(),193 probeSubnet: vi.fn(),194 getSubnet: vi.fn(async () => "192.168.1"),195 createAdapter: emptyAdapter,196 };197 await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({198 name: "VaultLockedError",199 });200 expect(deps.openSession).not.toHaveBeenCalled();201 });202203 it("throws when the device has no longTermPublicKeyHex", async () => {204 await bootVault();205 const { runWithDeps } = await import("./chirp-sync-runner");206 const deps: SyncRunnerDeps = {207 openSession: vi.fn(),208 probeSubnet: vi.fn(),209 getSubnet: vi.fn(),210 createAdapter: emptyAdapter,211 };212 await expect(213 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),214 ).rejects.toThrow("paired_device_missing_pubkey");215 });216217 it("uses the cached last-known IP first and skips the subnet probe", async () => {218 await bootVault();219 const { setLastKnownIp } = await import("./paired-devices");220 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");221222 const { session, peer } = inMemoryPair();223 const openSession = vi.fn(async (_e: SyncEndpoint) => session);224 const probeSubnet = vi.fn();225 const deps: SyncRunnerDeps = {226 openSession,227 probeSubnet,228 getSubnet: vi.fn(async () => "192.168.1"),229 createAdapter: emptyAdapter,230 };231232 const { runWithDeps } = await import("./chirp-sync-runner");233 const runPromise = runWithDeps(makeDevice(), deps);234 await runPeer(peer);235 const result = await runPromise;236 expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });237 expect(openSession).toHaveBeenCalledTimes(1);238 expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });239 expect(probeSubnet).not.toHaveBeenCalled();240 });241242 it("falls back to subnet probe when cached IP fails to connect", async () => {243 await bootVault();244 const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");245 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");246247 const { session, peer } = inMemoryPair();248 const openSession = vi.fn()249 .mockRejectedValueOnce(new Error("ECONNREFUSED"))250 .mockResolvedValueOnce(session);251 const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));252 const deps: SyncRunnerDeps = {253 openSession,254 probeSubnet,255 getSubnet: vi.fn(async () => "192.168.1"),256 createAdapter: emptyAdapter,257 };258259 const { runWithDeps } = await import("./chirp-sync-runner");260 const runPromise = runWithDeps(makeDevice(), deps);261 await runPeer(peer);262 await runPromise;263 expect(openSession).toHaveBeenCalledTimes(2);264 expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });265 expect(probeSubnet).toHaveBeenCalledTimes(1);266 expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");267 });268269 it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {270 await bootVault();271 const { setLastKnownIp } = await import("./paired-devices");272 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");273274 const deps: SyncRunnerDeps = {275 openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),276 probeSubnet: vi.fn(async () => null),277 getSubnet: vi.fn(async () => "192.168.1"),278 createAdapter: emptyAdapter,279 };280 const { runWithDeps } = await import("./chirp-sync-runner");281 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");282 });283284 it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {285 await bootVault();286 const deps: SyncRunnerDeps = {287 openSession: vi.fn(),288 probeSubnet: vi.fn(),289 getSubnet: vi.fn(async () => null),290 createAdapter: emptyAdapter,291 };292 const { runWithDeps } = await import("./chirp-sync-runner");293 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");294 });295296 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {297 await bootVault();298 const { session, peer } = inMemoryPair();299 const openSession = vi.fn(async () => session);300 const deps: SyncRunnerDeps = {301 openSession,302 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),303 getSubnet: vi.fn(async () => "192.168.1"),304 createAdapter: emptyAdapter,305 };306 const { runWithDeps } = await import("./chirp-sync-runner");307 const runPromise = runWithDeps(makeDevice(), deps);308 await runPeer(peer, {309 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },310 });311 const result = await runPromise;312 expect(result.entriesReceived).toBe(3);313 expect(result.entriesSent).toBe(0);314 });315316 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {317 await bootVault();318 const { session, peer } = inMemoryPair();319 const adapter: ChirpSyncVaultAdapter = {320 ...emptyAdapter(),321 async getEncryptedEntries(ids) {322 const out: Record<string, string> = {};323 for (const id of ids) out[id] = "ENVELOPE_" + id;324 return out;325 },326 };327 const deps: SyncRunnerDeps = {328 openSession: vi.fn(async () => session),329 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),330 getSubnet: vi.fn(async () => "192.168.1"),331 createAdapter: () => adapter,332 };333 const { runWithDeps } = await import("./chirp-sync-runner");334 const runPromise = runWithDeps(makeDevice(), deps);335 await runPeer(peer, { wantFromUsIds: ["a", "b"] });336 const result = await runPromise;337 expect(result.entriesSent).toBe(2);338 });339340 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {341 await bootVault();342 const { session, peer } = inMemoryPair();343 const deps: SyncRunnerDeps = {344 openSession: vi.fn(async () => session),345 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),346 getSubnet: vi.fn(async () => "192.168.1"),347 createAdapter: emptyAdapter,348 };349 const { runWithDeps } = await import("./chirp-sync-runner");350 const runPromise = runWithDeps(makeDevice(), deps);351 352 353 (async () => {354 const hello = await peer.receive();355 if (hello.type === "SYNC_HELLO") {356 await peer.send({357 type: "SYNC_HELLO",358 vaultId: "wrong-vault-id",359 deviceId: "peer-device",360 });361 }362 })().catch(() => undefined);363 await expect(runPromise).rejects.toThrow("vault_id_mismatch");364 });365});366367describe("sync-trigger persistence", () => {368 let local: StorageShape;369 let sync: StorageShape;370371 beforeEach(() => {372 vi.resetModules();373 local = {};374 sync = {};375 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);376 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));377 });378379 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {380 const trigger = await import("./sync-trigger");381 const paired = await import("./paired-devices");382 trigger._resetSyncTriggerForTest();383 await paired.upsertPairedDevice({384 deviceId: "P",385 deviceName: "Pixel",386 deviceType: "android",387 longTermPublicKeyHex: "a".repeat(64),388 pairedAt: 1700000000000,389 });390 trigger.setSyncRunner(async () => ({ entriesReceived: 1, entriesSent: 0 }));391 await trigger.triggerSyncIfReachable({ reason: "manual" });392 expect(typeof local.peach_last_sync_at).toBe("number");393 expect(local.peach_last_sync_at).toBeGreaterThan(0);394 });395396 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {397 local.peach_last_sync_at = 1_700_005_000_000;398 const trigger = await import("./sync-trigger");399 trigger._resetSyncTriggerForTest();400 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();401 await trigger.hydratePersistedSyncState();402 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);403 });404});