feat(demo): the public demo's module-side locks and seed hook
website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10). Nothing changes off the demo; everything below sits behind ctx.demoMode. - demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id. - The console-command deny list (DM11): quit, restart, oxide.unload, oxide.reload, rcon.*, fps.limit and server.tickrate, by first word, with global. stripped and o. read as oxide. Refused at save through core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with retry: false, before the rig is asked. Teardown commands too. - The config editor (DM14): the bridge's files are left out of the list and refused for reading, writing and in the write history, by name (canonicalised) and by the catalogue's self. Other plugins' credential fields are redacted in the text and the form and made read-only; raw-JSON saves are refused; a file that does not parse is withheld. The admin server list leaves out the sidecar URL. - onDemoSeed (DM15): upserts the rig's server row with an encrypted token and demo-player's stand-in Steam link. - The release's install manifest carries protocol, read from PROTOCOL_VERSION, for the wipe's bundle match (DM12). coreApi is ^1.19.0; ci/core-ref.json pins website#224's head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||
demoMode: false,
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
@@ -169,6 +171,8 @@ function fakeApi() {
|
||||
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
|
||||
// MODULE_API 1.18.0 (the public demo's seed, DM15).
|
||||
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
|
||||
387
server/test/demo.test.js
Normal file
387
server/test/demo.test.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
|
||||
//
|
||||
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
|
||||
// 1.18.0): the console-command deny list (DM11) at save through core's
|
||||
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
|
||||
// written (DM14); secrets in every other config, redacted and read-only; the
|
||||
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
|
||||
// held twice: on the demo it refuses, and off it nothing changes.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore({ demo = true, select = () => [] } = {}) {
|
||||
const queries = []
|
||||
require('../core')._reset()
|
||||
require('../core').init(
|
||||
fakeCtx({
|
||||
demoMode: demo,
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
const flat = sql.trim().replace(/\s+/g, ' ')
|
||||
queries.push({ sql: flat, params })
|
||||
const verb = flat.split(' ')[0].toUpperCase()
|
||||
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
|
||||
return Promise.resolve({ affectedRows: 1, insertId: 1 })
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
return queries
|
||||
}
|
||||
|
||||
function fakeRes() {
|
||||
const res = { statusCode: 200, body: null }
|
||||
res.status = (code) => {
|
||||
res.statusCode = code
|
||||
return res
|
||||
}
|
||||
res.json = (body) => {
|
||||
res.body = body
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4) ───────────────────────────────────
|
||||
|
||||
test('the deny list compares the first word, normalised', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const line of [
|
||||
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
|
||||
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
|
||||
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), true, line)
|
||||
}
|
||||
for (const line of [
|
||||
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
|
||||
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), false, line)
|
||||
}
|
||||
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
|
||||
})
|
||||
|
||||
test('the deny list refuses nothing off the demo', () => {
|
||||
withCore({ demo: false })
|
||||
assert.equal(require('../demo').deniedCommand(['quit']), null)
|
||||
})
|
||||
|
||||
test('both command actions refuse a denied command at save, through demoRefuses', () => {
|
||||
withCore()
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
|
||||
// A teardown runs at the end of the event, so it is checked as well.
|
||||
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
|
||||
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
|
||||
})
|
||||
|
||||
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
|
||||
withCore()
|
||||
const sidecar = require('../sidecarClient')
|
||||
let asked = 0
|
||||
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
|
||||
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
|
||||
|
||||
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
|
||||
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
|
||||
assert.equal(asked, 0)
|
||||
})
|
||||
|
||||
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
|
||||
|
||||
test('a bridge path is recognised however it is spelled', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
|
||||
}
|
||||
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
|
||||
}
|
||||
})
|
||||
|
||||
const SECRET_FILE = `{
|
||||
"Discord Webhook": "https://discord.example/hook/abc",
|
||||
"Api Key": 123456789,
|
||||
"Empty Token": "",
|
||||
"Use Auth": true,
|
||||
"Credentials": { "User": "admin", "List": ["one", "two"] },
|
||||
"Gather": { "Wood": 1.0 },
|
||||
"Enabled": true
|
||||
}`
|
||||
|
||||
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
|
||||
const sidecar = require('../sidecarClient')
|
||||
const calls = []
|
||||
sidecar.configFile = async (server, asked) => {
|
||||
calls.push(['read', asked])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
|
||||
}
|
||||
sidecar.configFiles = async () => {
|
||||
calls.push(['list'])
|
||||
return {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
kind: 'config.catalogue',
|
||||
root: '/home/container/oxide/config',
|
||||
self,
|
||||
files: [
|
||||
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
|
||||
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
|
||||
],
|
||||
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
sidecar.configWrite = async (server, body) => {
|
||||
calls.push(['write', body])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function stubServer() {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
|
||||
require('../model/servers/servers.db').getServer = async () => row
|
||||
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
|
||||
}
|
||||
|
||||
const controller = () => require('../router/admin/config.controller')
|
||||
|
||||
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
|
||||
|
||||
withCore({ demo: false })
|
||||
const off = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
|
||||
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
|
||||
})
|
||||
|
||||
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
|
||||
assert.equal(res.statusCode, 403, path)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
}
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
// Named by the catalogue's own word for the plugin, when that differs.
|
||||
const renamed = stubSidecar({ self: 'Bridge' })
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.ok(renamed.some(([kind]) => kind === 'list'))
|
||||
})
|
||||
|
||||
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
const { text, fields } = res.body
|
||||
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
|
||||
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
|
||||
// Still JSON, and the values that are not credentials are untouched.
|
||||
const parsed = JSON.parse(text)
|
||||
assert.equal(parsed['Empty Token'], '')
|
||||
assert.equal(parsed['Use Auth'], true)
|
||||
assert.equal(parsed.Gather.Wood, 1)
|
||||
assert.match(text, /"Wood": 1\.0/)
|
||||
|
||||
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
|
||||
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Discord Webhook'].locked, true)
|
||||
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
|
||||
assert.equal('raw' in byPath['Api Key'], false)
|
||||
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Gather.Wood'].locked, false)
|
||||
assert.equal(res.body.redacted, true)
|
||||
})
|
||||
|
||||
test('off the demo the file is returned as it is', async () => {
|
||||
withCore({ demo: false })
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
assert.equal(res.body.text, SECRET_FILE)
|
||||
assert.equal(res.body.redacted, undefined)
|
||||
})
|
||||
|
||||
test('a file that does not parse is withheld whole on the demo', () => {
|
||||
withCore()
|
||||
const model = require('../model/config/config.model')
|
||||
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
|
||||
const out = model.redactSecrets(shaped, '(hidden)')
|
||||
assert.equal(out.text, '')
|
||||
assert.match(out.withheld, /does not parse/)
|
||||
})
|
||||
|
||||
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const save = async (body) => {
|
||||
const res = fakeRes()
|
||||
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
|
||||
return res
|
||||
}
|
||||
|
||||
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
|
||||
assert.equal(bridge.statusCode, 403)
|
||||
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
|
||||
assert.equal(raw.statusCode, 403)
|
||||
assert.match(raw.body.message, /Raw JSON saves/)
|
||||
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
|
||||
assert.equal(secret.statusCode, 403)
|
||||
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
|
||||
assert.equal(nested.statusCode, 403)
|
||||
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const [, body] = calls.find(([kind]) => kind === 'write')
|
||||
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
|
||||
assert.match(body.files[0].text, /"Enabled": false/)
|
||||
})
|
||||
|
||||
test('a conflict\'s current file is redacted too', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.doesNotMatch(res.body.current.text, /discord\.example/)
|
||||
})
|
||||
|
||||
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
|
||||
const rows = [
|
||||
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
|
||||
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
|
||||
]
|
||||
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
|
||||
const res = fakeRes()
|
||||
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
|
||||
|
||||
const one = fakeRes()
|
||||
const db = require('../model/config/config.db')
|
||||
db.getWrite = async () => rows[0]
|
||||
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
|
||||
assert.equal(one.statusCode, 403)
|
||||
assert.equal(one.body.code, 'DEMO_LOCKED')
|
||||
})
|
||||
|
||||
// ── The Servers view (§3.5) ────────────────────────────────────────────────
|
||||
|
||||
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const db = require('../model/servers/servers.db')
|
||||
db.listServers = async () => [row]
|
||||
db.listState = async () => []
|
||||
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
|
||||
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
|
||||
assert.equal(shaped.hasToken, true)
|
||||
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
|
||||
|
||||
const SECTION = {
|
||||
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
|
||||
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
|
||||
}
|
||||
|
||||
test('the seed section is checked whole before anything is written', async () => {
|
||||
const queries = withCore()
|
||||
const seed = require('../demoSeed')
|
||||
const accounts = { 'demo-player': 3 }
|
||||
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
|
||||
const problems = seed.checkSection({
|
||||
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
|
||||
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
|
||||
}, accounts)
|
||||
assert.equal(problems.length, 7)
|
||||
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
|
||||
assert.deepEqual(queries, [])
|
||||
})
|
||||
|
||||
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
|
||||
const queries = withCore()
|
||||
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
|
||||
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
|
||||
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
|
||||
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
|
||||
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
|
||||
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
|
||||
})
|
||||
|
||||
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
|
||||
let owner = 3
|
||||
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
|
||||
const seed = require('../demoSeed')
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
|
||||
|
||||
owner = 9
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
|
||||
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
||||
})
|
||||
|
||||
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
||||
|
||||
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
|
||||
const manifest = require('../../module.json')
|
||||
assert.deepEqual(manifest.demoLocks, [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
])
|
||||
assert.equal(manifest.coreApi, '^1.19.0')
|
||||
})
|
||||
Reference in New Issue
Block a user