chore(server): freeze the URL surface with a generated route manifest
PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.
scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.
Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.
Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.
The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.
CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
70
server/test/routeManifest.test.js
Normal file
70
server/test/routeManifest.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// The route manifest is the freeze that proves the domain split (docs/website/
|
||||
// API_V2_PLAN.md § Phase 2) moves no URL. CI runs `npm run routes:manifest -- --check`,
|
||||
// but that only fires on a pull request — this test makes the same drift visible on
|
||||
// `npm test`, and adds the two structural invariants the manifest alone can't state.
|
||||
//
|
||||
// Point the pool at a closed port BEFORE requiring anything: the generator loads the
|
||||
// real app, which pulls in every model and builds a mariadb pool at require time. No
|
||||
// query is ever run here (the Express stack is introspected, not called).
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const manifestTool = require('../scripts/routeManifest')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const read = (file) => fs.readFileSync(path.join(SERVER_ROOT, file), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
const collected = manifestTool.collect()
|
||||
|
||||
test('routes.manifest.json is in sync with the live Express stack', () => {
|
||||
const generated = manifestTool.serialize(manifestTool.buildManifest(collected))
|
||||
assert.equal(
|
||||
read('routes.manifest.json'),
|
||||
generated,
|
||||
'The URL surface changed. If that was deliberate, run `npm run routes:manifest` and ' +
|
||||
'commit the result so the change is reviewed — do not smuggle a URL change into a ' +
|
||||
'"mechanical" refactor PR.',
|
||||
)
|
||||
})
|
||||
|
||||
test('routes.guards.json is in sync with the live Express stack', () => {
|
||||
const generated = manifestTool.serialize(manifestTool.buildGuards(collected))
|
||||
assert.equal(read('routes.guards.json'), generated, 'Run `npm run routes:manifest`.')
|
||||
})
|
||||
|
||||
test('the manifest only inventories API surface, never static mounts', () => {
|
||||
// The SPA catch-all, /uploads and /brand are filesystem-conditional, so including
|
||||
// them would make the manifest depend on whether the client had been built.
|
||||
for (const route of collected.public) {
|
||||
assert.ok(
|
||||
route.path.startsWith('/api/') || route.path.startsWith('/.well-known/'),
|
||||
`unexpected non-API path in the manifest: ${route.method} ${route.path}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('every /admin and /player route still sits behind the shared auth gate', () => {
|
||||
// Router-level `use()` gates do not appear in an individual route's own stack, so a
|
||||
// capability router extracted from admin.routes.js without re-applying the gate would
|
||||
// silently publish authenticated endpoints. Names are only a hint — `requireRole(...)`
|
||||
// returns an anonymous arrow and cannot be seen here — but a *missing* requireAuth is
|
||||
// unambiguous.
|
||||
const gated = collected.public.filter(
|
||||
(r) => r.path.startsWith('/api/v1/admin/') || r.path.startsWith('/api/v1/player/'),
|
||||
)
|
||||
assert.ok(gated.length > 100, 'expected the gated surface to be found')
|
||||
for (const route of gated) {
|
||||
assert.ok(
|
||||
route.gates.includes('requireAuth'),
|
||||
`${route.method} ${route.path} is missing requireAuth`,
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user