feat(rust): mod configuration from the site, and an editor that will not rewrite a float
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

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
This commit is contained in:
2026-09-22 08:55:28 -05:00
parent b1abd87c3d
commit e54ae3afb9
21 changed files with 2862 additions and 21 deletions

437
server/test/config.test.js Normal file
View File

@@ -0,0 +1,437 @@
// ── 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), [])
})