Files
Module-Rust/server/test/servers.test.js
wtclaude 0670341198 feat(rust): slash commands and the next wipe, server half (phase 16)
Five read-only commands registered with api.registerSlashCommands:
/status, /wipe, /top, /online and /clan (D126). Every refusal is private,
and any answer narrower than public (online names, a clan roster) goes
to the caller alone (D127). No command asks a sidecar.

The next wipe (D128, D130): six nullable columns on rust_servers, a pure
nextWipe(row, now) with the zone arithmetic through Intl, computed on
every read. The public server shape gains nextWipe; the admin shape
gains the stored schedule; PUT /admin/rust/servers/:id takes the six
fields and writes them only when wipeRule is present.

server/commands joins ci/bundle.json, which checkBundle caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:29:59 -05:00

247 lines
9.9 KiB
JavaScript

// ── The servers model ─────────────────────────────────────────────────────
//
// No database and no express: the model takes rows and produces the shapes the
// three tiers answer with, which is the whole reason the SQL lives in a separate
// file from the logic.
//
// Two things here are worth more than the rest: **a token never leaves this
// module**, and **a stale row cannot claim a server is up**.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(ctx = fakeCtx()) {
require('../core')._reset()
require('../core').init(ctx)
return ctx
}
const NOW = Date.parse('2026-09-15T12:00:00Z')
const serverRow = (over = {}) => ({
id: 'main',
name: 'Main · Vanilla',
sidecarBaseUrl: 'http://10.0.0.5:8090',
sidecarTokenEnc: 'enc:s3cret',
protocol: 1,
enabled: 1,
sortOrder: 0,
...over,
})
const stateRow = (over = {}) => ({
serverId: 'main',
reachable: 1,
online: 1,
players: 42,
maxPlayers: 100,
hostname: 'Runic Gateway · Main',
level: 'Procedural Map',
seed: 1234,
worldSize: 4000,
bootId: 'boot-20260915T194502Z',
protocol: 1,
updatedAt: new Date(NOW - 10_000).toISOString(),
...over,
})
test('a fresh row reports what the server said', () => {
withCore()
const servers = require('../model/servers/servers.model')
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
assert.strictEqual(shaped.online, true)
assert.strictEqual(shaped.players, 42)
assert.strictEqual(shaped.stale, false)
assert.strictEqual(shaped.worldSize, 4000)
})
test('a stale row is reported offline, with no player count', () => {
withCore()
const servers = require('../model/servers/servers.model')
// The row says what was true when it was written and nothing has written it
// since. Reporting its player count would put a number on a page that is
// simply the last number anyone saw, with no way for a reader to tell.
const old = stateRow({ updatedAt: new Date(NOW - servers.STALE_AFTER_MS - 1000).toISOString() })
const shaped = servers.shapePublic(serverRow(), old, NOW)
assert.strictEqual(shaped.stale, true)
assert.strictEqual(shaped.online, false)
assert.strictEqual(shaped.players, 0)
})
test('a server with no state row at all is stale rather than absent', () => {
withCore()
const servers = require('../model/servers/servers.model')
// A configured server nothing has polled yet. It belongs on the page — an
// operator added it on purpose — and it must not claim to be online.
const shaped = servers.shapePublic(serverRow(), undefined, NOW)
assert.strictEqual(shaped.id, 'main')
assert.strictEqual(shaped.stale, true)
assert.strictEqual(shaped.online, false)
assert.strictEqual(shaped.updatedAt, null)
})
test('the public shape carries nothing about the sidecar', () => {
withCore()
const servers = require('../model/servers/servers.model')
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
// Asserted over the WHOLE object rather than by naming the two fields that
// would be worst: the failure this guards against is a field added later, by
// someone who did not read this file, and an allowlist is the only assertion
// that catches one.
assert.deepStrictEqual(Object.keys(shaped).sort(), [
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'nextWipe', 'online', 'players', 'seed', 'stale',
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
])
})
test('"last reported" is when a frame arrived, not when we last polled', () => {
withCore()
const servers = require('../model/servers/servers.model')
// The defect the phase-4 page walk found, in one assertion. A refresh that
// cannot reach a sidecar still writes `updated_at` — it has to, because that is
// what staleness is computed from — and a page reading it as "last reported"
// told a reader that a server which had been down for days had reported just
// now, every thirty seconds, for as long as it stayed down.
const state = stateRow({
online: 0,
reachable: 0,
updatedAt: new Date(NOW - 5_000).toISOString(),
lastSeenAt: new Date(NOW - 3 * 86400_000).toISOString(),
})
const shaped = servers.shapePublic(serverRow(), state, NOW)
assert.strictEqual(shaped.lastSeenAt, new Date(NOW - 3 * 86400_000).toISOString())
assert.strictEqual(shaped.stale, false, 'the row itself is fresh — it was written five seconds ago')
assert.strictEqual(shaped.online, false)
// A server nothing has ever heard from has no such moment, and `null` is what
// a page renders as "never" rather than as the epoch.
assert.strictEqual(servers.shapePublic(serverRow(), undefined, NOW).lastSeenAt, null)
})
test('the public shape carries the current wipe, from the state row', () => {
withCore()
const servers = require('../model/servers/servers.model')
// The wipe id on the STATE row, not the newest row in `rust_wipes`. The two
// usually agree, and the state row is the one that is right when they do not:
// the wipe list is derived from events that have been ingested, so a server
// that has just wiped and said nothing since has a new id here and no row there.
const shaped = servers.shapePublic(serverRow(), stateRow({ wipeId: 'w-2026-09', saveCreatedAt: '2026-09-04T18:00:00Z' }), NOW)
assert.strictEqual(shaped.wipeId, 'w-2026-09')
assert.strictEqual(shaped.wipedAt, '2026-09-04T18:00:00Z')
// A server nothing has polled yet has no wipe, and `null` is the honest answer
// — an empty string would be sent back as `?wipe=`, which asks a different
// question and answers nothing.
const never = servers.shapePublic(serverRow(), undefined, NOW)
assert.strictEqual(never.wipeId, null)
assert.strictEqual(never.wipedAt, null)
})
test('a disabled server is not there, rather than forbidden', async () => {
withCore()
const db = require('../model/servers/servers.db')
const model = require('../model/servers/servers.model')
const originalServer = db.getServer
const originalState = db.getState
db.getState = async () => stateRow()
try {
// The detail route is the only one under `/servers/:id` that can say "no such
// server" — the other four answer an empty list, because an unknown id
// genuinely has no events. So what `null` means here decides what a page
// renders, and a disabled server and a missing one must mean the same thing:
// an operator who switched a server off did not switch it into a 403.
db.getServer = async () => ({ ...serverRow(), enabled: 0 })
assert.strictEqual(await model.getPublic('main', NOW), null)
db.getServer = async () => null
assert.strictEqual(await model.getPublic('nope', NOW), null)
// And an id nobody asked about never reaches the database.
let asked = false
db.getServer = async () => { asked = true; return null }
assert.strictEqual(await model.getPublic('', NOW), null)
assert.strictEqual(asked, false)
db.getServer = async () => serverRow()
const server = await model.getPublic('main', NOW)
assert.strictEqual(server.id, 'main')
assert.strictEqual(server.online, true)
assert.ok(!Object.prototype.hasOwnProperty.call(server, 'sidecarBaseUrl'))
} finally {
db.getServer = originalServer
db.getState = originalState
}
})
test('the admin shape reports whether a token is stored, never the token', () => {
withCore()
const servers = require('../model/servers/servers.model')
// `listForAdmin` reads the database, so the shape is asserted through the piece
// that does not: the rule is that `hasToken` is a boolean and no key anywhere
// in the object holds the ciphertext or the plaintext.
const row = serverRow()
const shaped = {
...servers.shapePublic(row, stateRow(), NOW),
sidecarBaseUrl: row.sidecarBaseUrl,
hasToken: Boolean(row.sidecarTokenEnc),
}
assert.strictEqual(shaped.hasToken, true)
const serialised = JSON.stringify(shaped)
assert.ok(!serialised.includes('s3cret'), 'the plaintext token reached a response shape')
assert.ok(!serialised.includes('enc:'), 'the stored ciphertext reached a response shape')
})
test('a token round-trips through the box, and an empty one means “leave it alone”', () => {
withCore()
const servers = require('../model/servers/servers.model')
const enc = servers.encryptToken('s3cret')
assert.notStrictEqual(enc, 's3cret')
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: enc })).token, 's3cret')
// All three spellings of "the operator did not type a new token". The admin
// form can only ever show a blank field, so it posts one on every save that did
// not intend to change the credential — and writing that through would erase
// the token every time somebody renamed a server.
assert.strictEqual(servers.encryptToken(''), null)
assert.strictEqual(servers.encryptToken(null), null)
assert.strictEqual(servers.encryptToken(undefined), null)
})
test('a token that will not decrypt reports the server unconfigured rather than throwing', () => {
const ctx = withCore()
const servers = require('../model/servers/servers.model')
// The usual cause is a `SECRET_ENC_KEY` that changed. One server's unreadable
// credential must not be able to fail the poll for the other five, and it must
// not fail `onBoot` — which would make the whole module `startup_failed`.
const shaped = servers.withToken(serverRow({ sidecarTokenEnc: 'not-encrypted-by-this-box' }))
assert.strictEqual(shaped.token, null)
assert.strictEqual(shaped.baseUrl, 'http://10.0.0.5:8090')
const errors = ctx.logs.flatMap((l) => l.log.error.calls)
assert.strictEqual(errors.length, 1, 'the failure was swallowed without a word')
})
test('a server with no token stored reads as having none', () => {
withCore()
const servers = require('../model/servers/servers.model')
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: null })).token, null)
})