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 202 203 const identity = await import("./device-identity");204 await identity.getOrCreateDeviceId();205 return vault;206 }207208 209 210 async function seedPairSecret(secret = "11".repeat(32)): Promise<void> {211 const vault = await import("./vault");212 const rootHex = vault.getRootKeyHex();213 if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets");214 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));215 try {216 const { setPairSecret } = await import("./pair-secrets");217 await setPairSecret("pixel-xyz", secret, root);218 } finally {219 root.fill(0);220 }221 }222223 224 225 226 it("completes a sync round while locked, using the session-scoped spool", async () => {227 const vault = await bootVault();228 await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });229 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");230 await refreshLockedSyncSpool();231 await seedPairSecret();232 vault.lock();233 expect(vault.isUnlocked()).toBe(false);234235 const { session, peer } = inMemoryPair();236 const deps: SyncRunnerDeps = {237 openRelaySession: vi.fn(async () => session),238 getSyncSettings: relaySettings,239 createAdapter: emptyAdapter,240 };241 const { runWithDeps } = await import("./sync-runner");242 const runPromise = runWithDeps(makeDevice(), deps);243 await runPeer(peer, {});244 const result = await runPromise;245246 expect(result.ok).toBe(true);247 248 expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");249 expect(local.peach_locked_sync_spool_v1).toBeUndefined();250 });251252 it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {253 const vault = await bootVault();254 vault.lock();255 const { runWithDeps } = await import("./sync-runner");256 const deps: SyncRunnerDeps = {257 openRelaySession: vi.fn(),258 getSyncSettings: relaySettings,259 createAdapter: emptyAdapter,260 };261 const result = await runWithDeps(makeDevice(), deps);262 expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });263 expect(deps.openRelaySession).not.toHaveBeenCalled();264 });265266 it("throws when the device has no longTermPublicKeyHex", async () => {267 await bootVault();268 const { runWithDeps } = await import("./sync-runner");269 const deps: SyncRunnerDeps = {270 openRelaySession: vi.fn(),271 getSyncSettings: relaySettings,272 createAdapter: emptyAdapter,273 };274 await expect(275 runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),276 ).rejects.toThrow("paired_device_missing_pubkey");277 });278279 it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {280 await bootVault();281 const { runWithDeps } = await import("./sync-runner");282 const deps: SyncRunnerDeps = {283 openRelaySession: vi.fn(),284 getSyncSettings: relaySettings,285 createAdapter: emptyAdapter,286 };287 await expect(288 runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),289 ).rejects.toThrow("paired_device_missing_rendezvous_secret");290 });291292 it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {293 await bootVault();294 await seedPairSecret();295 const { session, peer } = inMemoryPair();296 const openRelaySession = vi.fn(async () => session);297 const deps: SyncRunnerDeps = {298 openRelaySession,299 getSyncSettings: relaySettings,300 createAdapter: emptyAdapter,301 };302 const { runWithDeps } = await import("./sync-runner");303 const runPromise = runWithDeps(makeDevice(), deps);304 await runPeer(peer, {305 peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },306 });307 const result = await runPromise;308 expect(result.ok).toBe(true);309 if (result.ok) {310 expect(result.entriesReceived).toBe(3);311 expect(result.entriesSent).toBe(0);312 }313 });314315 it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {316 await bootVault();317 await seedPairSecret();318 const { session, peer } = inMemoryPair();319 const adapter: SyncVaultAdapter = {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 openRelaySession: vi.fn(async () => session),329 getSyncSettings: relaySettings,330 createAdapter: () => adapter,331 };332 const { runWithDeps } = await import("./sync-runner");333 const runPromise = runWithDeps(makeDevice(), deps);334 await runPeer(peer, { wantFromUsIds: ["a", "b"] });335 const result = await runPromise;336 expect(result.ok).toBe(true);337 if (result.ok) expect(result.entriesSent).toBe(2);338 });339340 it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {341 await bootVault();342 await seedPairSecret();343 const { session, peer } = inMemoryPair();344 const deps: SyncRunnerDeps = {345 openRelaySession: vi.fn(async () => session),346 getSyncSettings: relaySettings,347 createAdapter: emptyAdapter,348 };349 const { runWithDeps } = await import("./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 version: 0,361 deviceTime: Date.now(),362 protocolVersion: 3,363 });364 }365 })().catch(() => undefined);366 await expect(runPromise).rejects.toThrow("vault_id_mismatch");367 });368});369370describe("sync-trigger persistence", () => {371 let local: StorageShape;372 let sync: StorageShape;373374 beforeEach(() => {375 vi.resetModules();376 local = {};377 sync = {};378 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);379 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));380 });381382 it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {383 const trigger = await import("./sync-trigger");384 const paired = await import("./paired-devices");385 trigger._resetSyncTriggerForTest();386 await paired.upsertPairedDevice({387 deviceId: "P",388 deviceName: "Pixel",389 deviceType: "android",390 longTermPublicKeyHex: "a".repeat(64),391 trustSource: "direct",392 pairedAt: 1700000000000,393 });394 trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));395 await trigger.triggerSyncIfReachable({ reason: "manual" });396 expect(typeof local.peach_last_sync_at).toBe("number");397 expect(local.peach_last_sync_at).toBeGreaterThan(0);398 });399400 it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {401 local.peach_last_sync_at = 1_700_005_000_000;402 const trigger = await import("./sync-trigger");403 trigger._resetSyncTriggerForTest();404 expect(trigger.getSyncState().lastSyncAt).toBeUndefined();405 await trigger.hydratePersistedSyncState();406 expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);407 });408});