Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.
- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
/admin/settings keys that reach a Tier A lock. Any multipart request is refused
app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
with registration disabled, seeds content on the first run only, calls each
module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
module screens get it too), and the published logins on both sign-in pages.
Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
answer DEMO_LOCKED on the demo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
219 lines
10 KiB
JavaScript
219 lines
10 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 } = 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('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/)
|
|
})
|