mandrel-platform 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-environments-isolation-audit.test.mjs — behavioural guard for the
4
+ * environments isolation audit's verdicts (Story #367).
5
+ *
6
+ * The bug this pins: reading an environment's `deployment_branch_policy`
7
+ * requires repo Administration: read, which the default GITHUB_TOKEN cannot be
8
+ * granted. Without it GitHub omits the field from the response entirely — and
9
+ * the audit read that absence as "this environment has NO deployment branch
10
+ * policy" and failed with a security finding it had never observed. A check
11
+ * that reports the insecure conclusion when the truth is that it could not look
12
+ * is a check operators learn to wave through, which is why no consumer in the
13
+ * fleet had it enabled.
14
+ *
15
+ * Absent-because-unreadable and absent-because-unset are different states and
16
+ * must produce different outcomes. Reading the YAML cannot prove that: what
17
+ * decides the verdict is a shell branch over a `jq` probe. So this extracts the
18
+ * real `run:` body and executes it against a stub `gh` serving fixture
19
+ * responses — the same read-then-execute approach as
20
+ * check-setup-toolchain-store.test.mjs / check-osv-scan-mode.test.mjs.
21
+ *
22
+ * Run: node --test scripts/check-environments-isolation-audit.test.mjs
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { test } from "node:test";
27
+ import { execFileSync } from "node:child_process";
28
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import path from "node:path";
31
+
32
+ import { stepByName, runScript } from "./lib/yaml-step.mjs";
33
+
34
+ const ACTION = ".github/actions/environments-isolation-audit/action.yml";
35
+ const REPO = "acme/widgets";
36
+
37
+ const auditScript = runScript(stepByName(readFileSync(ACTION, "utf8"), "Audit deployment branch policies"));
38
+
39
+ /**
40
+ * Run the extracted audit body against a stub `gh` that serves `responses`
41
+ * keyed by API path. A path with no fixture makes the stub exit non-zero with
42
+ * no output — exactly how `gh api` behaves on a 404 or a permission failure.
43
+ *
44
+ * @param {object} opts
45
+ * @param {Record<string, unknown>} opts.responses API path → JSON body.
46
+ * @param {string} [opts.environments] ENVIRONMENTS_CSV.
47
+ * @param {string} [opts.allowedBranch] ALLOWED_BRANCH.
48
+ * @returns {{ code: number, output: string }} `output` is stdout+stderr;
49
+ * GitHub's `::error::` annotations are written to stdout.
50
+ */
51
+ function runAudit({ responses, environments = "staging", allowedBranch = "main" }) {
52
+ const dir = mkdtempSync(path.join(tmpdir(), "env-isolation-audit-"));
53
+ try {
54
+ const fixtures = path.join(dir, "fixtures");
55
+ mkdirSync(fixtures);
56
+ for (const [apiPath, body] of Object.entries(responses)) {
57
+ writeFileSync(path.join(fixtures, apiPath.replace(/\//g, "_") + ".json"), JSON.stringify(body));
58
+ }
59
+ const stub = path.join(dir, "gh");
60
+ writeFileSync(
61
+ stub,
62
+ "#!/bin/sh\n" +
63
+ 'f="$FIXTURE_DIR/$(printf %s "$2" | tr / _).json"\n' +
64
+ 'if [ -f "$f" ]; then cat "$f"; exit 0; fi\n' +
65
+ 'echo "gh: Not Found (HTTP 404)" >&2\n' +
66
+ "exit 1\n"
67
+ );
68
+ chmodSync(stub, 0o755);
69
+ const script = path.join(dir, "audit.sh");
70
+ writeFileSync(script, auditScript);
71
+ const env = {
72
+ PATH: `${dir}${path.delimiter}${process.env.PATH}`,
73
+ FIXTURE_DIR: fixtures,
74
+ GITHUB_REPOSITORY: REPO,
75
+ AUDIT_REPO: "",
76
+ ENVIRONMENTS_CSV: environments,
77
+ ALLOWED_BRANCH: allowedBranch,
78
+ };
79
+ try {
80
+ const stdout = execFileSync("bash", [script], { cwd: dir, encoding: "utf8", env });
81
+ return { code: 0, output: stdout };
82
+ } catch (err) {
83
+ return { code: err.status, output: `${err.stdout ?? ""}${err.stderr ?? ""}` };
84
+ }
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ }
89
+
90
+ const ENV_API = `repos/${REPO}/environments/staging`;
91
+ const POLICIES_API = `${ENV_API}/deployment-branch-policies`;
92
+
93
+ // ── the canonical posture still passes ─────────────────────────────────────
94
+
95
+ test("an environment restricted to the allowed branch passes", () => {
96
+ const res = runAudit({
97
+ responses: {
98
+ [ENV_API]: {
99
+ name: "staging",
100
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
101
+ },
102
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "main" }] },
103
+ },
104
+ });
105
+ assert.equal(res.code, 0, res.output);
106
+ assert.match(res.output, /restricts deploys to 'main'/);
107
+ });
108
+
109
+ // ── unreadable ≠ unset (the Story #367 split) ──────────────────────────────
110
+
111
+ test("a policy field the token cannot read is reported as unreadable, not as absent", () => {
112
+ // What GitHub actually returns without Administration: read — the key is
113
+ // simply not in the response.
114
+ const res = runAudit({ responses: { [ENV_API]: { name: "staging", id: 1 } } });
115
+ assert.equal(res.code, 1);
116
+ assert.match(res.output, /UNREADABLE/);
117
+ assert.match(res.output, /Administration: read/);
118
+ assert.doesNotMatch(
119
+ res.output,
120
+ /has NO deployment branch policy/,
121
+ "the audit must not assert a conclusion it never observed"
122
+ );
123
+ });
124
+
125
+ test("a genuinely unset policy still fails as a real finding", () => {
126
+ const res = runAudit({
127
+ responses: { [ENV_API]: { name: "staging", deployment_branch_policy: null } },
128
+ });
129
+ assert.equal(res.code, 1);
130
+ assert.match(res.output, /has NO deployment branch policy/);
131
+ assert.doesNotMatch(res.output, /UNREADABLE/, "a null field is observed, not unreadable");
132
+ });
133
+
134
+ test("the two absent-policy states produce different messages", () => {
135
+ const unreadable = runAudit({ responses: { [ENV_API]: { name: "staging" } } }).output;
136
+ const unset = runAudit({
137
+ responses: { [ENV_API]: { name: "staging", deployment_branch_policy: null } },
138
+ }).output;
139
+ assert.notEqual(unreadable, unset);
140
+ });
141
+
142
+ test("an unreadable read is summarised as not-a-verdict at the end of the run", () => {
143
+ const res = runAudit({ responses: { [ENV_API]: { name: "staging" } } });
144
+ assert.match(res.output, /are NOT policy verdicts/);
145
+ });
146
+
147
+ test("unreadable named branch policies are not reported as ZERO policies", () => {
148
+ // The policy field reads fine; the follow-up policies call fails.
149
+ const res = runAudit({
150
+ responses: {
151
+ [ENV_API]: {
152
+ name: "staging",
153
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
154
+ },
155
+ },
156
+ });
157
+ assert.equal(res.code, 1);
158
+ assert.match(res.output, /named branch policies UNREADABLE/);
159
+ assert.doesNotMatch(res.output, /ZERO named policies/);
160
+ });
161
+
162
+ // ── every other finding is unchanged ───────────────────────────────────────
163
+
164
+ test("protected-branches-only still fails with its own message", () => {
165
+ const res = runAudit({
166
+ responses: {
167
+ [ENV_API]: {
168
+ name: "staging",
169
+ deployment_branch_policy: { protected_branches: true, custom_branch_policies: false },
170
+ },
171
+ },
172
+ });
173
+ assert.equal(res.code, 1);
174
+ assert.match(res.output, /protected branches only/);
175
+ assert.doesNotMatch(res.output, /UNREADABLE/);
176
+ });
177
+
178
+ test("zero named policies, a wildcard, and a wrong branch each still fail", () => {
179
+ const custom = {
180
+ name: "staging",
181
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
182
+ };
183
+ const zero = runAudit({
184
+ responses: { [ENV_API]: custom, [POLICIES_API]: { total_count: 0, branch_policies: [] } },
185
+ });
186
+ assert.equal(zero.code, 1);
187
+ assert.match(zero.output, /ZERO named policies/);
188
+
189
+ const wildcard = runAudit({
190
+ responses: {
191
+ [ENV_API]: custom,
192
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "release/*" }] },
193
+ },
194
+ });
195
+ assert.equal(wildcard.code, 1);
196
+ assert.match(wildcard.output, /wildcard branch policy/);
197
+
198
+ const wrongBranch = runAudit({
199
+ responses: {
200
+ [ENV_API]: custom,
201
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "develop" }] },
202
+ },
203
+ });
204
+ assert.equal(wrongBranch.code, 1);
205
+ assert.match(wrongBranch.output, /allows branch 'develop', not 'main'/);
206
+ });
207
+
208
+ test("a missing environment still fails with the does-not-exist message", () => {
209
+ const res = runAudit({ responses: {} });
210
+ assert.equal(res.code, 1);
211
+ assert.match(res.output, /does not exist on/);
212
+ });
@@ -136,12 +136,29 @@ const jobs = extractJobs();
136
136
  // are wired into every tier job that participates in fail-fast.
137
137
  // ---------------------------------------------------------------------------
138
138
 
139
- test("the collateral explainer fires only on cancellation under fail-fast", () => {
139
+ test("the collateral explainer fires on cancellation and nothing else", () => {
140
+ // Story #364 widened this from `cancelled() && inputs.fail-fast` to
141
+ // `cancelled()`: a job killed for exceeding its own `timeout-minutes` is also
142
+ // recorded as `cancelled`, and with fail-fast off nothing explained it at all.
143
+ // `cancelled()` remains the ceiling — an `always()` gate would fire the step
144
+ // on the green path, and `success()`/bare truthiness would fire it on a real
145
+ // failure, where the tier's own logs are the evidence.
140
146
  assert.match(
141
147
  explainStep,
142
- /^\s+if:\s+\$\{\{\s*cancelled\(\)\s*&&\s*inputs\.fail-fast\s*\}\}\s*$/m,
143
- "`&explain-cancellation` must be gated `cancelled() && inputs.fail-fast` " +
144
- "an `always()` gate would fire it on the green path"
148
+ /^\s+if:\s+\$\{\{\s*cancelled\(\)\s*\}\}\s*$/m,
149
+ "`&explain-cancellation` must be gated `cancelled()` alone"
150
+ );
151
+ assert.doesNotMatch(
152
+ explainStep,
153
+ /^\s+if:\s+\$\{\{\s*always\(\)/m,
154
+ "an `always()` gate would fire the explainer on the green path"
155
+ );
156
+ // The fail-fast input still has to reach the script — it is what separates a
157
+ // collateral cancellation from a timeout kill.
158
+ assert.match(
159
+ explainStep,
160
+ /^\s+FAIL_FAST:\s+\$\{\{\s*inputs\.fail-fast\s*\}\}\s*$/m,
161
+ "the explainer must receive inputs.fail-fast to classify the cancellation"
145
162
  );
146
163
  });
147
164
 
@@ -288,6 +305,9 @@ function runStep(script, { ghExit = 0, ghStdout = "", env = {} } = {}) {
288
305
  RUN_ID: "30168441137",
289
306
  REPO: "Beestera/swarm-os",
290
307
  TIER_ID: "typecheck",
308
+ // The collateral branch is the fail-fast-on case; a case that is about
309
+ // the timeout branch overrides this explicitly.
310
+ FAIL_FAST: "true",
291
311
  ...env,
292
312
  },
293
313
  });
@@ -369,6 +389,7 @@ test("collateral side: still explains itself when gh is absent entirely", () =>
369
389
  REPO: "o/r",
370
390
  TIER_ID: "lint",
371
391
  GH_TOKEN: "stub-token",
392
+ FAIL_FAST: "true",
372
393
  },
373
394
  });
374
395
  assert.equal(r.status, 0, r.stderr);
@@ -379,6 +400,71 @@ test("collateral side: still explains itself when gh is absent entirely", () =>
379
400
  }
380
401
  });
381
402
 
403
+ // ---------------------------------------------------------------------------
404
+ // Timeout provenance (Story #364) — the other half of the same step. A job
405
+ // killed for exceeding `timeout-minutes` is recorded as `cancelled`, not
406
+ // `failure`, and `ci-required` reads `cancelled` as a red gate. With every work
407
+ // step green, the operator previously had only timings to go on.
408
+ // ---------------------------------------------------------------------------
409
+
410
+ test("timeout side: a cancellation with no failing sibling is not reported as collateral", () => {
411
+ // Lookup SUCCEEDS and returns no failing tier — evidence, not an unknown.
412
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "" });
413
+ assert.equal(r.status, 0, r.stderr);
414
+ assert.doesNotMatch(
415
+ r.stdout,
416
+ /fail-fast collateral/,
417
+ "blaming a sibling that did not fail is the same misattribution in reverse"
418
+ );
419
+ assert.match(r.stdout, /::notice title=cancelled without a failing sibling::/);
420
+ });
421
+
422
+ test("timeout side: names the job's own ceiling and the post-job teardown that eats it", () => {
423
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "" });
424
+ assert.match(r.summary, /timeout-minutes/, "the summary must name the ceiling");
425
+ assert.match(
426
+ r.summary,
427
+ /cache save/,
428
+ "the summary must name the teardown that runs after the last work step"
429
+ );
430
+ assert.match(
431
+ r.summary,
432
+ /toolchain-cache/,
433
+ "the summary must point at the input that turns the save off"
434
+ );
435
+ assert.match(r.summary, /run-wide cancel/i, "the other live cause must stay named");
436
+ });
437
+
438
+ test("timeout side: an unknown lookup under fail-fast stays collateral, not a timeout claim", () => {
439
+ // Absence of evidence is not evidence of absence: a failed lookup must not be
440
+ // read as "no sibling failed", or a genuine collateral cancel gets a timeout
441
+ // diagnosis and the operator triages the wrong job.
442
+ const r = runStep(explainScript, { ghExit: 1 });
443
+ assert.match(r.stdout, /fail-fast collateral/);
444
+ assert.doesNotMatch(r.stdout, /cancelled without a failing sibling/);
445
+ });
446
+
447
+ test("timeout side: with fail-fast disabled the cancellation is never collateral", () => {
448
+ // fail-fast off means no sibling could have cancelled this job, whatever the
449
+ // lookup returns.
450
+ const r = runStep(explainScript, {
451
+ ghExit: 0,
452
+ ghStdout: "e2e",
453
+ env: { FAIL_FAST: "false" },
454
+ });
455
+ assert.equal(r.status, 0, r.stderr);
456
+ assert.doesNotMatch(r.stdout, /fail-fast collateral/);
457
+ assert.match(r.summary, /fail-fast is disabled on this run/);
458
+ });
459
+
460
+ test("timeout side: stays non-fatal and emits no failure annotation", () => {
461
+ // Same terms as the collateral branch — an explainer that can fail a job
462
+ // would turn a diagnostic into a second failure mode.
463
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "", env: { FAIL_FAST: "false" } });
464
+ assert.equal(r.status, 0, r.stderr);
465
+ assert.doesNotMatch(r.stdout, /::error/);
466
+ });
467
+
382
468
  test("collateral side: never claims this tier failed", () => {
383
469
  const r = runStep(explainScript, { ghStdout: "Accessibility (2/3)" });
384
470
  assert.doesNotMatch(