Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
253 lines
12 KiB
JavaScript
253 lines
12 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 13 adds the meter beside `verify`, and it is the one route here that
|
|
// neither dispatches nor records — which is what makes it safe to call on a
|
|
// debounce while a form is edited.
|
|
//
|
|
// 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']],
|
|
// Phase 13's meter, in the same column and for a stronger version of the
|
|
// same reason: it dispatches nothing AND records nothing.
|
|
['POST', '/events/price', ['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']],
|
|
// Phase 8's cleanup, and it sits in the ADMIN column rather than with the live
|
|
// controls it is rendered beside. Re-running a teardown is not incident
|
|
// response — it asks core to write to the world again, which §K puts in the
|
|
// same row as the world-changing actions themselves.
|
|
['POST', '/events/runs/1/cleanup', ['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.
|
|
// Phase 13 gave the same author the meter, on the same argument.
|
|
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
|
|
assert.equal(await forbidden('POST', '/events/price', 'editor'), false)
|
|
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
|
|
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
|
|
})
|
|
|
|
test('a moderator may stop a run but not re-run its cleanup', async () => {
|
|
// The same shape as start-and-stop above, one row further on, and held as its
|
|
// own claim for the same reason: the two controls sit next to each other on the
|
|
// run console and a later tidying pass that gave them one gate would have to
|
|
// delete an assertion that says why they do not share one.
|
|
//
|
|
// Cancelling is the 2am incident. Cleanup asks core to delete things in a live
|
|
// world, which is the narrower decision even though it is the tidier-sounding
|
|
// button.
|
|
assert.equal(await forbidden('POST', '/events/runs/1/cancel', 'moderator'), false)
|
|
assert.equal(await forbidden('POST', '/events/runs/1/cleanup', 'moderator'), 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)
|
|
}
|
|
})
|