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.
@@ -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
- return JSON.stringify({ tag_name: TAG });
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
+ });