Merge branch 'main' into feature/techlog-ui-migration
Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI migration. Merged in this direction so every conflict is resolved and proved in the worktree; main is only fast-forwarded afterwards and never holds a state that was not verified here. 15 conflicts. The rule throughout: keep the template's mechanism, keep the product's content, and never invent a third state neither branch would accept. The template's product manifest and its runtime feature kill switch are adopted. The route registry is deliberately not composed from contract.routes: the reference feature still declares screens this product deleted, and reducing over them would register paths with no component behind them. ROUTE_FEATURE_OWNER is narrowed to registered routes for the same reason. The first resolution did compose from contract.routes and was rejected by product-features.test.ts. Three files pinned counts and a digest describing the gate contract. Neither side's numbers describe the merged config/ci/gates.json, so they were recomputed from it rather than chosen: 27 gates, 82 commands, 94 command references, 107 evidence references, 128 artifacts, shape sha256 5063586d. README.md and docs/accessibility/manual-checklist.md now enumerate this product's 27 routes, which the template's own verify:documentation requires. product-feature-switch.test.tsx was rewritten around the invariant that still applies here — no registered route without a component — rather than deleted with the screens it used to exercise. docs/operations/template-merge-2026-08-17.md records every decision, the gate results, and the three follow-ups this merge deliberately did not decide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,9 @@ jobs:
|
||||
outputs:
|
||||
dist_sha256: \${{ steps.candidate.outputs.dist_sha256 }}
|
||||
archive_sha256: \${{ steps.candidate.outputs.archive_sha256 }}
|
||||
env:
|
||||
APP_PROFILE: "\${{ vars.APP_PROFILE }}"
|
||||
RELEASE_TARGET: "\${{ vars.RELEASE_TARGET }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
declare module "../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
@@ -99,6 +99,7 @@ describe("application input/output boundary", () => {
|
||||
}),
|
||||
},
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
navigation: { reload: () => {} },
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
@@ -53,6 +53,7 @@ function applicationWith(options: {
|
||||
diagnostics: options.diagnostics ?? { record: () => {} },
|
||||
telemetry: options.telemetry ?? { emit: () => {} },
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
releaseInfo: {
|
||||
getCurrent: async () => current,
|
||||
refresh:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { constants, readFileSync } from "node:fs";
|
||||
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../scripts/lib/ci-artifact-validator.ts";
|
||||
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
|
||||
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
|
||||
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
|
||||
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
|
||||
import {
|
||||
CANDIDATE_ARCHIVE_USAGE,
|
||||
@@ -46,6 +47,14 @@ import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
|
||||
/**
|
||||
* Budget for the provider suites specifically. They spawn a systemd scope, a
|
||||
* bubblewrap sandbox and a signing provider, and build a release candidate to
|
||||
* do it; the 10s default is sized for pure-JS unit tests. Raising the global
|
||||
* default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
let providerBaseRoot: string | undefined;
|
||||
const sha256 = (value: Buffer | string) =>
|
||||
@@ -756,6 +765,10 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
},
|
||||
});
|
||||
const completionStarted = Date.now();
|
||||
// Start recording before anything is asserted: the provider's whole life is
|
||||
// shorter than one `systemctl show`, so the tree has to be sampled, not
|
||||
// sampled once at whatever moment the assertions happen to arrive.
|
||||
const tree = recordProviderProcessTree(execution.child.pid!);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
||||
const properties = showProviderUnit(unit);
|
||||
expect(properties).toMatchObject({
|
||||
@@ -775,10 +788,21 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
||||
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
const cgroupPids = await readCgroupPids(cgroupRoot);
|
||||
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
|
||||
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
|
||||
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
tree.stop();
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
expect(result.stderr).not.toContain(credential);
|
||||
|
||||
// Everything below reads the recording of the whole run rather than a live
|
||||
// snapshot. The sandbox exists for a few hundred milliseconds; asserting
|
||||
// while it runs meant racing it, and the assertions are about what the run
|
||||
// contained, not about what a particular instant looked like.
|
||||
const cgroupPids = tree.cgroupPids();
|
||||
const processArguments = tree.cgroupArguments();
|
||||
const directChildPids = tree.directChildPids();
|
||||
const directChildArguments = tree.directChildArguments();
|
||||
const observedArguments = [
|
||||
showProcessArguments(execution.child.pid),
|
||||
...directChildArguments,
|
||||
@@ -788,18 +812,15 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
|
||||
.toBeGreaterThan(0);
|
||||
expect(
|
||||
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
|
||||
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
).toBeGreaterThan(0);
|
||||
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
|
||||
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
|
||||
);
|
||||
expect(infrastructureArguments.join("\n")).not.toContain(command);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
expect(result.stderr).not.toContain(credential);
|
||||
expect(Date.now() - completionStarted).toBeLessThan(4_000);
|
||||
await expectProviderUnitGone(unit);
|
||||
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -807,7 +828,7 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
|
||||
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
||||
}, 10_000);
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("kills and collects an active provider when its guardian dies", async () => {
|
||||
const fixture = await createProviderFixture();
|
||||
@@ -842,12 +863,15 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
|
||||
.toEqual([]);
|
||||
|
||||
// `providerWriter` is only a path; the retry has to materialise the script
|
||||
// it names or the clean-retry claim is proven by a module-not-found error.
|
||||
await writeFile(fixture.providerWriter, providerV2WriterSource());
|
||||
const retried = runProviderSupervisor(fixture, {
|
||||
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
||||
sealedPath,
|
||||
});
|
||||
expect(retried.status, retried.stderr).toBe(0);
|
||||
}, 15_000);
|
||||
}, 20_000);
|
||||
|
||||
it("collects the whole provider scope when its supervisor dies", async () => {
|
||||
const fixture = await createProviderFixture();
|
||||
@@ -1272,9 +1296,17 @@ describe("verified promotion finalizer", () => {
|
||||
"provider-verification.json": valid["promotion-verification.json"],
|
||||
"promotion-verification.json": valid["provider-verification.json"],
|
||||
};
|
||||
// `{}` never reaches the digest comparison: it fails the report schema
|
||||
// first, so this case asserted a decode error while claiming to cover the
|
||||
// digest branch. The substitution has to be a structurally valid report
|
||||
// that simply is not the one the verification records committed to.
|
||||
const divergentReport = JSON.parse(
|
||||
valid["vulnerability-report.json"].toString("utf8"),
|
||||
) as Record<string, any>;
|
||||
divergentReport.provider = "divergent-provider";
|
||||
const reportMismatch = {
|
||||
...valid,
|
||||
"vulnerability-report.json": Buffer.from("{}\n"),
|
||||
"vulnerability-report.json": jsonBytes(divergentReport),
|
||||
};
|
||||
const absent = { ...valid } as Partial<typeof valid>;
|
||||
delete absent["provider-verification.json"];
|
||||
@@ -1513,7 +1545,10 @@ describe("verified promotion finalizer", () => {
|
||||
}),
|
||||
).rejects.toThrow(/leaf.*identity|staging leaf/u);
|
||||
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
||||
await expect(readdir(saved)).resolves.toEqual([]);
|
||||
// A substituted leaf aborts the cleanup before anything is removed, so the
|
||||
// promotion this call owned is still intact and a retry sees a coherent
|
||||
// directory rather than a half-emptied one.
|
||||
expect((await readdir(saved)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort());
|
||||
}, 30_000);
|
||||
|
||||
it("never deletes an unrelated leaf substituted after cleanup validation", async () => {
|
||||
@@ -1678,28 +1713,31 @@ async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
|
||||
.trim().split("\n").filter(Boolean).map(Number);
|
||||
}
|
||||
|
||||
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
|
||||
const children = (await readFile(childrenPath, "utf8"))
|
||||
.trim().split(/\s+/u).filter(Boolean).map(Number);
|
||||
const arguments_ = children.flatMap((pid) => {
|
||||
try {
|
||||
return [showProcessArguments(pid)];
|
||||
} catch (error) {
|
||||
if (!processExists(pid)) return [];
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
async function waitForRecordedProviderTree(
|
||||
tree: ReturnType<typeof recordProviderProcessTree>,
|
||||
providerScript: string,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||||
const children = tree.directChildArguments();
|
||||
const members = tree.cgroupArguments();
|
||||
// Every process the assertions below reason about. Returning as soon as
|
||||
// some of them are present is what left bubblewrap out of the recording:
|
||||
// it enters the scope a few milliseconds after the wrapper does.
|
||||
if (
|
||||
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
||||
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
|
||||
children.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
||||
children.some((value) => value.includes("provider-raw-guardian.ts")) &&
|
||||
members.some((value) => value.includes("provider-scope-wrapper.ts")) &&
|
||||
members.some((value) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(value)) &&
|
||||
members.some((value) => value.includes(providerScript))
|
||||
) {
|
||||
return children;
|
||||
return;
|
||||
}
|
||||
await delay(25);
|
||||
await delay(10);
|
||||
}
|
||||
throw new Error("provider supervisor children were not simultaneously observable");
|
||||
throw new Error(
|
||||
"the provider run never contained a systemd-run child, a guardian child, " +
|
||||
"a scope wrapper, a sandbox and the provider itself in its cgroup",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
|
||||
@@ -1844,6 +1882,8 @@ function readProviderUnitMetadata(unitName: string): string {
|
||||
|
||||
function showProcessArguments(pid: number | undefined): string {
|
||||
if (!pid) throw new Error("provider supervisor did not expose its PID");
|
||||
const argv = readProcessArguments(pid);
|
||||
if (argv !== null) return argv;
|
||||
const result = spawnSync(
|
||||
"/usr/bin/ps",
|
||||
["-o", "args=", "-p", String(pid)],
|
||||
@@ -1854,6 +1894,102 @@ function showProcessArguments(pid: number | undefined): string {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a process's argv straight from `/proc`.
|
||||
*
|
||||
* Spawning `ps` per pid costs milliseconds each, and the provider sandbox now
|
||||
* completes a whole run in well under a second — the observation was losing a
|
||||
* race against the thing it was observing. Returns null for a process that is
|
||||
* already gone, which the sampler treats as "nothing more to record".
|
||||
*/
|
||||
function readProcessArguments(pid: number): string | null {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cmdline`, "utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the provider's process tree for the whole life of the run.
|
||||
*
|
||||
* The assertions below are about what the sandbox looked like while it was
|
||||
* running, and a single snapshot taken afterwards can only ever be a guess at
|
||||
* that. Sampling from the moment the supervisor starts turns "did we look at
|
||||
* the right instant?" into "what did this run actually contain?".
|
||||
*/
|
||||
function recordProviderProcessTree(supervisorPid: number) {
|
||||
const directChildren = new Map<number, string>();
|
||||
const cgroupMembers = new Map<number, string>();
|
||||
let stopped = false;
|
||||
|
||||
const remember = (into: Map<number, string>, pid: number): void => {
|
||||
if (into.has(pid)) return;
|
||||
const argv = readProcessArguments(pid);
|
||||
if (argv !== null && argv.length > 0) into.set(pid, argv);
|
||||
};
|
||||
const childrenOf = (pid: number): number[] => {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Scope membership is read from the process itself rather than from
|
||||
* `systemctl show`. Waiting for the unit to be described before watching its
|
||||
* cgroup meant bubblewrap had usually already exited by the time the first
|
||||
* sample was taken — the recording missed exactly the process the assertions
|
||||
* are about.
|
||||
*/
|
||||
const inProviderScope = (pid: number): boolean => {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cgroup`, "utf8").includes("ca-provider-");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const sample = (): void => {
|
||||
if (stopped) return;
|
||||
const direct = childrenOf(supervisorPid);
|
||||
for (const pid of direct) remember(directChildren, pid);
|
||||
// Walk the whole subtree: the scope wrapper, bubblewrap and the provider
|
||||
// itself sit below systemd-run, not beside it.
|
||||
const pending = [...direct];
|
||||
const seen = new Set(direct);
|
||||
while (pending.length > 0 && seen.size < 512) {
|
||||
const pid = pending.pop()!;
|
||||
if (inProviderScope(pid)) remember(cgroupMembers, pid);
|
||||
for (const child of childrenOf(pid)) {
|
||||
if (seen.has(child)) continue;
|
||||
seen.add(child);
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
const timer = setInterval(sample, 5);
|
||||
timer.unref();
|
||||
sample();
|
||||
return Object.freeze({
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
},
|
||||
directChildPids: () => [...directChildren.keys()],
|
||||
directChildArguments: () => [...directChildren.values()],
|
||||
cgroupPids: () => [...cgroupMembers.keys()],
|
||||
cgroupArguments: () => [...cgroupMembers.values()],
|
||||
});
|
||||
}
|
||||
|
||||
async function startLoopbackCanary(root: string): Promise<Readonly<{
|
||||
child: ChildProcess;
|
||||
marker: string;
|
||||
@@ -2128,10 +2264,17 @@ async function ensureProviderBaseFixture(): Promise<string> {
|
||||
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
|
||||
},
|
||||
});
|
||||
await cp(path.join(sourceRoot, "artifacts"), path.join(root, "artifacts"), {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(root, "artifacts/release"), { recursive: true, force: true });
|
||||
// The release evidence, minus the trace and Storybook trees. Copying the
|
||||
// whole `artifacts/` directory pulled ~28MB of test output into every
|
||||
// provider fixture; sharing the copier with the removal fixture is also what
|
||||
// makes both fixtures contain the same evidence.
|
||||
// `artifacts/release` travels with the fixture rather than being deleted and
|
||||
// rebuilt. Supply-chain generation runs before the candidate is created and
|
||||
// validates the release evidence paths, so deleting them made that step
|
||||
// depend on a previous local run having left them behind — which is why this
|
||||
// fixture only worked in a workspace that had already built a candidate. The
|
||||
// build chain overwrites them anyway.
|
||||
await copyReleaseEvidenceTree(sourceRoot, root);
|
||||
await linkFixtureNodeModules(root, sourceRoot);
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: sourceRoot,
|
||||
|
||||
@@ -184,16 +184,18 @@ describe("CI gate contract", () => {
|
||||
const index = indexCiGateContract(contract);
|
||||
expect(contract.schemaVersion).toBe(2);
|
||||
expect(contract.gates.map(({ id }) => id)).toEqual(
|
||||
Array.from({ length: 26 }, (_, index) =>
|
||||
Array.from({ length: 27 }, (_, index) =>
|
||||
`FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||
),
|
||||
);
|
||||
expect(contract.jobs).toHaveLength(9);
|
||||
expect(contract.commands).toHaveLength(81);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93);
|
||||
expect(contract.commands).toHaveLength(82);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(106);
|
||||
expect(contract.artifacts).toHaveLength(126);
|
||||
// Template merge. Recounted from the merged config/ci/gates.json rather
|
||||
// than taking either side's number.
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(107);
|
||||
expect(contract.artifacts).toHaveLength(128);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEPLOYMENT_TARGETS,
|
||||
findAdmissionViolations,
|
||||
isDeploymentTarget,
|
||||
type AdmissionInput,
|
||||
} from "../../src/contracts/deployment-admission.ts";
|
||||
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
|
||||
import { generateRuntimeConfig } from "../../scripts/generate-runtime-config.ts";
|
||||
|
||||
const PRODUCTION_ARTIFACT: AdmissionInput = Object.freeze({
|
||||
APP_ENV: "production",
|
||||
API_BASE_URL: "https://api.example.com/",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "external",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-2026.08.15-1",
|
||||
CAPABILITY_OVERRIDES: Object.freeze({
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
}),
|
||||
}) as AdmissionInput;
|
||||
|
||||
const LOCAL_ARTIFACT: AdmissionInput = Object.freeze({
|
||||
...PRODUCTION_ARTIFACT,
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
AUTH_MODE: "demo",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
}) as AdmissionInput;
|
||||
|
||||
describe("deployment admission", () => {
|
||||
it("admits an artifact only to the environment it declares", () => {
|
||||
expect(findAdmissionViolations("production", PRODUCTION_ARTIFACT)).toEqual([]);
|
||||
expect(findAdmissionViolations("local", LOCAL_ARTIFACT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the exact local build that release coherence used to approve", () => {
|
||||
// The review's strongest reproduction: FE-GATE-015 passed on a build whose
|
||||
// runtime document was APP_ENV=local / AUTH_MODE=demo / loopback API. Each
|
||||
// of those is now an independent refusal, so fixing one does not admit it.
|
||||
const violations = findAdmissionViolations("production", LOCAL_ARTIFACT);
|
||||
const fields = violations.map((violation) => violation.field);
|
||||
expect(fields).toContain("APP_ENV");
|
||||
expect(fields).toContain("AUTH_MODE");
|
||||
expect(fields).toContain("API_BASE_URL");
|
||||
expect(fields).toContain("BUILD_ID");
|
||||
expect(fields).toContain("RELEASE_ID");
|
||||
});
|
||||
|
||||
it("refuses endpoints a browser on the public internet cannot reach", () => {
|
||||
for (const host of [
|
||||
"http://api.example.com/",
|
||||
"https://localhost/",
|
||||
"https://127.0.0.1/",
|
||||
"https://10.0.0.5/",
|
||||
"https://192.168.1.10/",
|
||||
"https://172.16.4.4/",
|
||||
"https://169.254.169.254/",
|
||||
"https://[::1]/",
|
||||
]) {
|
||||
const violations = findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
API_BASE_URL: host,
|
||||
} as AdmissionInput);
|
||||
expect(violations.map((violation) => violation.field), host).toContain(
|
||||
"API_BASE_URL",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("permits a routable public host", () => {
|
||||
expect(
|
||||
findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
API_BASE_URL: "https://api.172.16.example.com/",
|
||||
} as AdmissionInput),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a placeholder identity on a public target", () => {
|
||||
for (const buildId of ["local-build", "local", "dev", "unknown", ""]) {
|
||||
const violations = findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
BUILD_ID: buildId,
|
||||
} as AdmissionInput);
|
||||
expect(violations.map((violation) => violation.field), buildId).toContain(
|
||||
"BUILD_ID",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unknown target as not a target at all", () => {
|
||||
for (const value of ["prod", "PRODUCTION", "", undefined, null, 1]) {
|
||||
expect(isDeploymentTarget(value), String(value)).toBe(false);
|
||||
}
|
||||
for (const target of DEPLOYMENT_TARGETS) {
|
||||
expect(isDeploymentTarget(target)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime config profiles", () => {
|
||||
it("ships one valid profile per deployment target", async () => {
|
||||
const files = (await readdir("config/runtime")).sort();
|
||||
expect(files).toEqual(
|
||||
[...DEPLOYMENT_TARGETS].map((target) => `${target}.json`).sort(),
|
||||
);
|
||||
for (const target of DEPLOYMENT_TARGETS) {
|
||||
const source: unknown = JSON.parse(
|
||||
await readFile(path.join("config/runtime", `${target}.json`), "utf8"),
|
||||
);
|
||||
const parsed = runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...(source as Record<string, unknown>),
|
||||
BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-1",
|
||||
});
|
||||
expect(parsed.success, `${target}: ${JSON.stringify(parsed.error?.issues)}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect((source as Record<string, unknown>)["APP_ENV"]).toBe(target);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces an admissible document for every public target", async () => {
|
||||
for (const target of ["staging", "production"] as const) {
|
||||
const config = await generateRuntimeConfig(target, {
|
||||
VITE_BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-2026.08.15-1",
|
||||
});
|
||||
expect(
|
||||
findAdmissionViolations(target, config as unknown as AdmissionInput),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a deployment override that would make the document unservable", async () => {
|
||||
await expect(
|
||||
generateRuntimeConfig("production", {
|
||||
VITE_BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-1",
|
||||
RUNTIME_API_BASE_URL: "http://api.example.com/",
|
||||
}),
|
||||
).rejects.toThrow(/runtime config is invalid/u);
|
||||
});
|
||||
|
||||
it("keeps a developer build from carrying a released identity by default", async () => {
|
||||
const config = await generateRuntimeConfig("local", {});
|
||||
expect(config["BUILD_ID"]).toBe("local-build");
|
||||
expect(
|
||||
findAdmissionViolations("production", config as unknown as AdmissionInput)
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,76 @@ async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("runtime request deadline ceiling", () => {
|
||||
/**
|
||||
* `REQUEST_TIMEOUT_MS` was validated by the runtime config schema and then
|
||||
* never handed to the V3 executor, so the deployment dial did nothing and
|
||||
* every operation ran on its contract's own deadline. It is a ceiling: it may
|
||||
* tighten an operation, never loosen one.
|
||||
*/
|
||||
async function settlesWithin(
|
||||
contractDeadlineMs: number,
|
||||
ceilingMs: number | undefined,
|
||||
advanceMs: number,
|
||||
): Promise<boolean> {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
...(ceilingMs === undefined ? {} : { requestDeadlineCeilingMs: ceilingMs }),
|
||||
attachCredentials: () => ({ kind: "READY", headers: {} }),
|
||||
// A request that only ever ends by being cut off, so what settles it is
|
||||
// exactly the deadline under test.
|
||||
fetcher: (_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const signal = (init as RequestInit | undefined)?.signal;
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
let settled = false;
|
||||
const pending = executor
|
||||
.execute(operation({ deadlineMs: contractDeadlineMs }), {}, {
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
})
|
||||
.then(
|
||||
() => { settled = true; },
|
||||
() => { settled = true; },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(advanceMs);
|
||||
await flushMicrotasks();
|
||||
const observed = settled;
|
||||
if (!observed) await vi.advanceTimersByTimeAsync(contractDeadlineMs + 1_000);
|
||||
await pending;
|
||||
return observed;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
it("applies the tighter of the contract and deployment bounds", async () => {
|
||||
await expect(settlesWithin(5_000, 500, 800)).resolves.toBe(true);
|
||||
await expect(settlesWithin(5_000, undefined, 800)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("never extends a contract deadline", async () => {
|
||||
await expect(settlesWithin(500, 60_000, 800)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a ceiling that is not a usable duration", async () => {
|
||||
for (const ceiling of [0, -1, Number.NaN]) {
|
||||
await expect(settlesWithin(5_000, ceiling, 800), String(ceiling)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
it("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => {
|
||||
const executor = createContractHttpExecutor({
|
||||
|
||||
@@ -242,6 +242,42 @@ describe("presigned transfer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("answers a refused capability envelope with a re-issuable recovery", async () => {
|
||||
// BT-PRE-04. A capability document the adapter will not accept is closed as
|
||||
// `POLICY_REJECTED`, and the caller's only way forward is a new capability.
|
||||
// `NONE` said there was nothing to be done, which contradicted both the
|
||||
// design record for an unsupported protocol and the vault, which already
|
||||
// answers `REISSUE_CAPABILITY` for the same class of refusal.
|
||||
for (const [label, overrides] of [
|
||||
["unknown protocol", { protocol: "PRESIGNED_TRANSFER_V2" }],
|
||||
["missing protocol", { protocol: undefined }],
|
||||
] as const) {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload: Record<string, unknown> = {
|
||||
...downloadCapabilityPayload(bytes),
|
||||
...overrides,
|
||||
};
|
||||
if (overrides.protocol === undefined) delete payload["protocol"];
|
||||
const fetcher = vi.fn(async () => jsonResponse(payload)) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({ fetcher });
|
||||
|
||||
expect(
|
||||
await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
label,
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "POLICY_REJECTED",
|
||||
retryable: false,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps URL and headers adapter-private and streams bounded verified chunks", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
activeProductFeatureIds,
|
||||
resolveProductFeatures,
|
||||
selectCompiledProductFeatures,
|
||||
} from "../../src/contracts/product-features.ts";
|
||||
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../../src/features/installed-product-manifest.ts";
|
||||
import { PLATFORM_ROUTE_REGISTRY } from "../../src/contracts/routes.ts";
|
||||
import {
|
||||
ROUTE_FEATURE_OWNER,
|
||||
ROUTE_REGISTRY,
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const COMPILED = Object.freeze([
|
||||
Object.freeze({ featureId: "reference-feature" }),
|
||||
Object.freeze({ featureId: "billing" }),
|
||||
]);
|
||||
|
||||
describe("build-time product selection", () => {
|
||||
it("keeps everything when nothing is declared", () => {
|
||||
for (const declared of [undefined, "", " "]) {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
||||
String(declared),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows to the declared subset", () => {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
|
||||
).toEqual(["billing"]);
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
|
||||
(f) => f.featureId,
|
||||
),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
});
|
||||
|
||||
it("selects nothing only when asked explicitly", () => {
|
||||
// A blank value keeps everything on purpose: an unset CI variable expands
|
||||
// to a blank string, and that must not be how a build ships no features.
|
||||
expect(selectCompiledProductFeatures(COMPILED, "none")).toEqual([]);
|
||||
expect(selectCompiledProductFeatures(COMPILED, " none ")).toEqual([]);
|
||||
expect(selectCompiledProductFeatures(COMPILED, "").length).toBe(2);
|
||||
// A value that parses to no names at all is a typo, not an instruction.
|
||||
expect(() => selectCompiledProductFeatures(COMPILED, ",")).toThrow(
|
||||
/names no feature/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to name a feature this build does not contain", () => {
|
||||
// The whole point of the direction rule: an environment value can subtract
|
||||
// from the source tree and must never be able to add to it. Accepting an
|
||||
// unknown id silently would let a deployment believe it had switched on
|
||||
// something that is not in the bundle.
|
||||
expect(() => selectCompiledProductFeatures(COMPILED, "analytics")).toThrow(
|
||||
/does not contain: analytics/u,
|
||||
);
|
||||
expect(() =>
|
||||
selectCompiledProductFeatures(COMPILED, "billing,analytics"),
|
||||
).toThrow(/analytics/u);
|
||||
});
|
||||
|
||||
it("refuses a duplicated feature id in the manifest", () => {
|
||||
expect(() =>
|
||||
selectCompiledProductFeatures(
|
||||
[{ featureId: "a" }, { featureId: "a" }],
|
||||
undefined,
|
||||
),
|
||||
).toThrow(/duplicate product feature id/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime product feature resolution", () => {
|
||||
it("reports active, disabled and not-installed distinctly", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature", "billing"],
|
||||
["reference-feature"],
|
||||
{ "reference-feature": "DISABLED" },
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
{ featureId: "billing", state: "NOT_INSTALLED" },
|
||||
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
|
||||
]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cannot switch on a feature the build left out", () => {
|
||||
// `DEFAULT` on an uninstalled feature is not an instruction to install it.
|
||||
const statuses = resolveProductFeatures(["billing"], [], {
|
||||
billing: "DEFAULT",
|
||||
});
|
||||
expect(statuses).toEqual([{ featureId: "billing", state: "NOT_INSTALLED" }]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores an override naming a feature this build never declared", () => {
|
||||
// A shared runtime document may cover several builds, so a stale key is
|
||||
// inert rather than fatal.
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature"],
|
||||
["reference-feature"],
|
||||
{ analytics: "DISABLED" },
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
|
||||
});
|
||||
|
||||
it("leaves an installed feature active without an override", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([
|
||||
...INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime config carries the switch", () => {
|
||||
const base = {
|
||||
APP_ENV: "local" as const,
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo" as const,
|
||||
CONFIG_SCHEMA_VERSION: "2.0" as const,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
};
|
||||
|
||||
it("defaults to disabling nothing", () => {
|
||||
const parsed = runtimeConfigV2ArtifactSchema.parse(base);
|
||||
expect(parsed.FEATURE_OVERRIDES).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts only DEFAULT or DISABLED", () => {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.parse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
|
||||
}).FEATURE_OVERRIDES,
|
||||
).toEqual({ "reference-feature": "DISABLED" });
|
||||
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
||||
// unbreakable, not a check somewhere downstream.
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a malformed feature id", () => {
|
||||
for (const featureId of ["Reference", "reference_feature", "", "-x"]) {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { [featureId]: "DISABLED" },
|
||||
}).success,
|
||||
featureId,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("every installed registry consults the manifest", () => {
|
||||
/**
|
||||
* The manifest only means something if each registry actually asks it. A new
|
||||
* registry that spreads a feature in directly would reintroduce exactly the
|
||||
* coupling this file exists to remove, and nothing else would notice.
|
||||
*/
|
||||
it("gates every feature contribution on the selection", async () => {
|
||||
const { readdir, readFile } = await import("node:fs/promises");
|
||||
const nodePath = (await import("node:path")).default;
|
||||
const root = "src/features";
|
||||
const registries = (await readdir(root)).filter((entry) =>
|
||||
/^installed-.*\.tsx?$/u.test(entry),
|
||||
);
|
||||
expect(registries.length).toBeGreaterThan(3);
|
||||
const exempt = new Set([
|
||||
// The manifest is the selection.
|
||||
"installed-product-manifest.ts",
|
||||
// Capabilities have their own §3.5 selection file and override vocabulary.
|
||||
"installed-runtime-capabilities.ts",
|
||||
// Template merge. This product removed the reference feature's screens,
|
||||
// so no installed feature contributes a route component and this file has
|
||||
// nothing to gate. `tests/component/product-feature-switch.test.tsx`
|
||||
// holds the mountability invariant meanwhile; restore this entry to the
|
||||
// guarded set the day a feature contributes a route runtime again.
|
||||
"installed-feature-runtimes.tsx",
|
||||
// Message keys stay total on purpose; see the file for why.
|
||||
"installed-feature-messages.ts",
|
||||
]);
|
||||
for (const registry of registries) {
|
||||
if (exempt.has(registry)) continue;
|
||||
const source = await readFile(nodePath.join(root, registry), "utf8");
|
||||
expect(
|
||||
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
|
||||
`${registry} must compose from the product manifest`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("route ownership", () => {
|
||||
it("attributes no platform route to a feature", () => {
|
||||
// Template merge. The template named its own example routes here. This
|
||||
// product deleted them, so the invariant is asserted against the platform
|
||||
// registry itself: whatever the shell owns, disabling a feature can never
|
||||
// withdraw it.
|
||||
for (const routeId of Object.keys(PLATFORM_ROUTE_REGISTRY)) {
|
||||
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
|
||||
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
|
||||
}
|
||||
expect(ROUTE_FEATURE_OWNER["NOT_FOUND"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("owns exactly the routes the registry received from features", () => {
|
||||
// Template merge. `owned` is empty in this product: the only installed
|
||||
// feature's screens were removed by the UI migration. The invariant still
|
||||
// holds and starts asserting again the moment a feature route returns.
|
||||
for (const routeId of Object.keys(ROUTE_FEATURE_OWNER)) {
|
||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,14 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -699,7 +707,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]);
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("still publishes near the lease deadline when post-processing completes in time", async () => {
|
||||
const { startProviderGuardian } = await import(
|
||||
|
||||
@@ -74,6 +74,7 @@ const runtimeV2 = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
} as const satisfies RuntimeConfigArtifact;
|
||||
|
||||
async function releaseV2With(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadCiGateContract } from "../../scripts/contracts/ci-gates.ts";
|
||||
import {
|
||||
@@ -88,3 +88,47 @@ it("rejects pruning that leaves a reduced gate without commands", async () => {
|
||||
removedEvidencePathFragments: ["runtime-schema.xml"],
|
||||
})).rejects.toThrow(/commandIds|too small|at least 1/i);
|
||||
});
|
||||
|
||||
describe("release evidence fixture copy", () => {
|
||||
it("keeps the release evidence and leaves the regenerated trees behind", async () => {
|
||||
const { copyReleaseEvidenceTree, RELEASE_EVIDENCE_REGENERATED_TREES } =
|
||||
await import("../../scripts/lib/removal-fixture.ts");
|
||||
const { RELEASE_CANDIDATE_EVIDENCE_PATHS } = await import(
|
||||
"../../scripts/lib/release-candidate.ts"
|
||||
);
|
||||
const { mkdtemp, access, readdir } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const nodePath = (await import("node:path")).default;
|
||||
|
||||
const root = await mkdtemp(nodePath.join(tmpdir(), "release-evidence-"));
|
||||
await copyReleaseEvidenceTree(process.cwd(), root);
|
||||
|
||||
// Every release artifact that exists here has to survive the copy; a
|
||||
// fixture missing one cannot build a candidate at all, and every provider
|
||||
// suite then fails while constructing its own fixture.
|
||||
//
|
||||
// Which ones exist depends on what this checkout has generated — a product
|
||||
// repository that has not run the release chain has fewer than the template
|
||||
// does — so the subject is preservation, not the presence of a full chain.
|
||||
// Requiring at least one keeps that from quietly asserting nothing.
|
||||
let preserved = 0;
|
||||
for (const evidence of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
||||
if (!evidence.startsWith("artifacts/")) continue;
|
||||
try {
|
||||
await access(evidence);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
await expect(access(nodePath.join(root, evidence)), evidence).resolves.toBeUndefined();
|
||||
preserved += 1;
|
||||
}
|
||||
expect(preserved).toBeGreaterThan(0);
|
||||
// The regenerated trees are why this is a filter and not a plain copy: they
|
||||
// are tens of megabytes of traces and coverage HTML. They still exist,
|
||||
// because the repository inventory expects the directories.
|
||||
for (const tree of RELEASE_EVIDENCE_REGENERATED_TREES) {
|
||||
await expect(access(nodePath.join(root, tree)), tree).resolves.toBeUndefined();
|
||||
await expect(readdir(nodePath.join(root, tree)), tree).resolves.toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
type ProductionModuleInventory,
|
||||
} from "../../scripts/lib/risk-coverage.ts";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const roots: string[] = [];
|
||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -141,7 +149,7 @@ describe("repository-aware risk coverage", () => {
|
||||
result.repositoryTotal - result.selectedTotal,
|
||||
);
|
||||
expect(result.status).toBe("FAIL");
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("reports exact inventory and generated-exclusion provenance", async () => {
|
||||
const repositoryRoot = await repositoryFixture();
|
||||
|
||||
@@ -26,6 +26,7 @@ const runtime: Runtime = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
@@ -262,6 +263,7 @@ describe("runtime adapter composition", () => {
|
||||
...runtime.config.CAPABILITY_OVERRIDES,
|
||||
SERVICE_WORKER: "DISABLED",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
},
|
||||
release,
|
||||
|
||||
@@ -47,6 +47,14 @@ import {
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const digest = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const digestBytes = (value: Buffer): string =>
|
||||
@@ -1383,7 +1391,7 @@ describe("security follow-up contracts", () => {
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
function providerExpectedContext() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
validateLicensePolicy,
|
||||
} from "../../scripts/lib/supply-chain.ts";
|
||||
import { digestReleaseInputFiles } from "../../scripts/lib/release-input-evidence.ts";
|
||||
import { isReducedCiContractRun } from "../../scripts/contracts/ci-gates.ts";
|
||||
import { findSecretMatches } from "../../scripts/lib/secret-scan.ts";
|
||||
import {
|
||||
parseSecretScanIncludedPaths,
|
||||
@@ -535,7 +536,11 @@ describe("supply-chain policy", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("covers every mandatory release input in the secret scan policy", async () => {
|
||||
// A removal fixture deletes some of these inputs on purpose — removing the
|
||||
// browser file/storage capability takes the whole browser-capability harness
|
||||
// with it — and prunes them from its own policy. This is a claim about the
|
||||
// full repository, so it does not describe a deliberately reduced one.
|
||||
it.skipIf(isReducedCiContractRun())("covers every mandatory release input in the secret scan policy", async () => {
|
||||
const policy = JSON.parse(
|
||||
await readFile("config/security/secret-scan-policy.json", "utf8"),
|
||||
) as { trackedRoots: string[] };
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isReducedCiContractRun,
|
||||
loadCiGateContract,
|
||||
parseCiGateContract,
|
||||
} from "../../scripts/contracts/ci-gates.ts";
|
||||
@@ -70,8 +71,19 @@ describe("selective Task 3 contract closure", () => {
|
||||
"--signal=SIGKILL",
|
||||
unit,
|
||||
]);
|
||||
// `bwrap --args FD` stops parsing at the first non-option and never hands
|
||||
// the remainder back, so a command placed in the args file is dropped and
|
||||
// bubblewrap exits with its usage text. Refusing `--` in the option stream
|
||||
// is what keeps that silent no-sandbox launch from returning.
|
||||
expect(() =>
|
||||
encodeProviderBwrapInput(
|
||||
["--unshare-net", "--", "/usr/bin/prlimit"],
|
||||
{ PROVIDER_COMMAND: command },
|
||||
),
|
||||
).toThrow(/terminate the option stream/u);
|
||||
const frame = encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("private-bwrap-vector\0"),
|
||||
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
@@ -81,6 +93,27 @@ describe("selective Task 3 contract closure", () => {
|
||||
Buffer.from("private-bwrap-vector\0").toString("base64"),
|
||||
);
|
||||
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
|
||||
// The command vector rides on real argv, so it must never be able to carry
|
||||
// the secret that the args file exists to hide.
|
||||
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
|
||||
expect(() =>
|
||||
encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("x\0"),
|
||||
bwrapCommand: [],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
}),
|
||||
).toThrow(/bwrap command is invalid/u);
|
||||
expect(() =>
|
||||
encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("x\0"),
|
||||
bwrapCommand: ["prlimit"],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
}),
|
||||
).toThrow(/bwrap command is invalid/u);
|
||||
});
|
||||
|
||||
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
|
||||
@@ -143,12 +176,18 @@ describe("selective Task 3 contract closure", () => {
|
||||
.toContain("package script missing: root -> missing");
|
||||
});
|
||||
|
||||
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||
// A removal fixture runs against a pruned contract on purpose, so the
|
||||
// canonical counts do not describe it. Asserting them there failed the
|
||||
// fixture for the reduction it exists to demonstrate.
|
||||
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||
const canonical = await loadCiGateContract(process.cwd());
|
||||
expect(canonical.gates).toHaveLength(26);
|
||||
expect(canonical.commands).toHaveLength(81);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
|
||||
expect(canonical.artifacts).toHaveLength(126);
|
||||
// Template merge. The template added one gate and one command to the
|
||||
// product's 26/81; the artifact set is the product's 126 plus the
|
||||
// template's 2.
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(82);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
||||
expect(canonical.artifacts).toHaveLength(128);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
@@ -157,7 +196,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
|
||||
});
|
||||
|
||||
it("rejects the retired validate-candidate-archive grammar", async () => {
|
||||
it.skipIf(isReducedCiContractRun())("rejects the retired validate-candidate-archive grammar", async () => {
|
||||
const canonical = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
|
||||
Reference in New Issue
Block a user