Files
website/server/test/eventVerify.test.js
wtclaude 4077c4e79e
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s
feat(events): enablement, per-run caps and mayInvoke (Phase 6)
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
2026-09-03 05:50:58 -05:00

231 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ─────────────────────
//
// *"Materialise the steps, dispatch each with `verify: true`, report what would
// happen and what it would cost against the caps."*
//
// **The finding worth the most is the one no other path can make.** Every
// per-step check here is also made at save or at dispatch; the TOTAL is not.
// Three steps each spawning 15 under a cap of 30 pass every individual check and
// breach the cap on the third — at two in the morning, unattended, with the world
// half-changed. Adding the costs up across the whole version is the thing only a
// look at the plan as a whole can do, and it is why a dry run is worth more than
// the sum of its step checks.
//
// The second property this file holds is the one §I states outright: **`verify:
// true` must change nothing and must answer honestly.** So the actions here
// record what they were asked and assert that the flag arrived — a dry run that
// silently dispatched for real is the single worst bug this feature could ship,
// and it would look exactly like a passing test otherwise.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const { verifySpec } = require('../src/events/verify')
const settingsDb = require('../src/model/events/eventActionSettings.db')
const db = require('../src/utils/db')
after(() => db.close())
const originals = { ...settingsDb }
let store
let seen
beforeEach(() => {
registries._reset()
store = new Map()
seen = []
settingsDb.byIds = async (ids) =>
new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)]))
settingsDb.get = async (id) => store.get(id) || null
})
afterEach(() => {
Object.assign(settingsDb, originals)
registries._reset()
})
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
registries.apply(api.staged)
}
const setSetting = (id, enabled, caps = {}) =>
store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
/** An action that records every envelope it is handed and then answers `answer`. */
const recorder = (id, over = {}) => ({
id,
label: over.label || id,
risk: over.risk || 'notify',
reversible: 'none',
version: over.version || 1,
params: over.params || [],
...(over.cost ? { cost: over.cost } : {}),
async perform(envelope) {
seen.push({ id, ...envelope })
if (over.throws) throw new Error(over.throws)
return over.answer || { ok: true }
},
})
const spec = (steps) => ({ phases: [{ key: 'one', steps }] })
const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra })
const ADMIN = { id: 1, role: 'admin' }
const EDITOR = { id: 2, role: 'editor' }
test('every step is dispatched with verify true, and nothing is asked to act', async () => {
// §I's promise, held as an assertion about the envelope each action received.
register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })])
const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.equal(report.steps, 2)
assert.deepEqual(report.findings, [])
assert.equal(seen.length, 2)
for (const envelope of seen) assert.equal(envelope.verify, true)
})
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
// The one check that only exists here. Each of the three steps fits under 30 on
// its own; together they do not.
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
const report = await verifySpec(
spec([
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
]),
{ user: ADMIN },
)
assert.equal(report.ok, false)
const total = report.findings.find((f) => f.code === 'cap-total')
assert.ok(total, 'the whole-plan total must be its own finding')
assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/)
assert.deepEqual(report.cost, [
{ dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
])
})
test('a plan that fits reports its cost without a finding', async () => {
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
user: ADMIN,
})
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
})
test('an uncapped dimension is reported with its total and no cap', async () => {
// Worth showing rather than hiding: "this event will spawn 40 creatures and
// nothing bounds that" is exactly what an operator opening the switchboard
// wants to have seen first.
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })])
setSetting('test.spawn', true, {})
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }])
})
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {
// Reported rather than thrown, so an author sees EVERY problem in one pass. A
// verification that stopped at the first finding would make fixing a
// twelve-step definition twelve round trips.
register([recorder('test.tell')])
const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings.length, 1)
assert.equal(report.findings[0].code, 'dormant')
assert.equal(report.findings[0].actionId, 'gone.away')
assert.equal(seen.length, 1, 'the registered step is still dispatched')
})
test('the callers own role is what the report answers against', async () => {
// The value of doing it here: an editor is told a step needs an administrator
// at the moment they can still do something about it, rather than at the moment
// it does not run.
register([recorder('test.change', { risk: 'change', label: 'Change things' })])
setSetting('test.change', true)
const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR })
assert.equal(asEditor.ok, false)
assert.equal(asEditor.findings[0].code, 'role')
const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(asAdmin.ok, true)
})
test('a disabled action is a finding, and its step is never dispatched', async () => {
// The order matters: dispatching a disabled action under `verify: true` would
// change nothing, but it would call code the deployment has switched off, and
// "we only ran it to ask whether we could run it" is not a defence anyone wants
// to make.
register([recorder('test.change', { risk: 'change' })])
const report = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(report.findings[0].code, 'disabled')
assert.equal(seen.length, 0)
})
test('the modules own refusal is a finding in the modules words', async () => {
// The half core cannot compute: whether the landmark exists, whether the
// creature is on the allowlist, whether the shard is reachable at all.
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /no such landmark/)
})
test('an action that throws under verify is a finding, not a 500 on the authors screen', async () => {
register([recorder('test.spawn', { throws: 'exploded' })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /exploded/)
})
test('a step authored against an older action version is a WARNING, not an error', async () => {
// §F: a bump makes the editor render a warning rather than refusing to run. The
// level is the whole point — a warning does not hold a scheduled start, and an
// error does.
register([recorder('test.tell', { version: 3 })])
const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN })
assert.equal(report.ok, true, 'a drift warning must not fail the dry run')
assert.equal(report.findings[0].level, 'warning')
assert.equal(report.findings[0].code, 'version-drift')
assert.match(report.findings[0].message, /authored against version 1/)
})
test('a finding says which phase and which step it is about', async () => {
// A twelve-step definition needs the finding anchored, or the author is left
// reading the message and counting rows.
register([recorder('test.tell')])
const twoPhases = {
phases: [
{ key: 'open', steps: [step('test.tell')] },
{ key: 'close', steps: [step('test.tell'), step('gone.away')] },
],
}
const report = await verifySpec(twoPhases, { user: ADMIN })
assert.equal(report.steps, 3)
assert.deepEqual(
report.findings.map((f) => ({ phase: f.phase, seq: f.seq })),
[{ phase: 'close', seq: 1 }],
)
})
test('an empty spec verifies clean and costs nothing', async () => {
const report = await verifySpec({ phases: [] }, { user: ADMIN })
assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] })
})