feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
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
This commit is contained in:
74
server/test/demoApp.test.js
Normal file
74
server/test/demoApp.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// ── The demo lock, wired into the real app (DEMO_MODE.md §3.1) ─────────────
|
||||
//
|
||||
// demoLocks.test.js proves each rule against a stand-in app. This file proves
|
||||
// the wiring: with DEMO_MODE=true, app.js mounts the lock ahead of every
|
||||
// router, so a locked route is refused before its handler — before even its
|
||||
// auth check — and an open one still reaches it. One process per flag value,
|
||||
// since app.js decides at require time: this file is the demo, and every other
|
||||
// test file that requires app.js is the normal deployment.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
process.env.DEMO_MODE = 'true'
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
async function withServer(fn) {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
return await fn(`http://127.0.0.1:${server.address().port}`)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
test('a locked route answers DEMO_LOCKED before its own auth check', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('an open route still reaches its handler, which answers as usual', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'x' }),
|
||||
})
|
||||
assert.equal(res.status, 401) // requireAuth, not the lock
|
||||
})
|
||||
})
|
||||
|
||||
test('an upload is refused before multer or auth sees it', async () => {
|
||||
await withServer(async (base) => {
|
||||
const form = new FormData()
|
||||
form.append('image', new Blob(['x'], { type: 'image/png' }), 'x.png')
|
||||
const res = await fetch(`${base}/api/v1/admin/uploads`, { method: 'POST', body: form })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('every response carries noindex, and robots.txt disallows everything', async () => {
|
||||
await withServer(async (base) => {
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${base}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
})
|
||||
})
|
||||
37
server/test/demoAppOff.test.js
Normal file
37
server/test/demoAppOff.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// ── Without DEMO_MODE, none of the demo is mounted (DEMO_MODE.md §2) ───────
|
||||
//
|
||||
// The other half of demoApp.test.js, in its own process because app.js reads
|
||||
// the flag at require time. A normal shard must answer exactly as it did before
|
||||
// demo mode existed: its own auth check, no noindex header, its own robots.txt.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
delete process.env.DEMO_MODE
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
test('a route the demo locks answers its own 401 on a normal deployment', async () => {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 401)
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), null)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
298
server/test/demoLocks.test.js
Normal file
298
server/test/demoLocks.test.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// Every row of §3.3 that core owns, the matcher's two express defaults, the
|
||||
// conditions on the shared-login rows, the settings keys that are another door
|
||||
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
|
||||
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const { compile, match } = require('../src/demo/paths')
|
||||
const locks = require('../src/demo/locks')
|
||||
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
const manifest = require('../routes.manifest.json')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalGetById = users.getById
|
||||
const originalValidate = sessionService.validateSession
|
||||
afterEach(() => {
|
||||
users.getById = originalGetById
|
||||
sessionService.validateSession = originalValidate
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
|
||||
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
|
||||
function stubUsers() {
|
||||
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
|
||||
}
|
||||
function signedInAs(userId) {
|
||||
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
|
||||
}
|
||||
|
||||
async function withLock(moduleLocks, fn) {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseMultipart)
|
||||
a.use('/api/v1', locks.createLock(moduleLocks))
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
return await fn((method, path, body, headers = {}) =>
|
||||
fetch(`${app.url}/api/v1${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLocked(res) {
|
||||
assert.equal(res.status, 403)
|
||||
const body = await res.json()
|
||||
assert.equal(body.code, 'DEMO_LOCKED')
|
||||
assert.equal(body.message, 'This is turned off on the demo site.')
|
||||
return body
|
||||
}
|
||||
async function expectReached(res) {
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(await res.json(), { reached: true })
|
||||
}
|
||||
|
||||
// ── The flag and the accounts ──────────────────────────────────────────────
|
||||
|
||||
test('only DEMO_MODE=true is the demo', () => {
|
||||
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
|
||||
if (v === undefined) delete process.env.DEMO_MODE
|
||||
else process.env.DEMO_MODE = v
|
||||
assert.equal(isDemo(), false, String(v))
|
||||
}
|
||||
process.env.DEMO_MODE = 'true'
|
||||
assert.equal(isDemo(), true)
|
||||
})
|
||||
|
||||
test('the four shared logins are recognised whatever their case', () => {
|
||||
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
|
||||
assert.equal(isDemoUsername('Demo-Admin'), true)
|
||||
assert.equal(isDemoUsername('alice'), false)
|
||||
assert.equal(isDemoUsername(null), false)
|
||||
})
|
||||
|
||||
// ── The matcher ────────────────────────────────────────────────────────────
|
||||
|
||||
test('patterns match the way express routes: any case, optional trailing slash', () => {
|
||||
const p = compile('/admin/users/:id')
|
||||
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
|
||||
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
|
||||
assert.equal(match(p, '/admin/users'), null)
|
||||
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
|
||||
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
|
||||
})
|
||||
|
||||
test('a trailing * is the path and everything under it', () => {
|
||||
const p = compile('/admin/modules/*')
|
||||
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
|
||||
assert.ok(match(p, path), path)
|
||||
}
|
||||
assert.equal(match(p, '/admin/modulesx'), null)
|
||||
})
|
||||
|
||||
test('a pattern that would compile to something else is refused', () => {
|
||||
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
|
||||
assert.throws(() => compile(bad), /lock path/, bad)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Core's list against the real routes ────────────────────────────────────
|
||||
|
||||
test("every row of core's list names a route the server actually serves", () => {
|
||||
// A row that matched nothing would read as a lock and lock nothing. Held
|
||||
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
|
||||
const served = manifest.public
|
||||
.filter((r) => r.path.startsWith('/api/v1/'))
|
||||
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
|
||||
for (const rule of locks.CORE_LOCKS) {
|
||||
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
|
||||
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
|
||||
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
|
||||
assert.ok(uploads.length >= 4)
|
||||
for (const r of uploads) {
|
||||
const path = r.path.slice('/api/v1'.length)
|
||||
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Tier A, unconditional ──────────────────────────────────────────────────
|
||||
|
||||
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
|
||||
await expectLocked(await req('DELETE', '/admin/modules/rust'))
|
||||
await expectLocked(await req('POST', '/admin/modules/restart'))
|
||||
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
|
||||
await expectLocked(await req('PUT', '/admin/email/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/email/test'))
|
||||
await expectLocked(await req('POST', '/admin/email/disconnect'))
|
||||
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
|
||||
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
|
||||
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
|
||||
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
|
||||
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
|
||||
await expectLocked(await req('POST', '/admin/uploads'))
|
||||
|
||||
await expectReached(await req('GET', '/admin/site-mode'))
|
||||
await expectReached(await req('GET', '/admin/modules'))
|
||||
await expectReached(await req('GET', '/admin/email/config'))
|
||||
await expectReached(await req('GET', '/auth/password/reset/abc'))
|
||||
})
|
||||
})
|
||||
|
||||
test('content, the rig and the rest of Tier C stay open', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
|
||||
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
|
||||
await expectReached(await req('POST', '/admin/events', {}))
|
||||
await expectReached(await req('POST', '/admin/invites', {}))
|
||||
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
|
||||
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
|
||||
await expectReached(await req('POST', '/auth/mobile/logout', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
|
||||
|
||||
test('PUT /admin/settings is open for theme and nav', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
|
||||
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
|
||||
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
|
||||
assert.deepEqual(body.keys, [key])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── The shared logins ──────────────────────────────────────────────────────
|
||||
|
||||
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
|
||||
stubUsers()
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const id of [1, 2, 3, 4]) {
|
||||
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
|
||||
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
|
||||
}
|
||||
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
|
||||
await expectReached(await req('DELETE', '/admin/users/9'))
|
||||
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
|
||||
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
|
||||
})
|
||||
})
|
||||
|
||||
const SELF_ROWS = [
|
||||
['PATCH', '/auth/me/account/username'],
|
||||
['PATCH', '/auth/me/account/password'],
|
||||
['PATCH', '/auth/me/account/email'],
|
||||
['POST', '/auth/me/account/totp/setup'],
|
||||
['POST', '/auth/me/account/totp/enable'],
|
||||
['POST', '/auth/me/account/totp/disable'],
|
||||
['POST', '/auth/me/account/recovery-codes/generate'],
|
||||
['DELETE', '/auth/me/account/identities/steam'],
|
||||
['DELETE', '/auth/me/sessions/12'], // DM17
|
||||
]
|
||||
|
||||
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
|
||||
stubUsers()
|
||||
signedInAs(4)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
|
||||
// Self-service that cannot lock anyone else out stays open.
|
||||
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
|
||||
stubUsers()
|
||||
signedInAs(9)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
signedInAs(null) // the route's own requireAuth answers 401
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('a lock that cannot decide refuses', async () => {
|
||||
users.getById = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/users/9', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Uploads ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
|
||||
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
|
||||
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
|
||||
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Module locks ───────────────────────────────────────────────────────────
|
||||
|
||||
test("a module's declared locks are refused alongside core's", async () => {
|
||||
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
|
||||
await withLock(() => declared, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
|
||||
await expectReached(await req('GET', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Search engines ─────────────────────────────────────────────────────────
|
||||
|
||||
test('noindex and robots keep the demo out of search engines', async () => {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.noindex)
|
||||
a.get('/robots.txt', locks.robots)
|
||||
a.get('/', (req, res) => res.send('ok'))
|
||||
})
|
||||
try {
|
||||
const page = await fetch(`${app.url}/`)
|
||||
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${app.url}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
218
server/test/demoModule.test.js
Normal file
218
server/test/demoModule.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── 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/)
|
||||
})
|
||||
@@ -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
|
||||
// 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.equal(MODULE_API_VERSION, '1.17.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.18.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
|
||||
@@ -550,8 +550,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
|
||||
// at a time. Linking stays core's, and a module takes part through the two
|
||||
// hooks on `api`, never by writing an identity itself.
|
||||
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
|
||||
// the module enforces in its own handlers.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
|
||||
Reference in New Issue
Block a user