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 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(): ChirpSyncVaultAdapter {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}173174describe("productionSyncRunner", () => {175 let local: StorageShape;176 let sync: StorageShape;177178 beforeEach(() => {179 vi.resetModules();180 local = {};181 sync = {};182 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);183 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));184 });185186 async function bootVault(): Promise<typeof import("./vault")> {187 const vault = await import("./vault");188 await vault.createVault("correct horse battery staple");189 return vault;190 }191192 it("throws VaultLockedError when the vault is locked", async () => {193 const vault = await bootVault();194 vault.lock();195 const { runWithDeps } = await import("./chirp-sync-runner");196 const deps: SyncRunnerDeps = {197 openSession: vi.fn(),198 probeSubnet: vi.fn(),199 getSubnet: vi.fn(async () => "192.168.1"),200 createAdapter: emptyAdapter,201 };202 await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({203 name: "VaultLockedError",204 });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("./chirp-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("uses the cached last-known IP first and skips the subnet probe", async () => {223 await bootVault();224 const { setLastKnownIp } = await import("./paired-devices");225 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");226227 const { session, peer } = inMemoryPair();228 const openSession = vi.fn(async (_e: SyncEndpoint) => session);229 const probeSubnet = vi.fn();230 const deps: SyncRunnerDeps = {231 openSession,232 probeSubnet,233 getSubnet: vi.fn(async () => "192.168.1"),234 createAdapter: emptyAdapter,235 };236237 const { runWithDeps } = await import("./chirp-sync-runner");238 const runPromise = runWithDeps(makeDevice(), deps);239 await runPeer(peer);240 const result = await runPromise;241 expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });242 expect(openSession).toHaveBeenCalledTimes(1);243 expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });244 expect(probeSubnet).not.toHaveBeenCalled();245 });246247 it("falls back to subnet probe when cached IP fails to connect", async () => {248 await bootVault();249 const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");250 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");251252 const { session, peer } = inMemoryPair();253 const openSession = vi.fn()254 .mockRejectedValueOnce(new Error("ECONNREFUSED"))255 .mockResolvedValueOnce(session);256 const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));257 const deps: SyncRunnerDeps = {258 openSession,259 probeSubnet,260 getSubnet: vi.fn(async () => "192.168.1"),261 createAdapter: emptyAdapter,262 };263264 const { runWithDeps } = await import("./chirp-sync-runner");265 const runPromise = runWithDeps(makeDevice(), deps);266 await runPeer(peer);267 await runPromise;268 expect(openSession).toHaveBeenCalledTimes(2);269 expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });270 expect(probeSubnet).toHaveBeenCalledTimes(1);271 expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");272 });273274 it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {275 await bootVault();276 const { setLastKnownIp } = await import("./paired-devices");277 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");278279 const deps: SyncRunnerDeps = {280 openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),281 probeSubnet: vi.fn(async () => null),282 getSubnet: vi.fn(async () => "192.168.1"),283 createAdapter: emptyAdapter,284 };285 const { runWithDeps } = await import("./chirp-sync-runner");286 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");287 });288289 it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {290 await bootVault();291 const deps: SyncRunnerDeps = {292 openSession: vi.fn(),293 probeSubnet: vi.fn(),294 getSubnet: vi.fn(async () => null),295 createAdapter: emptyAdapter,296 };297 const { runWithDeps } = await import("./chirp-sync-runner");298 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");299 });300301 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {302 await bootVault();303 const { session, peer } = inMemoryPair();304 const openSession = vi.fn(async () => session);305 const deps: SyncRunnerDeps = {306 openSession,307 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),308 getSubnet: vi.fn(async () => "192.168.1"),309 createAdapter: emptyAdapter,310 };311 const { runWithDeps } = await import("./chirp-sync-runner");312 const runPromise = runWithDeps(makeDevice(), deps);313 await runPeer(peer, {314 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },315 });316 const result = await runPromise;317 expect(result.entriesReceived).toBe(3);318 expect(result.entriesSent).toBe(0);319 });320321 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {322 await bootVault();323 const { session, peer } = inMemoryPair();324 const adapter: ChirpSyncVaultAdapter = {325 ...emptyAdapter(),326 async getEncryptedEntries(ids) {327 const out: Record<string, string> = {};328 for (const id of ids) out[id] = "ENVELOPE_" + id;329 return out;330 },331 };332 const deps: SyncRunnerDeps = {333 openSession: vi.fn(async () => session),334 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),335 getSubnet: vi.fn(async () => "192.168.1"),336 createAdapter: () => adapter,337 };338 const { runWithDeps } = await import("./chirp-sync-runner");339 const runPromise = runWithDeps(makeDevice(), deps);340 await runPeer(peer, { wantFromUsIds: ["a", "b"] });341 const result = await runPromise;342 expect(result.entriesSent).toBe(2);343 });344345 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {346 await bootVault();347 const { session, peer } = inMemoryPair();348 const deps: SyncRunnerDeps = {349 openSession: vi.fn(async () => session),350 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),351 getSubnet: vi.fn(async () => "192.168.1"),352 createAdapter: emptyAdapter,353 };354 const { runWithDeps } = await import("./chirp-sync-runner");355 const runPromise = runWithDeps(makeDevice(), deps);356 357 358 (async () => {359 const hello = await peer.receive();360 if (hello.type === "SYNC_HELLO") {361 await peer.send({362 type: "SYNC_HELLO",363 vaultId: "wrong-vault-id",364 deviceId: "peer-device",365 version: 0,366 deviceTime: Date.now(),367 });368 }369 })().catch(() => undefined);370 await expect(runPromise).rejects.toThrow("vault_id_mismatch");371 });372});373374describe("sync-trigger persistence", () => {375 let local: StorageShape;376 let sync: StorageShape;377378 beforeEach(() => {379 vi.resetModules();380 local = {};381 sync = {};382 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);383 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));384 });385386 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {387 const trigger = await import("./sync-trigger");388 const paired = await import("./paired-devices");389 trigger._resetSyncTriggerForTest();390 await paired.upsertPairedDevice({391 deviceId: "P",392 deviceName: "Pixel",393 deviceType: "android",394 longTermPublicKeyHex: "a".repeat(64),395 pairedAt: 1700000000000,396 });397 trigger.setSyncRunner(async () => ({ entriesReceived: 1, entriesSent: 0 }));398 await trigger.triggerSyncIfReachable({ reason: "manual" });399 expect(typeof local.peach_last_sync_at).toBe("number");400 expect(local.peach_last_sync_at).toBeGreaterThan(0);401 });402403 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {404 local.peach_last_sync_at = 1_700_005_000_000;405 const trigger = await import("./sync-trigger");406 trigger._resetSyncTriggerForTest();407 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();408 await trigger.hydratePersistedSyncState();409 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);410 });411});