|
5 | 5 | // every shared invariant (valid state, enforced-has-site, experimental/removed- |
6 | 6 | // has-note, proof-file-exists, high-risk-has-proof). A new fail-open or a deleted |
7 | 7 | // proof breaks the build. |
| 8 | +// |
| 9 | +// #2567 Phase 2 — the anonymous-deny SURFACES are additionally pinned by the |
| 10 | +// `discover()` ratchet: this test STATICALLY enumerates the data/meta/graphql |
| 11 | +// HTTP entry points from source and asserts each is classified by a matrix row. |
| 12 | +// A new ungated `/data` route (or a removed/stale `covers` key) then fails CI as |
| 13 | +// UNCLASSIFIED / STALE — the surface can't silently regress. |
8 | 14 |
|
9 | 15 | import { describe, expect, it } from 'vitest'; |
10 | 16 | import { fileURLToPath } from 'node:url'; |
11 | | -import { dirname } from 'node:path'; |
| 17 | +import { dirname, join } from 'node:path'; |
| 18 | +import { readFileSync } from 'node:fs'; |
12 | 19 | import { checkLedger } from '@objectstack/verify'; |
13 | | -import { AUTHZ_CONFORMANCE } from './authz-conformance.matrix.js'; |
| 20 | +import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js'; |
14 | 21 |
|
15 | 22 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 23 | +// packages/dogfood/test → repo root. |
| 24 | +const REPO_ROOT = join(HERE, '../../..'); |
| 25 | + |
| 26 | +// ── #2567 ratchet — static enumeration of anonymous-deny HTTP entry points ── |
| 27 | +// |
| 28 | +// A CURATED per-file probe table (not a blind repo grep): scoped to the four |
| 29 | +// source files and to data/meta/graphql segments only, so control-plane routes |
| 30 | +// (/health, /auth, /ready, /discovery) are never enumerated as data surfaces. |
| 31 | +// But each probe is pattern-based WITHIN its file, so a genuinely new `/data` |
| 32 | +// route (or a new graphql/meta handler) is auto-discovered → new key → a |
| 33 | +// missing `covers` fails CI. Keys are derived from source CONTENT (route |
| 34 | +// literals / handler names), never line numbers, so they don't churn on edits. |
| 35 | +const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray) => string }> = [ |
| 36 | + // REST /meta umbrella registrar — one guarded registrar covers all ~17 routes. |
| 37 | + { |
| 38 | + file: 'packages/rest/src/rest-server.ts', |
| 39 | + re: /private\s+registerMetadataEndpoints\s*\(/g, |
| 40 | + key: () => 'meta:rest-server.ts:registerMetadataEndpoints', |
| 41 | + }, |
| 42 | + // Dispatcher meta + graphql handlers — curated NAMES only (NOT handleAI / |
| 43 | + // handleData / handleSecurity, which are separate surfaces/rows). |
| 44 | + { |
| 45 | + file: 'packages/runtime/src/http-dispatcher.ts', |
| 46 | + re: /async\s+(handleMetadata|handleGraphQL)\s*\(/g, |
| 47 | + key: (m) => `${m[1] === 'handleGraphQL' ? 'graphql' : 'meta'}:http-dispatcher.ts:${m[1]}`, |
| 48 | + }, |
| 49 | + // Dispatcher-plugin direct GraphQL route (other server.post routes are |
| 50 | + // control-plane / feature endpoints, deliberately not enumerated here). |
| 51 | + { |
| 52 | + file: 'packages/runtime/src/dispatcher-plugin.ts', |
| 53 | + re: /server\.post\(\s*`\$\{prefix\}\/graphql`/g, |
| 54 | + key: () => 'graphql:dispatcher-plugin.ts:POST /api/v1/graphql', |
| 55 | + }, |
| 56 | + // Raw-hono standard /data routes — genuinely pattern-based: ANY new |
| 57 | + // `rawApp.<verb>(`${prefix}/data...`)` → a new key → CI fails until a row covers it. |
| 58 | + { |
| 59 | + file: 'packages/plugins/plugin-hono-server/src/hono-plugin.ts', |
| 60 | + re: /rawApp\.(get|post|put|patch|delete)\(\s*`\$\{prefix\}(\/data[^`]*)`/g, |
| 61 | + key: (m) => `data:hono-plugin.ts:${m[1].toUpperCase()} ${m[2]}`, |
| 62 | + }, |
| 63 | +]; |
| 64 | + |
| 65 | +/** Statically enumerate the anonymous-deny HTTP entry points from source. */ |
| 66 | +function discoverAnonymousDenySurfaces(): Set<string> { |
| 67 | + const found = new Set<string>(); |
| 68 | + for (const probe of PROBES) { |
| 69 | + const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8'); |
| 70 | + // Fresh lastIndex per file (the RegExp is shared, `g`-flagged). |
| 71 | + probe.re.lastIndex = 0; |
| 72 | + let m: RegExpExecArray | null; |
| 73 | + while ((m = probe.re.exec(src)) !== null) found.add(probe.key(m)); |
| 74 | + } |
| 75 | + return found; |
| 76 | +} |
| 77 | + |
| 78 | +const HIGH_RISK = [ |
| 79 | + 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', |
| 80 | + // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the |
| 81 | + // same object data as REST `/data` through a sibling entry point. |
| 82 | + 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', |
| 83 | +]; |
16 | 84 |
|
17 | 85 | describe('ADR-0056 D10 — authorization conformance matrix', () => { |
18 | | - it('is a sound conformance ledger (ADR-0060 checkLedger)', () => { |
| 86 | + it('is a sound conformance ledger (ADR-0060 checkLedger) + the #2567 surface ratchet holds', () => { |
19 | 87 | const problems = checkLedger(AUTHZ_CONFORMANCE, { |
20 | 88 | proofRoot: HERE, // proofs are dogfood test files alongside this one |
21 | | - highRisk: [ |
22 | | - 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', |
23 | | - // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the |
24 | | - // same object data as REST `/data` through a sibling entry point. |
25 | | - 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', |
26 | | - ], |
| 89 | + highRisk: HIGH_RISK, |
| 90 | + // The ratchet: every discovered data/meta/graphql entry point must be |
| 91 | + // classified by exactly one row's `covers`, and no `covers` key may be |
| 92 | + // stale (no longer in source). |
| 93 | + discover: () => discoverAnonymousDenySurfaces(), |
27 | 94 | }); |
28 | 95 | expect(problems, problems.join('\n')).toEqual([]); |
29 | 96 | }); |
30 | 97 | }); |
| 98 | + |
| 99 | +// #2567 — prove the ratchet actually BITES. Drives `checkLedger` with controlled |
| 100 | +// inputs (deep-cloned matrix / synthetic discover) so it's deterministic and |
| 101 | +// needs no source edits. If these ever pass vacuously, the ratchet is asleep. |
| 102 | +describe('#2567 — anonymous-deny surface ratchet bites', () => { |
| 103 | + const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE)); |
| 104 | + const opts = (discover: () => Iterable<string>) => ({ proofRoot: HERE, highRisk: HIGH_RISK, discover }); |
| 105 | + |
| 106 | + it('the real matrix + real discover is sound (baseline lock)', () => { |
| 107 | + const problems = checkLedger(AUTHZ_CONFORMANCE, opts(() => discoverAnonymousDenySurfaces())); |
| 108 | + expect(problems).toEqual([]); |
| 109 | + }); |
| 110 | + |
| 111 | + it('(a) a row that DROPS its covers → UNCLASSIFIED surface failure', () => { |
| 112 | + const m = clone(); |
| 113 | + const row = m.find((r) => r.id === 'anonymous-deny-hono-data')!; |
| 114 | + row.covers = []; |
| 115 | + const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces())); |
| 116 | + expect(problems.some((p) => /UNCLASSIFIED surface/.test(p) && /data:hono-plugin\.ts/.test(p))).toBe(true); |
| 117 | + }); |
| 118 | + |
| 119 | + it('(b) a NEW ungated route appearing in source → UNCLASSIFIED surface failure', () => { |
| 120 | + const fake = 'data:hono-plugin.ts:DELETE /data/fake'; |
| 121 | + const problems = checkLedger( |
| 122 | + AUTHZ_CONFORMANCE, |
| 123 | + opts(() => new Set([...discoverAnonymousDenySurfaces(), fake])), |
| 124 | + ); |
| 125 | + expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fake))).toBe(true); |
| 126 | + }); |
| 127 | + |
| 128 | + it('(c) a covers key no longer in source → STALE covers failure', () => { |
| 129 | + const m = clone(); |
| 130 | + const row = m.find((r) => r.id === 'anonymous-deny-graphql')!; |
| 131 | + row.covers = [...(row.covers ?? []), 'graphql:http-dispatcher.ts:handleRemovedThing']; |
| 132 | + const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces())); |
| 133 | + expect(problems.some((p) => /STALE covers/.test(p) && /handleRemovedThing/.test(p))).toBe(true); |
| 134 | + }); |
| 135 | +}); |
0 commit comments