Files
website/server/test/eventsRoles.test.js
wtclaude fdc118166c
Some checks failed
PR Checks / client-build (pull_request) Successful in 3m15s
PR Checks / server-tests (pull_request) Failing after 8m21s
PR Checks / bot-tests (pull_request) Successful in 11m12s
feat(events): the resource ledger, leases and cleanup (Phase 8)
Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN.

One table, one core action, one route, one body field, and two members added to
MODULE_API 1.10.0 in place. The safety property the whole world-write half
depends on: core now remembers what a run changed in the world, and gives it
back on every terminal path.

Four decisions settled by the org lead on 2026-09-03, all as recommended:

- A lease is acquired by a new CORE action, `core.lease`. Section F puts the
  duration bound and the two-events-one-target conflict check on core's side of
  the seam, and a lease verb per module would be both re-implemented once per
  module, advisory everywhere.
- Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A
  spawn's ref does not exist until the module answers, so what core writes
  before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never
  comes it stands, and cleanup calls revert() with the key and no resources --
  which is why section F's revert takes the key at all.
- Cleanup is one sweep over the ledger, not synthetic step rows. The
  step-shaped version costs a second retry counter beside `revert_attempts`.
- `reconcile` is declared here and TRIGGERED BY THE MODULE, through
  `ctx.events.reconcile()`. Core has no concept of the game being up, so it
  cannot decide when to ask; it asks once at its own boot.

MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`;
while it is on `edge` it is amended in place, so the whole module contract
reaches an author as one version they read once.

Verify

- `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the
  pre-existing engagementManifest CRLF failure, in a file this branch does not
  touch (`edge` before: 1950/1876/73/1). +75 tests.
- The unique key was proved against a REAL MariaDB, because nothing else can
  prove it: whether multiple NULLs collide in a unique index, whether a STORED
  generated column is recomputed on UPDATE, and whether the SET NULL foreign key
  survives beside it are properties of the server. eventRunnerSql.test.js gained
  16 tests; 65 pass against the container. The real schema.sql was applied to a
  fresh database and to an existing one.
- Client: 362 pass, and it builds. routes:manifest and swagger -- one route
  added, none moved.

The live walk found three defects, and two of them are the phase's real finding

Driven by a throwaway `rig` module in website/modules/, deleted before commit.

1. A lease was never given back at all. `core.lease` reserves its own ledger
   row, so it never went through the ledger's dirty-marking, so a run holding
   only a lease kept `cleanup_status = 'not_required'` and the cleanup leg --
   which selected on `pending` -- never looked at it.
2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing
   sweep moved the run to `incomplete`, which took it out of the leg's own scan
   for ever. The test covering the bound asserted `<= 3` and was satisfied by 1:
   a bound has two halves, and a test that only asserts the ceiling passes
   against a floor.
3. The first fix for (2) made the console lie. Spending every row's
   `revert_attempts` was a tidy way to take a `cleanup: false` run out of a
   counter-bounded scan, and the run page then rendered "3 attempts" beside
   resources nothing had ever tried. Found by opening the page.

Both (1) and (2) are the same mistake: deriving "is there anything to do" from a
summary column instead of from the rows. Neither was visible to a unit test,
because a test that calls the sweep directly never asks what would have selected
the run.

The two properties that need the process to die were walked as the plan asks.
With the module's perform() hanging, the placeholder existed while the dispatch
was in flight and nothing was named; after taskkill and a restart the reclaim
re-dispatched the same idempotency key, the retry re-used its own placeholder,
and everything was given back. Then, with the module reporting one of two
resources as no longer in force, the boot-time reconcile marked the other
`orphaned` -- never `reverted`.

This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as
Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 21:19:27 -05:00

244 lines
11 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']],
// 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.
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('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)
}
})