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
1033 lines
43 KiB
JavaScript
1033 lines
43 KiB
JavaScript
// ── The events admin surface (EVENTS.md § API surface, Phase 1) ────────────
|
|
//
|
|
// `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js`
|
|
// the registry; re-asserting either here would be a second copy of a test rather
|
|
// than a second test. What is genuinely new is what the SURFACE decides:
|
|
//
|
|
// • **publish snapshots.** It cuts an immutable version, points the definition
|
|
// at it, and a later edit does not touch the version a run would pin.
|
|
// • **publish re-validates against the registries as they stand now**, not
|
|
// against the save that wrote the spec. A module uninstalled in between must
|
|
// block the publish, because the alternative is a run that fails at dispatch
|
|
// with the world half-changed.
|
|
// • **an empty event does not publish.** It would run cleanly and do nothing,
|
|
// which reads as a broken run rather than an empty one.
|
|
// • **the slug is frozen after create**, because the public event page lives
|
|
// at it and a retitle must not break a posted link.
|
|
// • **archiving is refused while a run is in flight**, and there is no hard
|
|
// delete at all.
|
|
// • **creating an occurrence twice creates ONE run.** The unique index is what
|
|
// makes that true, so the second call answers `created: false` with the
|
|
// existing row rather than erroring.
|
|
// • **a created run stays `scheduled`.** There is no runner until Phase 2, and
|
|
// that has to be visible as the correct state rather than as a stall.
|
|
//
|
|
// The `.db` layer is stubbed in-memory and the real models and controllers run
|
|
// against it, the shape `engagementAdmin.test.js` uses.
|
|
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 ctrl = require('../src/router/v1/admin/events.controller')
|
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
|
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')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
// ── In-memory stand-ins ────────────────────────────────────────────────────
|
|
|
|
let store
|
|
const originals = {}
|
|
for (const [name, mod] of [
|
|
['definitionsDb', definitionsDb],
|
|
['versionsDb', versionsDb],
|
|
['runsDb', runsDb],
|
|
['stepsDb', stepsDb],
|
|
['logDb', logDb],
|
|
['seriesDb', seriesDb],
|
|
['gatesDb', gatesDb],
|
|
['settingsDb', settingsDb],
|
|
['budgetDb', budgetDb],
|
|
['activity', activity],
|
|
]) {
|
|
originals[name] = { mod, fns: { ...mod } }
|
|
}
|
|
const restoreOriginals = () => {
|
|
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
|
}
|
|
|
|
// The occurrence key, as a string, so the stub can enforce the UNIQUE index the
|
|
// real table enforces. Reproducing it is the point of several tests below: the
|
|
// index — not the claim — is what makes "one run per occurrence per scope" true.
|
|
const occurrenceKey = (definitionId, scope, when) =>
|
|
`${definitionId}|${scope || ''}|${new Date(when).toISOString()}`
|
|
|
|
function installStubs() {
|
|
store = {
|
|
definitions: new Map(),
|
|
versions: new Map(),
|
|
runs: new Map(),
|
|
steps: new Map(),
|
|
log: [],
|
|
series: new Map(),
|
|
gates: [],
|
|
settings: new Map(),
|
|
budget: new Map(),
|
|
occurrences: new Set(),
|
|
nextDefinition: 1,
|
|
nextVersion: 1,
|
|
nextRun: 1,
|
|
nextStep: 1,
|
|
}
|
|
|
|
const shape = (d) => ({
|
|
...d,
|
|
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 } = {}) =>
|
|
[...store.definitions.values()].filter((d) => !state || d.state === state).map(shape)
|
|
definitionsDb.getById = async (id) =>
|
|
store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined
|
|
definitionsDb.getBySlug = async (slug) =>
|
|
[...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0]
|
|
definitionsDb.slugTaken = async (slug, exceptId = null) =>
|
|
[...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId)
|
|
definitionsDb.insert = async (d) => {
|
|
const id = store.nextDefinition++
|
|
store.definitions.set(id, {
|
|
id,
|
|
state: 'draft',
|
|
current_version_id: null,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
...d,
|
|
})
|
|
return id
|
|
}
|
|
definitionsDb.update = async (id, d) => {
|
|
const existing = store.definitions.get(id)
|
|
if (existing) Object.assign(existing, d, { updated_at: new Date() })
|
|
}
|
|
definitionsDb.markReady = async (id, versionId, userId) => {
|
|
const existing = store.definitions.get(id)
|
|
if (existing) {
|
|
Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId })
|
|
}
|
|
}
|
|
definitionsDb.archive = async (id, userId) => {
|
|
const existing = store.definitions.get(id)
|
|
if (existing) Object.assign(existing, { state: 'archived', updated_by: userId })
|
|
}
|
|
|
|
versionsDb.listForDefinition = async (definitionId) =>
|
|
[...store.versions.values()]
|
|
.filter((v) => v.definition_id === definitionId)
|
|
.sort((a, b) => b.version - a.version)
|
|
versionsDb.getById = async (id) => store.versions.get(id) || undefined
|
|
versionsDb.nextVersion = async (definitionId) =>
|
|
[...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1
|
|
versionsDb.insert = async (definitionId, version, spec, userId) => {
|
|
const id = store.nextVersion++
|
|
// Deep-copied on the way in, because the whole point of a version is that a
|
|
// later edit of the working spec cannot reach it. A stub that stored the
|
|
// reference would make the snapshot test pass for the wrong reason.
|
|
store.versions.set(id, {
|
|
id,
|
|
definition_id: definitionId,
|
|
version,
|
|
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,
|
|
definition_title: store.definitions.get(r.definition_id)?.title ?? null,
|
|
definition_slug: store.definitions.get(r.definition_id)?.slug ?? null,
|
|
version_number: store.versions.get(r.version_id)?.version ?? null,
|
|
})
|
|
|
|
runsDb.list = async ({ definitionId = null, status = null } = {}) =>
|
|
[...store.runs.values()]
|
|
.filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
|
|
.map(shapeRun)
|
|
runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
|
|
runsDb.listScheduledFor = async (definitionId) =>
|
|
[...store.runs.values()]
|
|
.filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at)
|
|
.map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for }))
|
|
runsDb.repinScheduled = async (definitionId, versionId) => {
|
|
let moved = 0
|
|
for (const run of store.runs.values()) {
|
|
if (run.definition_id !== definitionId) continue
|
|
if (run.status !== 'scheduled' || run.started_at) continue
|
|
if (run.version_id === versionId) continue
|
|
run.version_id = versionId
|
|
moved += 1
|
|
}
|
|
return moved
|
|
}
|
|
runsDb.materialise = async (run) => {
|
|
const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
|
|
if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
|
|
store.occurrences.add(key)
|
|
const id = store.nextRun++
|
|
store.runs.set(id, {
|
|
id,
|
|
status: 'scheduled',
|
|
health: 'ok',
|
|
cleanup_status: 'not_required',
|
|
current_phase: null,
|
|
started_at: null,
|
|
ended_at: null,
|
|
last_error: null,
|
|
created_at: new Date(),
|
|
...run,
|
|
scheduled_for: new Date(run.scheduled_for),
|
|
rehearsal: Boolean(run.rehearsal),
|
|
})
|
|
return id
|
|
}
|
|
runsDb.findOccurrence = async (definitionId, scope, when) =>
|
|
[...store.runs.values()]
|
|
.filter(
|
|
(r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when),
|
|
)
|
|
.map(shapeRun)[0]
|
|
runsDb.countActiveForDefinition = async (definitionId) =>
|
|
[...store.runs.values()].filter(
|
|
(r) =>
|
|
r.definition_id === definitionId &&
|
|
['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status),
|
|
).length
|
|
|
|
stepsDb.listForRun = async (runId) =>
|
|
[...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq)
|
|
stepsDb.getById = async (id) => store.steps.get(id) || undefined
|
|
stepsDb.materialisePhase = async (runId, phase, steps) => {
|
|
steps.forEach((step, seq) => {
|
|
const taken = [...store.steps.values()].some(
|
|
(s) => s.run_id === runId && s.phase === phase && s.seq === seq,
|
|
)
|
|
if (taken) return
|
|
const id = store.nextStep++
|
|
store.steps.set(id, {
|
|
id,
|
|
run_id: runId,
|
|
phase,
|
|
seq,
|
|
action_id: step.actionId,
|
|
params: step.params || {},
|
|
action_version: step.actionVersion || 1,
|
|
status: 'pending',
|
|
due_at: null,
|
|
attempts: 0,
|
|
on_failure: step.onFailure || 'pause',
|
|
// The real materialiser stamps this from the row's own id, so the stub
|
|
// does too: a key that varied by attempt would defeat the whole retry
|
|
// story, and a stub that faked it would hide that.
|
|
idempotency_key: stepsDb.idempotencyKey(runId, id),
|
|
last_error: null,
|
|
started_at: null,
|
|
finished_at: null,
|
|
})
|
|
})
|
|
return stepsDb.listForRun(runId)
|
|
}
|
|
stepsDb.statusCounts = async (runId) => {
|
|
const counts = {}
|
|
for (const s of store.steps.values()) {
|
|
if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// Phase 5 gave `runs.detail()` a third leg, and an unstubbed one is a real
|
|
// query against the dead port this file points at: the run-console test hung
|
|
// for ten seconds and then failed with ECONNREFUSED, saying nothing whatever
|
|
// about the route. **This is the third time a new leg has caught a stubbing
|
|
// file out** — Phase 4's expansion leg did it to `eventRunner.test.js`, where
|
|
// it merely made the file slow. Worth the comment: when the runner or a model
|
|
// gains a leg, every file that stubs the layer under it needs the stub.
|
|
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
|
|
|
|
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
|
|
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
|
|
store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
|
|
return true
|
|
}
|
|
|
|
seriesDb.list = async () => [...store.series.values()]
|
|
seriesDb.getById = async (id) => store.series.get(id) || null
|
|
seriesDb.exists = async (id) => store.series.has(id)
|
|
|
|
// The audit log is a side effect, not a subject: it writes to a real table and
|
|
// never throws into the request path, so the stub records and stays quiet.
|
|
activity.log = async (entry) => {
|
|
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 ───────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
registries._reset()
|
|
registries.registerCore()
|
|
installStubs()
|
|
})
|
|
afterEach(() => {
|
|
registries._reset()
|
|
restoreOriginals()
|
|
})
|
|
|
|
/** A module whose one action a definition can be built around. */
|
|
function registerDemoModule() {
|
|
const api = registries.stage('demo')
|
|
api.registerEventActions([
|
|
{
|
|
id: 'demo.world.change',
|
|
label: 'Change the world',
|
|
risk: 'change',
|
|
reversible: 'none',
|
|
params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }],
|
|
perform: async () => ({ ok: true }),
|
|
},
|
|
])
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const announceStep = (body = 'The gates open at dusk.') => ({
|
|
actionId: 'core.announce',
|
|
params: { leg: 'discord', body },
|
|
})
|
|
|
|
const draftBody = (over = {}) => ({
|
|
title: 'The Siege of Cove',
|
|
summary: 'An invasion, in three phases.',
|
|
timezone: 'Europe/Berlin',
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
|
|
},
|
|
...over,
|
|
})
|
|
|
|
function mockRes() {
|
|
return {
|
|
statusCode: 200,
|
|
body: null,
|
|
status(c) { this.statusCode = c; return this },
|
|
json(b) { this.body = b; return this },
|
|
}
|
|
}
|
|
|
|
async function call(handler, req) {
|
|
const res = mockRes()
|
|
let thrown = null
|
|
await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => {
|
|
thrown = err
|
|
})
|
|
if (thrown) throw thrown
|
|
return res
|
|
}
|
|
|
|
const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) })
|
|
|
|
// ── The catalog ────────────────────────────────────────────────────────────
|
|
|
|
test('the catalog serves the registry, callables stripped, with its vocabularies', async () => {
|
|
const res = await call(ctrl.catalog, {})
|
|
assert.equal(res.statusCode, 200)
|
|
assert.deepEqual(
|
|
res.body.actions.map((a) => a.id),
|
|
['core.announce', 'core.wait', 'core.cue'],
|
|
)
|
|
for (const action of res.body.actions) assert.equal(action.perform, undefined)
|
|
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
|
|
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
|
|
// Phase 1 is honest about what it does not have: budget dimensions arrive with
|
|
// the module contract, so the catalog does not pretend to carry any.
|
|
assert.equal(res.body.budgets, undefined)
|
|
})
|
|
|
|
// ── Create, edit, slug ─────────────────────────────────────────────────────
|
|
|
|
test('a draft is created with a derived slug and no version', async () => {
|
|
const res = await createDraft()
|
|
assert.equal(res.statusCode, 201)
|
|
assert.equal(res.body.event.state, 'draft')
|
|
assert.equal(res.body.event.slug, 'the-siege-of-cove')
|
|
assert.equal(res.body.event.currentVersionId, null)
|
|
assert.equal(res.body.event.timezone, 'Europe/Berlin')
|
|
})
|
|
|
|
test('the slug is frozen after create — a retitle does not move the public page', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
const res = await call(ctrl.update, {
|
|
params: { id: String(id) },
|
|
body: draftBody({ title: 'The Second Siege of Cove' }),
|
|
})
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.event.title, 'The Second Siege of Cove')
|
|
assert.equal(res.body.event.slug, 'the-siege-of-cove')
|
|
})
|
|
|
|
test('a bad timezone and a bad grace window are refused with both problems named', async () => {
|
|
const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 })
|
|
assert.equal(res.statusCode, 400)
|
|
const joined = res.body.errors.join('\n')
|
|
assert.match(joined, /not an IANA zone name/)
|
|
assert.match(joined, /graceSeconds must be an integer/)
|
|
})
|
|
|
|
test('a step naming an unregistered action is refused at create', async () => {
|
|
const res = await createDraft({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }],
|
|
},
|
|
})
|
|
assert.equal(res.statusCode, 400)
|
|
assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/)
|
|
})
|
|
|
|
// ── Publish ────────────────────────────────────────────────────────────────
|
|
|
|
test('publish snapshots the spec, and a later edit does not touch the version', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
|
|
const published = await call(ctrl.publish, { params: { id: String(id) } })
|
|
assert.equal(published.statusCode, 200)
|
|
assert.equal(published.body.version, 1)
|
|
assert.equal(published.body.event.state, 'ready')
|
|
|
|
// Edit the working copy afterwards.
|
|
await call(ctrl.update, {
|
|
params: { id: String(id) },
|
|
body: draftBody({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }],
|
|
},
|
|
}),
|
|
})
|
|
|
|
const version = await versionsDb.getById(published.body.versionId)
|
|
assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.')
|
|
|
|
// …and publishing again cuts version 2 rather than mutating version 1.
|
|
const again = await call(ctrl.publish, { params: { id: String(id) } })
|
|
assert.equal(again.body.version, 2)
|
|
const versions = await call(ctrl.listVersions, { params: { id: String(id) } })
|
|
assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1])
|
|
assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false])
|
|
})
|
|
|
|
test('publish is refused when a step went dormant after the save that wrote it', async () => {
|
|
registerDemoModule()
|
|
const created = await createDraft({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [
|
|
{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] },
|
|
],
|
|
},
|
|
})
|
|
const id = created.body.event.id
|
|
|
|
// The module is uninstalled between the save and the publish.
|
|
registries._reset()
|
|
registries.registerCore()
|
|
|
|
const res = await call(ctrl.publish, { params: { id: String(id) } })
|
|
assert.equal(res.statusCode, 409)
|
|
assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/)
|
|
|
|
// …and the definition is still editable, which is the other half of the rule:
|
|
// an uninstall must not be destructive after the fact.
|
|
const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } })
|
|
assert.equal(saved.statusCode, 200)
|
|
assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true)
|
|
})
|
|
|
|
test('an event with no steps does not publish', async () => {
|
|
const created = await createDraft({
|
|
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
|
})
|
|
const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } })
|
|
assert.equal(res.statusCode, 400)
|
|
assert.match(res.body.errors.join('\n'), /no phase has any steps/)
|
|
})
|
|
|
|
// ── Runs ───────────────────────────────────────────────────────────────────
|
|
|
|
test('a draft has nothing to run', async () => {
|
|
const created = await createDraft()
|
|
const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } })
|
|
assert.equal(res.statusCode, 409)
|
|
assert.match(res.body.errors.join('\n'), /no published version to run/)
|
|
})
|
|
|
|
test('a created run stays scheduled, with its first phase materialised', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
|
|
const started = await call(ctrl.startRun, { params: { id: String(id) } })
|
|
assert.equal(started.statusCode, 201)
|
|
assert.equal(started.body.created, true)
|
|
// The correct state for this phase, and it has to be visible as such rather
|
|
// than looking like a stall: there is no runner until Phase 2.
|
|
assert.equal(started.body.run.status, 'scheduled')
|
|
|
|
const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } })
|
|
assert.equal(detail.body.steps.length, 1)
|
|
assert.equal(detail.body.steps[0].actionId, 'core.announce')
|
|
assert.equal(detail.body.steps[0].status, 'pending')
|
|
assert.deepEqual(detail.body.counts, { pending: 1 })
|
|
// Minted at materialisation, 40 hex, and a function of identity alone.
|
|
assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/)
|
|
})
|
|
|
|
test('one occurrence, asked for twice, is one run', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
|
|
const when = '2026-10-31T20:00:00.000Z'
|
|
const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
|
|
const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
|
|
|
|
assert.equal(first.statusCode, 201)
|
|
assert.equal(first.body.created, true)
|
|
// Not an error — the unique index doing exactly what it is for. The existing
|
|
// row is the answer.
|
|
assert.equal(second.statusCode, 200)
|
|
assert.equal(second.body.created, false)
|
|
assert.equal(second.body.run.id, first.body.run.id)
|
|
|
|
const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } })
|
|
assert.equal(runs.body.runs.length, 1)
|
|
})
|
|
|
|
test('the same instant in two scopes is two runs', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
|
|
const when = '2026-10-31T20:00:00.000Z'
|
|
const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } })
|
|
const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } })
|
|
assert.equal(a.body.created, true)
|
|
assert.equal(b.body.created, true)
|
|
assert.notEqual(a.body.run.id, b.body.run.id)
|
|
})
|
|
|
|
test('the concurrency key is rendered from the run params', async () => {
|
|
const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
const res = await call(ctrl.startRun, {
|
|
params: { id: String(id) },
|
|
body: { params: { region: 'Yew' } },
|
|
})
|
|
assert.equal(res.body.run.concurrencyKey, 'invasion:Yew')
|
|
})
|
|
|
|
test('an unrendered placeholder is left standing rather than emptied', async () => {
|
|
// `invasion:` would collide with every other unrendered key on the deployment,
|
|
// which is the opposite of what a concurrency key is for.
|
|
const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
const res = await call(ctrl.startRun, { params: { id: String(id) } })
|
|
assert.equal(res.body.run.concurrencyKey, 'invasion:{region}')
|
|
})
|
|
|
|
test('the run log records the creation and the phase entry', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
const started = await call(ctrl.startRun, { params: { id: String(id) } })
|
|
|
|
const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } })
|
|
const kinds = res.body.log.map((l) => l.kind)
|
|
assert.ok(kinds.includes('run.created'))
|
|
assert.ok(kinds.includes('phase.entered'))
|
|
})
|
|
|
|
test('an unknown log kind is refused rather than stored', async () => {
|
|
// The closed set is enforced in the db layer, not by an ENUM, because it grows
|
|
// with almost every later phase — so it has to actually refuse.
|
|
restoreOriginals()
|
|
const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' })
|
|
assert.equal(ok, false)
|
|
installStubs()
|
|
})
|
|
|
|
// ── Archive ────────────────────────────────────────────────────────────────
|
|
|
|
test('archiving is refused while a run is in flight, and allowed once it is not', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
const started = await call(ctrl.startRun, { params: { id: String(id) } })
|
|
|
|
const refused = await call(ctrl.archive, { params: { id: String(id) } })
|
|
assert.equal(refused.statusCode, 409)
|
|
assert.match(refused.body.errors.join('\n'), /still in flight/)
|
|
|
|
store.runs.get(started.body.run.id).status = 'completed'
|
|
|
|
const res = await call(ctrl.archive, { params: { id: String(id) } })
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.event.state, 'archived')
|
|
})
|
|
|
|
test('an archived definition can be neither edited nor published', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.archive, { params: { id: String(id) } })
|
|
|
|
const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() })
|
|
assert.equal(edited.statusCode, 409)
|
|
const published = await call(ctrl.publish, { params: { id: String(id) } })
|
|
assert.equal(published.statusCode, 409)
|
|
})
|
|
|
|
test('the list filters by state, and an unknown id is 404 rather than 500', async () => {
|
|
await createDraft()
|
|
const second = await createDraft({ title: 'A Second Event' })
|
|
await call(ctrl.publish, { params: { id: String(second.body.event.id) } })
|
|
|
|
const ready = await call(ctrl.list, { query: { state: 'ready' } })
|
|
assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event'])
|
|
|
|
const missing = await call(ctrl.get, { params: { id: '9999' } })
|
|
assert.equal(missing.statusCode, 404)
|
|
const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
|
|
assert.equal(bad.statusCode, 400)
|
|
})
|
|
|
|
|
|
test('publishing re-pins the occurrences that have not started, and says how many', async () => {
|
|
// The case an operator meets on their SECOND edit of any recurring event: a
|
|
// fortnight of occurrences is already on the calendar, each carrying the spec
|
|
// as it was. Left alone, an edit reaches none of them and the only recourse --
|
|
// cancelling each -- makes the occurrence vanish rather than come back, because
|
|
// a cancelled row still holds its slot in `uq_evrun_occurrence`.
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
|
|
const ahead = await call(ctrl.startRun, {
|
|
params: { id: String(id) },
|
|
body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' },
|
|
})
|
|
assert.equal(ahead.statusCode, 201)
|
|
const aheadId = ahead.body.run.id
|
|
const v1 = store.runs.get(aheadId).version_id
|
|
|
|
// A second occurrence, this one already under way. Its pin is what makes it
|
|
// explicable afterwards, so it must not move.
|
|
const inFlight = await call(ctrl.startRun, {
|
|
params: { id: String(id) },
|
|
body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' },
|
|
})
|
|
const inFlightId = inFlight.body.run.id
|
|
store.runs.get(inFlightId).status = 'running'
|
|
store.runs.get(inFlightId).started_at = new Date()
|
|
|
|
const republished = await call(ctrl.publish, { params: { id: String(id) } })
|
|
assert.equal(republished.statusCode, 200)
|
|
assert.equal(republished.body.version, 2)
|
|
assert.equal(republished.body.repinned, 1)
|
|
|
|
assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId)
|
|
assert.notEqual(store.runs.get(aheadId).version_id, v1)
|
|
assert.equal(store.runs.get(inFlightId).version_id, v1)
|
|
|
|
// The move is on the run's own log, because "which version did this actually
|
|
// use" is the first question an audit asks.
|
|
const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned)
|
|
assert.equal(line.detail.fromVersionId, v1)
|
|
assert.equal(line.detail.toVersionId, republished.body.versionId)
|
|
})
|
|
|
|
test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
|
|
const created = await createDraft()
|
|
const id = created.body.event.id
|
|
await call(ctrl.publish, { params: { id: String(id) } })
|
|
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)
|
|
})
|