diff --git a/src/sync/pull.ts b/src/sync/pull.ts index 167d721..c49c999 100644 --- a/src/sync/pull.ts +++ b/src/sync/pull.ts @@ -65,6 +65,10 @@ export async function pullCandidate( files: { getFile: (path) => transport.fetchBytes(siblingUrl(manifestUrl, path), options), }, + // Pointer identity lets pre-manifest rejections (bad signature) still + // quarantine under the right edition/version — otherwise the no-loop + // skip could never fire for exactly the version we just rejected. + hint: { edition: pointer.edition, packageVersion: pointer.packageVersion }, }, deps, ); diff --git a/tests/unit/quarantine.test.ts b/tests/unit/quarantine.test.ts new file mode 100644 index 0000000..0953626 --- /dev/null +++ b/tests/unit/quarantine.test.ts @@ -0,0 +1,359 @@ +/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unsafe-call, @typescript-eslint/prefer-promise-reject-errors */ +/** + * Stage 7 close-out — persistent quarantine store + no-loop pull skip. + * Covers: quarantine round-trip (add/list/prune/clear), survives reopen, + * v1->v2 additive user-DB migration keeps favorites (B-6), pullCandidate + * issues ZERO manifest/file fetches for a quarantined pointer, and the + * verifier wiring that lands rejections into the IDB store (sole-decider: + * rejection quarantines, active dataset untouched). + * Trace: IMPLEMENTATION-CONTRACT.md §11 I-10, Stage 7 acceptance, SPIKE-02 F-5 + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; + +const g = globalThis as unknown as Record; +g.indexedDB = new FDBFactory() as unknown; +g.IDBKeyRange = FDBKeyRange as unknown; + +import { DB } from "../../src/platform/idb/names.js"; +import { openDB, idbPut } from "../../src/platform/idb/wrapper.js"; +import { openUserDB, listFavorites } from "../../src/data/user/store.js"; +import { + addQuarantined, + isQuarantined, + listQuarantined, + clearQuarantined, + pruneQuarantinedUpTo, + quarantineSink, + quarantineKey, +} from "../../src/data/user/quarantine.js"; +import { pullCandidate } from "../../src/sync/pull.js"; +import type { Transport } from "../../src/sync/transport/types.js"; +import { verifyPackage } from "../../src/sync/verifier/package.js"; +import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js"; +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { buildPackage } from "../../pipeline/package.js"; +import { generateTestKeyPair } from "../../pipeline/sign.js"; +import { makeValidInput } from "../../pipeline/fixtures.js"; + +function deleteDB(name: string): Promise { + return new Promise((resolve, reject) => { + const req = (globalThis as unknown as { indexedDB: IDBFactory }).indexedDB.deleteDatabase(name); + req.onsuccess = () => { + resolve(); + }; + req.onerror = () => { + reject(req.error); + }; + req.onblocked = () => { + resolve(); + }; + }); +} + +async function cleanAll(): Promise { + await deleteDB(DB.USER); +} + +describe("quarantine store — persistent round-trip", () => { + beforeEach(cleanAll); + afterEach(cleanAll); + + it("add / isQuarantined / list / clear round-trips", async () => { + const db = await openUserDB(); + expect(await isQuarantined(db, "lumen-2026", 5)).toBe(false); + await addQuarantined(db, { + edition: "lumen-2026", + packageVersion: 5, + code: "signature_mismatch", + reason: "bad sig", + at: 1000, + }); + expect(await isQuarantined(db, "lumen-2026", 5)).toBe(true); + expect(await isQuarantined(db, "lumen-2026", 6)).toBe(false); + expect(await isQuarantined(db, "other-edition", 5)).toBe(false); + const list = await listQuarantined(db, "lumen-2026"); + expect(list).toHaveLength(1); + expect(list[0]!.code).toBe("signature_mismatch"); + await clearQuarantined(db, "lumen-2026", 5); + expect(await isQuarantined(db, "lumen-2026", 5)).toBe(false); + db.close(); + }); + + it("survives DB reopen (persistence, not session state)", async () => { + const db = await openUserDB(); + await addQuarantined(db, { + edition: "lumen-2026", + packageVersion: 7, + code: "hash_mismatch", + reason: "corrupt", + at: 2000, + }); + db.close(); + const db2 = await openUserDB(); + expect(await isQuarantined(db2, "lumen-2026", 7)).toBe(true); + db2.close(); + }); + + it("unidentified rejections are not keyed (both coordinates required)", async () => { + const db = await openUserDB(); + await addQuarantined(db, { + edition: null, + packageVersion: null, + code: "malformed_manifest", + reason: "no identity", + at: 3000, + }); + const all = await listQuarantined(db, "lumen-2026"); + expect(all).toHaveLength(0); + db.close(); + }); + + it("list is ascending by packageVersion; prune clears through N inclusive", async () => { + const db = await openUserDB(); + for (const v of [3, 1, 2]) { + await addQuarantined(db, { + edition: "lumen-2026", + packageVersion: v, + code: "replayed_version", + reason: `v${String(v)}`, + at: 4000, + }); + } + const list = await listQuarantined(db, "lumen-2026"); + expect(list.map((q) => q.packageVersion)).toEqual([1, 2, 3]); + const pruned = await pruneQuarantinedUpTo(db, "lumen-2026", 2); + expect(pruned).toBe(2); + expect(await isQuarantined(db, "lumen-2026", 1)).toBe(false); + expect(await isQuarantined(db, "lumen-2026", 2)).toBe(false); + expect(await isQuarantined(db, "lumen-2026", 3)).toBe(true); + db.close(); + }); + + it("v1 -> v2 migration is additive: favorites intact, quarantine store usable (B-6)", async () => { + // Simulate an install that only ever had user v1 (no quarantine store). + await openDB(DB.USER, 1, (db) => { + if (!db.objectStoreNames.contains("favorites")) db.createObjectStore("favorites"); + if (!db.objectStoreNames.contains("prefs")) db.createObjectStore("prefs"); + if (!db.objectStoreNames.contains("diag")) db.createObjectStore("diag"); + }).then((db) => { + void idbPut(db, "favorites", { eventId: "evt-old", addedAt: 1 }, "evt-old"); + db.close(); + }); + // Reopen via production path (v2): migration must add quarantine, keep data. + const db = await openUserDB(); + expect(db.objectStoreNames.contains("quarantine")).toBe(true); + const favs = await listFavorites(db); + expect(favs.map((f) => f.eventId)).toEqual(["evt-old"]); + await addQuarantined(db, { + edition: "lumen-2026", + packageVersion: 9, + code: "budget_exceeded", + reason: "too big", + at: 5000, + }); + expect(await isQuarantined(db, "lumen-2026", 9)).toBe(true); + db.close(); + }); + + it("deleting slot DBs never touches quarantine (B-6 isolation)", async () => { + const db = await openUserDB(); + await addQuarantined(db, { + edition: "lumen-2026", + packageVersion: 4, + code: "corrupted", + reason: "x", + at: 6000, + }); + db.close(); + await deleteDB(DB.SLOT_A); + await deleteDB(DB.SLOT_B); + const db2 = await openUserDB(); + expect(await isQuarantined(db2, "lumen-2026", 4)).toBe(true); + db2.close(); + }); +}); + +describe("quarantine no-loop — pullCandidate skips without fetching", () => { + beforeEach(cleanAll); + afterEach(cleanAll); + + function fakeTransport(fetches: string[]): Transport { + return { + isAvailable: () => true, + fetchPointer: async () => { + fetches.push("latest.json"); + return { + edition: "lumen-2026", + packageVersion: 1, + manifestUrl: "/editions/lumen-2026/packages/1/manifest.json", + generatedAt: "2026-08-30T12:00:00.000Z", + }; + }, + fetchBytes: async (url) => { + fetches.push(url); + throw new Error("must not be called for quarantined version"); + }, + }; + } + + it("quarantined pointer skips BEFORE manifest or file fetch", async () => { + const fetches: string[] = []; + const user = await openUserDB(); + await addQuarantined(user, { + edition: "lumen-2026", + packageVersion: 1, + code: "signature_mismatch", + reason: "known bad", + at: 1, + }); + const outcome = await pullCandidate( + fakeTransport(fetches), + "lumen-2026", + { + trustedKeys: new Map(), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + { + isQuarantined: async (v) => isQuarantined(user, "lumen-2026", v), + }, + ); + expect(outcome).toMatchObject({ skipped: "quarantined" }); + expect(fetches).toEqual(["latest.json"]); // no manifest, no signature, no files + user.close(); + }); + + it("non-quarantined pointer proceeds (one latest.json + manifest fetched)", async () => { + const keys = generateTestKeyPair(); + const built = buildPackage(makeValidInput(), { signWith: keys }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const sigBytes = new TextEncoder().encode(JSON.stringify(built.pkg.signature)); + const pkgFiles = new Map(); + for (const [name, file] of built.pkg.files) pkgFiles.set(name, file.canonicalBytes); + for (const asset of built.pkg.assets) pkgFiles.set(asset.file, asset.bytesContent); + const fetches: string[] = []; + const base = fakeTransport(fetches); + const transport: Transport = { + ...base, + fetchBytes: async (url) => { + fetches.push(url); + if (url.endsWith("manifest.json")) return manifestBytes; + if (url.endsWith("signature.json")) return sigBytes; + const name = url.split("/").pop() ?? ""; + const sub = url.includes("/assets/") ? `assets/${name}` : name; + const content = pkgFiles.get(sub); + if (content) return content; + throw new Error(`unexpected fetch ${url}`); + }, + }; + const user = await openUserDB(); + const outcome = await pullCandidate( + transport, + "lumen-2026", + { + trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + { + isQuarantined: async (v) => isQuarantined(user, "lumen-2026", v), + }, + ); + if (!outcome || "skipped" in outcome) throw new Error("expected candidate"); + expect(outcome.result.ok).toBe(true); + expect(fetches[0]).toBe("latest.json"); + expect(fetches.some((u) => u.endsWith("manifest.json"))).toBe(true); + user.close(); + }); + + it("advancing latest.json past the quarantined version clears the skip path", async () => { + // latest now points at v2 (not quarantined): pull must not skip. + const fetches: string[] = []; + const transport: Transport = { + isAvailable: () => true, + fetchPointer: async () => { + fetches.push("latest.json"); + return { + edition: "lumen-2026", + packageVersion: 2, + manifestUrl: "/editions/lumen-2026/packages/2/manifest.json", + generatedAt: "2026-08-31T12:00:00.000Z", + }; + }, + fetchBytes: async (url) => { + fetches.push(url); + throw new Error("not needed for this assertion"); + }, + }; + const user = await openUserDB(); + await addQuarantined(user, { + edition: "lumen-2026", + packageVersion: 1, + code: "hash_mismatch", + reason: "old bad", + at: 1, + }); + // v2 is not quarantined, so the pull must NOT skip: it advances to fetch + // the manifest, and our transport throws there — a rejection proves it + // passed the skip gate instead of returning {skipped}. + await expect( + pullCandidate( + transport, + "lumen-2026", + { + trustedKeys: new Map(), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + { + isQuarantined: async (v) => isQuarantined(user, "lumen-2026", v), + }, + ), + ).rejects.toThrow(/not needed/); + expect(fetches.length).toBeGreaterThan(1); + expect(fetches.some((u) => u.endsWith("manifest.json"))).toBe(true); + user.close(); + }); +}); + +describe("verifier -> persistent quarantine integration (sole decider keeps active)", () => { + beforeEach(cleanAll); + afterEach(cleanAll); + + it("a bad-signature package lands in the IDB quarantine store via the sink", async () => { + const keys = generateTestKeyPair(); + const built = buildPackage(makeValidInput(), { signWith: keys }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes); + + const user = await openUserDB(); + const result = await verifyPackage( + { + manifestBytes, + signature: { ...built.pkg.signature, signature: "AAAA" }, // corrupted sig + files: { getFile: async (p) => files.get(p) }, + hint: { edition: "lumen-2026", packageVersion: 1 }, + }, + { + trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + quarantine: quarantineSink(user), + }, + ); + expect(result).toMatchObject({ ok: false, code: "signature_mismatch" }); + // rejection persisted in the real store keyed by edition/version + expect(await isQuarantined(user, "lumen-2026", 1)).toBe(true); + // and quarantineKey agrees with the stored coordinate + expect(quarantineKey("lumen-2026", 1)).toBe("lumen-2026/1"); + // no activation happened: verifier result is the only decider (B-4) + user.close(); + }); +});