Files
Module-Rust/server/test/config.test.js
wtclaude e54ae3afb9
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s
feat(rust): mod configuration from the site, and an editor that will not rewrite a float
R18's two tiers: a form generated from a config file's own values, and raw JSON
for what a form cannot express. Admin → Rust mod config, one live round trip per
action, nothing cached between a browser and a game host's disk.

`configEdit.js` is the part that could not be done naively. JavaScript cannot
tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C#
classes — so a read-modify-write silently rewrites every whole-numbered float as
an integer on fields nobody touched, and a plugin that then throws at load does
not come back. It never parses, mutates and re-serialises: it records the SOURCE
SPAN of every value and splices literals into them, so an untouched `1.0` is
still `1.0` and a number an admin types travels as text the whole way (D35/D36).

The bridge's own config is editable with `Host`, `Port` and `ServerId` locked,
in the form and in the raw tier, because either would cut the link carrying the
edit or strand every row this site holds (D38). Credentials render masked with a
reveal; the raw tier shows them (D37) and the audit trail never does.

`rust_config_writes` records every save including the refused and the rolled
back — an operator asking why a setting is not what they set needs to see that
somebody tried.

Three defects a browser walk found that 179 green tests did not:

* every save of the bridge's own config was refused while the page said the
  opposite — a `<select>` whose value matches no `<option>` shows the first one,
  so the reload guess `RunicGateway` was on the wire and "nothing" was on the
  screen;
* `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every
  secondary button in this module has rendered as a primary one since phase 7 —
  here it made the open file and the active tier indistinguishable;
* a save's refusal rendered at the top of a long form, far from the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:55:28 -05:00

438 lines
14 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 bridges own file is recognised by the plugins 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), [])
})