// ── 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']) }) // ── NPCs and chat styles from the seed (DM26) ────────────────────────────── const NPC_SECTION = { npcs: { factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }], profiles: [ { name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } }, { name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } }, ], }, chat: [ { serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } }, { serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } }, ], } /** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */ function stubSeedModels({ profiles = [], groups = {} } = {}) { const calls = [] const npcs = require('../model/npcs/npcs.model') const npcsDb = require('../model/npcs/npcs.db') const apply = require('../model/permissions/permissions.apply') const permDb = require('../model/permissions/permissions.db') npcs.setFactions = async (f) => calls.push(['setFactions', f]) npcs.create = async (input) => calls.push(['create', input.name, input.servers]) npcs.update = async (id, input) => calls.push(['update', id, input.name]) npcsDb.listProfiles = async () => profiles apply.groupOn = async (name) => groups[name] || null permDb.insertGroup = async ({ name }) => { calls.push(['insertGroup', name]) return 50 } permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers]) permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length]) permDb.markDirty = async (scope) => calls.push(['markDirty', scope]) return calls } test('the NPC and chat parts of the seed section are checked before anything is written', async () => { withCore() const seed = require('../demoSeed') assert.deepEqual(seed.checkSection(NPC_SECTION, {}), []) const problems = seed.checkSection({ npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] }, chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }], }, {}) assert.equal(problems.length, 6, problems.join(' | ')) assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object']) const calls = stubSeedModels() await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/) assert.deepEqual(calls, []) }) test('the seed sets the faction table, saves each profile, and styles each group', async () => { withCore() const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } }) await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} }) assert.deepEqual(calls, [ ['setFactions', NPC_SECTION.npcs.factions], ['update', 7, 'bandit'], // already there: updated, not created twice ['create', 'guard', ['main']], ['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in ['markDirty', 'main'], ['insertGroup', 'moderator'], // the rig's import has not brought it in yet ['setGroupServers', 50, ['main']], ['setGroupChat', 50, '[MOD]', '#55aaff', 12], ['markDirty', 'main'], ]) }) // ── The manifest (§3.2) ──────────────────────────────────────────────────── test('module.json locks the two rig-link routes and the player Steam unlink, 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' }, { method: 'DELETE', path: '/player/rust/links/:steamId' }, ]) // A floor, not an exact value: later contract members raise it (1.20.0 for // account deletion), and what this test holds is that it never drops below the // core that asks demoRefuses. const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number) assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`) }) // ── The staff Steam unlink (W4) ──────────────────────────────────────────── test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => { for (const demoMode of [true, false]) { withCore({ demo: demoMode }) const links = require('../model/links/links.model') const asked = [] links.unlinkOwned = async (steamId, userId) => { asked.push([steamId, userId]) return true } const res = fakeRes() await require('../router/admin/usersRust.controller').removeLink( { params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } }, res, ) if (demoMode) { assert.equal(res.statusCode, 403) assert.equal(res.body.code, 'DEMO_LOCKED') assert.deepEqual(asked, []) } else { assert.equal(res.statusCode, 200) assert.deepEqual(res.body, { unlinked: true }) assert.deepEqual(asked, [['76561190000000001', '3']]) } } })