Files
Module-Rust/server/test/configEdit.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

237 lines
9.5 KiB
JavaScript

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