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 wsPort: 49142,46 pairedAt: 1700000000000,47 ...overrides,48 };49}5051interface FakePeer {52 send(msg: SyncMessage): Promise<void>;53 receive(): Promise<SyncMessage>;54}5556575859606162function inMemoryPair(): { session: SyncSession; 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: SyncSession = {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 SyncMessage;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(): SyncVaultAdapter {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("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {193 const vault = await bootVault();194 vault.lock();195 const { runWithDeps } = await import("./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 const result = await runWithDeps(makeDevice(), deps);203 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });204 expect(deps.openSession).not.toHaveBeenCalled();205 });206207 it("throws when the device has no longTermPublicKeyHex", async () => {208 await bootVault();209 const { runWithDeps } = await import("./sync-runner");210 const deps: SyncRunnerDeps = {211 openSession: vi.fn(),212 probeSubnet: vi.fn(),213 getSubnet: vi.fn(),214 createAdapter: emptyAdapter,215 };216 await expect(217 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),218 ).rejects.toThrow("paired_device_missing_pubkey");219 });220221 it("uses the cached last-known IP first and skips the subnet probe", async () => {222 await bootVault();223 const { setLastKnownIp } = await import("./paired-devices");224 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");225226 const { session, peer } = inMemoryPair();227 const openSession = vi.fn(async (_e: SyncEndpoint) => session);228 const probeSubnet = vi.fn();229 const deps: SyncRunnerDeps = {230 openSession,231 probeSubnet,232 getSubnet: vi.fn(async () => "192.168.1"),233 createAdapter: emptyAdapter,234 };235236 const { runWithDeps } = await import("./sync-runner");237 const runPromise = runWithDeps(makeDevice(), deps);238 await runPeer(peer);239 const result = await runPromise;240 expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });241 expect(openSession).toHaveBeenCalledTimes(1);242 expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });243 expect(probeSubnet).not.toHaveBeenCalled();244 });245246 it("falls back to subnet probe when cached IP fails to connect", async () => {247 await bootVault();248 const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");249 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");250251 const { session, peer } = inMemoryPair();252 const openSession = vi.fn()253 .mockRejectedValueOnce(new Error("ECONNREFUSED"))254 .mockResolvedValueOnce(session);255 const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 49142 }));256 const deps: SyncRunnerDeps = {257 openSession,258 probeSubnet,259 getSubnet: vi.fn(async () => "192.168.1"),260 createAdapter: emptyAdapter,261 };262263 const { runWithDeps } = await import("./sync-runner");264 const runPromise = runWithDeps(makeDevice(), deps);265 await runPeer(peer);266 await runPromise;267 expect(openSession).toHaveBeenCalledTimes(2);268 expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 49142 });269 expect(probeSubnet).toHaveBeenCalledTimes(1);270 expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");271 });272273 it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {274 await bootVault();275 const { setLastKnownIp } = await import("./paired-devices");276 await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");277278 const deps: SyncRunnerDeps = {279 openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),280 probeSubnet: vi.fn(async () => null),281 getSubnet: vi.fn(async () => "192.168.1"),282 createAdapter: emptyAdapter,283 };284 const { runWithDeps } = await import("./sync-runner");285 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");286 });287288 it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {289 await bootVault();290 const deps: SyncRunnerDeps = {291 openSession: vi.fn(),292 probeSubnet: vi.fn(),293 getSubnet: vi.fn(async () => null),294 createAdapter: emptyAdapter,295 };296 const { runWithDeps } = await import("./sync-runner");297 await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");298 });299300 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {301 await bootVault();302 const { session, peer } = inMemoryPair();303 const openSession = vi.fn(async () => session);304 const deps: SyncRunnerDeps = {305 openSession,306 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),307 getSubnet: vi.fn(async () => "192.168.1"),308 createAdapter: emptyAdapter,309 };310 const { runWithDeps } = await import("./sync-runner");311 const runPromise = runWithDeps(makeDevice(), deps);312 await runPeer(peer, {313 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },314 });315 const result = await runPromise;316 expect(result.ok).toBe(true);317 if (result.ok) {318 expect(result.entriesReceived).toBe(3);319 expect(result.entriesSent).toBe(0);320 }321 });322323 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {324 await bootVault();325 const { session, peer } = inMemoryPair();326 const adapter: SyncVaultAdapter = {327 ...emptyAdapter(),328 async getEncryptedEntries(ids) {329 const out: Record<string, string> = {};330 for (const id of ids) out[id] = "ENVELOPE_" + id;331 return out;332 },333 };334 const deps: SyncRunnerDeps = {335 openSession: vi.fn(async () => session),336 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),337 getSubnet: vi.fn(async () => "192.168.1"),338 createAdapter: () => adapter,339 };340 const { runWithDeps } = await import("./sync-runner");341 const runPromise = runWithDeps(makeDevice(), deps);342 await runPeer(peer, { wantFromUsIds: ["a", "b"] });343 const result = await runPromise;344 expect(result.ok).toBe(true);345 if (result.ok) expect(result.entriesSent).toBe(2);346 });347348 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {349 await bootVault();350 const { session, peer } = inMemoryPair();351 const deps: SyncRunnerDeps = {352 openSession: vi.fn(async () => session),353 probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),354 getSubnet: vi.fn(async () => "192.168.1"),355 createAdapter: emptyAdapter,356 };357 const { runWithDeps } = await import("./sync-runner");358 const runPromise = runWithDeps(makeDevice(), deps);359 360 361 (async () => {362 const hello = await peer.receive();363 if (hello.type === "SYNC_HELLO") {364 await peer.send({365 type: "SYNC_HELLO",366 vaultId: "wrong-vault-id",367 deviceId: "peer-device",368 version: 0,369 deviceTime: Date.now(),370 });371 }372 })().catch(() => undefined);373 await expect(runPromise).rejects.toThrow("vault_id_mismatch");374 });375});376377describe("sync-trigger persistence", () => {378 let local: StorageShape;379 let sync: StorageShape;380381 beforeEach(() => {382 vi.resetModules();383 local = {};384 sync = {};385 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);386 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));387 });388389 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {390 const trigger = await import("./sync-trigger");391 const paired = await import("./paired-devices");392 trigger._resetSyncTriggerForTest();393 await paired.upsertPairedDevice({394 deviceId: "P",395 deviceName: "Pixel",396 deviceType: "android",397 longTermPublicKeyHex: "a".repeat(64),398 trustSource: "direct",399 pairedAt: 1700000000000,400 });401 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));402 await trigger.triggerSyncIfReachable({ reason: "manual" });403 expect(typeof local.peach_last_sync_at).toBe("number");404 expect(local.peach_last_sync_at).toBeGreaterThan(0);405 });406407 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {408 local.peach_last_sync_at = 1_700_005_000_000;409 const trigger = await import("./sync-trigger");410 trigger._resetSyncTriggerForTest();411 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();412 await trigger.hydratePersistedSyncState();413 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);414 });415});