Files
Module-Rust/server/test/catalogue.test.js
wtclaude da1d74bdff
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

- gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads
  each row per viewer (the clearer's name for the presence audience, Steam id,
  position and key for staff, `won` for everyone).
- Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only).
- The step rust.gc.place: an event from rust.options.gc_events and a world
  call's location; refused at verify for Guarded Crate absent, NpcSpawn missing
  (D364), console unavailable, or an unknown event. Kept (D373); the run log's
  success line says so (D380). Budget rust.gc.crates.
- Warden (D375): guarded_won, title category guardedwon, Crates cleared column.
- Map marker, server settings line, swagger, engagement manifest, bundle, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:50 -05:00

235 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The boundary, asserted ────────────────────────────────────────────────
//
// `catalogue.js` is the only thing standing between a frame carrying an IP
// address and a public page, so it gets a suite of its own rather than being
// covered incidentally by a route test.
//
// The most valuable test here is the last one: it holds the classification
// against the specification in `docs/rust-link/PROTOCOL.md` §8.4. Without it the
// two drift the first time somebody adds a kind to the protocol, and the drift
// is silent in the direction that matters — a new kind is simply never served,
// until the day somebody "fixes" that by adding it to the wrong list.
const test = require('node:test')
const assert = require('node:assert')
const catalogue = require('../catalogue')
test('an unknown kind is not public — the default is deny', () => {
assert.equal(catalogue.isPublic('player.death'), true)
assert.equal(catalogue.isPublic('something.new'), false)
assert.equal(catalogue.isPublic(''), false)
assert.equal(catalogue.isPublic(undefined), false)
// The shape of the mistake this prevents: a kind a LATER protocol adds, which
// this build ingests happily and would publish on the day it first arrived if
// the filter were a deny list.
assert.equal(catalogue.isKnown('player.location'), false)
assert.equal(catalogue.isPublic('player.location'), false)
})
test('nothing carrying an IP address, a report or an identity is public', () => {
for (const kind of [
'player.login.attempt',
'player.approved',
'player.banned',
'player.unbanned',
'player.reported',
'entity.destroyed',
// Protocol 3. A link request on a public killfeed would tell everyone which
// Steam id is about to become a named website account, and an unlink would
// say when somebody stopped being one.
'account.link.requested',
'account.unlinked',
]) {
assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
}
})
test('a viewer with no kinds asked for gets the allowlist, never everything', () => {
const asPublic = catalogue.kindsFor({})
const asAdmin = catalogue.kindsFor({ admin: true })
// The public view with nothing said about presence is the kinds that name
// nobody — a wipe, a start, a shutdown.
assert.deepEqual(
asPublic,
catalogue.PUBLIC_KINDS.filter((k) => !catalogue.PRESENCE_KINDS.includes(k)),
)
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
// The property that makes the route safe by construction: there is no argument
// a caller can omit that turns the filter off.
assert.ok(asPublic.length > 0)
assert.ok(!asPublic.includes('player.banned'))
})
test('a kind a viewer may not see is dropped, not refused', () => {
const asked = catalogue.kindsFor({ presence: true, requested: ['player.death', 'player.banned'] })
assert.deepEqual(asked, ['player.death'])
// Without the presence audience a death is dropped too.
assert.deepEqual(catalogue.kindsFor({ requested: ['player.death', 'server.wipe'] }), ['server.wipe'])
// Asking for only forbidden kinds answers with nothing to select, which the
// model turns into an empty list — the events are, as far as this viewer is
// concerned, not there.
assert.deepEqual(catalogue.kindsFor({ requested: ['player.banned'] }), [])
// And an admin gets what they asked for.
assert.deepEqual(catalogue.kindsFor({ admin: true, requested: ['player.banned'] }), [
'player.banned',
])
})
test('every kind is classified exactly once', () => {
const seen = new Set()
for (const kind of catalogue.ALL_KINDS) {
assert.ok(!seen.has(kind), `${kind} appears in both lists`)
seen.add(kind)
}
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the event kinds the protocol defines, through protocol 14', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
//
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
const PROTOCOL_4 = [
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
'entity.destroyed',
'player.reported',
'player.banned',
'player.unbanned',
'player.login.attempt',
'player.approved',
'server.wipe',
'server.initialized',
'server.shutdown',
'account.link.requested',
'account.unlinked',
'perm.drift',
// Protocol 6 (§12). Clan membership is members-only (D49), so every one of
// these is staff-class here and reaches members through core's Team feed.
'clan.created',
'clan.disbanded',
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
// Protocol 8 (§14) and protocol 9 (§15): an event's machinery ending on its
// own deadline. Never classified until protocol 13 made `world.expired`
// recognisable (F13) — default deny kept both off public pages meanwhile.
'lease.expired',
'world.expired',
// Protocol 13 (§19). A configuration save's outcome carries the server's
// log tail, which is an operator's console: staff only.
'config.outcome',
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
// registers: an operator's inventory.
// Protocol 13, RunicNPC (runicnpc stage 4): an NPC's death names its killer
// and everyone who hurt it; its health and a placement's change are an
// operator's. All staff.
'npc.died',
'npc.health',
'npc.placement.changed',
'plugin.loaded',
'plugin.unloaded',
// Protocol 13, RunicNPC (runicnpc stage 6): a boss's spawn and death are said to
// the whole server and are public; its phases are told only to players near it.
'npc.boss.spawned',
'npc.boss.phase',
'npc.boss.died',
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
'world.event',
// Protocol 14 (§20.7): Dynamic PVP's zones. The opening is public and names
// nobody; the closing is a staff line (PLAN_REDESIGNS §14.3.3).
'pvp.zone.created',
'pvp.zone.deleted',
// Protocol 14 (§20.8): Dangerous Treasures. The opening and the win are
// public, read per viewer by `dangerousTreasures.js`; the ending is staff's
// (PLAN_REDESIGNS §14.3.4).
'dt.started',
'dt.won',
'dt.ended',
// Protocol 14 (§20.9): Guarded Crate. The landing and the clearing are
// public, read per viewer by `guardedCrate.js`; the timeout is staff's
// (PLAN_REDESIGNS §14.3.5).
'gc.started',
'gc.ended',
'gc.failed',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) {
assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`)
}
assert.equal(catalogue.isPublic('config.outcome'), false, 'a server’s log tail must not be public')
for (const kind of ['plugin.loaded', 'plugin.unloaded', 'lease.expired', 'world.expired', 'npc.boss.phase']) {
assert.equal(catalogue.isPublic(kind), false, `${kind} is staff-only`)
}
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
}
// D353, D376: the world event is public and names nobody below the presence
// audience — the names come off per viewer, so the kind is not a presence kind.
assert.equal(catalogue.isPublic('world.event'), true)
assert.equal(catalogue.isPresence('world.event'), false)
assert.equal(catalogue.isPublic('pvp.zone.created'), true)
assert.equal(catalogue.isPresence('pvp.zone.created'), false)
assert.equal(catalogue.isPublic('pvp.zone.deleted'), false, 'a zone closing is a staff line')
// D376 for Dangerous Treasures: the win names its winner only to the presence
// audience, per viewer, so neither public kind is a presence kind.
for (const kind of ['dt.started', 'dt.won']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line')
for (const kind of ['gc.started', 'gc.ended']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('gc.failed'), false, 'a guarded crate timing out is a staff line')
})
test('every kind that names a player who was on is behind the presence setting', () => {
// The org lead's rule (2026-09-22): nothing tells who is online by default.
// Each of these says a named player was on the server at a given moment.
for (const kind of [
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
'npc.boss.died',
]) {
assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
assert.ok(catalogue.kindsFor({ presence: true }).includes(kind))
}
// A presence kind is a subset of the public ones, never a staff kind widened.
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
// has taken its names off for a viewer below the presence audience (D376).
// Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor
// a guarded crate cleared once `guardedCrate.shapeFor` has.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
})