93a60129744a

test: add IV.4 coordinator (HELLO fields, equal-version, clock skew), IV.5 dispatcher, IV.7 static safety assertions; fix test fixtures for new type shapes (v3.6.103)

time
2026-05-22 22:16 UTC · 5 months ago
machine
koh2
parent
d14b4d0b8ef7
7 files src/background/chirp-sync-coordinator.test.ts+80 −1src/background/chirp-sync-runner.test.ts+5 −5src/background/chirp-sync-static.test.ts+99 −0src/background/sync-trigger.test.ts+47 −0src/background/vault.ts+1 −1src/popup/chirp-pair-capacitor.test.ts+4 −1src/shared/sync-identity.test.ts+2 −2
src/background/chirp-sync-coordinator.test.ts+80 −1
@@ -1,4 +1,4 @@1-import { describe, expect, it } from "vitest";1+import { describe, expect, it, vi } from "vitest";22 import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";33 import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";44 import type { ChirpSyncSession } from "../shared/chirp-sync-transport";@@ -78,6 +78,85 @@7878     appliedTombstones,7979   };8080 }81+82+describe("runChirpSyncInitiator — HELLO fields and version optimisation", () => {83+  it("sends HELLO with version and deviceTime", async () => {84+    const [session, peer] = inMemoryPair();85+    const vault = makeVaultAdapter({ localVersion: 7 });86+87+    const initPromise = runChirpSyncInitiator(session, vault);88+89+    const sentHello = await peer.receive();90+    expect(sentHello.type).toBe("SYNC_HELLO");91+    if (sentHello.type === "SYNC_HELLO") {92+      expect(sentHello.version).toBe(7);93+      expect(typeof sentHello.deviceTime).toBe("number");94+      expect(sentHello.deviceTime).toBeGreaterThan(0);95+    }96+    // Send a mismatched vaultId to abort early so the test doesn't hang.97+    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });98+    await peer.receive(); // SYNC_ERROR99+    await initPromise;100+  });101+102+  it("skips manifest exchange and returns completed when versions are equal", async () => {103+    const [session, peer] = inMemoryPair();104+    const vault = makeVaultAdapter({ localVersion: 10 });105+106+    const initPromise = runChirpSyncInitiator(session, vault);107+108+    await peer.receive(); // SYNC_HELLO109+    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });110+111+    // Coordinator should send SYNC_DONE without any manifest exchange.112+    const done = await peer.receive();113+    expect(done.type).toBe("SYNC_DONE");114+115+    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.116+    await peer.send({ type: "SYNC_DONE" });117+118+    const outcome = await initPromise;119+    expect(outcome.status).toBe("completed");120+    expect(outcome.entriesReceived).toBe(0);121+    expect(outcome.entriesSent).toBe(0);122+  });123+124+  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {125+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);126+    const [session, peer] = inMemoryPair();127+    const vault = makeVaultAdapter({ localVersion: 5 });128+129+    const initPromise = runChirpSyncInitiator(session, vault);130+131+    await peer.receive(); // SYNC_HELLO132+    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.133+    await peer.send({134+      type: "SYNC_HELLO",135+      vaultId: "vault-abc",136+      deviceId: "p",137+      version: 3, // different version so exchange proceeds138+      deviceTime: Date.now() - 200_000,139+    });140+    // Yield so the coordinator's async continuation can run the clock-skew check.141+    await new Promise((r) => setTimeout(r, 0));142+    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));143+144+    // Drive the sync to completion so the test doesn't leak.145+    await peer.receive(); // SYNC_MANIFEST146+    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });147+    await peer.receive(); // SYNC_REQUEST148+    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });149+    await peer.receive(); // SYNC_ENTRIES150+    await peer.receive(); // SYNC_TOMBSTONES151+    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });152+    await peer.receive(); // SYNC_DONE153+    await peer.send({ type: "SYNC_DONE" });154+155+    const outcome = await initPromise;156+    expect(outcome.status).toBe("completed");157+    warnSpy.mockRestore();158+  });159+});81160 82161 describe("runChirpSyncInitiator", () => {83162   it("completes a full sync with bi-directional updates", async () => {

view file

src/background/chirp-sync-runner.test.ts+5 −5
@@ -189,7 +189,7 @@189189     return vault;190190   }191191 192-  it("throws VaultLockedError when the vault is locked", async () => {192+  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {193193     const vault = await bootVault();194194     vault.lock();195195     const { runWithDeps } = await import("./chirp-sync-runner");@@ -199,9 +199,8 @@199199       getSubnet: vi.fn(async () => "192.168.1"),200200       createAdapter: emptyAdapter,201201     };202-    await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({203-      name: "VaultLockedError",204-    });202+    const result = await runWithDeps(makeDevice(), deps);203+    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205204     expect(deps.openSession).not.toHaveBeenCalled();206205   });207206 @@ -238,7 +237,7 @@238237     const runPromise = runWithDeps(makeDevice(), deps);239238     await runPeer(peer);240239     const result = await runPromise;241-    expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });240+    expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });242241     expect(openSession).toHaveBeenCalledTimes(1);243242     expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });244243     expect(probeSubnet).not.toHaveBeenCalled();@@ -396,6 +395,7 @@396395       deviceName: "Pixel",397396       deviceType: "android",398397       longTermPublicKeyHex: "a".repeat(64),398+      trustSource: "direct",399399       pairedAt: 1700000000000,400400     });401401     trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));

view file

src/background/chirp-sync-static.test.ts+99 −0
@@ -0,0 +1,99 @@1+// IV.7 — Static safety assertions for Chirp Sync.2+// These tests read source files to enforce structural invariants that3+// are cheaper to verify statically than to exercise at runtime.4+5+import { describe, expect, it } from "vitest";6+import { readFileSync } from "node:fs";7+import { resolve } from "node:path";8+9+const here = __dirname;10+const src = (rel: string) => readFileSync(resolve(here, rel), "utf8");11+12+describe("Chirp Sync — static safety assertions", () => {13+  it("no WebSocket server in any browser-extension sync source file", () => {14+    const files = [15+      "./sync-trigger.ts",16+      "./chirp-sync-runner.ts",17+      "./chirp-sync-coordinator.ts",18+      "./chirp-sync-vault-adapter.ts",19+      "../shared/chirp-sync-transport.ts",20+    ];21+    const forbidden = /WebSocketServer|wss\.listen|createServer|http\.listen|net\.listen/;22+    for (const f of files) {23+      expect(src(f), `${f} must not start a WebSocket server`).not.toMatch(forbidden);24+    }25+  });26+27+  it("applyMergedRemoteEntries does not call fireSyncTrigger or TRIGGER_SYNC", () => {28+    const vaultSrc = src("./vault.ts");29+    const fnStart = vaultSrc.indexOf("applyMergedRemoteEntries");30+    expect(fnStart, "applyMergedRemoteEntries must exist in vault.ts").toBeGreaterThan(-1);31+    // Grab a generous slice starting from the function — large enough to cover32+    // the implementation but not so large that it bleeds into unrelated code.33+    const fnBody = vaultSrc.slice(fnStart, fnStart + 4000);34+    expect(fnBody).not.toMatch(/triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/);35+  });36+37+  it("addEntryDirect / updateEntryDirect / deleteEntryDirect do not call fireSyncTrigger", () => {38+    const vaultSrc = src("./vault.ts");39+    for (const fn of ["addEntryDirect", "updateEntryDirect", "deleteEntryDirect"]) {40+      const start = vaultSrc.indexOf(`function ${fn}`);41+      if (start === -1) continue;42+      // Slice up to the start of the very next export declaration so we don't43+      // bleed into adjacent function bodies or their JSDoc comments.44+      const nextExport = vaultSrc.indexOf("\nexport ", start + fn.length);45+      const end = nextExport > start ? nextExport : start + 600;46+      const body = vaultSrc.slice(start, end);47+      expect(body, `${fn} must not call fireSyncTrigger`).not.toMatch(48+        /triggerSyncIfReachable|TRIGGER_SYNC|fireSyncTrigger/,49+      );50+    }51+  });52+53+  it("device registry is stored in chrome.storage.local, not chrome.storage.sync", () => {54+    const registrySrc = src("./paired-devices.ts");55+    expect(registrySrc).toContain("storage.local");56+    expect(registrySrc).not.toMatch(57+      /storage\.sync[^;]*peach_paired_devices|peach_paired_devices[^;]*storage\.sync/,58+    );59+  });60+61+  it("setSyncRunner and productionSyncRunner are called in entry.ts", () => {62+    const entrySrc = src("./entry.ts");63+    expect(entrySrc).toContain("setSyncRunner");64+    expect(entrySrc).toContain("productionSyncRunner");65+  });66+67+  it("sync-trigger filters to trustSource:'direct' — vouched devices excluded", () => {68+    const triggerSrc = src("./sync-trigger.ts");69+    expect(triggerSrc).toContain('"direct"');70+    expect(triggerSrc).toMatch(/trustSource.*direct|direct.*trustSource/);71+  });72+73+  it("App.tsx wires startMDNSListener with onCleanup registered before the import resolves", () => {74+    const appSrc = src("../popup/App.tsx");75+    expect(appSrc).toContain("startMDNSListener");76+    expect(appSrc).toContain("onCleanup");77+    // onCleanup must appear BEFORE the .then() call that starts the listener,78+    // i.e. stopMDNS must be captured into the closure before dynamic import.79+    const cleanupIdx = appSrc.indexOf("onCleanup(() => stopMDNS");80+    const thenIdx = appSrc.indexOf("startMDNSListener");81+    expect(cleanupIdx).toBeGreaterThan(-1);82+    expect(thenIdx).toBeGreaterThan(-1);83+    expect(cleanupIdx).toBeLessThan(thenIdx);84+  });85+86+  it("vault_id uses the full 64-char BLAKE3 hex digest", () => {87+    const identitySrc = src("../shared/sync-identity.ts");88+    expect(identitySrc).toContain("VAULT_ID_HEX_LENGTH = 64");89+    expect(identitySrc).not.toContain("VAULT_ID_HEX_LENGTH = 16");90+  });91+92+  it("per-entry key derivation uses entryId as part of HKDF info", () => {93+    const adapterSrc = src("./chirp-sync-vault-adapter.ts");94+    expect(adapterSrc).toContain("ENTRY_KEY_INFO_SUFFIX");95+    expect(adapterSrc).toContain("sync-entry-v1");96+    // The info must be derived from entryId — look for the concat pattern.97+    expect(adapterSrc).toMatch(/entryId.*ENTRY_KEY_INFO_SUFFIX|ENTRY_KEY_INFO_SUFFIX.*entryId/);98+  });99+});

view file

src/background/sync-trigger.test.ts+47 −0
@@ -6,6 +6,7 @@66   setSyncRunner,77   _resetSyncTriggerForTest,88   getSyncState,9+  hydratePersistedSyncState,910 } from "./sync-trigger";1011 1112 function makeStorageStub() {@@ -44,6 +45,7 @@4445   deviceName: "Pixel",4546   deviceType: "android",4647   longTermPublicKeyHex: "a".repeat(64),48+  trustSource: "direct",4749   pairedAt: 1700000000000,4850 };4951 @@ -52,6 +54,7 @@5254   deviceName: "iPhone",5355   deviceType: "ios",5456   longTermPublicKeyHex: "b".repeat(64),57+  trustSource: "direct",5558   pairedAt: 1700000000000,5659 };5760 @@ -147,5 +150,49 @@147150     expect(s.entriesReceived).toBe(6);148151     expect(s.entriesSent).toBe(4);149152     expect(typeof s.lastSyncAt).toBe("number");153+  });154+155+  it("syncInFlight is cleared in finally even when runner throws", async () => {156+    await upsertPairedDevice(ANDROID);157+    setSyncRunner(async () => { throw new Error("runner exploded"); });158+    await triggerSyncIfReachable({ reason: "manual", force: true });159+    // After the trigger resolves, a second non-force trigger must be able to run.160+    let ran = false;161+    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });162+    await triggerSyncIfReachable({ reason: "vault_change" });163+    expect(ran).toBe(true);164+  });165+166+  it("does not call runner when only vouched devices are in registry", async () => {167+    const vouched: PairedDevice = {168+      ...ANDROID,169+      deviceId: "VVV-vouched",170+      trustSource: "vouched",171+    };172+    await upsertPairedDevice(vouched);173+    let ran = false;174+    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });175+    await triggerSyncIfReachable({ reason: "foreground" });176+    expect(ran).toBe(false);177+  });178+179+  it("persists peach_last_sync_result to storage after a successful sync", async () => {180+    await upsertPairedDevice(ANDROID);181+    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));182+    await triggerSyncIfReachable({ reason: "manual" });183+    // Read the raw storage value to confirm persistence.184+    const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })185+      .chrome.storage.local.get("peach_last_sync_result");186+    expect(raw.peach_last_sync_result).toEqual({ entriesReceived: 3, entriesSent: 1 });187+  });188+189+  it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {190+    // Write a value directly to storage, then hydrate — simulates SW restart.191+    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })192+      .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });193+    _resetSyncTriggerForTest();194+    expect(getSyncState().lastSyncAt).toBeUndefined();195+    await hydratePersistedSyncState();196+    expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);150197   });151198 });

view file

src/background/vault.ts+1 −1
@@ -1727,7 +1727,7 @@17271727  * Apply a batch of remote entries + tombstones from a chirp-sync peer.17281728  * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when17291729  * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and1730- * persists with reason `sync_received` — does NOT call `fireSyncTrigger`, so1730+ * persists with reason `sync_received` — does NOT fire the sync dispatcher, so17311731  * applying a sync round cannot cascade into another outbound sync.17321732  *17331733  * `applied` counts entries that actually changed local state (new add,

view file

src/popup/chirp-pair-capacitor.test.ts+4 −1
@@ -74,7 +74,10 @@7474     // sendMessage runs inside the handler's `await`; let microtasks flush.7575     await new Promise((r) => setTimeout(r, 0));7676     expect(sentMessages).toEqual([77-      { type: "TRIGGER_SYNC", payload: { reason: "mdns" } },77+      {78+        type: "TRIGGER_SYNC",79+        payload: { reason: "mdns", deviceId: "pixel-7", discoveredIp: "192.168.1.42", discoveredPort: 8765 },80+      },7881     ]);7982   });8083 

view file

src/shared/sync-identity.test.ts+2 −2
@@ -66,9 +66,9 @@6666     expect(a).not.toBe(b);6767   });6868 69-  it("returns 16 hex chars (first 8 bytes of BLAKE3)", async () => {69+  it("returns 64 hex chars (full 32-byte BLAKE3 digest)", async () => {7070     const id = await computeVaultId(MASTER);71-    expect(id).toMatch(/^[0-9a-f]{16}$/);71+    expect(id).toMatch(/^[0-9a-f]{64}$/);7272   });7373 });7474 

view file