mandrel-platform 0.13.0 → 0.14.2
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.
- package/README.md +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -22,12 +22,17 @@ import { test } from "node:test";
|
|
|
22
22
|
import {
|
|
23
23
|
buildReport,
|
|
24
24
|
classifyNpmPin,
|
|
25
|
+
classifyStaleLiterals,
|
|
25
26
|
combineDrift,
|
|
26
27
|
compareSemver,
|
|
27
28
|
detectSurfaceSkew,
|
|
28
29
|
extractNpmPlatformVersion,
|
|
30
|
+
extractStaleLiterals,
|
|
29
31
|
fetchConsumerPackageJson,
|
|
30
32
|
hasDrift,
|
|
33
|
+
isHolding,
|
|
34
|
+
isWithinReleaseAgeWindow,
|
|
35
|
+
parseDurationMs,
|
|
31
36
|
parseSemver,
|
|
32
37
|
renderReport,
|
|
33
38
|
runCli,
|
|
@@ -156,6 +161,82 @@ test("detectSurfaceSkew is false when either surface is not comparable", () => {
|
|
|
156
161
|
assert.equal(detectSurfaceSkew("no-pins", "lagging"), false);
|
|
157
162
|
});
|
|
158
163
|
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// extractStaleLiterals / classifyStaleLiterals (Story #110)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const PLAT = "dsj1984/mandrel-platform";
|
|
169
|
+
|
|
170
|
+
test("extractStaleLiterals finds platform refs in comments and run/echo strings", () => {
|
|
171
|
+
const text = [
|
|
172
|
+
"jobs:",
|
|
173
|
+
" deploy:",
|
|
174
|
+
" steps:",
|
|
175
|
+
` # pinned via ${PLAT}/.github/workflows/deploy-cloudflare.yml@${"c".repeat(40)}`,
|
|
176
|
+
" - name: summary",
|
|
177
|
+
" run: |",
|
|
178
|
+
` echo "deployed with ${PLAT}/.github/workflows/deploy-cloudflare.yml@v0.11.6"`,
|
|
179
|
+
].join("\n");
|
|
180
|
+
const lits = extractStaleLiterals("ci.yml", text, PLAT);
|
|
181
|
+
assert.equal(lits.length, 2);
|
|
182
|
+
assert.equal(lits[0].kind, "comment");
|
|
183
|
+
assert.equal(lits[0].ref, "c".repeat(40));
|
|
184
|
+
assert.equal(lits[1].kind, "run");
|
|
185
|
+
assert.equal(lits[1].ref, "v0.11.6");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("extractStaleLiterals ignores uses: lines (owned by extractPlatformPins)", () => {
|
|
189
|
+
const text = [
|
|
190
|
+
"jobs:",
|
|
191
|
+
" q:",
|
|
192
|
+
` uses: ${PLAT}/.github/workflows/pr-quality.yml@${"a".repeat(40)}`,
|
|
193
|
+
` # uses: ${PLAT}/.github/workflows/pr-quality.yml@${"a".repeat(40)}`,
|
|
194
|
+
].join("\n");
|
|
195
|
+
const lits = extractStaleLiterals("ci.yml", text, PLAT);
|
|
196
|
+
// The bare `uses:` line is skipped; the commented `# uses:` line is a comment
|
|
197
|
+
// literal (it is NOT a live uses directive), so it IS captured.
|
|
198
|
+
assert.equal(lits.length, 1);
|
|
199
|
+
assert.equal(lits[0].kind, "comment");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("extractStaleLiterals returns nothing when no platform refs are present", () => {
|
|
203
|
+
const text = "jobs:\n q:\n run: echo hello\n # owner/other-repo/x.yml@abc";
|
|
204
|
+
assert.deepEqual(extractStaleLiterals("ci.yml", text, PLAT), []);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("classifyStaleLiterals flags a literal that drifts from the canonical pin", () => {
|
|
208
|
+
const canonicalSha = "a".repeat(40);
|
|
209
|
+
const staleSha = "c".repeat(40);
|
|
210
|
+
const literals = [
|
|
211
|
+
{ file: "ci.yml", line: 9, target: PLAT, ref: staleSha, kind: "run" },
|
|
212
|
+
{ file: "ci.yml", line: 4, target: PLAT, ref: canonicalSha, kind: "comment" },
|
|
213
|
+
];
|
|
214
|
+
const out = classifyStaleLiterals(literals, [canonicalSha]);
|
|
215
|
+
assert.equal(out.hasStaleLiteral, true);
|
|
216
|
+
assert.equal(out.staleLiterals.length, 1);
|
|
217
|
+
assert.equal(out.staleLiterals[0].ref, staleSha);
|
|
218
|
+
assert.equal(out.staleLiterals[0].reason, "stale");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("classifyStaleLiterals matches the canonical pin case-insensitively", () => {
|
|
222
|
+
const sha = "abc123" + "0".repeat(34);
|
|
223
|
+
const literals = [
|
|
224
|
+
{ file: "ci.yml", line: 9, target: PLAT, ref: sha.toUpperCase(), kind: "run" },
|
|
225
|
+
];
|
|
226
|
+
const out = classifyStaleLiterals(literals, [sha]);
|
|
227
|
+
assert.equal(out.hasStaleLiteral, false);
|
|
228
|
+
assert.equal(out.staleLiterals.length, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("classifyStaleLiterals reports an orphan literal when there is no canonical pin", () => {
|
|
232
|
+
const literals = [
|
|
233
|
+
{ file: "ci.yml", line: 9, target: PLAT, ref: "v0.11.6", kind: "run" },
|
|
234
|
+
];
|
|
235
|
+
const out = classifyStaleLiterals(literals, []);
|
|
236
|
+
assert.equal(out.hasStaleLiteral, true);
|
|
237
|
+
assert.equal(out.staleLiterals[0].reason, "orphan");
|
|
238
|
+
});
|
|
239
|
+
|
|
159
240
|
// ---------------------------------------------------------------------------
|
|
160
241
|
// combineDrift
|
|
161
242
|
// ---------------------------------------------------------------------------
|
|
@@ -170,6 +251,114 @@ test("combineDrift folds uses-drift, npm-lag, and surface-skew", () => {
|
|
|
170
251
|
assert.equal(combineDrift({ drift: true }, { npmState: "current" }, false), true);
|
|
171
252
|
});
|
|
172
253
|
|
|
254
|
+
test("combineDrift suppresses lag/skew during the minimumReleaseAge hold", () => {
|
|
255
|
+
const lagging = { drift: true, splitPinned: false };
|
|
256
|
+
// Without the hold flag, lag is drift.
|
|
257
|
+
assert.equal(combineDrift(lagging, { npmState: "lagging" }, false, false), true);
|
|
258
|
+
// Within the hold window, the same lag/skew is suppressed.
|
|
259
|
+
assert.equal(combineDrift(lagging, { npmState: "lagging" }, false, true), false);
|
|
260
|
+
assert.equal(
|
|
261
|
+
combineDrift({ drift: false, splitPinned: false }, { npmState: "current" }, true, true),
|
|
262
|
+
false,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("combineDrift never suppresses a split pin, even within the hold window", () => {
|
|
267
|
+
const split = { drift: true, splitPinned: true };
|
|
268
|
+
assert.equal(combineDrift(split, { npmState: "current" }, false, true), true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("combineDrift flags a stale literal even inside the hold window (Story #110)", () => {
|
|
272
|
+
const clean = { drift: false, splitPinned: false };
|
|
273
|
+
// No other deviation, but a stale literal is present → drift, hold or not.
|
|
274
|
+
assert.equal(
|
|
275
|
+
combineDrift(clean, { npmState: "current" }, false, true, true),
|
|
276
|
+
true,
|
|
277
|
+
);
|
|
278
|
+
// No stale literal and otherwise clean → not drift.
|
|
279
|
+
assert.equal(
|
|
280
|
+
combineDrift(clean, { npmState: "current" }, false, true, false),
|
|
281
|
+
false,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// parseDurationMs — Renovate-style minimumReleaseAge strings (Story #107)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
290
|
+
|
|
291
|
+
test("parseDurationMs parses days / hours / weeks / minutes", () => {
|
|
292
|
+
assert.equal(parseDurationMs("3 days"), 3 * DAY_MS);
|
|
293
|
+
assert.equal(parseDurationMs("1 day"), DAY_MS);
|
|
294
|
+
assert.equal(parseDurationMs("36 hours"), 36 * 60 * 60 * 1000);
|
|
295
|
+
assert.equal(parseDurationMs("1 week"), 7 * DAY_MS);
|
|
296
|
+
assert.equal(parseDurationMs("90 minutes"), 90 * 60 * 1000);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("parseDurationMs treats a bare number as days and rejects junk", () => {
|
|
300
|
+
assert.equal(parseDurationMs(3), 3 * DAY_MS);
|
|
301
|
+
assert.equal(parseDurationMs("0 days"), null);
|
|
302
|
+
assert.equal(parseDurationMs("-2 days"), null);
|
|
303
|
+
assert.equal(parseDurationMs("soon"), null);
|
|
304
|
+
assert.equal(parseDurationMs(""), null);
|
|
305
|
+
assert.equal(parseDurationMs(null), null);
|
|
306
|
+
assert.equal(parseDurationMs("5 fortnights"), null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// isWithinReleaseAgeWindow
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
test("isWithinReleaseAgeWindow is true for a release younger than the window", () => {
|
|
314
|
+
const now = Date.parse("2026-06-30T00:00:00Z");
|
|
315
|
+
const oneDayAgo = "2026-06-29T00:00:00Z";
|
|
316
|
+
assert.equal(isWithinReleaseAgeWindow(oneDayAgo, 3 * DAY_MS, now), true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("isWithinReleaseAgeWindow is false once the release ages past the window", () => {
|
|
320
|
+
const now = Date.parse("2026-06-30T00:00:00Z");
|
|
321
|
+
const fourDaysAgo = "2026-06-26T00:00:00Z";
|
|
322
|
+
assert.equal(isWithinReleaseAgeWindow(fourDaysAgo, 3 * DAY_MS, now), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("isWithinReleaseAgeWindow fails safe (false) on missing inputs", () => {
|
|
326
|
+
const now = Date.parse("2026-06-30T00:00:00Z");
|
|
327
|
+
assert.equal(isWithinReleaseAgeWindow(null, 3 * DAY_MS, now), false);
|
|
328
|
+
assert.equal(isWithinReleaseAgeWindow("2026-06-29T00:00:00Z", null, now), false);
|
|
329
|
+
assert.equal(isWithinReleaseAgeWindow("not-a-date", 3 * DAY_MS, now), false);
|
|
330
|
+
assert.equal(isWithinReleaseAgeWindow("2026-06-29T00:00:00Z", 0, now), false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// isHolding
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
test("isHolding is true when lag would drift but the release is inside the window", () => {
|
|
338
|
+
assert.equal(
|
|
339
|
+
isHolding({ drift: true, splitPinned: false }, { npmState: "lagging" }, true, true),
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("isHolding is false outside the window, for a clean consumer, or a split pin", () => {
|
|
345
|
+
// Outside the window — the lag is real drift, not a hold.
|
|
346
|
+
assert.equal(
|
|
347
|
+
isHolding({ drift: true, splitPinned: false }, { npmState: "lagging" }, false, false),
|
|
348
|
+
false,
|
|
349
|
+
);
|
|
350
|
+
// No deviation to suppress.
|
|
351
|
+
assert.equal(
|
|
352
|
+
isHolding({ drift: false, splitPinned: false }, { npmState: "current" }, false, true),
|
|
353
|
+
false,
|
|
354
|
+
);
|
|
355
|
+
// A split pin is a real error regardless of the hold.
|
|
356
|
+
assert.equal(
|
|
357
|
+
isHolding({ drift: true, splitPinned: true }, { npmState: "current" }, false, true),
|
|
358
|
+
false,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
173
362
|
// ---------------------------------------------------------------------------
|
|
174
363
|
// Integration: buildReport + renderReport with an injected gh runner
|
|
175
364
|
// ---------------------------------------------------------------------------
|
|
@@ -203,7 +392,10 @@ function makeRunGh(fixtures) {
|
|
|
203
392
|
return (args) => {
|
|
204
393
|
const path = args[1];
|
|
205
394
|
if (path === `repos/${PLATFORM}/releases/latest`) {
|
|
206
|
-
|
|
395
|
+
// `publishedAt` is opt-in: existing fixtures omit it (so the
|
|
396
|
+
// minimumReleaseAge window resolves to "not within", preserving legacy
|
|
397
|
+
// behaviour); the hold-window tests pass it explicitly.
|
|
398
|
+
return JSON.stringify({ tag_name: TAG, published_at: fixtures.__publishedAt });
|
|
207
399
|
}
|
|
208
400
|
if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
|
|
209
401
|
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
@@ -307,6 +499,55 @@ test("renderReport surfaces the npm columns and drift lines", () => {
|
|
|
307
499
|
assert.match(text, /`1\.4\.0`/);
|
|
308
500
|
});
|
|
309
501
|
|
|
502
|
+
test("buildReport: a stale pin literal beyond uses: is drift (Story #110)", () => {
|
|
503
|
+
const canonical = "a".repeat(40); // == LATEST_SHA, so uses: is current
|
|
504
|
+
const stale = "c".repeat(40);
|
|
505
|
+
const yaml = [
|
|
506
|
+
"jobs:",
|
|
507
|
+
" deploy:",
|
|
508
|
+
` uses: ${PLATFORM}/.github/workflows/deploy-cloudflare.yml@${canonical}`,
|
|
509
|
+
" summary:",
|
|
510
|
+
" steps:",
|
|
511
|
+
` # legacy pin note: ${PLATFORM}/.github/workflows/deploy-cloudflare.yml@${stale}`,
|
|
512
|
+
" - run: |",
|
|
513
|
+
` echo "deployed ${PLATFORM}/.github/workflows/deploy-cloudflare.yml@${stale}"`,
|
|
514
|
+
].join("\n");
|
|
515
|
+
const runGh = (args) => {
|
|
516
|
+
const path = args[1];
|
|
517
|
+
if (path === `repos/${PLATFORM}/releases/latest`) {
|
|
518
|
+
return JSON.stringify({ tag_name: TAG });
|
|
519
|
+
}
|
|
520
|
+
if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
|
|
521
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
522
|
+
}
|
|
523
|
+
if (path === "repos/o/lit/contents/.github/workflows?ref=main") {
|
|
524
|
+
return JSON.stringify([
|
|
525
|
+
{ type: "file", name: "ci.yml", encoding: "base64", content: b64(yaml) },
|
|
526
|
+
]);
|
|
527
|
+
}
|
|
528
|
+
if (path === "repos/o/lit/contents/package.json?ref=main") {
|
|
529
|
+
return JSON.stringify({ encoding: "base64", content: b64(pkgJson("1.4.0")) });
|
|
530
|
+
}
|
|
531
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
532
|
+
};
|
|
533
|
+
const report = buildReport(
|
|
534
|
+
{ platformRepo: PLATFORM, consumers: [{ name: "lit", repo: "o/lit", branch: "main" }] },
|
|
535
|
+
runGh,
|
|
536
|
+
);
|
|
537
|
+
const r = byName(report, "lit");
|
|
538
|
+
// The uses: pin and npm dep are both current — the ONLY deviation is the
|
|
539
|
+
// stale echoed literal, which the uses:-only check would have missed.
|
|
540
|
+
assert.equal(r.verdict.lagState, "current");
|
|
541
|
+
assert.equal(r.npm.npmState, "current");
|
|
542
|
+
assert.equal(r.hasStaleLiteral, true);
|
|
543
|
+
assert.equal(r.staleLiterals.length, 2); // comment + echo, same stale SHA
|
|
544
|
+
assert.equal(r.staleLiterals.every((l) => l.reason === "stale"), true);
|
|
545
|
+
assert.equal(r.drift, true);
|
|
546
|
+
const text = renderReport(report);
|
|
547
|
+
assert.match(text, /stale pin literal/);
|
|
548
|
+
assert.match(text, /STALE PIN LITERAL/);
|
|
549
|
+
});
|
|
550
|
+
|
|
310
551
|
test("fetchConsumerPackageJson returns null when the file is missing", () => {
|
|
311
552
|
const runGh = () => {
|
|
312
553
|
throw new Error("404");
|
|
@@ -368,3 +609,105 @@ test("runCli --strict exits 1 when drift is present", () => {
|
|
|
368
609
|
rmSync(cfgDir, { recursive: true, force: true });
|
|
369
610
|
}
|
|
370
611
|
});
|
|
612
|
+
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// minimumReleaseAge hold window — the false-positive guard (Story #107)
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
// A fresh release: the `skew` and `both-lag` consumers lag it, but the release
|
|
618
|
+
// is younger than the 3-day hold window, so Renovate has not bumped them yet.
|
|
619
|
+
const FRESH_RELEASE_AT = "2026-06-29T00:00:00Z"; // 1 day before NOW
|
|
620
|
+
const AGED_RELEASE_AT = "2026-06-25T00:00:00Z"; // 5 days before NOW
|
|
621
|
+
const NOW = Date.parse("2026-06-30T00:00:00Z");
|
|
622
|
+
|
|
623
|
+
const HOLD_CONFIG = { ...CONFIG, minimumReleaseAge: "3 days" };
|
|
624
|
+
|
|
625
|
+
test("buildReport: lag against a release inside the hold window is holding, not drift", () => {
|
|
626
|
+
const runGh = makeRunGh({ ...FIXTURES, __publishedAt: FRESH_RELEASE_AT });
|
|
627
|
+
const report = buildReport(HOLD_CONFIG, runGh, NOW);
|
|
628
|
+
assert.equal(report.releaseAge.withinWindow, true);
|
|
629
|
+
|
|
630
|
+
// npm lags but workflows are current → would be a surface skew, suppressed.
|
|
631
|
+
const skew = byName(report, "skew");
|
|
632
|
+
assert.equal(skew.surfaceSkew, true);
|
|
633
|
+
assert.equal(skew.holding, true);
|
|
634
|
+
assert.equal(skew.drift, false);
|
|
635
|
+
|
|
636
|
+
// Both surfaces lag → would be drift, suppressed during the hold.
|
|
637
|
+
const both = byName(report, "both-lag");
|
|
638
|
+
assert.equal(both.holding, true);
|
|
639
|
+
assert.equal(both.drift, false);
|
|
640
|
+
|
|
641
|
+
// The aligned consumer is genuinely current — not holding.
|
|
642
|
+
const aligned = byName(report, "aligned");
|
|
643
|
+
assert.equal(aligned.holding, false);
|
|
644
|
+
assert.equal(aligned.drift, false);
|
|
645
|
+
|
|
646
|
+
// No consumer drifts during the hold → the dashboard does not page.
|
|
647
|
+
assert.equal(hasDrift(report), false);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("buildReport: the same lag against an aged release is real drift again", () => {
|
|
651
|
+
const runGh = makeRunGh({ ...FIXTURES, __publishedAt: AGED_RELEASE_AT });
|
|
652
|
+
const report = buildReport(HOLD_CONFIG, runGh, NOW);
|
|
653
|
+
assert.equal(report.releaseAge.withinWindow, false);
|
|
654
|
+
|
|
655
|
+
const skew = byName(report, "skew");
|
|
656
|
+
assert.equal(skew.holding, false);
|
|
657
|
+
assert.equal(skew.drift, true);
|
|
658
|
+
|
|
659
|
+
const both = byName(report, "both-lag");
|
|
660
|
+
assert.equal(both.holding, false);
|
|
661
|
+
assert.equal(both.drift, true);
|
|
662
|
+
|
|
663
|
+
assert.equal(hasDrift(report), true);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("renderReport surfaces the holding banner + section during the hold", () => {
|
|
667
|
+
const runGh = makeRunGh({ ...FIXTURES, __publishedAt: FRESH_RELEASE_AT });
|
|
668
|
+
const report = buildReport(HOLD_CONFIG, runGh, NOW);
|
|
669
|
+
const text = renderReport(report);
|
|
670
|
+
assert.match(text, /minimumReleaseAge` hold active/);
|
|
671
|
+
assert.match(text, /⏳ holding/);
|
|
672
|
+
assert.match(text, /Holding \(minimumReleaseAge\)/);
|
|
673
|
+
// Held consumers must NOT appear in a "Drift detected" section.
|
|
674
|
+
assert.doesNotMatch(text, /Drift detected/);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("runCli --strict does NOT exit 1 when the only deviation is a hold", () => {
|
|
678
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-hold-"));
|
|
679
|
+
const p = join(cfgDir, "consumers.json");
|
|
680
|
+
writeFileSync(p, JSON.stringify(HOLD_CONFIG));
|
|
681
|
+
try {
|
|
682
|
+
const code = runCli({
|
|
683
|
+
argv: ["--config", p, "--strict"],
|
|
684
|
+
runGh: makeRunGh({ ...FIXTURES, __publishedAt: FRESH_RELEASE_AT }),
|
|
685
|
+
stdout: capture(),
|
|
686
|
+
stderr: capture(),
|
|
687
|
+
summaryPath: undefined,
|
|
688
|
+
nowMs: NOW, // release is 1 day old → inside the 3-day hold window.
|
|
689
|
+
});
|
|
690
|
+
assert.equal(code, 0);
|
|
691
|
+
} finally {
|
|
692
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("runCli --strict DOES exit 1 once the held release ages out", () => {
|
|
697
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-aged-"));
|
|
698
|
+
const p = join(cfgDir, "consumers.json");
|
|
699
|
+
writeFileSync(p, JSON.stringify(HOLD_CONFIG));
|
|
700
|
+
try {
|
|
701
|
+
const code = runCli({
|
|
702
|
+
argv: ["--config", p, "--strict"],
|
|
703
|
+
runGh: makeRunGh({ ...FIXTURES, __publishedAt: AGED_RELEASE_AT }),
|
|
704
|
+
stdout: capture(),
|
|
705
|
+
stderr: capture(),
|
|
706
|
+
summaryPath: undefined,
|
|
707
|
+
nowMs: NOW, // release is 5 days old → past the 3-day hold window.
|
|
708
|
+
});
|
|
709
|
+
assert.equal(code, 1);
|
|
710
|
+
} finally {
|
|
711
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
712
|
+
}
|
|
713
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* edge-security.test.mjs — node:test suite for the reusable edge-security
|
|
4
|
+
* middleware units (Story #116): CORS (Astro + hono variants), security
|
|
5
|
+
* headers, and app-layer rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* The units live under `config/edge-security/` (the npm package-export channel)
|
|
8
|
+
* and are imported here by relative path. Run: `node --test scripts/` or
|
|
9
|
+
* `npm test`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createAllowlist,
|
|
17
|
+
normalizeOrigin,
|
|
18
|
+
} from "../config/edge-security/allowlist.mjs";
|
|
19
|
+
import { createAstroCors } from "../config/edge-security/cors-astro.mjs";
|
|
20
|
+
import { createHonoCorsOptions } from "../config/edge-security/cors-hono.mjs";
|
|
21
|
+
import {
|
|
22
|
+
applySecurityHeaders,
|
|
23
|
+
buildSecurityHeaders,
|
|
24
|
+
} from "../config/edge-security/security-headers.mjs";
|
|
25
|
+
import {
|
|
26
|
+
createAstroRateLimit,
|
|
27
|
+
createMemoryStore,
|
|
28
|
+
createRateLimiter,
|
|
29
|
+
rateLimitHeaders,
|
|
30
|
+
} from "../config/edge-security/rate-limit.mjs";
|
|
31
|
+
import * as barrel from "../config/edge-security/index.mjs";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// allowlist — the no-wildcard-with-credentials invariant
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
test("normalizeOrigin strips trailing slash / path to scheme+host+port", () => {
|
|
38
|
+
assert.equal(normalizeOrigin("https://app.example.com/"), "https://app.example.com");
|
|
39
|
+
assert.equal(normalizeOrigin("https://app.example.com:8443/x"), "https://app.example.com:8443");
|
|
40
|
+
assert.equal(normalizeOrigin("*"), "*");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("normalizeOrigin rejects a non-absolute / bare-host entry", () => {
|
|
44
|
+
assert.throws(() => normalizeOrigin("app.example.com"), /not a valid absolute origin/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("createAllowlist THROWS on wildcard + credentials (invariant by construction)", () => {
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => createAllowlist(["*"], { credentials: true }),
|
|
50
|
+
/wildcard origin .* AND credentials/i,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("createAllowlist allows wildcard when credentials are off", () => {
|
|
55
|
+
const al = createAllowlist(["*"], { credentials: false });
|
|
56
|
+
assert.equal(al.isWildcard, true);
|
|
57
|
+
assert.equal(al.credentials, false);
|
|
58
|
+
assert.equal(al.resolve("https://anything.example.com"), "*");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("createAllowlist rejects wildcard mixed with explicit origins", () => {
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => createAllowlist(["*", "https://a.example.com"]),
|
|
64
|
+
/cannot be combined with explicit origins/,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("closed allowlist echoes only trusted origins, null otherwise", () => {
|
|
69
|
+
const al = createAllowlist(["https://app.example.com"], { credentials: true });
|
|
70
|
+
assert.equal(al.resolve("https://app.example.com"), "https://app.example.com");
|
|
71
|
+
assert.equal(al.resolve("https://app.example.com/"), "https://app.example.com"); // normalized
|
|
72
|
+
assert.equal(al.resolve("https://evil.example.com"), null);
|
|
73
|
+
assert.equal(al.resolve(null), null);
|
|
74
|
+
assert.equal(al.resolve("not a url"), null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// cors-astro
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
test("createAstroCors throws on wildcard + credentials at construction", () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => createAstroCors({ allowedOrigins: ["*"], credentials: true }),
|
|
84
|
+
/wildcard origin .* AND credentials/i,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("createAstroCors requires allowedOrigins", () => {
|
|
89
|
+
assert.throws(() => createAstroCors({}), /allowedOrigins/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("astro CORS preflight: trusted origin gets full CORS headers", async () => {
|
|
93
|
+
const cors = createAstroCors({
|
|
94
|
+
allowedOrigins: ["https://godomio.com"],
|
|
95
|
+
credentials: true,
|
|
96
|
+
methods: ["GET", "POST"],
|
|
97
|
+
});
|
|
98
|
+
const req = new Request("https://godomio.com/api", {
|
|
99
|
+
method: "OPTIONS",
|
|
100
|
+
headers: { Origin: "https://godomio.com" },
|
|
101
|
+
});
|
|
102
|
+
const res = await cors({ request: req }, async () => new Response("unused"));
|
|
103
|
+
assert.equal(res.status, 204);
|
|
104
|
+
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "https://godomio.com");
|
|
105
|
+
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), "true");
|
|
106
|
+
assert.equal(res.headers.get("Access-Control-Allow-Methods"), "GET, POST");
|
|
107
|
+
assert.match(res.headers.get("Vary") ?? "", /Origin/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("astro CORS preflight: untrusted origin gets 204 with NO cors headers", async () => {
|
|
111
|
+
const cors = createAstroCors({ allowedOrigins: ["https://godomio.com"] });
|
|
112
|
+
const req = new Request("https://godomio.com/api", {
|
|
113
|
+
method: "OPTIONS",
|
|
114
|
+
headers: { Origin: "https://evil.example.com" },
|
|
115
|
+
});
|
|
116
|
+
const res = await cors({ request: req }, async () => new Response("unused"));
|
|
117
|
+
assert.equal(res.status, 204);
|
|
118
|
+
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("astro CORS actual request: annotates downstream response for trusted origin", async () => {
|
|
122
|
+
const cors = createAstroCors({ allowedOrigins: ["https://godomio.com"] });
|
|
123
|
+
const req = new Request("https://godomio.com/api", {
|
|
124
|
+
method: "GET",
|
|
125
|
+
headers: { Origin: "https://godomio.com" },
|
|
126
|
+
});
|
|
127
|
+
const res = await cors({ request: req }, async () => new Response("ok"));
|
|
128
|
+
assert.equal(await res.text(), "ok");
|
|
129
|
+
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "https://godomio.com");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("astro CORS actual request: untrusted origin response carries no ACAO", async () => {
|
|
133
|
+
const cors = createAstroCors({ allowedOrigins: ["https://godomio.com"] });
|
|
134
|
+
const req = new Request("https://godomio.com/api", {
|
|
135
|
+
method: "GET",
|
|
136
|
+
headers: { Origin: "https://evil.example.com" },
|
|
137
|
+
});
|
|
138
|
+
const res = await cors({ request: req }, async () => new Response("ok"));
|
|
139
|
+
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// cors-hono
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
test("createHonoCorsOptions throws on wildcard + credentials at construction", () => {
|
|
147
|
+
assert.throws(
|
|
148
|
+
() => createHonoCorsOptions({ allowedOrigins: ["*"], credentials: true }),
|
|
149
|
+
/wildcard origin .* AND credentials/i,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("hono CORS options origin callback enforces the closed allowlist", () => {
|
|
154
|
+
const opts = createHonoCorsOptions({
|
|
155
|
+
allowedOrigins: ["https://athportal.com"],
|
|
156
|
+
credentials: true,
|
|
157
|
+
});
|
|
158
|
+
assert.equal(opts.credentials, true);
|
|
159
|
+
assert.equal(opts.origin("https://athportal.com"), "https://athportal.com");
|
|
160
|
+
assert.equal(opts.origin("https://evil.example.com"), null);
|
|
161
|
+
assert.deepEqual(opts.allowMethods.includes("GET"), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("hono CORS wildcard env returns * via origin callback (no credentials)", () => {
|
|
165
|
+
const opts = createHonoCorsOptions({ allowedOrigins: ["*"] });
|
|
166
|
+
assert.equal(opts.credentials, false);
|
|
167
|
+
assert.equal(opts.origin("https://anything.example.com"), "*");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// security-headers
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
test("buildSecurityHeaders ships the hardened default set", () => {
|
|
175
|
+
const h = buildSecurityHeaders();
|
|
176
|
+
assert.match(h["Content-Security-Policy"], /default-src 'self'/);
|
|
177
|
+
assert.match(h["Strict-Transport-Security"], /max-age=63072000/);
|
|
178
|
+
assert.match(h["Strict-Transport-Security"], /includeSubDomains/);
|
|
179
|
+
assert.equal(h["X-Frame-Options"], "DENY");
|
|
180
|
+
assert.equal(h["X-Content-Type-Options"], "nosniff");
|
|
181
|
+
assert.equal(h["Referrer-Policy"], "strict-origin-when-cross-origin");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("buildSecurityHeaders omits headers set to false", () => {
|
|
185
|
+
const h = buildSecurityHeaders({
|
|
186
|
+
contentSecurityPolicy: false,
|
|
187
|
+
hsts: false,
|
|
188
|
+
frameOptions: false,
|
|
189
|
+
});
|
|
190
|
+
assert.equal("Content-Security-Policy" in h, false);
|
|
191
|
+
assert.equal("Strict-Transport-Security" in h, false);
|
|
192
|
+
assert.equal("X-Frame-Options" in h, false);
|
|
193
|
+
// The remaining defaults are still present.
|
|
194
|
+
assert.equal(h["X-Content-Type-Options"], "nosniff");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("buildSecurityHeaders honours custom CSP + HSTS preload", () => {
|
|
198
|
+
const h = buildSecurityHeaders({
|
|
199
|
+
contentSecurityPolicy: "default-src 'none'",
|
|
200
|
+
hsts: { maxAge: 100, preload: true, includeSubDomains: false },
|
|
201
|
+
});
|
|
202
|
+
assert.equal(h["Content-Security-Policy"], "default-src 'none'");
|
|
203
|
+
assert.equal(h["Strict-Transport-Security"], "max-age=100; preload");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("applySecurityHeaders mutates a Headers instance in place", () => {
|
|
207
|
+
const headers = new Headers();
|
|
208
|
+
applySecurityHeaders(headers);
|
|
209
|
+
assert.equal(headers.get("X-Content-Type-Options"), "nosniff");
|
|
210
|
+
assert.throws(() => applySecurityHeaders({}), /must be a Headers instance/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// rate-limit
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function ipReq(ip) {
|
|
218
|
+
return new Request("https://api.example.com/", {
|
|
219
|
+
headers: { "CF-Connecting-IP": ip },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test("createRateLimiter validates its options", () => {
|
|
224
|
+
assert.throws(() => createRateLimiter({}), /limit.*windowMs/);
|
|
225
|
+
assert.throws(() => createRateLimiter({ limit: 0, windowMs: 1000 }), />= 1/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("fixed-window limiter allows up to limit then denies", async () => {
|
|
229
|
+
const limiter = createRateLimiter({ limit: 2, windowMs: 60_000 });
|
|
230
|
+
const a = await limiter.check(ipReq("1.1.1.1"));
|
|
231
|
+
assert.equal(a.allowed, true);
|
|
232
|
+
assert.equal(a.remaining, 1);
|
|
233
|
+
const b = await limiter.check(ipReq("1.1.1.1"));
|
|
234
|
+
assert.equal(b.allowed, true);
|
|
235
|
+
assert.equal(b.remaining, 0);
|
|
236
|
+
const c = await limiter.check(ipReq("1.1.1.1"));
|
|
237
|
+
assert.equal(c.allowed, false);
|
|
238
|
+
assert.ok(c.retryAfter > 0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("rate limiter buckets are per-key (per-IP)", async () => {
|
|
242
|
+
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
243
|
+
assert.equal((await limiter.check(ipReq("1.1.1.1"))).allowed, true);
|
|
244
|
+
assert.equal((await limiter.check(ipReq("2.2.2.2"))).allowed, true);
|
|
245
|
+
assert.equal((await limiter.check(ipReq("1.1.1.1"))).allowed, false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("default key extractor fails closed to a shared bucket when no IP", async () => {
|
|
249
|
+
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
250
|
+
const bare = () => new Request("https://api.example.com/");
|
|
251
|
+
assert.equal((await limiter.check(bare())).allowed, true);
|
|
252
|
+
assert.equal((await limiter.check(bare())).allowed, false); // same "anonymous" bucket
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("memory store self-prunes expired buckets", async () => {
|
|
256
|
+
const store = createMemoryStore();
|
|
257
|
+
store.set("k", { count: 5, resetAt: Date.now() - 1 });
|
|
258
|
+
assert.equal(store.get("k"), null);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("rateLimitHeaders includes Retry-After only when denied", () => {
|
|
262
|
+
const allowed = rateLimitHeaders({ allowed: true, limit: 10, remaining: 9, resetAt: Date.now() + 1000, retryAfter: 0 });
|
|
263
|
+
assert.equal("Retry-After" in allowed, false);
|
|
264
|
+
assert.equal(allowed["RateLimit-Limit"], "10");
|
|
265
|
+
const denied = rateLimitHeaders({ allowed: false, limit: 10, remaining: 0, resetAt: Date.now() + 1000, retryAfter: 1, });
|
|
266
|
+
assert.equal(denied["Retry-After"], "1");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("createAstroRateLimit short-circuits with 429 when denied", async () => {
|
|
270
|
+
const mw = createAstroRateLimit({ limit: 1, windowMs: 60_000 });
|
|
271
|
+
const ok = await mw({ request: ipReq("9.9.9.9") }, async () => new Response("body"));
|
|
272
|
+
assert.equal(ok.status, 200);
|
|
273
|
+
assert.equal(ok.headers.get("RateLimit-Limit"), "1");
|
|
274
|
+
const denied = await mw({ request: ipReq("9.9.9.9") }, async () => new Response("body"));
|
|
275
|
+
assert.equal(denied.status, 429);
|
|
276
|
+
assert.equal(denied.headers.get("Retry-After") !== null, true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// barrel
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
test("index barrel re-exports every public unit", () => {
|
|
284
|
+
for (const name of [
|
|
285
|
+
"createAllowlist",
|
|
286
|
+
"normalizeOrigin",
|
|
287
|
+
"WILDCARD",
|
|
288
|
+
"createAstroCors",
|
|
289
|
+
"createHonoCorsOptions",
|
|
290
|
+
"buildSecurityHeaders",
|
|
291
|
+
"applySecurityHeaders",
|
|
292
|
+
"createRateLimiter",
|
|
293
|
+
"createMemoryStore",
|
|
294
|
+
"createAstroRateLimit",
|
|
295
|
+
"createHonoRateLimit",
|
|
296
|
+
"rateLimitHeaders",
|
|
297
|
+
]) {
|
|
298
|
+
assert.equal(typeof barrel[name] !== "undefined", true, `barrel missing ${name}`);
|
|
299
|
+
}
|
|
300
|
+
});
|