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
This commit is contained in:
437
server/test/config.test.js
Normal file
437
server/test/config.test.js
Normal 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 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), [])
|
||||
})
|
||||
236
server/test/configEdit.test.js
Normal file
236
server/test/configEdit.test.js
Normal file
@@ -0,0 +1,236 @@
|
||||
// ── The editor that must not touch what it was not asked to ───────────────
|
||||
//
|
||||
// `configEdit.js` exists for one reason: a config file goes back to the game
|
||||
// host byte-identical except where an admin deliberately changed something. So
|
||||
// the suite is mostly about what does NOT change, and the first test is the one
|
||||
// the whole design is for.
|
||||
//
|
||||
// It is worth being concrete about the failure being prevented. `Rate: 1.0` in
|
||||
// an untouched field, read through `JSON.parse` and written back through
|
||||
// `JSON.stringify`, becomes `Rate: 1`. Newtonsoft may coerce that into a
|
||||
// `float` or may throw; if it throws, the plugin does not come back from its
|
||||
// reload — and R6/R17 make four plugins required, so "ZoneManager is down" is
|
||||
// also "event participation is down".
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const configEdit = require('../configEdit')
|
||||
|
||||
/** A config with every shape that has ever caused trouble. */
|
||||
const SAMPLE = `{
|
||||
"Gather": {
|
||||
"Wood": 1.0,
|
||||
"Stone": 2.50,
|
||||
"Sulfur": 3,
|
||||
"Scale": 1e3
|
||||
},
|
||||
"Enabled": true,
|
||||
"Message": "Welcome, {name}",
|
||||
"Discord Webhook": "https://discord.com/api/webhooks/1/abc",
|
||||
"Zones": ["a", "b"],
|
||||
"Nothing": null,
|
||||
"Empty": []
|
||||
}`
|
||||
|
||||
test('an untouched float keeps its literal — the whole point of this file', () => {
|
||||
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: false }])
|
||||
|
||||
assert.equal(changes.length, 1)
|
||||
assert.match(text, /"Wood": 1\.0/)
|
||||
assert.match(text, /"Stone": 2\.50/)
|
||||
assert.match(text, /"Scale": 1e3/)
|
||||
assert.match(text, /"Enabled": false/)
|
||||
|
||||
// And the proof that the naive implementation would have failed this: the same
|
||||
// document through parse/stringify loses all three.
|
||||
const naive = JSON.stringify(JSON.parse(SAMPLE))
|
||||
assert.match(naive, /"Wood":1,/)
|
||||
assert.doesNotMatch(naive, /2\.50/)
|
||||
assert.doesNotMatch(naive, /1e3/)
|
||||
})
|
||||
|
||||
test('a number is written as the literal an admin typed, not as a Number', () => {
|
||||
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood'], raw: '2.0' }])
|
||||
assert.match(text, /"Wood": 2\.0/)
|
||||
|
||||
// The same edit through a JavaScript number would have produced `2`, which is
|
||||
// a different C# type at the far end.
|
||||
assert.equal(String(2.0), '2')
|
||||
})
|
||||
|
||||
test('everything else in the document is byte-identical', () => {
|
||||
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw: '4' }])
|
||||
|
||||
const before = SAMPLE.split('\n')
|
||||
const after = text.split('\n')
|
||||
|
||||
assert.equal(before.length, after.length)
|
||||
before.forEach((line, i) => {
|
||||
if (line.includes('"Sulfur"')) return
|
||||
assert.equal(after[i], line, `line ${i + 1} changed and should not have`)
|
||||
})
|
||||
})
|
||||
|
||||
test('a literal that is not a JSON number is refused', () => {
|
||||
for (const raw of ['0x10', '', ' ', '1.', '.5', '01', 'NaN', 'Infinity', '1,0', '5; rm -rf /']) {
|
||||
const { error, text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
|
||||
assert.ok(error, `'${raw}' should be refused`)
|
||||
assert.equal(text, undefined)
|
||||
}
|
||||
|
||||
// And the ones that must keep working, because preserving them is the point.
|
||||
for (const raw of ['1.0', '-2', '1e3', '1E-3', '0', '0.5', '123456789012345678']) {
|
||||
const { error } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
|
||||
assert.equal(error, undefined, `'${raw}' should be accepted`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the form cannot change a value KIND — that is the raw tier', () => {
|
||||
assert.match(
|
||||
configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: 'yes' }]).error,
|
||||
/true or false/,
|
||||
)
|
||||
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Message'], value: 7 }]).error, /expected text/)
|
||||
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Zones'], value: 'a' }]).error, /raw tier/)
|
||||
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nothing'], value: 1 }]).error, /raw tier/)
|
||||
})
|
||||
|
||||
test('a string is escaped on the way in', () => {
|
||||
const { text } = configEdit.applyEdits(SAMPLE, [
|
||||
{ pointer: ['Message'], value: 'He said "hi"\nand left\\' },
|
||||
])
|
||||
|
||||
assert.match(text, /"Message": "He said \\"hi\\"\\nand left\\\\"/)
|
||||
// Still JSON, and still the same string coming back out.
|
||||
assert.equal(JSON.parse(text).Message, 'He said "hi"\nand left\\')
|
||||
})
|
||||
|
||||
test('a pointer that is not in the file is refused rather than created', () => {
|
||||
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nope'], value: true }]).error, /not in this file/)
|
||||
assert.match(
|
||||
configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood', 'Deeper'], raw: '1' }]).error,
|
||||
/not in this file/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an edit set that changes nothing writes nothing', () => {
|
||||
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: true }])
|
||||
|
||||
assert.equal(text, SAMPLE)
|
||||
assert.deepEqual(changes, [])
|
||||
})
|
||||
|
||||
test('two edits to the same field in one save are refused', () => {
|
||||
const { error } = configEdit.applyEdits(SAMPLE, [
|
||||
{ pointer: ['Gather', 'Wood'], raw: '1.0' },
|
||||
{ pointer: ['Gather', 'Wood'], raw: '2.0' },
|
||||
])
|
||||
|
||||
assert.match(error, /edited twice/)
|
||||
})
|
||||
|
||||
test('several edits land together, and later offsets are not shifted by earlier ones', () => {
|
||||
const { text, changes } = configEdit.applyEdits(SAMPLE, [
|
||||
{ pointer: ['Gather', 'Wood'], raw: '10.0' },
|
||||
{ pointer: ['Message'], value: 'much longer than it was before' },
|
||||
{ pointer: ['Zones', 1], value: 'bb' },
|
||||
])
|
||||
|
||||
assert.equal(changes.length, 3)
|
||||
const parsed = JSON.parse(text)
|
||||
assert.equal(parsed.Gather.Wood, 10)
|
||||
assert.equal(parsed.Message, 'much longer than it was before')
|
||||
assert.deepEqual(parsed.Zones, ['a', 'bb'])
|
||||
assert.match(text, /"Wood": 10\.0/)
|
||||
})
|
||||
|
||||
test('a locked key cannot be edited, and the refusal names it (D38)', () => {
|
||||
const own = '{\n "Host": "127.0.0.1",\n "Port": 7799,\n "QueueCap": 5000\n}'
|
||||
const locked = ['Host', 'Port', 'ServerId']
|
||||
|
||||
assert.match(configEdit.applyEdits(own, [{ pointer: ['Port'], raw: '1' }], { locked }).error, /Port/)
|
||||
assert.match(
|
||||
configEdit.applyEdits(own, [{ pointer: ['Host'], value: '10.0.0.5' }], { locked }).error,
|
||||
/cannot be edited/,
|
||||
)
|
||||
|
||||
// Everything else in the bridge's own config stays editable, which is the
|
||||
// half of D38 that is easy to lose.
|
||||
const { text } = configEdit.applyEdits(own, [{ pointer: ['QueueCap'], raw: '9000' }], { locked })
|
||||
assert.match(text, /"QueueCap": 9000/)
|
||||
})
|
||||
|
||||
test('a secret is flagged by WORD, not by substring', () => {
|
||||
for (const key of ['ApiKey', 'Discord Webhook', 'steam_api_key', 'Token', 'Password', 'authToken']) {
|
||||
assert.equal(configEdit.isSecretKey(key), true, `${key} should be a secret`)
|
||||
}
|
||||
|
||||
// The false positives a substring match would produce, and they matter: a
|
||||
// form that masks a third of every config teaches an operator to ignore the
|
||||
// mask, which is worse than not masking.
|
||||
for (const key of ['Monkey', 'Keybind', 'Passive Mode', 'Authority', 'Keycards Allowed']) {
|
||||
assert.equal(configEdit.isSecretKey(key), false, `${key} should not be a secret`)
|
||||
}
|
||||
|
||||
// A genuinely ambiguous one, resolved toward masking on purpose: a field
|
||||
// called `Keys` is a credential often enough, and the cost of being wrong is
|
||||
// a field an admin has to click to read rather than a credential on a page.
|
||||
assert.equal(configEdit.isSecretKey('Keys'), true)
|
||||
})
|
||||
|
||||
test("a secret's values never reach the audit trail, though the change is recorded", () => {
|
||||
const { changes } = configEdit.applyEdits(SAMPLE, [
|
||||
{ pointer: ['Discord Webhook'], value: 'https://discord.com/api/webhooks/2/def' },
|
||||
])
|
||||
|
||||
assert.equal(changes.length, 1)
|
||||
assert.equal(changes[0].path, 'Discord Webhook')
|
||||
assert.equal(changes[0].from, '***')
|
||||
assert.equal(changes[0].to, '***')
|
||||
assert.equal(changes[0].secret, true)
|
||||
})
|
||||
|
||||
test('the form description says which fields it cannot draw, and why', () => {
|
||||
const fields = configEdit.describe(configEdit.scan(SAMPLE))
|
||||
const by = (path) => fields.find((f) => f.path === path)
|
||||
|
||||
assert.equal(by('Gather.Wood').type, 'number')
|
||||
assert.equal(by('Gather.Wood').raw, '1.0')
|
||||
assert.equal(by('Enabled').type, 'boolean')
|
||||
assert.equal(by('Zones[0]').value, 'a')
|
||||
assert.equal(by('Discord Webhook').secret, true)
|
||||
|
||||
// The three things a value cannot tell us anything about.
|
||||
assert.equal(by('Nothing').advanced, true)
|
||||
assert.equal(by('Empty').advanced, true)
|
||||
assert.ok(by('Nothing').reason)
|
||||
assert.ok(by('Empty').reason)
|
||||
})
|
||||
|
||||
test('a subtree past the depth limit is advanced-only rather than half-drawn', () => {
|
||||
const deep = '{"a":{"b":{"c":{"d":{"e":{"f":{"g":1}}}}}}}'
|
||||
const fields = configEdit.describe(configEdit.scan(deep), { maxDepth: 3 })
|
||||
|
||||
const past = fields.find((f) => f.path === 'a.b.c')
|
||||
assert.equal(past.advanced, true)
|
||||
assert.equal(fields.some((f) => f.path.startsWith('a.b.c.')), false)
|
||||
})
|
||||
|
||||
test('a document that is not JSON is refused with a position', () => {
|
||||
assert.throws(() => configEdit.scan('{"a": }'), /offset/)
|
||||
assert.throws(() => configEdit.scan('{"a": 1,}'), /expected a key/)
|
||||
assert.throws(() => configEdit.scan('{} trailing'), /trailing content/)
|
||||
assert.throws(() => configEdit.scan('{"a": "unterminated'), /unterminated/)
|
||||
|
||||
const { error } = configEdit.applyEdits('{ not json', [{ pointer: ['a'], value: true }])
|
||||
assert.match(error, /not valid JSON/)
|
||||
})
|
||||
|
||||
test('escapes and unicode survive a scan of a document nobody edited', () => {
|
||||
const text = '{"a":"tab\\there","b":"\\u00e9\\u0041","c":"slash\\/"}'
|
||||
const root = configEdit.scan(text)
|
||||
const values = Object.fromEntries(root.children.map((c) => [c.key, c.value]))
|
||||
|
||||
assert.deepEqual(values, JSON.parse(text))
|
||||
})
|
||||
Reference in New Issue
Block a user