// ── 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') })