Files
Module-Rust/server/test/events.test.js
wtclaude f211969ee1
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 7m57s
feat: ingest protocol 2, and keep the record a wipe cannot erase
The module half of the read path. Seven tables, an ingest cursor, four public
routes, and one file whose only job is deciding who may see what.

**The record and the window are different things.** `rust_player_wipe_stats` and
`rust_gather_totals` are permanent and per-wipe, so all-time is those rows SUMmed
rather than a second set of counters that can disagree with them — that is R12's
"per-wipe detail plus all-time rollups" in one table instead of two.
`rust_events` is a bounded 30-day window of raw frames for the killfeed, and
`rust_presence` is a board: replaced wholesale, never appended.

**The feed is a cursor, not a socket, and the header says why.** Core runs Node
20, where a global WebSocket is still behind a flag, so a socket means taking
`ws` — against a release that asserts it has no runtime dependencies (D5). The
deciding argument is the other one though: a socket needs a cursor anyway, for
whatever it missed while the module was restarting, and the catch-up path is the
one that has to be right. A cursor alone is one mechanism exercised every five
seconds rather than two where the second only runs after an outage.

**The cursor advances after the batch, never before.** A crash between the two
re-reads events already counted, which inflates a total; the other order loses
them silently and for ever. One is visible and bounded, the other is invisible
and permanent, so the code fails in the visible direction. A server with no
cursor starts at the sidecar's current END rather than at zero — replaying a
fortnight of deaths into stats for wipes the site never saw is not a catch-up.

**`catalogue.js` is a security boundary, default-deny.** Protocol 2 carries IP
addresses (login attempts, approvals, bans), one player's report about another,
and the grid reference of somebody's base. They are stored, because an operator
chasing ban evasion needs them; they are not served below the admin tier. The
allowlist lives here rather than as a field on the wire, because a boundary
declared by the sender is one a compromised or merely out-of-date game host can
widen — the same reason core's own shard fan-out filters on the serving side. A
kind this build has never heard of is not public, and a test holds the list
against PROTOCOL.md §8.4 so that adding a kind to the protocol without
classifying it fails a build.

`PROTOCOL_VERSION` goes to 2 here in the same change as the emitters, though this
module consumes none of the new frames yet: the sidecar refuses a mismatched
client with a 409, so a module left on 1 would stop being able to read the board
it has been reading all along. A constant that lags the deployment is an outage
with a version number on it.

95 server tests, 20 client tests, every guard green, and `routes.manifest.json`
regenerated against a real core at the pinned ref: 10 routes, all documented,
none of core's moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:37:16 -05:00

145 lines
4.6 KiB
JavaScript

// ── The read path's logic ─────────────────────────────────────────────────
//
// The model decides what a caller gets. Two properties are worth more than the
// rest, and both are about a caller who did something slightly wrong:
//
// • a route that forgets to say who is asking gets the PUBLIC view;
// • a caller asking for a million rows gets two hundred.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore() {
require('../core')._reset()
require('../core').init(fakeCtx())
}
test('the limit is bounded, whatever was asked for', () => {
withCore()
const model = require('../model/events/events.model')
assert.equal(model.boundedLimit(10), 10)
assert.equal(model.boundedLimit(undefined), 50)
assert.equal(model.boundedLimit('nonsense'), 50)
assert.equal(model.boundedLimit(-5), 50)
assert.equal(model.boundedLimit(0), 50)
assert.equal(model.boundedLimit(1e9), model.MAX_LIMIT)
assert.equal(model.boundedLimit(12.9), 12)
})
test('kinds parse from one name or a list, and nothing means "not specified"', () => {
withCore()
const model = require('../model/events/events.model')
assert.deepEqual(model.parseKinds('player.death'), ['player.death'])
assert.deepEqual(model.parseKinds('player.death, player.chat'), ['player.death', 'player.chat'])
// Null rather than an empty list: "I did not ask" and "I asked for nothing"
// are different, and only the first means "whatever I am allowed".
assert.equal(model.parseKinds(''), null)
assert.equal(model.parseKinds(undefined), null)
assert.equal(model.parseKinds(' , , '), null)
})
test('a reader who does not say who they are gets the public view', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
const original = db.recentEvents
let asked = null
db.recentEvents = async (args) => {
asked = args
return []
}
try {
await model.recent({ serverId: 'main' })
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
assert.ok(asked.kinds.includes('player.death'))
await model.recent({ serverId: 'main', admin: true })
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
} finally {
db.recentEvents = original
}
})
test('asking only for kinds you may not see answers with nothing, and queries nothing', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
const original = db.recentEvents
let called = false
db.recentEvents = async () => {
called = true
return []
}
try {
const rows = await model.recent({ serverId: 'main', kind: 'player.banned,player.approved' })
assert.deepEqual(rows, [])
assert.equal(called, false, 'a query with no permitted kinds must not reach the database')
} finally {
db.recentEvents = original
}
})
test('a row whose stored frame will not parse still answers with its envelope', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
const original = db.recentEvents
db.recentEvents = async () => [
{ id: 7, kind: 'player.death', t: 12, wipeId: 'w-1', steamId: 'p1', raw: '{not json' },
]
try {
const [row] = await model.recent({ serverId: 'main' })
// One unreadable row must not fail a whole page. What is known is still
// reported; the body is empty rather than absent.
assert.equal(row.id, 7)
assert.equal(row.kind, 'player.death')
assert.deepEqual(row.frame, {})
} finally {
db.recentEvents = original
}
})
test('the leaderboard answers numbers, never nulls', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
const original = db.leaderboard
// SUM() over no rows is NULL in SQL, and a JOIN with no player row gives a
// null name. A page that has to defend against both is a page with the
// defence in three places.
db.leaderboard = async () => [
{ steamId: 'p1', name: null, kills: null, deaths: '3', npcKills: null, playtimeSec: null },
]
try {
const [row] = await model.leaderboard({ serverId: 'main' })
assert.equal(row.kills, 0)
assert.equal(row.deaths, 3)
assert.equal(row.npcKills, 0)
assert.equal(row.playtimeSec, 0)
assert.equal(row.name, null)
} finally {
db.leaderboard = original
}
})