1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, SyncMessage } from "../shared/sync-types";3import type {4 SyncSession,5} from "../shared/sync-transport";6import type { SyncVaultAdapter } from "./sync-coordinator";7import type { SyncRunnerDeps } from "./sync-runner";89type StorageShape = Record<string, unknown>;1011function makeChromeMock(12 local: StorageShape,13 sync: StorageShape,14 session: StorageShape = {},15): typeof chrome {16 const area = (store: StorageShape) => ({17 get: vi.fn(async (key?: string | string[]) => {18 if (!key) return store;19 if (typeof key === "string") return { [key]: store[key] };20 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});21 }),22 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),23 remove: vi.fn(async (keys: string | string[]) => {24 const list = Array.isArray(keys) ? keys : [keys];25 for (const k of list) delete store[k];26 }),27 });28 return {29 storage: { local: area(local), sync: area(sync), session: area(session) },30 alarms: {31 clear: vi.fn(),32 create: vi.fn(),33 onAlarm: { addListener: vi.fn() },34 },35 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },36 runtime: { sendMessage: vi.fn(async () => undefined) },37 } as unknown as typeof chrome;38}3940const REMOTE_PUBKEY_HEX = "ab".repeat(32);4142function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {43 return {44 deviceId: "pixel-xyz",45 deviceName: "Pixel",46 deviceType: "android",47 longTermPublicKeyHex: REMOTE_PUBKEY_HEX,48 rendezvousSecretHex: "11".repeat(32),49 wsPort: 49142,50 pairedAt: 1700000000000,51 ...overrides,52 };53}5455interface FakePeer {56 send(msg: SyncMessage): Promise<void>;57 receive(): Promise<SyncMessage>;58}5960616263646566function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {67 const inboundToSession: Uint8Array[] = [];68 const outboundFromSession: Uint8Array[] = [];69 const sessionReaders: Array<(v: Uint8Array) => void> = [];70 const peerReaders: Array<(v: Uint8Array) => void> = [];71 const closed = { v: false };7273 const session: SyncSession = {74 send(bytes) {75 const r = peerReaders.shift();76 if (r) r(bytes); else outboundFromSession.push(bytes);77 },78 receive(): Promise<Uint8Array> {79 const v = inboundToSession.shift();80 if (v) return Promise.resolve(v);81 return new Promise<Uint8Array>((res) => sessionReaders.push(res));82 },83 async close() { closed.v = true; },84 remoteStaticPublicKey: new Uint8Array(32),85 };86 const peer: FakePeer = {87 async send(msg) {88 const bytes = new TextEncoder().encode(JSON.stringify(msg));89 const r = sessionReaders.shift();90 if (r) r(bytes); else inboundToSession.push(bytes);91 },92 async receive() {93 const v = outboundFromSession.shift();94 const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));95 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;96 },97 };98 return { session, peer, closed };99}100101102103104105106107108async function runPeer(109 peer: FakePeer,110 opts: {111 vaultId?: string;112 peerEntriesB64?: Record<string, string>;113 wantFromUsIds?: string[];114 } = {},115): Promise<void> {116 const hello = await peer.receive();117 if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");118 await peer.send({119 type: "SYNC_HELLO",120 vaultId: opts.vaultId ?? hello.vaultId,121 deviceId: "peer-device",122 version: 0,123 deviceTime: Date.now(),124 });125 const manifest = await peer.receive();126 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");127128 129 const peerEntryManifest: Record<string, number> = {};130 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {131 peerEntryManifest[id] = Date.now();132 }133 await peer.send({134 type: "SYNC_MANIFEST",135 entries: peerEntryManifest,136 tombstones: {},137 });138139 const ourRequest = await peer.receive();140 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");141142 143 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });144145 const ourEntries = await peer.receive();146 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");147148 149 150 if (Object.keys(peerEntryManifest).length > 0) {151 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });152 }153154 const ourTombstones = await peer.receive();155 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");156 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });157158 const done = await peer.receive();159 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");160 await peer.send({ type: "SYNC_DONE" });161}162163function emptyAdapter(): SyncVaultAdapter {164 return {165 async computeVaultId() { return "vault-abc"; },166 async getOurDeviceId() { return "device-init"; },167 168 169 async getLocalVersion() { return 5; },170 async buildEntryManifest() { return {}; },171 async buildTombstoneManifest() { return {}; },172 async getEncryptedEntries() { return {}; },173 async applyEntries() { },174 async applyTombstones() { },175 };176}177178const relaySettings = async () => ({179 mode: "public" as const,180 relayUrl: "wss://relay.peachpasswords.com",181});182183describe("productionSyncRunner", () => {184 let local: StorageShape;185 let sync: StorageShape;186 let sessionStore: StorageShape;187188 beforeEach(() => {189 vi.resetModules();190 local = {};191 sync = {};192 sessionStore = {};193 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore);194 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));195 });196197 async function bootVault(): Promise<typeof import("./vault")> {198 const vault = await import("./vault");199 await vault.createVault("correct horse battery staple");200 return vault;201 }202203 204 205 206 it("completes a sync round while locked, using the session-scoped spool", async () => {207 const vault = await bootVault();208 await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });209 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");210 await refreshLockedSyncSpool();211 vault.lock();212 expect(vault.isUnlocked()).toBe(false);213214 const { session, peer } = inMemoryPair();215 const deps: SyncRunnerDeps = {216 openRelaySession: vi.fn(async () => session),217 getSyncSettings: relaySettings,218 createAdapter: emptyAdapter,219 };220 const { runWithDeps } = await import("./sync-runner");221 const runPromise = runWithDeps(makeDevice(), deps);222 await runPeer(peer, {});223 const result = await runPromise;224225 expect(result.ok).toBe(true);226 227 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");228 expect(local.peach_locked_sync_spool_v1).toBeUndefined();229 });230231 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {232 const vault = await bootVault();233 vault.lock();234 const { runWithDeps } = await import("./sync-runner");235 const deps: SyncRunnerDeps = {236 openRelaySession: vi.fn(),237 getSyncSettings: relaySettings,238 createAdapter: emptyAdapter,239 };240 const result = await runWithDeps(makeDevice(), deps);241 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });242 expect(deps.openRelaySession).not.toHaveBeenCalled();243 });244245 it("throws when the device has no longTermPublicKeyHex", async () => {246 await bootVault();247 const { runWithDeps } = await import("./sync-runner");248 const deps: SyncRunnerDeps = {249 openRelaySession: vi.fn(),250 getSyncSettings: relaySettings,251 createAdapter: emptyAdapter,252 };253 await expect(254 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),255 ).rejects.toThrow("paired_device_missing_pubkey");256 });257258 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {259 await bootVault();260 const { runWithDeps } = await import("./sync-runner");261 const deps: SyncRunnerDeps = {262 openRelaySession: vi.fn(),263 getSyncSettings: relaySettings,264 createAdapter: emptyAdapter,265 };266 await expect(267 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),268 ).rejects.toThrow("paired_device_missing_rendezvous_secret");269 });270271 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {272 await bootVault();273 const { session, peer } = inMemoryPair();274 const openRelaySession = vi.fn(async () => session);275 const deps: SyncRunnerDeps = {276 openRelaySession,277 getSyncSettings: relaySettings,278 createAdapter: emptyAdapter,279 };280 const { runWithDeps } = await import("./sync-runner");281 const runPromise = runWithDeps(makeDevice(), deps);282 await runPeer(peer, {283 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },284 });285 const result = await runPromise;286 expect(result.ok).toBe(true);287 if (result.ok) {288 expect(result.entriesReceived).toBe(3);289 expect(result.entriesSent).toBe(0);290 }291 });292293 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {294 await bootVault();295 const { session, peer } = inMemoryPair();296 const adapter: SyncVaultAdapter = {297 ...emptyAdapter(),298 async getEncryptedEntries(ids) {299 const out: Record<string, string> = {};300 for (const id of ids) out[id] = "ENVELOPE_" + id;301 return out;302 },303 };304 const deps: SyncRunnerDeps = {305 openRelaySession: vi.fn(async () => session),306 getSyncSettings: relaySettings,307 createAdapter: () => adapter,308 };309 const { runWithDeps } = await import("./sync-runner");310 const runPromise = runWithDeps(makeDevice(), deps);311 await runPeer(peer, { wantFromUsIds: ["a", "b"] });312 const result = await runPromise;313 expect(result.ok).toBe(true);314 if (result.ok) expect(result.entriesSent).toBe(2);315 });316317 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {318 await bootVault();319 const { session, peer } = inMemoryPair();320 const deps: SyncRunnerDeps = {321 openRelaySession: vi.fn(async () => session),322 getSyncSettings: relaySettings,323 createAdapter: emptyAdapter,324 };325 const { runWithDeps } = await import("./sync-runner");326 const runPromise = runWithDeps(makeDevice(), deps);327 328 329 (async () => {330 const hello = await peer.receive();331 if (hello.type === "SYNC_HELLO") {332 await peer.send({333 type: "SYNC_HELLO",334 vaultId: "wrong-vault-id",335 deviceId: "peer-device",336 version: 0,337 deviceTime: Date.now(),338 });339 }340 })().catch(() => undefined);341 await expect(runPromise).rejects.toThrow("vault_id_mismatch");342 });343});344345describe("sync-trigger persistence", () => {346 let local: StorageShape;347 let sync: StorageShape;348349 beforeEach(() => {350 vi.resetModules();351 local = {};352 sync = {};353 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);354 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));355 });356357 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {358 const trigger = await import("./sync-trigger");359 const paired = await import("./paired-devices");360 trigger._resetSyncTriggerForTest();361 await paired.upsertPairedDevice({362 deviceId: "P",363 deviceName: "Pixel",364 deviceType: "android",365 longTermPublicKeyHex: "a".repeat(64),366 trustSource: "direct",367 pairedAt: 1700000000000,368 });369 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));370 await trigger.triggerSyncIfReachable({ reason: "manual" });371 expect(typeof local.peach_last_sync_at).toBe("number");372 expect(local.peach_last_sync_at).toBeGreaterThan(0);373 });374375 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {376 local.peach_last_sync_at = 1_700_005_000_000;377 const trigger = await import("./sync-trigger");378 trigger._resetSyncTriggerForTest();379 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();380 await trigger.hydratePersistedSyncState();381 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);382 });383});