Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
223 lines
9.8 KiB
JavaScript
223 lines
9.8 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']],
|
|
['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)
|
|
}
|
|
})
|