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 protocolVersion: 3,125 });126 const manifest = await peer.receive();127 if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");128129 130 const peerEntryManifest: Record<string, number> = {};131 for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {132 peerEntryManifest[id] = Date.now();133 }134 await peer.send({135 type: "SYNC_MANIFEST",136 entries: peerEntryManifest,137 tombstones: {},138 });139140 const ourRequest = await peer.receive();141 if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");142143 144 await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });145146 const ourEntries = await peer.receive();147 if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");148149 150 151 if (Object.keys(peerEntryManifest).length > 0) {152 await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });153 }154155 const ourTombstones = await peer.receive();156 if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");157 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });158159 const done = await peer.receive();160 if (done.type !== "SYNC_DONE") throw new Error("expected DONE");161 await peer.send({ type: "SYNC_DONE" });162}163164function emptyAdapter(): SyncVaultAdapter {165 return {166 async computeVaultId() { return "vault-abc"; },167 async getOurDeviceId() { return "device-init"; },168 169 170 async getLocalVersion() { return 5; },171 async buildEntryManifest() { return {}; },172 async buildTombstoneManifest() { return {}; },173 async getEncryptedEntries() { return {}; },174 async applyEntries() { },175 async applyTombstones() { },176 };177}178179const relaySettings = async () => ({180 mode: "public" as const,181 relayUrl: "wss://relay.peachpasswords.com",182});183184describe("productionSyncRunner", () => {185 let local: StorageShape;186 let sync: StorageShape;187 let sessionStore: StorageShape;188189 beforeEach(() => {190 vi.resetModules();191 local = {};192 sync = {};193 sessionStore = {};194 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore);195 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));196 });197198 async function bootVault(): Promise<typeof import("./vault")> {199 const vault = await import("./vault");200 await vault.createVault("correct horse battery staple");201 return vault;202 }203204 205 206 async function seedPairSecret(secret = "11".repeat(32)): Promise<void> {207 const vault = await import("./vault");208 const rootHex = vault.getRootKeyHex();209 if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets");210 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));211 try {212 const { setPairSecret } = await import("./pair-secrets");213 await setPairSecret("pixel-xyz", secret, root);214 } finally {215 root.fill(0);216 }217 }218219 220 221 222 it("completes a sync round while locked, using the session-scoped spool", async () => {223 const vault = await bootVault();224 await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });225 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");226 await refreshLockedSyncSpool();227 await seedPairSecret();228 vault.lock();229 expect(vault.isUnlocked()).toBe(false);230231 const { session, peer } = inMemoryPair();232 const deps: SyncRunnerDeps = {233 openRelaySession: vi.fn(async () => session),234 getSyncSettings: relaySettings,235 createAdapter: emptyAdapter,236 };237 const { runWithDeps } = await import("./sync-runner");238 const runPromise = runWithDeps(makeDevice(), deps);239 await runPeer(peer, {});240 const result = await runPromise;241242 expect(result.ok).toBe(true);243 244 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");245 expect(local.peach_locked_sync_spool_v1).toBeUndefined();246 });247248 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {249 const vault = await bootVault();250 vault.lock();251 const { runWithDeps } = await import("./sync-runner");252 const deps: SyncRunnerDeps = {253 openRelaySession: vi.fn(),254 getSyncSettings: relaySettings,255 createAdapter: emptyAdapter,256 };257 const result = await runWithDeps(makeDevice(), deps);258 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });259 expect(deps.openRelaySession).not.toHaveBeenCalled();260 });261262 it("throws when the device has no longTermPublicKeyHex", async () => {263 await bootVault();264 const { runWithDeps } = await import("./sync-runner");265 const deps: SyncRunnerDeps = {266 openRelaySession: vi.fn(),267 getSyncSettings: relaySettings,268 createAdapter: emptyAdapter,269 };270 await expect(271 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),272 ).rejects.toThrow("paired_device_missing_pubkey");273 });274275 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {276 await bootVault();277 const { runWithDeps } = await import("./sync-runner");278 const deps: SyncRunnerDeps = {279 openRelaySession: vi.fn(),280 getSyncSettings: relaySettings,281 createAdapter: emptyAdapter,282 };283 await expect(284 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),285 ).rejects.toThrow("paired_device_missing_rendezvous_secret");286 });287288 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {289 await bootVault();290 await seedPairSecret();291 const { session, peer } = inMemoryPair();292 const openRelaySession = vi.fn(async () => session);293 const deps: SyncRunnerDeps = {294 openRelaySession,295 getSyncSettings: relaySettings,296 createAdapter: emptyAdapter,297 };298 const { runWithDeps } = await import("./sync-runner");299 const runPromise = runWithDeps(makeDevice(), deps);300 await runPeer(peer, {301 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },302 });303 const result = await runPromise;304 expect(result.ok).toBe(true);305 if (result.ok) {306 expect(result.entriesReceived).toBe(3);307 expect(result.entriesSent).toBe(0);308 }309 });310311 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {312 await bootVault();313 await seedPairSecret();314 const { session, peer } = inMemoryPair();315 const adapter: SyncVaultAdapter = {316 ...emptyAdapter(),317 async getEncryptedEntries(ids) {318 const out: Record<string, string> = {};319 for (const id of ids) out[id] = "ENVELOPE_" + id;320 return out;321 },322 };323 const deps: SyncRunnerDeps = {324 openRelaySession: vi.fn(async () => session),325 getSyncSettings: relaySettings,326 createAdapter: () => adapter,327 };328 const { runWithDeps } = await import("./sync-runner");329 const runPromise = runWithDeps(makeDevice(), deps);330 await runPeer(peer, { wantFromUsIds: ["a", "b"] });331 const result = await runPromise;332 expect(result.ok).toBe(true);333 if (result.ok) expect(result.entriesSent).toBe(2);334 });335336 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {337 await bootVault();338 await seedPairSecret();339 const { session, peer } = inMemoryPair();340 const deps: SyncRunnerDeps = {341 openRelaySession: vi.fn(async () => session),342 getSyncSettings: relaySettings,343 createAdapter: emptyAdapter,344 };345 const { runWithDeps } = await import("./sync-runner");346 const runPromise = runWithDeps(makeDevice(), deps);347 348 349 (async () => {350 const hello = await peer.receive();351 if (hello.type === "SYNC_HELLO") {352 await peer.send({353 type: "SYNC_HELLO",354 vaultId: "wrong-vault-id",355 deviceId: "peer-device",356 version: 0,357 deviceTime: Date.now(),358 protocolVersion: 3,359 });360 }361 })().catch(() => undefined);362 await expect(runPromise).rejects.toThrow("vault_id_mismatch");363 });364});365366describe("sync-trigger persistence", () => {367 let local: StorageShape;368 let sync: StorageShape;369370 beforeEach(() => {371 vi.resetModules();372 local = {};373 sync = {};374 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);375 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));376 });377378 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {379 const trigger = await import("./sync-trigger");380 const paired = await import("./paired-devices");381 trigger._resetSyncTriggerForTest();382 await paired.upsertPairedDevice({383 deviceId: "P",384 deviceName: "Pixel",385 deviceType: "android",386 longTermPublicKeyHex: "a".repeat(64),387 trustSource: "direct",388 pairedAt: 1700000000000,389 });390 trigger.setSyncRunner(async () => ({ ok: true, 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});