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
This commit is contained in:
451
server/test/eventAuthorize.test.js
Normal file
451
server/test/eventAuthorize.test.js
Normal file
@@ -0,0 +1,451 @@
|
||||
// ── mayInvoke: the whole authorisation decision (EVENTS_PLAN.md Phase 6) ───
|
||||
//
|
||||
// §K asks for four layers behind ONE function — role, enablement, cap, and the
|
||||
// shard's own switch — and says why: *"it is what makes an EM-style delegation
|
||||
// model a later option rather than a redesign"*. This file is that function's
|
||||
// contract, and it is worth stating what each test is actually protecting,
|
||||
// because three of them protect a decision rather than a mechanism.
|
||||
//
|
||||
// • **The default-off line falls between `inspect` and `change`** (org lead,
|
||||
// 2026-09-03). §K's sentence read literally would have shipped `core.wait`
|
||||
// disabled — it is `risk: 'inspect'` — so every published event that waits
|
||||
// would break on a fresh deployment. The same line is the role floor.
|
||||
// • **The tightest cap wins.** `event_action_settings.caps` is per action while
|
||||
// `event_run_budget` is one row per dimension, so two actions spending
|
||||
// `uo.creatures` must agree on one number.
|
||||
// • **An unpriceable action is refused, not free.** A `cost()` that throws is
|
||||
// an action whose own accounting is broken, and reading that as zero would
|
||||
// make the broken one the only one nothing bounds.
|
||||
//
|
||||
// The registry is the REAL one, staged and applied the way a module does it, for
|
||||
// `eventRunner.test.js`'s reason: an action that would not register is not one
|
||||
// this function has to survive.
|
||||
|
||||
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 authorize = require('../src/events/authorize')
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = { settings: { ...settingsDb }, budget: { ...budgetDb } }
|
||||
|
||||
let store
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
store = { settings: new Map(), budget: new Map() }
|
||||
|
||||
settingsDb.get = async (id) => store.settings.get(id) || null
|
||||
settingsDb.byIds = async (ids) =>
|
||||
new Map([...new Set(ids || [])].filter((i) => store.settings.has(i)).map((i) => [i, store.settings.get(i)]))
|
||||
budgetDb.forRun = async (runId) =>
|
||||
[...store.budget.values()].filter((b) => Number(b.run_id) === Number(runId))
|
||||
budgetDb.spend = async (runId, dimension, amount) => {
|
||||
if (!(amount > 0)) return true
|
||||
const row = store.budget.get(`${runId}:${dimension}`)
|
||||
if (!row) return false
|
||||
if (row.cap !== null && row.consumed + amount > row.cap) return false
|
||||
row.consumed += amount
|
||||
return true
|
||||
}
|
||||
budgetDb.refund = async (runId, dimension, amount) => {
|
||||
const row = store.budget.get(`${runId}:${dimension}`)
|
||||
if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(settingsDb, originals.settings)
|
||||
Object.assign(budgetDb, originals.budget)
|
||||
registries._reset()
|
||||
})
|
||||
|
||||
const register = (entries, owner = 'test') => {
|
||||
const api = registries.stage(owner)
|
||||
api.registerEventActions(entries)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
const action = (id, over = {}) => ({
|
||||
id,
|
||||
label: over.label || id,
|
||||
risk: over.risk || 'notify',
|
||||
reversible: over.reversible || 'none',
|
||||
params: over.params || [],
|
||||
...(over.cost ? { cost: over.cost } : {}),
|
||||
async perform() {
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
const setSetting = (id, enabled, caps = {}) =>
|
||||
store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||||
|
||||
const setBudget = (runId, dimension, { consumed = 0, cap = null } = {}) =>
|
||||
store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap })
|
||||
|
||||
const RUN = { id: 1, scope: '' }
|
||||
const ADMIN = { id: 1, role: 'admin' }
|
||||
const EDITOR = { id: 2, role: 'editor' }
|
||||
const MODERATOR = { id: 3, role: 'moderator' }
|
||||
|
||||
// ── Layer 2: the role floor ────────────────────────────────────────────────
|
||||
|
||||
test('the role floor falls between inspect and change, not between notify and inspect', async () => {
|
||||
// The decision, held as a test rather than as a comment. Read §K's sentence
|
||||
// literally and an editor cannot author a step that WAITS, which is an
|
||||
// authoring role that cannot author.
|
||||
register([
|
||||
action('test.tell', { risk: 'notify' }),
|
||||
action('test.look', { risk: 'inspect' }),
|
||||
action('test.change', { risk: 'change' }),
|
||||
action('test.burn', { risk: 'irreversible' }),
|
||||
])
|
||||
|
||||
for (const id of ['test.tell', 'test.look']) {
|
||||
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
||||
assert.equal(v.ok, true, `${id} should be open to an editor`)
|
||||
}
|
||||
for (const id of ['test.change', 'test.burn']) {
|
||||
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.code, 'role')
|
||||
assert.match(v.reason, /only an administrator/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a moderator is no more able to invoke a world-changing action than an editor', async () => {
|
||||
// The moderator's entire power over this feature is the run console (§K). The
|
||||
// floor is `admin`, not "not a player".
|
||||
register([action('test.change', { risk: 'change' })])
|
||||
setSetting('test.change', true)
|
||||
const v = await authorize.mayInvoke({ user: MODERATOR, action: registries.eventAction('test.change') })
|
||||
assert.equal(v.code, 'role')
|
||||
})
|
||||
|
||||
test('an admin passes the role layer for every risk class', async () => {
|
||||
register([action('test.burn', { risk: 'irreversible' })])
|
||||
setSetting('test.burn', true)
|
||||
const v = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.burn') })
|
||||
assert.equal(v.ok, true)
|
||||
})
|
||||
|
||||
test('a null user skips the role layer, because the runner is not a person', async () => {
|
||||
// The unattended path. The role was checked when a human published the version
|
||||
// and again when a human or the scheduler started the run; re-checking here
|
||||
// would mean demoting an admin at midnight silently strands every event they
|
||||
// started.
|
||||
register([action('test.burn', { risk: 'irreversible' })])
|
||||
setSetting('test.burn', true)
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.burn') })
|
||||
assert.equal(v.ok, true)
|
||||
})
|
||||
|
||||
// ── Layer 3a: enablement ───────────────────────────────────────────────────
|
||||
|
||||
test('nothing that changes the world is enabled by default, and everything else is', async () => {
|
||||
register([
|
||||
action('test.tell', { risk: 'notify' }),
|
||||
action('test.look', { risk: 'inspect' }),
|
||||
action('test.change', { risk: 'change' }),
|
||||
action('test.burn', { risk: 'irreversible' }),
|
||||
])
|
||||
const enabled = (id) => authorize.isEnabled(registries.eventAction(id), null)
|
||||
assert.equal(enabled('test.tell'), true)
|
||||
assert.equal(enabled('test.look'), true)
|
||||
assert.equal(enabled('test.change'), false)
|
||||
assert.equal(enabled('test.burn'), false)
|
||||
})
|
||||
|
||||
test('a stored row beats the risk-class default in both directions', async () => {
|
||||
// The switch is the operator's, and it has to be able to turn a `notify` action
|
||||
// OFF as well as a `change` action on. An "enable only" switchboard would leave
|
||||
// a deployment unable to stop an announcement it did not want.
|
||||
register([action('test.tell', { risk: 'notify' }), action('test.change', { risk: 'change' })])
|
||||
setSetting('test.tell', false)
|
||||
setSetting('test.change', true)
|
||||
|
||||
const off = await authorize.mayInvoke({ action: registries.eventAction('test.tell') })
|
||||
assert.equal(off.ok, false)
|
||||
assert.equal(off.code, 'disabled')
|
||||
|
||||
const on = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
||||
assert.equal(on.ok, true)
|
||||
})
|
||||
|
||||
test('the refusal names the action by its LABEL, not its id', async () => {
|
||||
// The reason is rendered to an operator on the run console. `"Spawn creatures"
|
||||
// is not enabled on this deployment` is a sentence; the id is a slug.
|
||||
register([action('test.change', { risk: 'change', label: 'Spawn creatures' })])
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
||||
assert.match(v.reason, /"Spawn creatures" is not enabled/)
|
||||
})
|
||||
|
||||
// ── Pricing ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('cost() is called with the step params and its answer is what core enforces', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true)
|
||||
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
|
||||
assert.deepEqual(priced, { 'x.creatures': 12 })
|
||||
})
|
||||
|
||||
test('a cost() that throws makes the action unpriceable, never free', async () => {
|
||||
register([
|
||||
action('test.spawn', {
|
||||
risk: 'change',
|
||||
cost: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
}),
|
||||
])
|
||||
setSetting('test.spawn', true)
|
||||
assert.equal(authorize.priceOf(registries.eventAction('test.spawn'), {}), null)
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.code, 'unpriceable')
|
||||
})
|
||||
|
||||
test('a cost() answering a non-object, a negative or a NaN is unpriceable too', async () => {
|
||||
register([
|
||||
action('test.a', { risk: 'change', cost: () => 5 }),
|
||||
action('test.b', { risk: 'change', cost: () => ({ d: -1 }) }),
|
||||
action('test.c', { risk: 'change', cost: () => ({ d: 'lots' }) }),
|
||||
action('test.d', { risk: 'change', cost: () => [1, 2] }),
|
||||
])
|
||||
for (const id of ['test.a', 'test.b', 'test.c', 'test.d']) {
|
||||
assert.equal(authorize.priceOf(registries.eventAction(id), {}), null, id)
|
||||
}
|
||||
})
|
||||
|
||||
test('a zero cost is dropped rather than becoming a dimension with no spend', async () => {
|
||||
// A dimension present at 0 would be seeded as a budget row nothing ever draws
|
||||
// on, and it would appear on the console's meter as "0 of 30" for a verb this
|
||||
// event never uses.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ a: 0, b: 3 }) })])
|
||||
assert.deepEqual(authorize.priceOf(registries.eventAction('test.spawn'), {}), { b: 3 })
|
||||
})
|
||||
|
||||
test('an action with no cost() costs nothing and never touches the budget', async () => {
|
||||
register([action('test.tell')])
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.tell'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, true)
|
||||
assert.deepEqual(v.cost, {})
|
||||
// No budget row exists for this run at all, and that is not a refusal: an
|
||||
// action that spends nothing has nothing to be refused over.
|
||||
assert.equal(store.budget.size, 0)
|
||||
})
|
||||
|
||||
test('dimensions are discovered by pricing the declared examples', async () => {
|
||||
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
|
||||
// 7. Every param carries a required `example` precisely so a form has something
|
||||
// to show, and pricing them is what lets the switchboard offer a cap box.
|
||||
register([
|
||||
action('test.spawn', {
|
||||
risk: 'change',
|
||||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||
cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }),
|
||||
}),
|
||||
])
|
||||
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures'])
|
||||
})
|
||||
|
||||
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
|
||||
// Honest rather than clever: the operator loses a cap box, and the RUN loses
|
||||
// nothing, because a run's budget is seeded from the params its steps were
|
||||
// actually authored with.
|
||||
register([
|
||||
action('test.spawn', {
|
||||
risk: 'change',
|
||||
cost: (p) => ({ d: p.missing.count }),
|
||||
}),
|
||||
])
|
||||
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), [])
|
||||
})
|
||||
|
||||
// ── effectiveCaps: the tightest cap wins ───────────────────────────────────
|
||||
|
||||
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setSetting('test.horde', true, { 'x.creatures': 10 })
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
|
||||
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||
)
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } })
|
||||
})
|
||||
|
||||
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
|
||||
// `null` is uncapped and must never win a minimum. Otherwise adding a second
|
||||
// verb to an event would silently remove the bound on the first.
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setSetting('test.horde', true, {})
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
|
||||
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||
)
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } })
|
||||
})
|
||||
|
||||
test('a dimension nobody caps is still a row, uncapped', async () => {
|
||||
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
|
||||
// row means something else entirely: a step spending a dimension its own run's
|
||||
// version never priced.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })])
|
||||
setSetting('test.spawn', true, {})
|
||||
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } })
|
||||
})
|
||||
|
||||
test('a step naming an unregistered action contributes no dimension', async () => {
|
||||
assert.deepEqual(authorize.effectiveCaps([{ actionId: 'nobody.registers', params: {} }], new Map()), {})
|
||||
})
|
||||
|
||||
// ── Layer 3b: the cap ──────────────────────────────────────────────────────
|
||||
|
||||
test('with no run, the question is whether the cost could EVER fit', async () => {
|
||||
// The dry run's and the editor's question. A step asking for 40 under a cap of
|
||||
// 30 is an authoring error answerable before anything is scheduled, which is
|
||||
// the entire value of catching it here rather than at 2am.
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
|
||||
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
|
||||
assert.equal(bad.ok, false)
|
||||
assert.equal(bad.code, 'cap')
|
||||
assert.equal(bad.dimension, 'x.creatures')
|
||||
assert.equal(bad.requested, 40)
|
||||
assert.equal(bad.cap, 30)
|
||||
|
||||
const fine = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 30 } })
|
||||
assert.equal(fine.ok, true)
|
||||
})
|
||||
|
||||
test('with a run and spend, the check IS the spend', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({
|
||||
action: registries.eventAction('test.spawn'),
|
||||
params: { count: 12 },
|
||||
run: RUN,
|
||||
spend: true,
|
||||
})
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.spent, true)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 12)
|
||||
})
|
||||
|
||||
test('without spend the cap check is advisory and moves nothing', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.spent, undefined)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 0)
|
||||
})
|
||||
|
||||
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.code, 'cap')
|
||||
assert.equal(v.consumed, 28)
|
||||
assert.equal(v.cap, 30)
|
||||
assert.equal(v.requested, 5)
|
||||
assert.match(v.reason, /28 of 30 is already spent this run/)
|
||||
})
|
||||
|
||||
test('a partial spend across dimensions is given back when a later one is refused', async () => {
|
||||
// The property the whole multi-dimension path turns on. The spends must be
|
||||
// separate statements — the atomicity that matters is per dimension — so a step
|
||||
// costing creatures AND bosses can take the creatures and be refused the
|
||||
// bosses, and a step that did not run must not have spent anything.
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true)
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.dimension, 'x.bosses')
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back')
|
||||
assert.equal(store.budget.get('1:x.bosses').consumed, 1)
|
||||
})
|
||||
|
||||
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
|
||||
// Fail-closed, and a distinct code: "this run has no budget for that" is a
|
||||
// different diagnosis from "the cap is spent", and an operator who raises the
|
||||
// cap in answer to the wrong one has not fixed anything.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
||||
setSetting('test.spawn', true)
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.code, 'unbudgeted')
|
||||
assert.match(v.reason, /no budget for/)
|
||||
})
|
||||
|
||||
test('an uncapped budget row never refuses', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })])
|
||||
setSetting('test.spawn', true)
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null })
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 1004)
|
||||
})
|
||||
|
||||
// ── The layers are ordered ─────────────────────────────────────────────────
|
||||
|
||||
test('the layers answer in order: role before enablement before cap', async () => {
|
||||
// Not cosmetic. An editor told "that action is disabled" would go and ask an
|
||||
// admin to enable it, and still not be allowed to author the step — the honest
|
||||
// first answer is the role.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
||||
// Disabled AND over cap AND world-changing, all at once.
|
||||
const v = await authorize.mayInvoke({
|
||||
user: EDITOR,
|
||||
action: registries.eventAction('test.spawn'),
|
||||
run: RUN,
|
||||
})
|
||||
assert.equal(v.code, 'role')
|
||||
|
||||
setSetting('test.spawn', false, { 'x.creatures': 1 })
|
||||
const asAdmin = await authorize.mayInvoke({
|
||||
user: ADMIN,
|
||||
action: registries.eventAction('test.spawn'),
|
||||
run: RUN,
|
||||
})
|
||||
assert.equal(asAdmin.code, 'disabled')
|
||||
})
|
||||
|
||||
test('an action nobody registers is refused rather than thrown at', async () => {
|
||||
const v = await authorize.mayInvoke({ action: null })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.code, 'unregistered')
|
||||
})
|
||||
@@ -37,6 +37,14 @@ const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
// Phase 6 put a permission check in front of every dispatch, and it reads two
|
||||
// tables. **For the third time in this feature, a leg the stubbing file did not
|
||||
// know about is a ten-second ECONNREFUSED that says nothing about the route it
|
||||
// was testing** — Phase 4's expansion leg and Phase 5's gate read did the same.
|
||||
// The rule the three of them add up to: when the runner or a model gains a leg,
|
||||
// every file that stubs the layer under it needs the stub.
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const gates = require('../src/events/gates')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -50,7 +58,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
|
||||
let store
|
||||
const originals = {}
|
||||
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb]]) {
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb]]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
|
||||
@@ -69,6 +77,8 @@ function installStubs() {
|
||||
versions: new Map(),
|
||||
definitions: new Map(),
|
||||
gates: new Map(),
|
||||
settings: new Map(),
|
||||
budget: new Map(),
|
||||
nextStepId: 1,
|
||||
nextGateId: 1,
|
||||
}
|
||||
@@ -374,6 +384,46 @@ function installStubs() {
|
||||
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Phase 6's two tables ──
|
||||
//
|
||||
// `store.settings` is empty by default, which is not a gap: an empty
|
||||
// switchboard is what a fresh deployment HAS, and `authorize.isEnabled` then
|
||||
// answers from the risk class. Every test in this file that does not set a
|
||||
// switch is therefore exercising the default posture, which is the posture
|
||||
// almost every deployment will run under.
|
||||
settingsDb.get = async (actionId) => store.settings.get(actionId) || null
|
||||
settingsDb.byIds = async (ids) =>
|
||||
new Map(
|
||||
[...new Set(ids || [])]
|
||||
.filter((id) => store.settings.has(id))
|
||||
.map((id) => [id, store.settings.get(id)]),
|
||||
)
|
||||
|
||||
budgetDb.seed = async (runId, dimensions) => {
|
||||
for (const [dimension, d] of Object.entries(dimensions || {})) {
|
||||
const key = `${runId}:${dimension}`
|
||||
if (store.budget.has(key)) continue
|
||||
store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null })
|
||||
}
|
||||
return Object.keys(dimensions || {}).length
|
||||
}
|
||||
// The conditional increment, read the way the server reads it — the guard is
|
||||
// evaluated against the PRE-update value, and a NULL cap is uncapped.
|
||||
budgetDb.spend = async (runId, dimension, amount) => {
|
||||
if (!(amount > 0)) return true
|
||||
const row = store.budget.get(`${runId}:${dimension}`)
|
||||
if (!row) return false
|
||||
if (row.cap !== null && row.consumed + amount > row.cap) return false
|
||||
row.consumed += amount
|
||||
return true
|
||||
}
|
||||
budgetDb.refund = async (runId, dimension, amount) => {
|
||||
const row = store.budget.get(`${runId}:${dimension}`)
|
||||
if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0)
|
||||
}
|
||||
budgetDb.forRun = async (runId) =>
|
||||
[...store.budget.values()].filter((b) => b.run_id === runId).sort((a, b) => a.dimension.localeCompare(b.dimension))
|
||||
}
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -1035,3 +1085,203 @@ test('re-entering a phase does not open a second gate', async () => {
|
||||
assert.equal(store.gates.size, 1)
|
||||
assert.equal(gateOf(id, 'one').entered_at, entered, 'and the deadline it was given does not move')
|
||||
})
|
||||
|
||||
// ── Phase 6: enablement and caps, in front of the dispatch ─────────────────
|
||||
//
|
||||
// The runner gained one thing this phase: it asks `mayInvoke` before it asks a
|
||||
// module to do anything. What follows is the behaviour that produces, and the
|
||||
// three properties that are decisions rather than mechanisms.
|
||||
|
||||
const seedBudget = (runId, dimension, { consumed = 0, cap = null } = {}) =>
|
||||
store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap, effective_from: null })
|
||||
|
||||
const budgetOf = (runId, dimension) => store.budget.get(`${runId}:${dimension}`)
|
||||
|
||||
const setSwitch = (id, enabled, caps = {}) =>
|
||||
store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||||
|
||||
test('a disabled action is REFUSED, not failed, and the two look different on the record', async () => {
|
||||
// Decision 3 (org lead, 2026-09-03): a refusal takes the same disposition a
|
||||
// failure takes, and says a different thing. `refused` and `step.refused` are
|
||||
// what let an operator reading a stopped run at 2am see at a glance that
|
||||
// nothing is broken — the deployment simply does not permit what was asked.
|
||||
register([scriptedAction('test.change', { risk: 'change', label: 'Change things' })])
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
assert.equal(stepsOf(id)[0].last_error, '"Change things" is not enabled on this deployment')
|
||||
assert.ok(kinds(id).includes('step.refused'))
|
||||
assert.ok(!kinds(id).includes('step.status'), 'a refusal is not a step.status line')
|
||||
assert.equal(scripted['test.change'], undefined, 'a refused action is never dispatched')
|
||||
})
|
||||
|
||||
test('a refusal follows the step’s on_failure, exactly as a failure does', async () => {
|
||||
// The whole of decision 3. `change` defaults to `pause`, so the run stops where
|
||||
// it stands and waits for a human to raise the cap or edit the plan.
|
||||
register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')])
|
||||
const id = seedRun([
|
||||
{ key: 'main', steps: [step('test.change', {}, 'pause'), step('test.after')] },
|
||||
])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(run(id).status, 'paused')
|
||||
assert.equal(run(id).health, 'degraded')
|
||||
assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a pausing refusal runs')
|
||||
})
|
||||
|
||||
test('a refusal with on_failure skip lets the run carry on, degraded', async () => {
|
||||
register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')])
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change', {}, 'skip'), step('test.after')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
assert.equal(stepsOf(id)[1].status, 'done')
|
||||
assert.equal(run(id).status, 'completed')
|
||||
assert.equal(run(id).health, 'degraded')
|
||||
})
|
||||
|
||||
test('an enabled world-changing action runs, because the switch is the whole gate', async () => {
|
||||
// The other half of the default-off posture, and the one that proves the switch
|
||||
// is read rather than the risk class being a refusal on its own.
|
||||
register([scriptedAction('test.change', { risk: 'change' })])
|
||||
setSwitch('test.change', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(run(id).status, 'completed')
|
||||
})
|
||||
|
||||
test('a step over its cap is refused with the dimension and the numbers on the log line', async () => {
|
||||
// "You asked for 40 and this deployment allows 30" is an authoring error, and
|
||||
// it has to arrive as those words rather than as a stack trace.
|
||||
register([
|
||||
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }),
|
||||
])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
|
||||
assert.equal(line.detail.code, 'cap')
|
||||
assert.equal(line.detail.dimension, 'x.creatures')
|
||||
assert.equal(line.detail.requested, 12)
|
||||
assert.equal(line.detail.cap, 30)
|
||||
assert.equal(line.detail.consumed, 28)
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing')
|
||||
})
|
||||
|
||||
test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => {
|
||||
// The stub's half of the plan's acceptance criterion. The SERVER's half — two
|
||||
// spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because
|
||||
// the guard lives in a WHERE and a stub reproduces the reading rather than the
|
||||
// server.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
const id = seedRun([
|
||||
{
|
||||
key: 'main',
|
||||
steps: [
|
||||
step('test.spawn', { count: 20 }, 'skip'),
|
||||
step('test.spawn', { count: 20 }, 'skip'),
|
||||
step('test.spawn', { count: 10 }, 'skip'),
|
||||
],
|
||||
},
|
||||
])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
const s = stepsOf(id)
|
||||
assert.equal(s[0].status, 'done')
|
||||
assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30')
|
||||
assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 30)
|
||||
})
|
||||
|
||||
test('a retry does not pay the cap twice', async () => {
|
||||
// The spend happens on the first attempt only. A retry re-dispatches the same
|
||||
// idempotent operation against the same key, and charging a cap for a flaky
|
||||
// socket would exhaust a deployment's allowance through unreliability rather
|
||||
// than through effect.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'pending')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends')
|
||||
|
||||
// Past the retry backoff: the second attempt succeeds and must not spend again.
|
||||
await runner.tick(later(61_000))
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice')
|
||||
})
|
||||
|
||||
test('a step that spent and then failed for good keeps its spend', async () => {
|
||||
// The corollary, and it is deliberate: the attempt may have half-run, and a
|
||||
// refund would be core asserting that it did not.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'failed')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5)
|
||||
})
|
||||
|
||||
test('a step spending a dimension its run has no budget row for is refused', async () => {
|
||||
// Fail-closed. A run whose version names a costing action always has that
|
||||
// dimension seeded — uncapped ones included, as a row with a NULL cap — so a
|
||||
// missing row means the step is spending something its own version never
|
||||
// declared.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
|
||||
assert.equal(line.detail.code, 'unbudgeted')
|
||||
})
|
||||
|
||||
test('an uncapped dimension counts without ever refusing', async () => {
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: null })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done'])
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 198)
|
||||
})
|
||||
|
||||
test('the runner never re-checks the role of whoever started the run', async () => {
|
||||
// §K's "a demoted user loses access at once" is about reaching a ROUTE. A run
|
||||
// already in flight is deliberately not re-gated against its starter's current
|
||||
// role: demoting an admin at midnight must not silently strand every event they
|
||||
// started. Cancel is the control for a run that should stop.
|
||||
register([scriptedAction('test.burn', { risk: 'irreversible' })])
|
||||
setSwitch('test.burn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.burn')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
})
|
||||
|
||||
@@ -196,6 +196,25 @@ CREATE TABLE event_run_phase_gates (
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_run_budget (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
dimension VARCHAR(96) NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
cap INT NULL,
|
||||
effective_from VARCHAR(96) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
caps JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
// The statements under test, verbatim from `eventRuns.db.js` and
|
||||
@@ -1097,3 +1116,190 @@ test('a gate goes with its run', async (t) => {
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0)
|
||||
})
|
||||
|
||||
// ── Phase 6: the cap's conditional increment ───────────────────────────────
|
||||
//
|
||||
// **The plan's own acceptance criterion**: *"two concurrent steps against one cap
|
||||
// proving neither over-spends"*. It belongs here rather than beside the stub for
|
||||
// the reason the gate's increment does — the guard lives in a WHERE that the
|
||||
// server evaluates against the pre-update row, and a stub reproduces the reading
|
||||
// rather than the server. Engagement's cooldown claim was green against its stub
|
||||
// and always allowed the send, because the connector defaults `foundRows: true`
|
||||
// and a no-op UPDATE reports 1: exactly the mistake this shape of statement
|
||||
// invites, and the reason `spend()` reads `affectedRows` at all.
|
||||
//
|
||||
// The SET list has ONE assignment, and that is Phase 5's lesson applied rather
|
||||
// than rediscovered: MariaDB evaluates SET assignments left to right with the
|
||||
// values already assigned, so nothing in this statement may read `consumed`
|
||||
// after writing it. The guard stays in the WHERE.
|
||||
|
||||
// **Requiring a shipping model into THIS file starts a second pool**, and it is
|
||||
// the only file in the suite where that matters. Everywhere else the environment
|
||||
// points at a dead port and `utils/db`'s pool never connects; here it points at a
|
||||
// live server, so the pool holds open connections and the process never exits —
|
||||
// 49 green tests and a file that hangs until the harness kills it. Every other
|
||||
// event test file already closes it in an `after`; this one now has a reason to.
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const appDb = require('../src/utils/db')
|
||||
after(() => appDb.close())
|
||||
|
||||
const seedBudget = async (over = {}) => {
|
||||
const { runId } = await seedRun({ status: 'running' })
|
||||
await pool.query(
|
||||
'INSERT INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, ?, ?, ?)',
|
||||
// `'cap' in over`, not `over.cap ?? 30` -- `null` is a MEANINGFUL cap here
|
||||
// (uncapped) and `??` coalesces it straight back to 30, which made the
|
||||
// uncapped test seed a capped row and fail against the correct statement.
|
||||
[
|
||||
runId,
|
||||
over.dimension ?? 'uo.creatures',
|
||||
over.consumed ?? 0,
|
||||
'cap' in over ? over.cap : 30,
|
||||
over.from ?? 'uo.creature.spawn',
|
||||
],
|
||||
)
|
||||
return runId
|
||||
}
|
||||
|
||||
const consumedOf = async (runId, dimension = 'uo.creatures') =>
|
||||
Number(
|
||||
(
|
||||
await pool.query('SELECT consumed FROM event_run_budget WHERE run_id = ? AND dimension = ?', [
|
||||
runId,
|
||||
dimension,
|
||||
])
|
||||
)[0].consumed,
|
||||
)
|
||||
|
||||
// The statement, lifted verbatim from `eventRunBudget.db.js`. Run through this
|
||||
// file's pool rather than through the module, because the module builds its own
|
||||
// pool from the environment while this pool points at the throwaway database.
|
||||
const SPEND = `
|
||||
UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`
|
||||
|
||||
const spend = async (runId, amount, dimension = 'uo.creatures') =>
|
||||
Number((await pool.query(SPEND, [amount, runId, dimension, amount])).affectedRows) > 0
|
||||
|
||||
test('two steps spending one cap at once: the second is refused, not queued behind the first', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// 28 of 30 spent, and two steps each wanting 5 arrive together. A
|
||||
// read-then-write would let both see 28 and both spend, ending at 38 of 30 —
|
||||
// the exact over-spend the conditional increment exists to make impossible.
|
||||
const runId = await seedBudget({ consumed: 28, cap: 30 })
|
||||
const [a, b] = await Promise.all([spend(runId, 5), spend(runId, 5)])
|
||||
assert.equal(a, false)
|
||||
assert.equal(b, false)
|
||||
assert.equal(await consumedOf(runId), 28)
|
||||
})
|
||||
|
||||
test('two steps that BOTH fit both spend, and the total is exact', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The other half, and the one a too-strict guard would break: a cap is not a
|
||||
// lock. Three steps of 5 against 30 must all succeed and land on 15, or the
|
||||
// statement is refusing legal work.
|
||||
const runId = await seedBudget({ consumed: 0, cap: 30 })
|
||||
const results = await Promise.all([spend(runId, 5), spend(runId, 5), spend(runId, 5)])
|
||||
assert.deepEqual(results, [true, true, true])
|
||||
assert.equal(await consumedOf(runId), 15)
|
||||
})
|
||||
|
||||
test('a spend that exactly reaches the cap is allowed; one over it is not', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `<=`, not `<`. A cap of 30 means thirty creatures are permitted, and an
|
||||
// off-by-one here is a deployment that can never use the last unit of anything
|
||||
// it configured.
|
||||
const runId = await seedBudget({ consumed: 25, cap: 30 })
|
||||
assert.equal(await spend(runId, 5), true)
|
||||
assert.equal(await consumedOf(runId), 30)
|
||||
assert.equal(await spend(runId, 1), false)
|
||||
assert.equal(await consumedOf(runId), 30)
|
||||
})
|
||||
|
||||
test('a NULL cap is uncapped, and still counts', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The row exists so the meter has something to show; nothing bounds it. The
|
||||
// distinction matters because a MISSING row is a refusal — a step spending a
|
||||
// dimension its own run's version never priced — and the two must not collapse
|
||||
// into one behaviour.
|
||||
const runId = await seedBudget({ consumed: 0, cap: null })
|
||||
assert.equal(await spend(runId, 1000000), true)
|
||||
assert.equal(await consumedOf(runId), 1000000)
|
||||
})
|
||||
|
||||
test('spending a dimension with no row is refused', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const runId = await seedBudget()
|
||||
assert.equal(await spend(runId, 1, 'uo.bosses'), false)
|
||||
})
|
||||
|
||||
test('seeding is INSERT IGNORE: a tick that overruns cannot reset a spent cap', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The idempotence `materialisePhase` and `gates.open` both have. Without it a
|
||||
// second seed would either error on the unique key or — worse, written as an
|
||||
// upsert — hand a run that has spent 28 of 30 a fresh 0.
|
||||
const { runId } = await seedRun({ status: 'running' })
|
||||
const SEED =
|
||||
'INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, 0, ?, ?)'
|
||||
await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn'])
|
||||
await spend(runId, 28)
|
||||
await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn'])
|
||||
assert.equal(await consumedOf(runId), 28)
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 1)
|
||||
})
|
||||
|
||||
test('a refund floors at zero rather than going negative', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `GREATEST(consumed - ?, 0)`. A negative `consumed` would make every later cap
|
||||
// check lie in the permissive direction, permanently — a worse outcome than a
|
||||
// refund that is slightly too small.
|
||||
const runId = await seedBudget({ consumed: 3, cap: 30 })
|
||||
await pool.query(
|
||||
'UPDATE event_run_budget SET consumed = GREATEST(consumed - ?, 0) WHERE run_id = ? AND dimension = ?',
|
||||
[10, runId, 'uo.creatures'],
|
||||
)
|
||||
assert.equal(await consumedOf(runId), 0)
|
||||
})
|
||||
|
||||
test('a budget goes with its run', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const runId = await seedBudget()
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 0)
|
||||
})
|
||||
|
||||
test('a version records that a dry run passed against it', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// §K's gate is two columns on an immutable table, and the ALTER that adds them
|
||||
// has to actually apply — `verified_at` is what `runsModel.create` reads to
|
||||
// decide whether a scheduled occurrence may start at all, so a column that
|
||||
// silently did not exist would hold every schedule on the deployment.
|
||||
await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL')
|
||||
await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL')
|
||||
const v = await pool.query('INSERT INTO event_versions (definition_id, spec) VALUES (1, ?)', ['{}'])
|
||||
const before = (await pool.query('SELECT verified_at FROM event_versions WHERE id = ?', [v.insertId]))[0]
|
||||
assert.equal(before.verified_at, null)
|
||||
const moved = await pool.query('UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?', [
|
||||
T0,
|
||||
1,
|
||||
v.insertId,
|
||||
])
|
||||
assert.equal(Number(moved.affectedRows), 1)
|
||||
const after = (
|
||||
await pool.query('SELECT verified_at, verified_by FROM event_versions WHERE id = ?', [v.insertId])
|
||||
)[0]
|
||||
assert.ok(after.verified_at instanceof Date)
|
||||
assert.equal(Number(after.verified_by), 1)
|
||||
})
|
||||
|
||||
test('a non-positive spend never reaches the database', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Through the shipping module rather than a copy of its statement. An action
|
||||
// whose `cost()` answers 0 for its params is telling core it consumes nothing,
|
||||
// and pricing that as a query would be one round trip per step behind a fact
|
||||
// the caller already has.
|
||||
const runId = await seedBudget({ consumed: 0, cap: 10 })
|
||||
assert.equal(await budgetDb.spend(runId, 'uo.creatures', 0), true)
|
||||
assert.equal(await consumedOf(runId), 0)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,11 @@ const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
// Phase 6: `runsModel.create` prices the version against the switchboard and
|
||||
// seeds the run's budget, so expansion now reaches two more tables. Unstubbed
|
||||
// they are a ten-second ECONNREFUSED per occurrence.
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -55,6 +60,8 @@ for (const [name, mod] of [
|
||||
['stepsDb', stepsDb],
|
||||
['logDb', logDb],
|
||||
['versionsDb', versionsDb],
|
||||
['settingsDb', settingsDb],
|
||||
['budgetDb', budgetDb],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
@@ -88,6 +95,14 @@ function addDefinition(id, overrides = {}) {
|
||||
definition_id: id,
|
||||
version: 1,
|
||||
spec: definition.spec,
|
||||
// Verified by default (Phase 6). §K holds a scheduled occurrence of a version
|
||||
// nobody has dry-run, so an unverified fixture would make every test in this
|
||||
// file assert nothing about recurrence and everything about that one gate.
|
||||
// The gate has its own test below, where an occurrence is what is being
|
||||
// measured rather than what is in the way.
|
||||
verified_at: new Date('2026-08-01T00:00:00Z'),
|
||||
verified_by: 1,
|
||||
...(overrides.version || {}),
|
||||
})
|
||||
return definition
|
||||
}
|
||||
@@ -152,6 +167,11 @@ function installStubs() {
|
||||
|
||||
Object.assign(stepsDb, { materialisePhase: async () => [] })
|
||||
Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } })
|
||||
|
||||
// Phase 6. No stored switch anywhere in this file: an empty switchboard is a
|
||||
// fresh deployment, and expansion is not what this file is measuring.
|
||||
Object.assign(settingsDb, { byIds: async () => new Map(), get: async () => null })
|
||||
Object.assign(budgetDb, { seed: async () => 0, forRun: async () => [] })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
230
server/test/eventVerify.test.js
Normal file
230
server/test/eventVerify.test.js
Normal file
@@ -0,0 +1,230 @@
|
||||
// ── 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 caller’s 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 module’s own refusal is a finding in the module’s 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 author’s 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: [] })
|
||||
})
|
||||
@@ -37,6 +37,11 @@ const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
// Phase 6: the run console reads the budget meter and the switchboard route
|
||||
// reads the settings table. Same rule as Phases 4 and 5 -- a new leg under a
|
||||
// model needs a stub in every file that stubs that layer.
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const seriesDb = require('../src/model/events/eventSeries.db')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
@@ -56,6 +61,8 @@ for (const [name, mod] of [
|
||||
['logDb', logDb],
|
||||
['seriesDb', seriesDb],
|
||||
['gatesDb', gatesDb],
|
||||
['settingsDb', settingsDb],
|
||||
['budgetDb', budgetDb],
|
||||
['activity', activity],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
@@ -79,6 +86,8 @@ function installStubs() {
|
||||
log: [],
|
||||
series: new Map(),
|
||||
gates: [],
|
||||
settings: new Map(),
|
||||
budget: new Map(),
|
||||
occurrences: new Set(),
|
||||
nextDefinition: 1,
|
||||
nextVersion: 1,
|
||||
@@ -91,6 +100,10 @@ function installStubs() {
|
||||
series_name: store.series.get(d.series_id)?.name ?? null,
|
||||
series_slug: store.series.get(d.series_id)?.slug ?? null,
|
||||
current_version: store.versions.get(d.current_version_id)?.version ?? null,
|
||||
// Phase 6: joined in `SELECT_LIST` alongside `current_version`, and it has to
|
||||
// be joined HERE too or the stub answers a shape the real query never
|
||||
// returns — which is a test agreeing with itself rather than with the server.
|
||||
current_version_verified_at: store.versions.get(d.current_version_id)?.verified_at ?? null,
|
||||
})
|
||||
|
||||
definitionsDb.list = async ({ state = null } = {}) =>
|
||||
@@ -147,9 +160,19 @@ function installStubs() {
|
||||
spec: JSON.parse(JSON.stringify(spec)),
|
||||
published_at: new Date(),
|
||||
published_by: userId,
|
||||
// Phase 6. A version is unverified the moment it is cut, which is what
|
||||
// makes §K's gate mean anything: publishing is not the review.
|
||||
verified_at: null,
|
||||
verified_by: null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
versionsDb.markVerified = async (id, userId, at = new Date()) => {
|
||||
const v = store.versions.get(id)
|
||||
if (!v) return false
|
||||
Object.assign(v, { verified_at: at, verified_by: userId })
|
||||
return true
|
||||
}
|
||||
|
||||
const shapeRun = (r) => ({
|
||||
...r,
|
||||
@@ -278,6 +301,46 @@ function installStubs() {
|
||||
store.log.push({ audit: true, ...entry })
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Phase 6's two tables ──
|
||||
//
|
||||
// `store.settings` is a real store here rather than an empty stand-in, because
|
||||
// this file tests the switchboard ROUTES: the GET has to be able to tell a
|
||||
// stored opinion from a risk-class default, and the PUT has to be readable
|
||||
// back.
|
||||
settingsDb.all = async () => [...store.settings.values()]
|
||||
settingsDb.get = async (actionId) => store.settings.get(actionId) || null
|
||||
settingsDb.byIds = async (ids) =>
|
||||
new Map(
|
||||
[...new Set(ids || [])]
|
||||
.filter((id) => store.settings.has(id))
|
||||
.map((id) => [id, store.settings.get(id)]),
|
||||
)
|
||||
settingsDb.put = async (actionId, { enabled, caps }, userId = null) => {
|
||||
const row = {
|
||||
action_id: actionId,
|
||||
enabled: enabled ? 1 : 0,
|
||||
caps: caps || {},
|
||||
updated_by: userId,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
store.settings.set(actionId, row)
|
||||
return row
|
||||
}
|
||||
|
||||
budgetDb.seed = async (runId, dimensions) => {
|
||||
for (const [dimension, d] of Object.entries(dimensions || {})) {
|
||||
const key = `${runId}:${dimension}`
|
||||
if (!store.budget.has(key)) {
|
||||
store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null })
|
||||
}
|
||||
}
|
||||
return Object.keys(dimensions || {}).length
|
||||
}
|
||||
budgetDb.forRun = async (runId) =>
|
||||
[...store.budget.values()]
|
||||
.filter((b) => Number(b.run_id) === Number(runId))
|
||||
.sort((a, b) => a.dimension.localeCompare(b.dimension))
|
||||
}
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -675,3 +738,295 @@ test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
|
||||
const again = await call(ctrl.publish, { params: { id: String(id) } })
|
||||
assert.equal(again.body.repinned, 0)
|
||||
})
|
||||
|
||||
// ── Phase 6: the switchboard, the dry run, and the role floor on a step ────
|
||||
//
|
||||
// `eventAuthorize.test.js` holds `mayInvoke`'s layers and `eventVerify.test.js`
|
||||
// holds the dry run's report. What is genuinely new HERE is what the surface
|
||||
// decides on top of them: what the board serves when nobody has ever touched it,
|
||||
// what a cap is allowed to name, which spec a dry run is run against, and the one
|
||||
// gate that cannot live in route middleware because it depends on the BODY.
|
||||
|
||||
const ADMIN = { id: 1, role: 'admin' }
|
||||
const EDITOR = { id: 2, role: 'editor' }
|
||||
|
||||
/**
|
||||
* A module whose action declares a COST, which `demo.world.change` does not.
|
||||
*
|
||||
* Separate rather than folded into `registerDemoModule`, because the two answer
|
||||
* different questions: that one is "a world-changing verb exists", this one is "a
|
||||
* verb that spends something exists", and the switchboard's cap editor only has
|
||||
* anything to render for the second.
|
||||
*/
|
||||
function registerCosting() {
|
||||
// The owner must match the id's namespace: the registry refuses an action id
|
||||
// that is not prefixed with the module registering it, which is what keeps an
|
||||
// action's id space its own (§F).
|
||||
const api = registries.stage('test')
|
||||
api.registerEventActions([
|
||||
{
|
||||
id: 'test.spawn',
|
||||
label: 'Spawn creatures',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||
cost: (p) => ({ 'x.creatures': p.count }),
|
||||
perform: async () => ({ ok: true }),
|
||||
},
|
||||
])
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
// ── The switchboard ────────────────────────────────────────────────────────
|
||||
|
||||
test('the board serves every registered action with its risk-class default, and says nothing is configured', async () => {
|
||||
// A fresh deployment has no rows at all — nothing is seeded at boot, because
|
||||
// registration runs against a dead pool (MODULE_API §2.2) — so the board's
|
||||
// first render is entirely computed. `configured: false` is how the screen
|
||||
// tells "an admin turned this on" from "this has always been on".
|
||||
const res = await call(ctrl.actions, { user: ADMIN })
|
||||
assert.equal(res.statusCode, 200)
|
||||
|
||||
const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a]))
|
||||
assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.wait'])
|
||||
// core.wait is `inspect`, and it arrives ENABLED. Read §K's sentence literally
|
||||
// and it would not, and every published event that waits would break on a fresh
|
||||
// deployment (org lead, 2026-09-03).
|
||||
assert.equal(byId['core.wait'].enabled, true)
|
||||
assert.equal(byId['core.wait'].changesWorld, false)
|
||||
assert.equal(byId['core.announce'].enabled, true)
|
||||
for (const a of res.body.actions) assert.equal(a.configured, false)
|
||||
assert.deepEqual(res.body.worldChangingRisks, ['change', 'irreversible'])
|
||||
})
|
||||
|
||||
test('the board never serves a callable', async () => {
|
||||
// `allEventActions()` strips `perform`, `revert` and `cost`. This board adds
|
||||
// fields to that object, and adding them back by spreading the full
|
||||
// registration would be how a module's function comes to leave the process.
|
||||
const res = await call(ctrl.actions, { user: ADMIN })
|
||||
for (const a of res.body.actions) {
|
||||
assert.equal(a.perform, undefined)
|
||||
assert.equal(a.revert, undefined)
|
||||
assert.equal(a.cost, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('a stored switch is served back, marked configured, with who set it', async () => {
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
||||
const res = await call(ctrl.actions, { user: ADMIN })
|
||||
const announce = res.body.actions.find((a) => a.id === 'core.announce')
|
||||
assert.equal(announce.enabled, false)
|
||||
assert.equal(announce.configured, true)
|
||||
assert.ok(announce.updatedAt)
|
||||
})
|
||||
|
||||
test('the switch works in both directions, because an operator must be able to turn things OFF', async () => {
|
||||
const off = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: false } })
|
||||
assert.equal(off.statusCode, 200)
|
||||
assert.equal(off.body.action.enabled, false)
|
||||
|
||||
const on = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: true } })
|
||||
assert.equal(on.body.action.enabled, true)
|
||||
})
|
||||
|
||||
test('a switch for an action nobody registers is a 404, not a stored row', async () => {
|
||||
// The board is rendered from the registry, so a write against something not in
|
||||
// it is a client out of date — and storing it would put a row on the screen
|
||||
// that no action can ever claim.
|
||||
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'gone.away', enabled: true } })
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('enabled must be stated, because there is no safe value to guess', async () => {
|
||||
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce' } })
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.match(res.body.error, /enabled must be true or false/)
|
||||
})
|
||||
|
||||
test('a cap must name a dimension the action actually spends', async () => {
|
||||
// Not pedantry. A cap on a dimension an action never names is a number an
|
||||
// operator believes is protecting them, rendered back to them for ever,
|
||||
// bounding nothing. None of core's three actions declares a cost at all, so
|
||||
// every cap is refused here — which is itself the honest state of a deployment
|
||||
// with no module installed.
|
||||
const res = await call(ctrl.saveAction, {
|
||||
user: ADMIN,
|
||||
body: { actionId: 'core.announce', enabled: true, caps: { 'uo.creatures': 30 } },
|
||||
})
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.match(res.body.error, /does not spend "uo.creatures"/)
|
||||
})
|
||||
|
||||
test('a cap that is not a whole number of 0 or more is refused', async () => {
|
||||
registerCosting()
|
||||
for (const bad of [-1, 2.5, 'lots']) {
|
||||
const res = await call(ctrl.saveAction, {
|
||||
user: ADMIN,
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } },
|
||||
})
|
||||
assert.equal(res.statusCode, 400, String(bad))
|
||||
}
|
||||
})
|
||||
|
||||
test('a cap of zero is legal, and it means zero', async () => {
|
||||
// "This deployment permits this verb, and permits none of it" is a coherent
|
||||
// thing to say, and refusing 0 would make an operator disable the action
|
||||
// instead — which is a different fact with a different audit trail.
|
||||
registerCosting()
|
||||
const res = await call(ctrl.saveAction, {
|
||||
user: ADMIN,
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } },
|
||||
})
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 })
|
||||
})
|
||||
|
||||
test('the board offers a cap box per dimension, discovered from the declared examples', async () => {
|
||||
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
|
||||
// 7 — until then a param's required `example` is what tells core the names.
|
||||
registerCosting()
|
||||
const res = await call(ctrl.actions, { user: ADMIN })
|
||||
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
|
||||
assert.deepEqual(spawn.dimensions, ['x.creatures'])
|
||||
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
|
||||
assert.equal(spawn.changesWorld, true)
|
||||
})
|
||||
|
||||
// ── The dry run ────────────────────────────────────────────────────────────
|
||||
|
||||
test('a draft is verified against its working spec, and the pass is not recorded', async () => {
|
||||
// There is no version to record it on, and a pass on a draft would be a claim
|
||||
// about a spec that changes under the author's hands.
|
||||
const { body } = await createDraft()
|
||||
const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(body.event.id) } })
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.target, 'draft')
|
||||
assert.equal(res.body.versionId, null)
|
||||
assert.equal(res.body.recorded, false)
|
||||
assert.equal(res.body.report.ok, true)
|
||||
assert.equal(res.body.report.steps, 1)
|
||||
})
|
||||
|
||||
test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => {
|
||||
// §K's last bound. A version is immutable, so a dry run that passed against one
|
||||
// stays true — which is what makes the pass a property of the version.
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
|
||||
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(res.body.target, 'version')
|
||||
assert.equal(res.body.recorded, true)
|
||||
assert.equal(res.body.version, 1)
|
||||
|
||||
// And the definition now says so, on the screen its author is already looking
|
||||
// at rather than on the Friday it did not run.
|
||||
const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.ok(after.body.event.currentVersionVerifiedAt)
|
||||
})
|
||||
|
||||
test('a definition that has never been verified says so', async () => {
|
||||
const { body } = await createDraft()
|
||||
await call(ctrl.publish, { user: ADMIN, params: { id: String(body.event.id) } })
|
||||
const res = await call(ctrl.get, { user: ADMIN, params: { id: String(body.event.id) } })
|
||||
assert.equal(res.body.event.currentVersionVerifiedAt, null)
|
||||
})
|
||||
|
||||
test('a report with findings is a 200, and it does not record a pass', async () => {
|
||||
// The request succeeded; the plan has problems. A 4xx would make "this event
|
||||
// asks for 45 and you allow 30" indistinguishable from "you sent a bad id",
|
||||
// and rendering the findings is the whole value of the screen.
|
||||
const { body } = await createDraft({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
|
||||
},
|
||||
})
|
||||
const id = body.event.id
|
||||
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
|
||||
// Switch the action off underneath the published version: the plan is now one
|
||||
// this deployment will not carry out.
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
||||
|
||||
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.report.ok, false)
|
||||
assert.equal(res.body.recorded, false, 'a failing dry run must not unlock a scheduled start')
|
||||
assert.equal(res.body.report.findings[0].code, 'disabled')
|
||||
})
|
||||
|
||||
test('an archived definition cannot be verified', async () => {
|
||||
const { body } = await createDraft()
|
||||
await call(ctrl.archive, { user: ADMIN, params: { id: String(body.event.id) } })
|
||||
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(body.event.id) } })
|
||||
assert.equal(res.statusCode, 409)
|
||||
})
|
||||
|
||||
test('verifying a definition that does not exist is a 404', async () => {
|
||||
const res = await call(ctrl.verify, { user: ADMIN, params: { id: '9999' } })
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
// ── The role floor, which cannot live in route middleware ──────────────────
|
||||
|
||||
test('an editor cannot save a step whose action changes the world', async () => {
|
||||
// §K's "any step whose action is above notify — admin only", with the line
|
||||
// drawn between `inspect` and `change` (org lead, 2026-09-03). It is checked in
|
||||
// the model rather than on the route because it depends on the BODY: the route
|
||||
// is `admin, editor` and stays that way, and which of the two you have to be
|
||||
// depends on what you put in the spec.
|
||||
registerCosting()
|
||||
const res = await call(ctrl.create, {
|
||||
user: EDITOR,
|
||||
body: draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.match(res.body.errors[0], /only an administrator may author a step/)
|
||||
})
|
||||
|
||||
test('an editor may still save a step that only announces or waits', async () => {
|
||||
// The other half, and the one the literal reading of §K would have broken: an
|
||||
// editor who cannot author a step that waits has an authoring role that cannot
|
||||
// author.
|
||||
const res = await call(ctrl.create, { user: EDITOR, body: draftBody() })
|
||||
assert.equal(res.statusCode, 201)
|
||||
})
|
||||
|
||||
test('an admin may save the same world-changing step', async () => {
|
||||
registerCosting()
|
||||
const res = await call(ctrl.create, {
|
||||
user: ADMIN,
|
||||
body: draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.equal(res.statusCode, 201)
|
||||
})
|
||||
|
||||
test('the floor is checked on EDIT as well as on create', async () => {
|
||||
// Otherwise an editor writes a legal draft and then edits a world-changing step
|
||||
// into it, which is the same escalation with one more click.
|
||||
registerCosting()
|
||||
const { body } = await call(ctrl.create, { user: EDITOR, body: draftBody() })
|
||||
const res = await call(ctrl.update, {
|
||||
user: EDITOR,
|
||||
params: { id: String(body.event.id) },
|
||||
body: draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.equal(res.statusCode, 403)
|
||||
})
|
||||
|
||||
222
server/test/eventsRoles.test.js
Normal file
222
server/test/eventsRoles.test.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// ── 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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user