|
78 | 78 | // an outside family only by an amendment of ADR-0138, and the classification |
79 | 79 | // is edited after that amendment, never instead of it. |
80 | 80 | // |
81 | | -// ## Disclosure |
| 81 | +// ## Disclosure: what a red run prints, and the one local switch |
82 | 82 | // |
83 | | -// Classes, positions and functions only (#22146). The assertions are |
84 | | -// class-level; a failure names the route pattern and the status it answered, |
85 | | -// which is what fixing it needs, and never a body. |
| 83 | +// Classes, positions and functions only (#22146; ruling 6134095444: "no |
| 84 | +// route-level readings go on GitHub"). This suite runs on `main` with the |
| 85 | +// dogfood battery, so whatever a red case prints lands in a public CI log. |
| 86 | +// So a red sweep case prints, BY DEFAULT, one line per class and mount family: |
| 87 | +// the class (a door class, an outside family or the everything-else class), |
| 88 | +// the mount family the route table names (the pattern's leading segments; the |
| 89 | +// table records patterns, not registrars), the source, and the COUNT of red |
| 90 | +// routes in it. ⛔ It never pairs a route with the answer it got, and it never |
| 91 | +// prints a body. The judgement is unchanged: the same routes are judged, and a |
| 92 | +// case is red exactly when one of them misses its class. |
| 93 | +// |
| 94 | +// Route-level detail (each red route's pattern and why it missed its class) is |
| 95 | +// for a developer's own machine only, behind `OS_TEST_ANON_SWEEP_ROUTE_DETAIL`, |
| 96 | +// which is off by default: |
| 97 | +// |
| 98 | +// OS_TEST_ANON_SWEEP_ROUTE_DETAIL=1 pnpm --filter @objectstack/dogfood \ |
| 99 | +// exec vitest run test/anonymous-door-sweep.dogfood.test.ts |
| 100 | +// |
| 101 | +// ⛔ Never set it in CI or in a workflow. Two belts keep it local: through |
| 102 | +// `turbo run test` (the path CI takes) the variable is never forwarded, |
| 103 | +// because turbo's strict env mode hands a task only what its `env` declares |
| 104 | +// and `@objectstack/dogfood#test` declares `OS_TEST_TIERS` and `OS_TEST_SHARD` |
| 105 | +// alone; and the switch is ignored whenever `CI` is set. |
86 | 106 | // |
87 | 107 | // Not eligible for the shared showcase project: it boots a derived |
88 | 108 | // configuration with its own plugins. |
@@ -193,7 +213,11 @@ async function send(stack: VerifyStack, method: string, path: string, init: Requ |
193 | 213 | const withBody = method !== 'GET' && method !== 'HEAD'; |
194 | 214 | let timer: ReturnType<typeof setTimeout> | undefined; |
195 | 215 | const timeout = new Promise<never>((_, reject) => { |
196 | | - timer = setTimeout(() => reject(new Error(`${method} ${path} did not answer within 15s`)), 15_000); |
| 216 | + // Path-free unless the local switch is on: a hang is an answer too (header, Disclosure). |
| 217 | + timer = setTimeout( |
| 218 | + () => reject(new Error(`a request did not answer within 15s${ROUTE_DETAIL ? `: ${method} ${path}` : ''}`)), |
| 219 | + 15_000, |
| 220 | + ); |
197 | 221 | }); |
198 | 222 | try { |
199 | 223 | const res = await Promise.race([ |
@@ -223,6 +247,31 @@ async function send(stack: VerifyStack, method: string, path: string, init: Requ |
223 | 247 |
|
224 | 248 | const keyOf = (r: { method: string; pattern: string }) => `${r.method} ${r.pattern}`; |
225 | 249 |
|
| 250 | +/** |
| 251 | + * The local-only switch for route-level failure detail (header, Disclosure). |
| 252 | + * Off by default, and ignored whenever `CI` is set. |
| 253 | + */ |
| 254 | +const ROUTE_DETAIL = process.env.OS_TEST_ANON_SWEEP_ROUTE_DETAIL === '1' && !process.env.CI; |
| 255 | + |
| 256 | +/** |
| 257 | + * The mount family a route belongs to: the pattern's leading segments past the |
| 258 | + * API base, which is as much as the route table can say about who registered |
| 259 | + * it. A declared endpoint is in no route table, so its family is its kind. |
| 260 | + */ |
| 261 | +function mountFamilyOf(route: SweptRoute): string { |
| 262 | + if (route.source === 'declared') { |
| 263 | + return `declared ${route.endpoint?.type ?? 'unknown-type'} endpoints (authRequired: ${String(route.endpoint?.authRequired)})`; |
| 264 | + } |
| 265 | + const segs = route.pattern.split('/').filter((x) => x !== ''); |
| 266 | + const base = segs[0] === 'api' ? (segs[1] === 'v1' ? 2 : 1) : 0; |
| 267 | + const head = segs[base]; |
| 268 | + const kept = head === undefined || head.startsWith(':') || head === '*' ? segs.slice(0, base) : segs.slice(0, base + 1); |
| 269 | + return `/${kept.join('/')}`; |
| 270 | +} |
| 271 | + |
| 272 | +const classLabelOf = (cls: SweepClass): string => |
| 273 | + cls.kind === 'door' ? `door class ${cls.door.id}` : cls.kind === 'outside' ? `outside family ${cls.family.id}` : cls.kind; |
| 274 | + |
226 | 275 | interface Swept { |
227 | 276 | readonly route: SweptRoute; |
228 | 277 | readonly probePath: string; |
@@ -533,36 +582,63 @@ describe('ADR-0138 D2 anonymous sweep: one unauthenticated request per registere |
533 | 582 | const order = (p: Swept) => (p.cls.kind === 'outside' && p.cls.family.id === 'dev-metadata-hmr' ? 1 : 0); |
534 | 583 | for (const p of [...population].sort((a, b) => order(a) - order(b))) { |
535 | 584 | if (p.cls.kind === 'middleware') continue; |
536 | | - p.answer = await send(stack, p.route.method === 'ALL' ? 'GET' : p.route.method, p.probePath); |
| 585 | + try { |
| 586 | + p.answer = await send(stack, p.route.method === 'ALL' ? 'GET' : p.route.method, p.probePath); |
| 587 | + } catch { |
| 588 | + // No answer is recorded as status 0: it misses every class and is |
| 589 | + // counted, never thrown with its route (header, Disclosure). |
| 590 | + p.answer = { status: 0, contentType: '' }; |
| 591 | + } |
537 | 592 | } |
538 | 593 | }, 300_000); |
539 | 594 |
|
540 | | - const failures = (pick: (p: Swept) => string | undefined): string[] => |
541 | | - population.flatMap((p) => { |
| 595 | + /** |
| 596 | + * The red routes of one case, as that case prints them. By default one line |
| 597 | + * per class and mount family with the COUNT of red routes in it; with the |
| 598 | + * local switch, one line per route. Non-empty exactly when a route is red, |
| 599 | + * so the switch changes what is printed and never what is judged. |
| 600 | + */ |
| 601 | + const failures = (pick: (p: Swept) => string | undefined): string[] => { |
| 602 | + const red = population.flatMap((p) => { |
542 | 603 | const why = pick(p); |
543 | | - return why ? [`${keyOf(p.route)} (${p.route.source}) — ${why}`] : []; |
| 604 | + return why ? [{ p, why }] : []; |
544 | 605 | }); |
| 606 | + if (ROUTE_DETAIL) return red.map(({ p, why }) => `${keyOf(p.route)} (${p.route.source}) — ${why}`); |
| 607 | + const counts = new Map<string, number>(); |
| 608 | + for (const { p } of red) { |
| 609 | + const group = `${classLabelOf(p.cls)} · ${mountFamilyOf(p.route)} (${p.route.source})`; |
| 610 | + counts.set(group, (counts.get(group) ?? 0) + 1); |
| 611 | + } |
| 612 | + return [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([group, n]) => `${group}: ${n} route(s) red`); |
| 613 | + }; |
| 614 | + |
| 615 | + const DETAIL_HINT = ROUTE_DETAIL |
| 616 | + ? '' |
| 617 | + : 'Route-level detail is local only: set OS_TEST_ANON_SWEEP_ROUTE_DETAIL=1 and run this file directly (see its header).'; |
545 | 618 |
|
546 | 619 | it('every route but middleware got exactly one answer', () => { |
547 | | - const unanswered = population.filter((p) => p.cls.kind !== 'middleware' && p.answer === undefined); |
| 620 | + const unanswered = population.filter((p) => p.cls.kind !== 'middleware' && !(p.answer && p.answer.status > 0)); |
548 | 621 | expect(unanswered.map((p) => keyOf(p.route))).toEqual([]); |
549 | 622 | }); |
550 | 623 |
|
551 | 624 | it('the everything-else class answers 401 throughout, with nothing served', () => { |
552 | 625 | const swept = population.filter((p) => p.cls.kind === 'everything-else'); |
553 | 626 | expect(swept.length, 'the everything-else class is the bulk of the table').toBeGreaterThan(100); |
554 | 627 | const red = failures((p) => (p.cls.kind === 'everything-else' ? EVERYTHING_ELSE(p.answer!) : undefined)); |
555 | | - expect(red, `\nRoutes in no door class and outside no family, which ADR-0138 D2 says answer 401:\n${red.join('\n')}\n`).toEqual([]); |
| 628 | + expect( |
| 629 | + red, |
| 630 | + `\nADR-0138 D2: the everything-else class (no door class, no outside family) answers 401.\n${red.join('\n')}\n${DETAIL_HINT}\n`, |
| 631 | + ).toEqual([]); |
556 | 632 | }); |
557 | 633 |
|
558 | 634 | it('every door-class route answers its class, asked about no declared subject', () => { |
559 | 635 | const red = failures((p) => (p.cls.kind === 'door' && p.cls.door.expect ? p.cls.door.expect(p.answer!) : undefined)); |
560 | | - expect(red, `\n${red.join('\n')}\n`).toEqual([]); |
| 636 | + expect(red, `\nADR-0138 D2: a door-class route asked about no declared subject answers its class.\n${red.join('\n')}\n${DETAIL_HINT}\n`).toEqual([]); |
561 | 637 | }); |
562 | 638 |
|
563 | 639 | it('the outside families answer as D2 states, where it states an answer', () => { |
564 | 640 | const red = failures((p) => (p.cls.kind === 'outside' && p.cls.family.expect ? p.cls.family.expect(p.answer!) : undefined)); |
565 | | - expect(red, `\n${red.join('\n')}\n`).toEqual([]); |
| 641 | + expect(red, `\nADR-0138 D2: an outside family answers as D2 states, where it states an answer.\n${red.join('\n')}\n${DETAIL_HINT}\n`).toEqual([]); |
566 | 642 | }); |
567 | 643 |
|
568 | 644 | it('every row of the classification claims a route on this boot: none is stale', () => { |
|
0 commit comments