diff --git a/npm/marquette/README.md b/npm/marquette/README.md index 4e8ce7b6e..a71cad4de 100644 --- a/npm/marquette/README.md +++ b/npm/marquette/README.md @@ -99,9 +99,11 @@ deployment tooling can bind a `tests` check to retained, immutable facts: import { defineTestRunEvidence } from "@vizejs/marquette/test-run"; import { validateTestRunEvidence } from "@vizejs/marquette/test-run/validate"; import { testRunAdmissionId } from "@vizejs/marquette/test-run/canonical"; +import { admitTestRun } from "@vizejs/marquette/test-run/admission"; const diagnostics = validateTestRunEvidence(evidence); const admissionId = await testRunAdmissionId(evidence); // test-run: +const rejections = await admitTestRun(evidence, candidate, admissionId, now); ``` Validation shares its `VIZE_MARQUETTE_1xx` codes, paths, and ordering with the diff --git a/npm/marquette/package.json b/npm/marquette/package.json index ff663b9d4..32c12aa99 100644 --- a/npm/marquette/package.json +++ b/npm/marquette/package.json @@ -51,6 +51,11 @@ "import": "./dist/test-run-canonical.mjs", "default": "./dist/test-run-canonical.mjs" }, + "./test-run/admission": { + "types": "./dist/test-run-admission.d.mts", + "import": "./dist/test-run-admission.mjs", + "default": "./dist/test-run-admission.mjs" + }, "./schema": "./dist/application-contract.schema.json", "./test-run/schema": "./dist/test-run-evidence.schema.json" }, diff --git a/npm/marquette/scripts/check-size.mjs b/npm/marquette/scripts/check-size.mjs index 21c7b6a60..3e8a55cd5 100644 --- a/npm/marquette/scripts/check-size.mjs +++ b/npm/marquette/scripts/check-size.mjs @@ -22,6 +22,11 @@ const budgets = [ file: "dist/test-run-canonical.mjs", maximumGzipBytes: 2048, }, + { + entry: "@vizejs/marquette/test-run/admission", + file: "dist/test-run-admission.mjs", + maximumGzipBytes: 2048, + }, ]; for (const { entry, file, maximumGzipBytes } of budgets) { diff --git a/npm/marquette/src/test-run-admission.test.ts b/npm/marquette/src/test-run-admission.test.ts new file mode 100644 index 000000000..c102d1c79 --- /dev/null +++ b/npm/marquette/src/test-run-admission.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import type { TestRunEvidence } from "./test-run.js"; +import { admitTestRun, type TestRunCandidate } from "./test-run-admission.js"; +import { testRunAdmissionId } from "./test-run-canonical.js"; + +function readEvidence(): TestRunEvidence { + return JSON.parse( + readFileSync( + new URL("../../../tests/fixtures/test-run-evidence/valid.json", import.meta.url), + "utf8", + ), + ) as TestRunEvidence; +} + +function candidateFor(evidence: TestRunEvidence): TestRunCandidate { + return { + application: evidence.application, + environment: evidence.environment, + contractFingerprint: evidence.contractFingerprint, + sourceRevision: evidence.sourceRevision, + release: evidence.release, + artifactFingerprint: evidence.artifact.fingerprint, + }; +} + +const NOW = "2026-07-22T00:00:00.000Z"; + +function codes(diagnostics: { code: string }[]): string[] { + return diagnostics.map((diagnostic) => diagnostic.code); +} + +void test("an exact candidate is admitted", async () => { + const evidence = readEvidence(); + const id = await testRunAdmissionId(evidence); + assert.deepEqual(await admitTestRun(evidence, candidateFor(evidence), id, NOW), []); +}); + +void test("malformed admission ids are rejected", async () => { + const evidence = readEvidence(); + const rejected = await admitTestRun(evidence, candidateFor(evidence), "test-run:junk", NOW); + assert.deepEqual(codes(rejected), ["VIZE_MARQUETTE_141"]); +}); + +void test("a different record cannot reuse an admission id", async () => { + const evidence = readEvidence(); + const id = await testRunAdmissionId(evidence); + const tampered = { + ...evidence, + suites: evidence.suites.map((suite, index) => + index === 0 ? { ...suite, passed: suite.passed - 1 } : suite, + ), + verification: { ...evidence.verification, passed: evidence.verification.passed - 1 }, + }; + assert.deepEqual(codes(await admitTestRun(tampered, candidateFor(evidence), id, NOW)), [ + "VIZE_MARQUETTE_142", + ]); +}); + +void test("every candidate binding must match", async () => { + const evidence = readEvidence(); + const id = await testRunAdmissionId(evidence); + const candidate = { + ...candidateFor(evidence), + release: "0.999.0", + environment: "staging", + }; + assert.deepEqual(codes(await admitTestRun(evidence, candidate, id, NOW)), [ + "VIZE_MARQUETTE_144", + "VIZE_MARQUETTE_144", + ]); +}); + +void test("expired records and malformed instants fail closed", async () => { + const evidence = readEvidence(); + const id = await testRunAdmissionId(evidence); + assert.deepEqual( + codes(await admitTestRun(evidence, candidateFor(evidence), id, evidence.validUntil)), + ["VIZE_MARQUETTE_145"], + ); + assert.deepEqual(codes(await admitTestRun(evidence, candidateFor(evidence), id, "yesterday")), [ + "VIZE_MARQUETTE_148", + ]); +}); + +void test("skipped tests are not admitted", async () => { + const evidence = readEvidence(); + const skipped = { + ...evidence, + suites: evidence.suites.map((suite, index) => + index === 0 ? { ...suite, skipped: 1, passed: suite.passed - 1 } : suite, + ), + verification: { + ...evidence.verification, + skipped: 1, + passed: evidence.verification.passed - 1, + }, + }; + const id = await testRunAdmissionId(skipped); + assert.deepEqual(codes(await admitTestRun(skipped, candidateFor(skipped), id, NOW)), [ + "VIZE_MARQUETTE_147", + ]); +}); diff --git a/npm/marquette/src/test-run-admission.ts b/npm/marquette/src/test-run-admission.ts new file mode 100644 index 000000000..9a2515e5a --- /dev/null +++ b/npm/marquette/src/test-run-admission.ts @@ -0,0 +1,143 @@ +import type { TestRunEvidence } from "./test-run-model.js"; +import type { MarquetteDiagnostic } from "./validate.js"; +import { parseTestRunAdmissionId, testRunFingerprint } from "./test-run-canonical.js"; +import { validateTestRunEvidence } from "./test-run-validate.js"; +import { error, isStrictTimestamp } from "./test-run-validate-rules.js"; + +export { TEST_RUN_ADMISSION_PREFIX, parseTestRunAdmissionId } from "./test-run-canonical.js"; + +/** + * Exact release candidate a deployment gate wants evidence for. + * + * Every field must match the record exactly; admission never falls back to a + * newer, older, or partially matching record. + */ +export interface TestRunCandidate { + /** Application the gate is deploying. */ + readonly application: string; + /** Deployment environment the gate is promoting into. */ + readonly environment: string; + /** Lowercase SHA-256 fingerprint of the application contract. */ + readonly contractFingerprint: string; + /** Exact source revision of the candidate. */ + readonly sourceRevision: string; + /** Release the candidate belongs to. */ + readonly release: string; + /** Lowercase SHA-256 fingerprint of the exact artifact being promoted. */ + readonly artifactFingerprint: string; +} + +/** + * Decides whether one record admits one exact candidate at one instant. + * + * An empty result admits the deployment. Any diagnostic rejects it: the + * record must validate cleanly, its canonical fingerprint must be the one + * named by `admissionId`, every candidate binding must match exactly, the + * record must not be expired at `now`, the independent verification must + * have accepted the run, and no skipped test may remain unaccounted for. + * + * Codes, paths, messages, and ordering are identical to the native + * implementation, so both host families reach the same decision for the + * same record. + * + * `now` must be a millisecond-precision UTC timestamp such as + * `2026-01-01T00:00:00.000Z`; the fixed-width format keeps the expiry + * comparison exact. Callers own retrieval: fetch the canonical bytes from an + * immutable store within their own deadline, refuse oversized content, and + * hand the parsed record here. + */ +export async function admitTestRun( + evidence: TestRunEvidence, + candidate: TestRunCandidate, + admissionId: string, + now: string, +): Promise { + const diagnostics = validateTestRunEvidence(evidence); + + const nowIsExact = isStrictTimestamp(now); + if (!nowIsExact) { + diagnostics.push( + error( + "VIZE_MARQUETTE_148", + "admission.now", + "admission time must be a millisecond-precision UTC instant", + ), + ); + } + + const expected = parseTestRunAdmissionId(admissionId); + if (expected === undefined) { + diagnostics.push( + error( + "VIZE_MARQUETTE_141", + "admission.id", + "admission id must be test-run: followed by 64 lowercase hexadecimal characters", + ), + ); + } else if ((await testRunFingerprint(evidence)) !== expected) { + diagnostics.push( + error( + "VIZE_MARQUETTE_142", + "admission.id", + "admission id does not name this record's canonical fingerprint", + ), + ); + } + + const bindings = [ + [evidence.application, candidate.application, "application"], + [evidence.environment, candidate.environment, "environment"], + [evidence.contractFingerprint, candidate.contractFingerprint, "contractFingerprint"], + [evidence.sourceRevision, candidate.sourceRevision, "sourceRevision"], + [evidence.release, candidate.release, "release"], + [evidence.artifact.fingerprint, candidate.artifactFingerprint, "artifact.fingerprint"], + ] as const; + for (const [recorded, wanted, field] of bindings) { + if (recorded !== wanted) { + diagnostics.push( + error("VIZE_MARQUETTE_144", field, `record does not bind the candidate ${field}`), + ); + } + } + + if (nowIsExact && evidence.validUntil <= now) { + diagnostics.push( + error("VIZE_MARQUETTE_145", "validUntil", "record is expired at the admission time"), + ); + } + if (evidence.verification.outcome !== "accepted") { + diagnostics.push( + error( + "VIZE_MARQUETTE_146", + "verification.outcome", + "only an accepted verification can admit a deployment", + ), + ); + } + if (evidence.verification.skipped > 0) { + diagnostics.push( + error( + "VIZE_MARQUETTE_147", + "verification.skipped", + "skipped tests are not approved for deployment admission", + ), + ); + } + + diagnostics.sort((left, right) => + left.path !== right.path + ? left.path < right.path + ? -1 + : 1 + : left.code !== right.code + ? left.code < right.code + ? -1 + : 1 + : left.message < right.message + ? -1 + : left.message > right.message + ? 1 + : 0, + ); + return diagnostics; +} diff --git a/npm/marquette/vite.config.ts b/npm/marquette/vite.config.ts index ce999bcff..9f6f0020d 100644 --- a/npm/marquette/vite.config.ts +++ b/npm/marquette/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "src/test-run.ts", "src/test-run-validate.ts", "src/test-run-canonical.ts", + "src/test-run-admission.ts", ], format: "esm", dts: true,