Files
website/server/test/demoStepRefusal.test.js
wtclaude cee4ab8aea
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m0s
PR Checks / client-build (pull_request) Successful in 7m29s
feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
DEMO_MODE.md §3.4 refuses a denied console command when the event is
saved and when it runs. Saving is core's route, and core never asked a
module about a step's params, so the save-time half had no seam (DM18).

An event action may now declare demoRefuses(params), returning a
sentence or null. With DEMO_MODE on, create, save and publish ask it
for every step; the first sentence refuses the write 403 DEMO_LOCKED
with that sentence as the message (and in errors). A hook that throws
refuses too. Off the demo it is validated and never called, and the
catalog strips it with the other callables.

The events controller passes code and message through on create,
update and publish, so the client's one DEMO_LOCKED toast fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:39:42 -05:00

155 lines
5.6 KiB
JavaScript

// ── The demo refuses a step at save (MODULE_API 1.19.0, DEMO_MODE.md §3.4) ──
//
// DM18: an event action may declare `demoRefuses(params)`. With DEMO_MODE on,
// the definition save path asks it for every step at create, save and publish,
// and a sentence back refuses the write `403 DEMO_LOCKED`. Off the demo it is
// never called. The definitions table is stubbed on the module object the model
// holds; the pool points at a closed port.
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 db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const definitions = require('../src/model/events/eventDefinitions.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const controller = require('../src/router/v1/admin/events.controller')
after(() => db.close())
let asked
const register = (extra = {}) => {
const api = registries.stage('demo')
api.registerEventActions([{
id: 'demo.console.run',
label: 'Run console commands',
risk: 'irreversible',
reversible: 'none',
params: [{ name: 'command', type: 'string', required: true, example: 'say hi' }],
perform: async () => ({ ok: true }),
demoRefuses: (params) => {
asked.push(params)
return params.command === 'quit' ? '"quit" is turned off on the demo site.' : null
},
...extra,
}])
registries.apply(api.staged)
}
const input = (command) => ({
title: 'Console night',
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.console.run', params: { command } }] }],
},
})
const stubTable = (t, existing = null) => {
const wrote = []
t.mock.method(definitionsDb, 'list', async () => [])
t.mock.method(definitionsDb, 'insert', async (row) => { wrote.push(row); return 7 })
t.mock.method(definitionsDb, 'update', async (_id, row) => { wrote.push(row) })
t.mock.method(definitionsDb, 'getById', async () => existing || { id: 7, state: 'draft', title: 'Console night', spec: input('say hi').spec })
return wrote
}
beforeEach(() => {
asked = []
registries._reset()
registries.registerCore()
})
afterEach(() => {
registries._reset()
delete process.env.DEMO_MODE
})
test('demoRefuses must be a function, and never rides out to the catalog', () => {
assert.throws(() => register({ demoRefuses: 'quit' }), /demoRefuses must be a function/)
registries._reset()
register()
assert.equal(typeof registries.eventAction('demo.console.run').demoRefuses, 'function')
const served = registries.allEventActions().find((a) => a.id === 'demo.console.run')
assert.equal('demoRefuses' in served, false)
})
test('off the demo the hook is never asked, and a denied command saves', async (t) => {
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.equal(made.ok, true)
assert.equal(wrote.length, 1)
assert.deepEqual(asked, [])
})
test('on the demo a create naming a denied command is refused 403 DEMO_LOCKED and writes nothing', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.deepEqual(made, {
ok: false,
status: 403,
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
errors: ['"quit" is turned off on the demo site.'],
})
assert.equal(wrote.length, 0)
// The hook is handed the step's checked params.
assert.deepEqual(asked, [{ command: 'quit' }])
})
test('on the demo a command the hook allows saves', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
assert.equal((await definitions.create(input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal((await definitions.save(7, input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal(wrote.length, 2)
})
test('on the demo a save and a publish are refused too', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t, { id: 7, state: 'draft', title: 'Console night', spec: input('quit').spec })
const saved = await definitions.save(7, input('quit'), 1, { role: 'admin' })
assert.equal(saved.status, 403)
assert.equal(saved.code, 'DEMO_LOCKED')
// A draft written before the list grew: publish asks again.
const published = await definitions.publish(7, 1)
assert.equal(published.status, 403)
assert.equal(published.code, 'DEMO_LOCKED')
assert.equal(wrote.length, 0)
})
test('a hook that throws refuses with core\'s sentence (fails closed)', async (t) => {
process.env.DEMO_MODE = 'true'
register({ demoRefuses: () => { throw new Error('boom') } })
stubTable(t)
const made = await definitions.create(input('say hi'), 1, { role: 'admin' })
assert.equal(made.status, 403)
assert.equal(made.code, 'DEMO_LOCKED')
assert.match(made.message, /"Run console commands" could not be checked/)
})
test('the controller answers the refusal with code and message, so the toast fires', async (t) => {
process.env.DEMO_MODE = 'true'
register()
stubTable(t)
let status
let body
const res = {
status(s) { status = s; return this },
json(b) { body = b; return this },
}
await controller.create({ body: input('quit'), user: { id: 1, role: 'admin' } }, res)
assert.equal(status, 403)
assert.deepEqual(body, {
errors: ['"quit" is turned off on the demo site.'],
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
})
})