Merge pull request 'feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)' (#224) from feature/demo-step-refusal into main
Reviewed-on: #224 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -11,6 +11,9 @@
|
|||||||
// that the two files can drift, so a test asserts they agree
|
// that the two files can drift, so a test asserts they agree
|
||||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||||
// both.
|
// both.
|
||||||
|
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
|
||||||
|
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
|
||||||
|
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
|
||||||
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
|
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
|
||||||
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
|
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
|
||||||
// banner and refusal toast, which an installed module's requests reach through
|
// banner and refusal toast, which an installed module's requests reach through
|
||||||
@@ -102,4 +105,4 @@
|
|||||||
// but the two halves state ONE version: a module declares a single coreApi range
|
// but the two halves state ONE version: a module declares a single coreApi range
|
||||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||||
// answered 1.1.0 would be two answers to one question.
|
// answered 1.1.0 would be two answers to one question.
|
||||||
export const MODULE_API_VERSION = '1.18.0'
|
export const MODULE_API_VERSION = '1.19.0'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||||
"moduleApiVersion": "1.18.0",
|
"moduleApiVersion": "1.19.0",
|
||||||
"triggers": [
|
"triggers": [
|
||||||
{
|
{
|
||||||
"id": "event.phase.changed",
|
"id": "event.phase.changed",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const spec = require('../../events/spec')
|
|||||||
const authorize = require('../../events/authorize')
|
const authorize = require('../../events/authorize')
|
||||||
const verifier = require('../../events/verify')
|
const verifier = require('../../events/verify')
|
||||||
const registries = require('../../modules/registries')
|
const registries = require('../../modules/registries')
|
||||||
|
const { isDemo } = require('../../demo/mode')
|
||||||
|
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
|
||||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||||
const log = require('../../utils/logger')('events')
|
const log = require('../../utils/logger')('events')
|
||||||
@@ -181,6 +183,8 @@ async function create(input, userId, { role = null } = {}) {
|
|||||||
if (!result.ok) return result
|
if (!result.ok) return result
|
||||||
const floor = checkRoleFloor(result.definition.spec, role)
|
const floor = checkRoleFloor(result.definition.spec, role)
|
||||||
if (floor) return floor
|
if (floor) return floor
|
||||||
|
const refused = await checkDemoRefusals(result.definition.spec)
|
||||||
|
if (refused) return refused
|
||||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||||
return { ok: true, id, definition: await db.getById(id) }
|
return { ok: true, id, definition: await db.getById(id) }
|
||||||
}
|
}
|
||||||
@@ -203,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) {
|
|||||||
if (!result.ok) return result
|
if (!result.ok) return result
|
||||||
const floor = checkRoleFloor(result.definition.spec, role)
|
const floor = checkRoleFloor(result.definition.spec, role)
|
||||||
if (floor) return floor
|
if (floor) return floor
|
||||||
|
const refused = await checkDemoRefusals(result.definition.spec)
|
||||||
|
if (refused) return refused
|
||||||
await db.update(id, { ...result.definition, updated_by: userId })
|
await db.update(id, { ...result.definition, updated_by: userId })
|
||||||
return { ok: true, id, definition: await db.getById(id) }
|
return { ok: true, id, definition: await db.getById(id) }
|
||||||
}
|
}
|
||||||
@@ -228,6 +234,11 @@ async function publish(id, userId) {
|
|||||||
})
|
})
|
||||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||||
|
|
||||||
|
// Asked again here, as the registries are: a draft saved before the module's
|
||||||
|
// list grew must not become a version that runs (DM18).
|
||||||
|
const refused = await checkDemoRefusals(checked.spec)
|
||||||
|
if (refused) return refused
|
||||||
|
|
||||||
const publishable = spec.publishable(checked.spec)
|
const publishable = spec.publishable(checked.spec)
|
||||||
if (!publishable.ok) {
|
if (!publishable.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -365,6 +376,37 @@ function checkRoleFloor(specValue, role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
|
||||||
|
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
|
||||||
|
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
|
||||||
|
* the demo, where nothing is asked.
|
||||||
|
*
|
||||||
|
* A hook that throws refuses too, with core's sentence: this is a lock, and a
|
||||||
|
* lock that cannot decide fails closed, as the route lock does (§3.3).
|
||||||
|
*/
|
||||||
|
async function checkDemoRefusals(specValue) {
|
||||||
|
if (!isDemo()) return null
|
||||||
|
for (const phase of specValue?.phases || []) {
|
||||||
|
for (const step of phase.steps || []) {
|
||||||
|
const action = registries.eventAction(step.actionId)
|
||||||
|
if (!action || !action.demoRefuses) continue
|
||||||
|
let said
|
||||||
|
try {
|
||||||
|
said = await action.demoRefuses(step.params || {})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('demoRefuses failed', { action: action.id, message: err.message })
|
||||||
|
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
|
||||||
|
}
|
||||||
|
if (typeof said === 'string' && said.trim()) {
|
||||||
|
const message = said.trim()
|
||||||
|
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dry-run a definition, and record the pass when there is a version to record it
|
* Dry-run a definition, and record the pass when there is a version to record it
|
||||||
* on (Phase 6).
|
* on (Phase 6).
|
||||||
@@ -545,6 +587,7 @@ module.exports = {
|
|||||||
archive,
|
archive,
|
||||||
verify,
|
verify,
|
||||||
checkRoleFloor,
|
checkRoleFloor,
|
||||||
|
checkDemoRefusals,
|
||||||
isTimezone,
|
isTimezone,
|
||||||
MIN_GRACE_SECONDS,
|
MIN_GRACE_SECONDS,
|
||||||
MAX_GRACE_SECONDS,
|
MAX_GRACE_SECONDS,
|
||||||
|
|||||||
@@ -445,14 +445,14 @@ async function resolveAudience(id, params = {}) {
|
|||||||
/**
|
/**
|
||||||
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||||
*
|
*
|
||||||
* `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is
|
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
|
||||||
* stripped from an audience and `handler` from a slash command: this is the
|
* stripped from an audience and `handler` from a slash command: this is the
|
||||||
* object that leaves the process, and the browser's whole relationship with an
|
* object that leaves the process, and the browser's whole relationship with an
|
||||||
* action is naming one by id. §F's "a module registers actions server-side and
|
* action is naming one by id. §F's "a module registers actions server-side and
|
||||||
* adds no routes for them" is only true if the functions never ride out.
|
* adds no routes for them" is only true if the functions never ride out.
|
||||||
*/
|
*/
|
||||||
const allEventActions = () =>
|
const allEventActions = () =>
|
||||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, ...rest }) => rest)
|
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
|
||||||
|
|
||||||
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||||
const eventAction = (id) => eventActions.get(id) || null
|
const eventAction = (id) => eventActions.get(id) || null
|
||||||
@@ -1128,7 +1128,7 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`.
|
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
|
||||||
*
|
*
|
||||||
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||||
* the argument alone is decided here, at the call; the collision — is this id
|
* the argument alone is decided here, at the call; the collision — is this id
|
||||||
@@ -1211,6 +1211,13 @@ function checkEventActionShape(entry) {
|
|||||||
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
||||||
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
||||||
}
|
}
|
||||||
|
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
|
||||||
|
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
|
||||||
|
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
|
||||||
|
// at run, because a definition can predate the list it is checked against.
|
||||||
|
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
|
||||||
|
}
|
||||||
|
|
||||||
const version = a.version === undefined ? 1 : a.version
|
const version = a.version === undefined ? 1 : a.version
|
||||||
if (!Number.isInteger(version) || version < 1) {
|
if (!Number.isInteger(version) || version < 1) {
|
||||||
@@ -1244,6 +1251,7 @@ function checkEventActionShape(entry) {
|
|||||||
revert: a.revert || null,
|
revert: a.revert || null,
|
||||||
reconcile: a.reconcile || null,
|
reconcile: a.reconcile || null,
|
||||||
progress: a.progress || null,
|
progress: a.progress || null,
|
||||||
|
demoRefuses: a.demoRefuses || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||||
// has nothing to say about a website module) and from any module's own version.
|
// has nothing to say about a website module) and from any module's own version.
|
||||||
|
|
||||||
|
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
|
||||||
|
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
|
||||||
|
// sentence or null. With DEMO_MODE on, core asks it for every step when a
|
||||||
|
// definition is created, saved or published, and a sentence refuses the whole
|
||||||
|
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
|
||||||
|
// refuses too. Off the demo it is validated and never called. One optional
|
||||||
|
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
|
||||||
|
// Module-Rust's console-command deny list is the first user.
|
||||||
|
//
|
||||||
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
|
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
|
||||||
// Three additions, each optional:
|
// Three additions, each optional:
|
||||||
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
|
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
|
||||||
@@ -245,6 +254,6 @@
|
|||||||
// an admin action a module performs belongs in core's one audit log, the
|
// an admin action a module performs belongs in core's one audit log, the
|
||||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||||
const MODULE_API_VERSION = '1.18.0'
|
const MODULE_API_VERSION = '1.19.0'
|
||||||
|
|
||||||
module.exports = { MODULE_API_VERSION }
|
module.exports = { MODULE_API_VERSION }
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ const asId = (raw) => {
|
|||||||
return Number.isInteger(n) && n > 0 ? n : null
|
return Number.isInteger(n) && n > 0 ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refused save's body: the errors, plus `code` and `message` when the model
|
||||||
|
* set them. The demo's step refusal does (DM18), so the client's one
|
||||||
|
* `DEMO_LOCKED` toast fires as it does for a route lock.
|
||||||
|
*/
|
||||||
|
const refusal = (result) => ({
|
||||||
|
errors: result.errors,
|
||||||
|
...(result.code ? { code: result.code, message: result.message } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape a definition takes on the wire.
|
* The shape a definition takes on the wire.
|
||||||
*
|
*
|
||||||
@@ -413,7 +423,7 @@ exports.listVersions = async (req, res) => {
|
|||||||
/** POST /api/v1/admin/events */
|
/** POST /api/v1/admin/events */
|
||||||
exports.create = async (req, res) => {
|
exports.create = async (req, res) => {
|
||||||
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
||||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||||
await activity.log({
|
await activity.log({
|
||||||
req,
|
req,
|
||||||
action: 'event.definition.created',
|
action: 'event.definition.created',
|
||||||
@@ -427,7 +437,7 @@ exports.update = async (req, res) => {
|
|||||||
const id = asId(req.params.id)
|
const id = asId(req.params.id)
|
||||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||||
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
||||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||||
await activity.log({
|
await activity.log({
|
||||||
req,
|
req,
|
||||||
action: 'event.definition.updated',
|
action: 'event.definition.updated',
|
||||||
@@ -441,7 +451,7 @@ exports.publish = async (req, res) => {
|
|||||||
const id = asId(req.params.id)
|
const id = asId(req.params.id)
|
||||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||||
const result = await definitions.publish(id, req.user.id)
|
const result = await definitions.publish(id, req.user.id)
|
||||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||||
await activity.log({
|
await activity.log({
|
||||||
req,
|
req,
|
||||||
action: 'event.definition.published',
|
action: 'event.definition.published',
|
||||||
|
|||||||
@@ -18553,7 +18553,7 @@
|
|||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps."
|
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence."
|
||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -18615,6 +18615,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "array"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "array",
|
||||||
|
"example": [
|
||||||
|
"\"quit\" is turned off on the demo site."
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,11 +109,12 @@ const doc = {
|
|||||||
DemoLocked: {
|
DemoLocked: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps.',
|
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
|
||||||
properties: {
|
properties: {
|
||||||
message: { type: 'string', example: 'This is turned off on the demo site.' },
|
message: { type: 'string', example: 'This is turned off on the demo site.' },
|
||||||
code: { type: 'string', enum: ['DEMO_LOCKED'] },
|
code: { type: 'string', enum: ['DEMO_LOCKED'] },
|
||||||
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
|
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
|
||||||
|
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
|
||||||
},
|
},
|
||||||
required: ['message', 'code'],
|
required: ['message', 'code'],
|
||||||
},
|
},
|
||||||
|
|||||||
154
server/test/demoStepRefusal.test.js
Normal file
154
server/test/demoStepRefusal.test.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// ── 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.',
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
|||||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||||
// assertion that says it does. The number itself is pinned in version.js.
|
// assertion that says it does. The number itself is pinned in version.js.
|
||||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||||
assert.equal(MODULE_API_VERSION, '1.18.0')
|
assert.equal(MODULE_API_VERSION, '1.19.0')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a module registers actions, budgets, leases and option sources', () => {
|
test('a module registers actions, budgets, leases and option sources', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user