Files
Module-Rust/server/test/demo.test.js
wtclaude fb7511d700
All checks were successful
PR Checks / server-tests (pull_request) Successful in 54s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
fix(demo): lock removing a Steam link on the demo (DM27)
The step-5 walk found demo-player could remove its stand-in Steam link from
its Rust page, and staff could remove it from Admin -> Users. That link is
what the player tour's Rust pages, rewards and grants hang on.

- module.json demoLocks gains DELETE /player/rust/links/:steamId.
- The staff route, DELETE /admin/users/:id/rust/links/:steamId, serves under
  core's users mount, which a module's demoLocks may not name, so its handler
  refuses with DEMO_LOCKED behind ctx.demoMode before the link is touched.

Off the demo both behave as before. Checked on a real core with the demo
seed: both answer 403 DEMO_LOCKED and the link stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:28 -05:00

487 lines
21 KiB
JavaScript

// ── 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' },
])
assert.equal(manifest.coreApi, '^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']])
}
}
})