Stage 7 close-out: verifier hint wiring (pointer identity into quarantine) + quarantine round-trip tests S06-S09. 10 new tests (store round-trip, reopen persistence, B-6 v1->v2 migration keeps favorites, slot-DB deletion isolation, no-loop skip with ZERO file fetches, latest.json advance clears skip, bad-sig lands in IDB via sink). Full CI green: 285 tests.
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
48685b3cc8
commit
853c19fbac
2 changed files with 363 additions and 0 deletions
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
359
tests/unit/quarantine.test.ts
Normal file
359
tests/unit/quarantine.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, Uint8Array>();
|
||||
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<string, Uint8Array>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue