ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion, which runs the player's own unlinkOwned per link, logs rust.account.unlink with reason 'account deleted', and marks every server's permission sync dirty so the withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8); engagement manifest regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
491 lines
22 KiB
JavaScript
491 lines
22 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' },
|
|
])
|
|
// 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']])
|
|
}
|
|
}
|
|
})
|