// ── 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), []) })