Files
website/server/test/eventsRoles.test.js
wtclaude fd9fb50351
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s
feat(events): open the event contract to modules (Phase 7)
MODULE_API 1.10.0. Four names forwarded on the module-facing `api` --
registerEventActions, registerEventBudgets, registerEventLeases and
registerEventOptionSources -- one new route, and one rule made real: a
`cost()` naming a dimension no module declared is refused.

Only one of the four is new machinery. The action registry has staged
core's three actions on every boot since Phase 1; what it never had was a
way in, because loader.js builds its own `api` facade and had no method
that delegated to it. So the registry a module now reaches is one that has
been exercised on every boot for six phases.

Four decisions, settled 2026-09-03, all as recommended:

- Option sources are their own registration, modelled on registerAudiences,
  because a catalog has more than one consumer.
- An undeclared dimension is refused -- at save, at the dry run and at
  dispatch -- with its own code, because the fix is a module's declaration
  and not a deployment's cap.
- A lease is declared here and acquired by nothing; the ledger is Phase 8.
- Core registers core.options.legs, so an announce leg is a dropdown rather
  than the free-text box whose typo Phase 6's walk caught mid-run.

Proved with a throwaway module through the real loader, not with module-uo:
eventModuleContract.test.js writes a module to a real directory and lets the
loader scan it, covering all five envelope failure shapes, verify: true, the
four id spaces and dormancy on uninstall.

The live walk found the one defect nothing else could: the option-source
loader wrote its "already asked?" guard inside a setState updater and read
it on the next line, so the request was never made and the field sat on
"Reading the list..." for ever. It is a useRef now.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 14:15:04 -05:00

226 lines
10 KiB
JavaScript

// ── The 403 walk (EVENTS_PLAN.md Phase 6, EVENTS.md §K) ────────────────────
//
// The phase's second acceptance criterion: *"a 403 walk across all four roles on
// every route."* Phase 3 put the gates on the routes; this file is what makes
// them a contract rather than a line of code nobody re-reads.
//
// **It walks the real router, not the controllers.** `eventsAdmin.test.js` calls
// handlers directly, which is the right shape for testing what a handler
// DECIDES and exactly the wrong shape for testing what stands in front of it: a
// controller called directly has passed no gate at all. So the requests here go
// through `events.router.js` mounted under the same `staffOnly` tier gate
// `admin/index.js` applies, and the assertion is only ever "403 or not" — what
// the handler then answers is another file's subject.
//
// **The two lines this is protecting are §K's, and one of them is deliberately
// inconsistent:**
//
// • Publishing a version and starting a run are `admin` ONLY (§N2). Starting
// commits the deployment to everything the definition contains, unattended,
// up to every cap it declares — it wants the narrowest gate there is.
// • Cancelling, pausing, advancing and the step controls are `admin` AND
// `moderator`. The incident is "the event is doing something wrong at 2am",
// and it wants the widest. A split that read consistent, with one role owning
// both buttons, would behave badly in exactly the case the moderator role
// exists for.
//
// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row
// as the world-changing actions it governs — and `verify` to the `admin, editor`
// one, because a dry run dispatches nothing and the author who wrote the
// definition is who should be able to price it before asking an admin to publish.
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 express = require('express')
// ── Every handler is replaced before the router captures it ────────────────
//
// The router does `controller.catalog` at route-definition time, so mutating the
// controller module BEFORE requiring the router replaces what each route
// actually runs. That is the difference between a walk that measures gates and
// one that measures gates plus twenty-seven handlers reaching a dead database:
// the first draft let the handlers run and cut them off on a timer, and every
// one of them then rejected AFTER its test had ended — thirty-one green
// assertions and a file that failed, which is the least useful failure there is.
//
// It also makes the walk honest in the other direction. "Did it reach the
// handler" is now a fact this file establishes rather than infers from the
// absence of a 403.
const controller = require('../src/router/v1/admin/events.controller')
const REACHED = Symbol('reached the handler')
for (const name of Object.keys(controller)) {
if (typeof controller[name] === 'function') {
controller[name] = (_req, res) => res.status(299).json({ [REACHED]: true })
}
}
const eventsRouter = require('../src/router/v1/admin/events.router')
const { requireRole } = require('../src/utils/auth')
// Requiring the chain builds `utils/db`'s pool at require time. Every other event
// test file closes it; a file that does not leaves the process alive after the
// last assertion.
const db = require('../src/utils/db')
after(() => db.close())
const ROLES = ['admin', 'editor', 'moderator', 'player']
// The tier gate from `admin/index.js`, applied here the same way, because a walk
// that skipped it would report `player` reaching routes that no player can reach.
const staffOnly = requireRole('admin', 'editor', 'moderator')
const app = express()
app.use(express.json())
app.use((req, _res, next) => {
req.user = req.headers['x-test-role'] ? { id: 1, role: req.headers['x-test-role'] } : null
next()
})
app.use('/events', staffOnly, eventsRouter)
/**
* Dispatch one request and answer the status it ended on.
*
* `403` is a gate refusing; `299` is the stand-in handler saying it was reached;
* `404` is a path this file spelled wrong, which is worth telling apart from
* both — a walk that silently asserted "not forbidden" over a route that does
* not exist would pass for ever while protecting nothing.
*/
function dispatch(method, path, role) {
return new Promise((resolve, reject) => {
const req = new (require('http').IncomingMessage)(null)
req.method = method
req.url = path
req.headers = { 'x-test-role': role, 'content-type': 'application/json' }
req.push(null)
let status = 200
const done = () => resolve(status)
const res = {
statusCode: 200,
headersSent: false,
locals: {},
setHeader() {},
getHeader() {},
removeHeader() {},
status(c) {
status = c
this.statusCode = c
return this
},
json() {
done()
return this
},
send() {
done()
return this
},
end() {
done()
return this
},
}
app(req, res, (err) => (err ? reject(err) : resolve(404)))
})
}
const forbidden = async (method, path, role) => (await dispatch(method, path, role)) === 403
// method, path, and the roles §K says may reach the handler.
const SURFACE = [
// Reads: staff-wide, the tier gate and nothing added.
['GET', '/events/catalog', ['admin', 'editor', 'moderator']],
// Phase 7. Authoring data, so staff-wide like the catalog it belongs to: an
// editor who may write the step must be able to see which values it accepts.
['GET', '/events/catalog/options/core.options.legs', ['admin', 'editor', 'moderator']],
['GET', '/events/series', ['admin', 'editor', 'moderator']],
['GET', '/events/calendar', ['admin', 'editor', 'moderator']],
['GET', '/events/runs', ['admin', 'editor', 'moderator']],
['GET', '/events/runs/1', ['admin', 'editor', 'moderator']],
['GET', '/events/runs/1/log', ['admin', 'editor', 'moderator']],
['GET', '/events', ['admin', 'editor', 'moderator']],
['GET', '/events/1', ['admin', 'editor', 'moderator']],
['GET', '/events/1/versions', ['admin', 'editor', 'moderator']],
// Authoring: admin and editor. Naming an arc is authoring too (Phase 4).
['POST', '/events', ['admin', 'editor']],
['PUT', '/events/1', ['admin', 'editor']],
['POST', '/events/series', ['admin', 'editor']],
['PUT', '/events/series/1', ['admin', 'editor']],
['DELETE', '/events/series/1', ['admin', 'editor']],
// Phase 6. A dry run dispatches nothing and changes nothing.
['POST', '/events/1/verify', ['admin', 'editor']],
// Committing the deployment: admin only (§N2).
['POST', '/events/1/publish', ['admin']],
['POST', '/events/1/runs', ['admin']],
['DELETE', '/events/1', ['admin']],
// Phase 6's switchboard — configuration that can break things.
['GET', '/events/actions', ['admin']],
['PUT', '/events/actions', ['admin']],
// Live control of a run in flight: admin and moderator, deliberately WIDER
// than start.
['POST', '/events/runs/1/pause', ['admin', 'moderator']],
['POST', '/events/runs/1/resume', ['admin', 'moderator']],
['POST', '/events/runs/1/cancel', ['admin', 'moderator']],
['POST', '/events/runs/1/advance', ['admin', 'moderator']],
['POST', '/events/runs/1/steps/1/confirm', ['admin', 'moderator']],
['POST', '/events/runs/1/steps/1/skip', ['admin', 'moderator']],
['POST', '/events/runs/1/steps/1/retry', ['admin', 'moderator']],
]
for (const [method, path, allowed] of SURFACE) {
test(`${method} ${path} is reachable by ${allowed.join(', ')} and nobody else`, async () => {
for (const role of ROLES) {
const status = await dispatch(method, path, role)
if (allowed.includes(role)) {
// 299 is the stand-in handler. Asserting on it rather than on "not 403"
// is what stops a mistyped path in the table above passing as a 404 for
// every role and protecting nothing.
assert.equal(status, 299, `${method} ${path} as ${role}: expected to reach the handler`)
} else {
assert.equal(status, 403, `${method} ${path} as ${role}: expected a 403`)
}
}
})
}
test('a player reaches nothing at all under /events', async () => {
// Stated once as its own claim rather than left implicit in twenty-seven rows.
// The tier gate is what excludes them, not the per-route gates, and a refactor
// that moved a route out from under the mount would pass every row above.
for (const [method, path] of SURFACE) {
assert.equal(await forbidden(method, path, 'player'), true, `${method} ${path}`)
}
})
test('start and stop are NOT the same gate, and that is the point', async () => {
// §K's deliberate inconsistency, held as its own test so that a later tidying
// pass which "fixed" it has to delete an assertion that says why.
assert.equal(await forbidden('POST', '/events/1/runs', 'moderator'), true, 'a moderator may not start a run')
assert.equal(await forbidden('POST', '/events/runs/1/cancel', 'moderator'), false, 'but must be able to stop one')
})
test('an editor may price an event but not publish or start it', async () => {
// Phase 6's addition to the same shape: the author who wrote the definition can
// find out what it would cost before asking an admin to commit the deployment.
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
})
test('the switchboard is admin only in both directions', async () => {
// Reading which actions are enabled is as much `admin` as writing it: the board
// is the deployment's posture, and §K puts it in the same row as the actions it
// governs rather than with the staff-wide reads.
for (const role of ['editor', 'moderator', 'player']) {
assert.equal(await forbidden('GET', '/events/actions', role), true, role)
assert.equal(await forbidden('PUT', '/events/actions', role), true, role)
}
})