// ── 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) } })