|
19 | 19 | * registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11 |
20 | 20 | * extracts another branch, that list and the ledger move together. |
21 | 21 | * |
22 | | - * These are domain-level checks, not route-level ones, and worth naming |
23 | | - * precisely so nobody reads this suite's green as "every route has a row". |
| 22 | + * The registry checks are domain-level, not route-level, and worth naming |
| 23 | + * precisely so nobody reads their green as "every route has a row". |
24 | 24 | * "At least one ledger entry" is satisfied the moment any row exists for a |
25 | 25 | * domain — most domains here carry several routes, but a few carry exactly |
26 | 26 | * one, where domain- and route-granularity coincide only by accident. For a |
27 | 27 | * multi-route domain, any non-last row can be deleted, or never added, |
28 | | - * without turning anything red. That is not a slip in these six assertions — |
29 | | - * it is what the registry exposes: `DomainHandlerRegistry.list()` returns |
| 28 | + * without turning them red. That is not a slip in those assertions — it is |
| 29 | + * what the registry exposes: `DomainHandlerRegistry.list()` returns |
30 | 30 | * `{prefix, match, methods, handler}` per domain, with no sub-route field to |
31 | 31 | * walk, and a busy domain like `/automation` resolves its own routes through |
32 | 32 | * one function's inline `parts[N] === 'literal'` if-chain rather than a table |
33 | 33 | * anything here could enumerate. An ablation on #17038 measured the |
34 | 34 | * consequence directly: deleting a whole `ROUTE_LEDGER` row left this suite |
35 | 35 | * fully green, because the row's domain still held "at least one" through its |
36 | | - * siblings. Whether the ledger should also be checked at route granularity — |
37 | | - * and how, given the registry's shape above — is an open maintainer decision |
38 | | - * tracked in #17041; this paragraph states today's coverage, not a plan for |
39 | | - * tomorrow's. Deliberately no row/domain counts here: a hand-typed number in |
40 | | - * this prose is exactly the unguarded-count defect this lane spent the same |
41 | | - * day eliminating elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would |
| 36 | + * siblings. |
| 37 | + * |
| 38 | + * The route-granularity arm (#26513) reads a different source: the routes |
| 39 | + * `dispatcher-plugin.ts` registers on the host `IHttpServer`, recorded as the |
| 40 | + * plugin starts. That table is complete for what the plugin MOUNTS, so a |
| 41 | + * mounted route whose row is deleted now reds here. It cannot see a row with |
| 42 | + * no mount of its own spelling — a route only `dispatch()` answers behind a |
| 43 | + * catch-all host (`/meta`, `/data`, …), a `servedBy` specialization, a door a |
| 44 | + * plugin serves from the raw app — so those rows are still guarded per row by |
| 45 | + * `check:route-ledger-census` alone. Whether the ledger should be checked per |
| 46 | + * route beyond the mount is an open maintainer question, left open and tracked |
| 47 | + * at #26513 (the issue it was first filed under no longer resolves). |
| 48 | + * Deliberately no row/domain counts here: a hand-typed number in this prose is |
| 49 | + * exactly the unguarded-count defect this lane spent the same day eliminating |
| 50 | + * elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would |
42 | 51 | * ever notice it going stale. |
43 | 52 | */ |
44 | 53 |
|
45 | | -import { describe, it, expect } from 'vitest'; |
| 54 | +import { describe, it, expect, beforeAll } from 'vitest'; |
46 | 55 | import { HttpDispatcher } from './http-dispatcher.js'; |
| 56 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
47 | 57 | import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES, NON_DISPATCH_MOUNT_PREFIXES } from './route-ledger.js'; |
48 | 58 |
|
49 | 59 | /** Minimal kernel — enough for the constructor to register builtin domains. */ |
@@ -93,6 +103,109 @@ describe('route ledger ↔ dispatcher domain registry', () => { |
93 | 103 | }); |
94 | 104 | }); |
95 | 105 |
|
| 106 | +/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */ |
| 107 | +const LEDGER_WIRE_PREFIX = '/api/v1'; |
| 108 | + |
| 109 | +/** The base the plugin mounts an environment-scoped twin under (`dispatcher-plugin.ts`). */ |
| 110 | +const SCOPED_PREFIX = `${LEDGER_WIRE_PREFIX}/environments/:environmentId`; |
| 111 | + |
| 112 | +/** |
| 113 | + * Every `VERB /path` the dispatcher plugin registers on its host, recorded as |
| 114 | + * it starts. Mounts nothing; the plugin's own `start()` is the only author. |
| 115 | + * |
| 116 | + * The composition is the WIDEST the plugin has, so as few mounts as possible |
| 117 | + * go unobserved: |
| 118 | + * - no REST plugin on the kernel, so the plugin keeps `/discovery` (it cedes |
| 119 | + * that one route when REST is present — a strict subset); |
| 120 | + * - a serveable `analytics` service, so the capability-conditional |
| 121 | + * `/analytics` routes mount; |
| 122 | + * - environment scoping on, under the default `projectResolution: 'auto'`, so |
| 123 | + * every scoped twin mounts beside its unscoped route (`'required'` drops |
| 124 | + * the unscoped ones — again a strict subset). |
| 125 | + * Not exercised: routes a service emits at runtime on the `ai:routes` hook. |
| 126 | + * They are dynamic by construction and the `* /ai/**` family row speaks for |
| 127 | + * them; the plugin's own `/ai/*` mounts ARE recorded here. |
| 128 | + */ |
| 129 | +async function dispatcherPluginMounts(): Promise<string[]> { |
| 130 | + const mounts: string[] = []; |
| 131 | + const record = (verb: string) => (path: string) => { |
| 132 | + mounts.push(`${verb} ${path}`); |
| 133 | + }; |
| 134 | + const server = { |
| 135 | + get: record('GET'), |
| 136 | + post: record('POST'), |
| 137 | + put: record('PUT'), |
| 138 | + delete: record('DELETE'), |
| 139 | + patch: record('PATCH'), |
| 140 | + use: () => {}, |
| 141 | + }; |
| 142 | + const services: Record<string, unknown> = { analytics: {} }; |
| 143 | + const kernel = { |
| 144 | + getService: (name: string) => services[name], |
| 145 | + getServiceAsync: async (name: string) => services[name], |
| 146 | + }; |
| 147 | + const plugin = createDispatcherPlugin({ |
| 148 | + securityHeaders: false, |
| 149 | + scoping: { enableProjectScoping: true }, |
| 150 | + }); |
| 151 | + await plugin.start?.({ |
| 152 | + getKernel: () => kernel, |
| 153 | + getService: (name: string) => (name === 'http.server' ? server : services[name]), |
| 154 | + environmentId: undefined, |
| 155 | + logger: { info() {}, warn() {}, error() {}, debug() {} }, |
| 156 | + hook: () => {}, |
| 157 | + on: () => {}, |
| 158 | + } as any); |
| 159 | + return mounts; |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * Whether `mount` has a ledger row: its exact `VERB /pattern` under the wire |
| 164 | + * prefix, or a `* /prefix/**` family row whose base it sits at or under — the |
| 165 | + * same reading the live-mount parity gate gives a wildcard row. A scoped twin |
| 166 | + * answers to its unscoped row: the plugin mounts it by calling the same |
| 167 | + * registrar with the scoped base, and `dispatch()` strips the scope before |
| 168 | + * routing, so the ledger's dispatcher-internal `cleanPath` is the same route. |
| 169 | + */ |
| 170 | +function hasLedgerRow(mount: string): boolean { |
| 171 | + const sp = mount.indexOf(' '); |
| 172 | + const verb = mount.slice(0, sp); |
| 173 | + let path = mount.slice(sp + 1); |
| 174 | + if (path.startsWith(`${SCOPED_PREFIX}/`)) path = LEDGER_WIRE_PREFIX + path.slice(SCOPED_PREFIX.length); |
| 175 | + return ROUTE_LEDGER.some((row) => { |
| 176 | + const rsp = row.route.indexOf(' '); |
| 177 | + const rowVerb = row.route.slice(0, rsp); |
| 178 | + const rowPath = (row.absolute ? '' : LEDGER_WIRE_PREFIX) + row.route.slice(rsp + 1); |
| 179 | + if (rowPath.includes('*')) { |
| 180 | + const base = rowPath.replace(/\/\*+$/, ''); |
| 181 | + return (rowVerb === '*' || rowVerb === verb) && (path === base || path.startsWith(`${base}/`)); |
| 182 | + } |
| 183 | + return rowVerb === verb && rowPath === path; |
| 184 | + }); |
| 185 | +} |
| 186 | + |
| 187 | +describe('route ledger ← dispatcher plugin mounts, per route (#26513)', () => { |
| 188 | + let mounts: string[]; |
| 189 | + beforeAll(async () => { |
| 190 | + mounts = await dispatcherPluginMounts(); |
| 191 | + }); |
| 192 | + |
| 193 | + it('the composition reaches its conditional branches (a boot that skipped them would pass the arm below)', () => { |
| 194 | + expect(mounts.some((m) => m.endsWith(`${LEDGER_WIRE_PREFIX}/discovery`)), 'no `/discovery` mount').toBe(true); |
| 195 | + expect(mounts.some((m) => m.includes(`${LEDGER_WIRE_PREFIX}/analytics/`)), 'no `/analytics` mount').toBe(true); |
| 196 | + expect(mounts.some((m) => m.includes(`${SCOPED_PREFIX}/`)), 'no environment-scoped mount').toBe(true); |
| 197 | + }); |
| 198 | + |
| 199 | + it('every route the dispatcher plugin mounts has a ledger row', () => { |
| 200 | + const unledgered = [...new Set(mounts)].filter((m) => !hasLedgerRow(m)); |
| 201 | + expect( |
| 202 | + unledgered, |
| 203 | + `Dispatcher mounts with no route-ledger row: ${unledgered.join(', ')}. ` + |
| 204 | + 'Add the row to route-ledger.ts with its reviewed disposition (#3563), or delete the mount.', |
| 205 | + ).toEqual([]); |
| 206 | + }); |
| 207 | +}); |
| 208 | + |
96 | 209 | describe('non-dispatch mounts (#5090)', () => { |
97 | 210 | it('every pinned non-dispatch mount has a ledger row', () => { |
98 | 211 | const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain)); |
|
0 commit comments