Files
website/server/test/demoModule.test.js
wtclaude ebedee146f
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 6m6s
PR Checks / client-build (pull_request) Successful in 7m33s
fix(demo): switch on the actions the seeded events use (DM28)
The step-5 walk found the demo's three seeded events (Server greeting,
Treasure drop, Payday) showing as ready but failing when run: a fresh
database has no switchboard rows, so every change and irreversible action
starts disabled ("not enabled on this deployment").

seed:demo now switches on, with the events, every action their steps use,
uncapped, as an admin flipping the switch without filling the cap boxes
would. Nothing else is switched on. The deny list (DM11) still keeps a
visitor's own command step harmless, and the wipe drops the rows with the
rest of the database.

Checked on a scratch database with the v0.6.1 module: the seed leaves
rust.command.run, rust.dt.start and rust.coins.pay enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:25 -05:00

230 lines
11 KiB
JavaScript

// ── The demo's module contract (MODULE_API 1.18.0) and the seed's helpers ──
//
// `demoLocks` in module.json, `ctx.demoMode`, `api.onDemoSeed` (DM15), the
// `demo` block on GET /public/settings, and the pure parts of `seed:demo`.
// Each loader test builds a throwaway modules directory and re-requires the
// loader, the way moduleLoader.test.js does and for the same reason.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const { resolveEnv, nextResetAt, checkFixture, eventActionIds } = require('../db/seedDemo')
after(() => db.close())
afterEach(() => {
delete process.env.DEMO_MODE
})
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
function loadOne(manifest, server = 'module.exports = (ctx, api) => { const r = ctx.express.Router(); api.registerRoutes({ admin: { "/rust": r } }) }') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-'))
const dir = path.join(root, 'rust')
fs.mkdirSync(dir)
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id: 'rust',
name: 'Rust',
version: '1.0.0',
coreApi: '^1.18.0',
server: 'index.js',
mounts: { admin: ['/rust'] },
...manifest,
}))
fs.writeFileSync(path.join(dir, 'index.js'), server)
process.env.MODULES_DIR = root
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader
}
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
// ── demoLocks ──────────────────────────────────────────────────────────────
test('declared demoLocks load and come back compiled', () => {
const loader = loadOne({
demoLocks: [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
],
})
assert.equal(stateOf(loader).state, 'registered')
const got = loader.demoLocks()
assert.deepEqual(got.map((l) => [l.methods, l.path, l.source]), [
[['PUT'], '/admin/rust/servers/:id', 'rust'],
[['DELETE'], '/admin/rust/servers/:id', 'rust'],
])
assert.ok(got[0].compiled.re.test('/admin/rust/servers/demo'))
})
test('a module without demoLocks declares none', () => {
assert.deepEqual(loadOne({}).demoLocks(), [])
})
test('a bad demoLocks entry fails the module at load, demo or not', () => {
const cases = [
[{ demoLocks: {} }, /demoLocks must be an array/],
[{ demoLocks: ['PUT /x'] }, /must be an object/],
[{ demoLocks: [{ method: 'GET', path: '/admin/rust/x' }] }, /must be one of POST, PUT, PATCH, DELETE/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/x', why: 'y' }] }, /unknown key "demoLocks.why"/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/*/x' }] }, /\* must be the last segment/],
// Outside the module's own mounts: a module locks its routes and nobody else's.
[{ demoLocks: [{ method: 'PUT', path: '/admin/site-mode' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/public/rust/x' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/*' }] }, /outside the module's mounts/],
]
for (const [manifest, reason] of cases) {
const loader = loadOne(manifest)
const m = stateOf(loader)
assert.equal(m.state, 'startup_failed', JSON.stringify(manifest))
assert.match(m.reason, reason)
}
})
test('a module declaring ^1.18.0 and an empty demoLocks loads on this core', () => {
const loader = loadOne({ coreApi: '^1.18.0', demoLocks: [] })
assert.equal(stateOf(loader).state, 'registered')
})
// ── ctx.demoMode ───────────────────────────────────────────────────────────
const PROBE = `module.exports = (ctx, api) => {
global.__demoProbe = ctx.demoMode
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
}`
test('ctx.demoMode is false on a normal deployment and true on the demo', () => {
loadOne({}, PROBE)
assert.equal(global.__demoProbe, false)
process.env.DEMO_MODE = 'true'
loadOne({}, PROBE)
assert.equal(global.__demoProbe, true)
delete global.__demoProbe
})
// ── onDemoSeed ─────────────────────────────────────────────────────────────
test('onDemoSeed is collected for the seed and handed the fixture and account ids', async () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(async (data, { accounts }) => { global.__demoSeed = { data, accounts } })
}`)
const hooks = loader.demoSeedHooks()
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
await hooks[0].hook({ servers: [] }, { accounts: { 'demo-player': 3 } })
assert.deepEqual(global.__demoSeed, { data: { servers: [] }, accounts: { 'demo-player': 3 } })
delete global.__demoSeed
})
test('onDemoSeed twice is a registration error, like every other hook', () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(() => {})
api.onDemoSeed(() => {})
}`)
assert.equal(stateOf(loader).state, 'startup_failed')
assert.match(stateOf(loader).reason, /onDemoSeed\(\) called twice/)
})
// ── GET /public/settings ───────────────────────────────────────────────────
const originalGetAll = settingsDb.getAll
afterEach(() => {
settingsDb.getAll = originalGetAll
})
test('the demo block is present only on the demo, and read forgivingly', async () => {
const state = {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }, { username: 7 }],
}
settingsDb.getAll = async () => [{ key: 'demo_state', value: JSON.stringify(state) }]
const normal = await settings.getPublic()
assert.equal('demo' in normal, false)
process.env.DEMO_MODE = 'true'
const demo = await settings.getPublic()
assert.deepEqual(demo.demo, {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }],
})
settingsDb.getAll = async () => [{ key: 'demo_state', value: '{broken' }]
assert.deepEqual((await settings.getPublic()).demo, { resetsAt: null, logins: [] })
})
// ── seed:demo's pure helpers ───────────────────────────────────────────────
test('resolveEnv replaces $env: strings at any depth and refuses an unset one', () => {
const env = { DEMO_RUST_SIDECAR_TOKEN: 'tok', DEMO_RUST_SIDECAR_URL: 'http://rig:7790' }
assert.deepEqual(
resolveEnv({ a: '$env:DEMO_RUST_SIDECAR_URL', b: [{ t: '$env:DEMO_RUST_SIDECAR_TOKEN' }], c: 'plain $env:X', d: 3 }, env),
{ a: 'http://rig:7790', b: [{ t: 'tok' }], c: 'plain $env:X', d: 3 },
)
assert.throws(() => resolveEnv({ m: { t: '$env:NOPE' } }, env), /fixture\.m\.t: environment variable NOPE is not set/)
assert.throws(() => resolveEnv('$env:EMPTY', { EMPTY: '' }), /EMPTY is not set/)
})
test('nextResetAt is the next two-hour slot on the hour, UTC', () => {
const at = (iso) => nextResetAt(new Date(iso), 2).toISOString()
assert.equal(at('2026-10-24T14:02:10Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T13:59:59Z'), '2026-10-24T14:00:00.000Z')
assert.equal(at('2026-10-24T14:00:00Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T23:30:00Z'), '2026-10-25T00:00:00.000Z')
assert.equal(nextResetAt(new Date('2026-10-24T14:02:00Z'), 3).toISOString(), '2026-10-24T15:00:00.000Z')
})
const GOOD = {
accounts: {
'demo-admin': { password: 'demo-admin-pass', role: 'admin' },
'demo-moderator': { password: 'demo-moderator-pass', role: 'moderator' },
'demo-player': { password: 'demo-player-pass', role: 'player' },
'play-review': { password: 'play-review-pass', role: 'admin' },
},
}
test('checkFixture accepts the four accounts and refuses anything that would leave the lock wrong', () => {
assert.deepEqual(checkFixture(GOOD), [])
const { 'play-review': _gone, ...three } = GOOD.accounts
assert.match(checkFixture({ accounts: three }).join('\n'), /accounts\.play-review is missing/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, alice: { password: 'alicealice', role: 'admin' } } }).join('\n'),
/accounts\.alice is not a demo account/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'short', role: 'admin' } } }).join('\n'),
/at least 8 characters/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'longenough', role: 'root' } } }).join('\n'),
/role must be one of/)
assert.match(checkFixture({ ...GOOD, settings: { demo_state: '{}' } }).join('\n'), /written by the seed itself/)
assert.match(checkFixture({ ...GOOD, content: { posts: {} } }).join('\n'), /content\.posts must be an array/)
assert.match(checkFixture({ ...GOOD, modules: [] }).join('\n'), /modules must be an object/)
})
test('eventActionIds lists each action the seeded events use, once, and the shipped fixture names all three', () => {
const step = (actionId) => ({ actionId, params: {} })
const ev = (...phases) => ({ spec: { phases: phases.map((steps) => ({ steps: steps.map(step) })) } })
assert.deepEqual(eventActionIds([ev(['a.run', 'core.wait'], ['a.run']), ev(['b.pay']), {}, { spec: {} }]),
['a.run', 'core.wait', 'b.pay'])
assert.deepEqual(eventActionIds(), [])
const fixture = require('../../deploy/demo/seed.json') // eslint-disable-line global-require
assert.deepEqual(eventActionIds(fixture.content.events), ['rust.command.run', 'rust.dt.start', 'rust.coins.pay'])
})
test('seed:demo refuses to run off the demo', async () => {
const { run } = require('../db/seedDemo') // eslint-disable-line global-require
await assert.rejects(() => run('anything.json'), /only with DEMO_MODE=true/)
})