Files
Module-Rust/server/test/config.test.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 07:42:24 -05:00

579 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Configuration from the site, above the editor ─────────────────────────
//
// `configEdit.test.js` covers the bytes. This covers the decisions made around
// them, and each of these is a way the feature could look fine and be wrong:
//
// • a save that never re-reads the host writes a browser's stale copy over
// somebody else's edit;
// • the raw tier is a whole document, so a locked key can change without
// anything resembling an edit to a field (D38);
// • a rollback is a round trip that WORKED, carrying bad news, and reporting
// it as a failure throws away the only diagnosis there is;
// • a refusal that is never recorded leaves the operator asking why a setting
// is not what they set, with nothing to read.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(overrides = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1, insertId: 1 })
},
pool: {},
},
...overrides,
}),
)
return queries
}
/** A response double that records what a controller decided. */
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
}
const FILE = `{
"Gather": { "Wood": 1.0 },
"Enabled": true
}`
const OWN = `{
"Host": "127.0.0.1",
"Port": 7799,
"QueueCap": 5000,
"ServerId": "main"
}`
/** Stubs the four calls this controller can make, and records them. */
function stubSidecar({ file = FILE, self = 'RunicGateway', write } = {}) {
const sidecar = require('../sidecarClient')
const calls = []
sidecar.configFile = async (server, asked) => {
calls.push(['read', asked])
const text = asked === 'RunicGateway.json' ? OWN : file
return {
ok: true,
status: 'ok',
data: { kind: 'config.file', path: asked, text, version: `v-${asked}`, bytes: text.length },
}
}
sidecar.configFiles = async () => {
calls.push(['list'])
return {
ok: true,
status: 'ok',
data: {
kind: 'config.catalogue',
root: '/home/container/oxide/config',
self,
files: [
{ path: 'ZoneManager.json', bytes: 40, editable: true, plugin: 'ZoneManager' },
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
{ path: 'Huge.json', bytes: 9e6, editable: false, reason: 'larger than this bridge will carry', plugin: 'Huge' },
],
plugins: [{ name: 'ZoneManager', title: 'Zone Manager', version: '3.1.14' }],
truncated: false,
},
}
}
sidecar.configWrite = async (server, body) => {
calls.push(['write', body])
return write || { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
}
return calls
}
/** The controller's own server lookup, satisfied without a database. */
function stubServer(row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 5 }) {
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
serversDb.getServer = async () => row
servers.withToken = () => (row ? { id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' } : null)
}
function controller() {
return require('../router/admin/config.controller')
}
test('the catalogue is grouped by plugin, and an unloaded one is marked rather than dropped', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
const zone = res.body.plugins.find((p) => p.plugin === 'ZoneManager')
const bridge = res.body.plugins.find((p) => p.plugin === 'RunicGateway')
const huge = res.body.plugins.find((p) => p.plugin === 'Huge')
assert.equal(zone.loaded, true)
assert.equal(zone.version, '3.1.14')
// Not loaded, still listed. A config that vanished from the page would read
// as "the bridge cannot see it", which is a much more alarming problem than
// the true one.
assert.equal(huge.loaded, false)
assert.equal(huge.files[0].editable, false)
assert.ok(huge.files[0].reason)
// The bridge's own config is named as such, because it is the one plugin that
// cannot be reloaded from here.
assert.equal(bridge.isBridge, true)
assert.equal(res.body.root, '/home/container/oxide/config')
})
test('a save re-reads the host and refuses a stale version with the current file', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: { path: 'ZoneManager.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] },
},
res,
)
assert.equal(res.statusCode, 409)
// The current file comes back, so a person can merge their change rather than
// retype it from memory.
assert.equal(res.body.current.version, 'v-ZoneManager.json')
assert.match(res.body.message, /changed on the server/)
})
test('a form edit is spliced into what is on disk NOW, and sent as whole text', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
const [, body] = calls.find((c) => c[0] === 'write')
assert.equal(body.files.length, 1)
assert.equal(body.reload, 'ZoneManager')
assert.equal(body.files[0].version, 'v-ZoneManager.json')
assert.match(body.files[0].text, /"Enabled": false/)
// The untouched float, which is the entire reason this path exists.
assert.match(body.files[0].text, /"Wood": 1\.0/)
assert.equal(res.statusCode, 200)
assert.equal(res.body.changed, true)
assert.equal(res.body.report.reloaded, true)
})
test('a save that changes nothing does not reach the game at all', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: true }],
},
},
res,
)
assert.equal(res.body.changed, false)
// A write would have spent a reload, and a reload is the one part of this
// feature that can take a plugin down.
assert.equal(calls.some((c) => c[0] === 'write'), false)
})
test('the raw tier cannot change a locked key, even though it sends a whole document (D38)', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('7799', '9999'),
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /Port/)
assert.equal(calls.some((c) => c[0] === 'write'), false)
// And the rest of our own config is still editable, which is the half of D38
// that is easy to lose.
const ok = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('5000', '9000'),
},
},
ok,
)
assert.equal(ok.statusCode, 200)
assert.equal(ok.body.changed, true)
})
test('a rollback is a 200 carrying bad news, and the log line survives to the admin', async () => {
const queries = withCore()
stubSidecar({
write: {
ok: true,
status: 'ok',
data: {
kind: 'config.report',
ok: false,
reloaded: false,
rolledBack: true,
reason: "'ZoneManager' did not reload within 4s",
log: 'Error while compiling ZoneManager: expected , at line 14',
files: [{ path: 'ZoneManager.json', version: 'v-restored' }],
},
},
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 200)
assert.equal(res.body.report.rolledBack, true)
assert.match(res.body.report.log, /line 14/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit, 'a rollback must be recorded')
assert.ok(audit.params.includes('rolled-back'))
})
test('a refusal from the game is recorded too, with its own status', async () => {
const queries = withCore()
stubSidecar({
write: { ok: true, status: 'ok', data: { kind: 'config.error', reason: 'reload-self' } },
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'RunicGateway',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /cannot be reloaded from the website/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit && audit.params.includes('refused'), 'a refusal is part of the audit trail')
})
test('a server that cannot be reached answers 503 with the reason, and records the attempt', async () => {
const queries = withCore()
stubSidecar({ write: { ok: false, status: 'http-503', data: null } })
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 503)
assert.match(res.body.message, /not connected/)
assert.ok(queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes')))
})
test('a path that could not have come from the host is refused before any round trip', async () => {
withCore()
const calls = stubSidecar()
stubServer()
for (const path of ['../oxide/data/oxide.users.data', '/etc/passwd', 'C:/x.json', 'notjson.txt', '']) {
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
assert.equal(res.statusCode, 400, `${path} should be refused`)
}
assert.equal(calls.length, 0, 'nothing malformed should cost a round trip')
// And the shapes a host really does list.
for (const path of ['Kits.json', 'Kits/kits.json', 'My Mod/sub dir/file.json']) {
assert.equal(require('../model/config/config.model').isPlausiblePath(path), true, path)
}
})
test('the audit trail records which fields changed, and never a credential', async () => {
const queries = withCore()
stubSidecar({ file: '{\n "Discord Webhook": "https://hooks/1",\n "Enabled": true\n}' })
stubServer()
await controller().writeFile(
{
params: { serverId: 'main' },
user: { id: 7 },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Discord Webhook'], value: 'https://hooks/2' }],
},
},
fakeRes(),
)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
const changes = audit.params.find((p) => typeof p === 'string' && p.startsWith('['))
assert.match(changes, /Discord Webhook/)
assert.ok(!changes.includes('hooks/1'), 'the old credential must not be recorded')
assert.ok(!changes.includes('hooks/2'), 'the new credential must not be recorded')
assert.ok(audit.params.includes(7), 'the person who did it is recorded')
})
test('a file that is already broken on disk still opens, in the tier that can fix it', () => {
const model = require('../model/config/config.model')
const shaped = model.shapeFile(
{ path: 'Broken.json', text: '{ "a": }', version: 'v1', bytes: 8 },
{ self: 'RunicGateway' },
)
assert.equal(shaped.fields, null)
assert.ok(shaped.parseError, 'the reason it cannot be drawn is part of the answer')
assert.equal(shaped.text, '{ "a": }')
})
test('the bridge’s own file is recognised by the plugin’s name, not by a filename we matched', () => {
const model = require('../model/config/config.model')
assert.equal(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('RunicGateway/extra.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('ZoneManager.json', 'RunicGateway'), false)
// Renamed on the host: the lock follows the plugin, which is the only thing
// that knows what it is called.
assert.equal(model.isBridgeConfig('Bridge.json', 'Bridge'), true)
assert.deepEqual(model.lockedKeysFor('Bridge.json', 'Bridge'), model.LOCKED_KEYS)
// And a host that said nothing about itself locks nothing, rather than
// locking everything or guessing.
assert.deepEqual(model.lockedKeysFor('RunicGateway.json', null), [])
})
// ── Protocol 13: the save that answers before its reload does (F9, D179) ──
test('a save the plugin is still reloading is recorded as reloading, with the id the page polls', async () => {
const queries = withCore()
stubSidecar({
write: {
ok: true,
status: 'ok',
data: {
kind: 'config.report',
ok: true,
reloaded: false,
rolledBack: false,
pending: true,
writeId: 'w-1',
reload: 'ZoneManager',
ceilingMs: 30000,
files: [{ path: 'ZoneManager.json', version: 'v-written' }],
},
},
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 200)
assert.equal(res.body.pending, true)
assert.deepEqual(res.body.write, { id: 1, writeId: 'w-1' })
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit.params.includes('reloading'))
assert.ok(audit.params.includes('w-1'), 'the plugin’s write id is what ingest settles the row by')
// Two ceilings — the edit's reload and a restore's — plus slack, as seconds
// on the database's clock.
assert.equal(audit.params[audit.params.length - 1], 80)
assert.match(audit.sql, /DATE_ADD\(NOW\(\), INTERVAL \? SECOND\)/)
})
test('a config.outcome settles only the write it names, on the server that sent it, and only once', async () => {
const queries = withCore()
const ingest = require('../ingest')
await ingest.apply('main', {
kind: 'config.outcome',
frame: {
kind: 'config.outcome',
type: 'event',
serverId: 'main',
writeId: 'w-1',
reload: 'Kits',
ok: false,
reloaded: false,
rolledBack: true,
restored: true,
reason: "'Kits' failed to load: Failed to initialize plugin 'Kits v4.4.9'",
log: 'Failed to initialize plugin ...',
files: [{ path: 'Kits.json', version: 'v-restored' }],
},
})
const settle = queries.find((q) => q.sql.startsWith('UPDATE rust_config_writes'))
assert.ok(settle, 'the outcome must settle the row')
assert.match(settle.sql, /WHERE server_id = \? AND write_id = \? AND outcome = 'reloading'/)
assert.deepEqual(settle.params.slice(0, 4), ['rolled-back', 0, 1, 'v-restored'])
assert.deepEqual(settle.params.slice(-2), ['main', 'w-1'])
})
test('an outcome is applied when the edit stands, and rolled-back says whether the plugin came back', () => {
const model = require('../model/config/config.model')
const applied = model.outcomeOf({ writeId: 'w', reloaded: true, rolledBack: false, files: [{ path: 'a.json', version: 'v2' }] })
assert.equal(applied.outcome, 'applied')
assert.equal(applied.restored, null)
assert.equal(applied.versionAfter, 'v2')
// The framework never reloaded it: the file stands, so it is applied, and the
// reason is what says it is not in effect yet.
const standing = model.outcomeOf({ writeId: 'w', reloaded: false, rolledBack: false, reason: 'the file stands', files: [] })
assert.equal(standing.outcome, 'applied')
assert.equal(standing.detail, 'the file stands')
const down = model.outcomeOf({ writeId: 'w', rolledBack: true, restored: false, files: [] })
assert.equal(down.outcome, 'rolled-back')
assert.equal(down.restored, false)
assert.equal(model.outcomeOf({ rolledBack: true }), null, 'an outcome naming no write settles nothing')
assert.equal(model.settleSeconds(null), 80, 'a plugin that sent no ceiling gets the one protocol 13 shipped with')
})
test('a write still reloading past its deadline reads as lost, and a settled one as itself', () => {
const db = require('../model/config/config.db')
const base = { id: 7, outcome: 'reloading', reloaded: 0, restored: null, changes: null }
assert.equal(db.shapeWrite({ ...base, lost: 1 }).outcome, 'lost')
assert.equal(db.shapeWrite({ ...base, lost: 0 }).outcome, 'reloading')
assert.equal(db.shapeWrite({ ...base, outcome: 'rolled-back', restored: 1, lost: 0 }).restored, true)
assert.equal('lost' in db.shapeWrite({ ...base, lost: 0 }), false, 'the flag is folded into outcome, not leaked')
})
test('the page’s poll answers 404 for a write that is not on that server', async () => {
withCore()
const res = fakeRes()
await controller().writeStatus({ params: { serverId: 'main', writeId: '9' } }, res)
assert.equal(res.statusCode, 404)
})
// ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ────────────────
test('a path names its plugin, and a RaidableBases profile names RaidableBases, not "@data" (W8)', () => {
const model = require('../model/config/config.model')
assert.strictEqual(model.pluginOf('@data/RaidableBases/Profiles/Hard Bases.json'), 'RaidableBases')
assert.strictEqual(model.pluginOf('Kits.json'), 'Kits')
assert.strictEqual(model.pluginOf('RaidableBases/Profiles.json'), 'RaidableBases')
assert.strictEqual(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
assert.strictEqual(model.isBridgeConfig('@data/RaidableBases/Profiles/x.json', 'RunicGateway'), false)
})
test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => {
const model = require('../model/config/config.model')
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
for (const bad of ['@data/RaidableBases/Profiles/../x.json', '@data/RaidableBases/Profiles/a/b.json', '@data/Kits/data.json', '@data/RaidableBases/Profiles/x.txt', '@data/RaidableBases/Profiles/.json']) {
assert.strictEqual(model.isPlausiblePath(bad), false, bad)
}
const shaped = model.shapeCatalogue({ files: [{ path: '@data/RaidableBases/Profiles/Hard Bases.json', plugin: 'RaidableBases', bytes: 10, editable: true, profile: true }], plugins: [{ name: 'RaidableBases' }] })
assert.strictEqual(shaped.plugins[0].files[0].profile, true)
})