Files
Module-Rust/server/test/engagement.test.js
wtclaude 648d3fd2e1
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m48s
fix(rust): every notice says which server, clan or player it is about
The live walk rendered a generic in-app notice as "A server came online.
A server's game started..." Core's structural projection falls back to
the trigger's label and description when the payload has no title, and
on a multi-server site that never says which server. Core's rule is that
the payload wins, so every trigger now declares `title` and `intro`, and
the emitter writes the sentence ("Oxide rig is online"). An operator's
own template can still ignore it and use the parts.

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

493 lines
21 KiB
JavaScript

// ── Notifications and engagement (phase 10, PLAN.md §25) ───────────────────
//
// The properties this suite holds, each with a failure behind it:
//
// • every declaration is one core will accept — a ceiling, a subjectKey that
// names a declared variable, an example on every variable, a closed type —
// because core refuses the WHOLE module at boot over one bad declaration;
// • no trigger declares an address, or the raider (D66);
// • the raid alert reaches exactly the authorised, linked people, one emit
// each with `ownerUserId`, and nobody when there is no cupboard (D59, D67);
// • a replayed event is told only while it is still news (D63);
// • a clan notice goes to the clan and never to whoever caused it;
// • a transition is announced once, and a first sighting never;
// • a tie at the top of the leaderboard is not a new leader;
// • every seed body interpolates only variables its trigger declares.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
// Core's own check on a `url` variable (utils/engagementEmit.js RELATIVE_URL),
// copied so a path this module builds is held to the rule it will meet.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
const CEILINGS = ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'admin', 'owner']
const TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
const SERVER = { id: 'main', name: 'Main' }
const NOW = Date.now()
/** A fresh ctx, a recording emit, and a link table the tests control. */
function setup({ links = {}, members = {}, state = null, board = [] } = {}) {
require('../core')._reset()
const ctx = fakeCtx()
require('../core').init(ctx)
const emit = require('../engagement/emit')
emit.reset()
const linksDb = require('../model/links/links.db')
const clansDb = require('../model/clans/clans.db')
const clans = require('../model/clans/clans.model')
const serversDb = require('../model/servers/servers.db')
const eventsDb = require('../model/events/events.db')
const originals = [
[linksDb, { ...linksDb }], [clansDb, { ...clansDb }], [clans, { ...clans }],
[serversDb, { ...serversDb }], [eventsDb, { ...eventsDb }],
]
linksDb.userIdsForSteamIds = async (ids) =>
ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
clans.resolveExternalId = async (serverId, frame) => (frame.clanId ? `${serverId}:${frame.clanId}:1` : null)
clansDb.listMembers = async (externalId) => (members[externalId] || []).map((steamId) => ({ steamId }))
serversDb.getState = async () => state
eventsDb.leaderboard = async () => board.shift() || []
const restore = () => {
for (const [mod, copy] of originals) Object.assign(mod, copy)
}
return { emit, calls: ctx.events.emit.calls, restore, eventsDb }
}
const raidFrame = (extra = {}) => ({
kind: 'entity.destroyed',
t: NOW,
ownerId: '100',
prefab: 'door.hinged.metal',
structure: 'door',
attackerId: '900',
attackerName: 'Raider',
grid: 'H7',
buildingId: '8113',
authorized: [
{ steamId: '101', online: false },
{ steamId: '102', online: true },
{ steamId: '103', online: false },
],
...extra,
})
// ── The declarations ───────────────────────────────────────────────────────
test('every trigger is a declaration core will accept', () => {
const { TRIGGERS } = require('../engagement/triggers')
const ids = new Set()
for (const t of TRIGGERS) {
assert.ok(t.id.startsWith('rust.'), `${t.id} is namespaced`)
assert.ok(!ids.has(t.id), `${t.id} is declared once`)
ids.add(t.id)
assert.ok(CEILINGS.includes(t.ceiling), `${t.id} has a real ceiling (there is no "self")`)
assert.ok(CEILINGS.includes(t.audience), `${t.id} has a real default audience`)
assert.strictEqual(t.kind, 'event')
const names = new Set(t.variables.map((v) => v.name))
assert.ok(names.has(t.subjectKey), `${t.id}'s subjectKey names a declared variable`)
for (const v of t.variables) {
assert.ok(TYPES.includes(v.type), `${t.id}.${v.name} has a closed type`)
assert.ok(v.example !== undefined, `${t.id}.${v.name} has an example`)
if (v.type === 'url') assert.match(v.example, RELATIVE_URL, `${t.id}.${v.name}'s example is site-relative`)
}
}
})
test('the default audience is never wider than the ceiling', () => {
const { TRIGGERS } = require('../engagement/triggers')
// The pairs this catalogue uses, each one core's `permits` accepts. A new
// pairing is a new line here — decided, not assumed.
const ALLOWED = new Set(['everyone>subscribers', 'owner>owner', 'members>members', 'staff>staff'])
for (const t of TRIGGERS) {
assert.ok(ALLOWED.has(`${t.ceiling}>${t.audience}`), `${t.id}: ${t.audience} under ${t.ceiling}`)
}
})
test('no trigger declares an address, or who raided whom (D66)', () => {
const { TRIGGERS } = require('../engagement/triggers')
for (const t of TRIGGERS) {
for (const v of t.variables) {
// By camelCase WORD: `wipeId` contains the letters "ip" and is not one.
const words = v.name.split(/(?=[A-Z])/).map((w) => w.toLowerCase())
for (const banned of ['ip', 'address', 'attacker', 'raider']) {
assert.ok(!words.includes(banned), `${t.id}.${v.name}`)
}
}
}
})
test('the ceilings are the ones §25.2 decided', () => {
const { TRIGGERS } = require('../engagement/triggers')
const by = Object.fromEntries(TRIGGERS.map((t) => [t.id, t.ceiling]))
assert.strictEqual(by['rust.base.destroyed'], 'owner')
assert.strictEqual(by['rust.player.linked'], 'owner')
for (const id of ['rust.player.reported', 'rust.player.banned', 'rust.player.unbanned', 'rust.login.denied']) {
assert.strictEqual(by[id], 'staff', id)
}
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
assert.strictEqual(by[id], 'members', id)
}
// D64: core's team.member.joined already covers it, and kits wait for phase 13.
assert.strictEqual(by['rust.clan.member.added'], undefined)
assert.strictEqual(by['rust.kit.entitled'], undefined)
})
test('a clan path survives core\'s url check, colons and all', () => {
const { clanPath, serverPath, leaderboardPath } = require('../engagement/triggers')
assert.match(clanPath('main:12:1790142840000'), RELATIVE_URL)
assert.match(serverPath('eu 2'), RELATIVE_URL)
assert.match(leaderboardPath('main'), RELATIVE_URL)
})
test('every trigger names what happened and where, even with its optionals missing', () => {
// The walk found a multi-server site's generic notice reading "A server came
// online" — core's projection falls back to the LABEL when the payload carries
// no `title`. Every trigger therefore declares its own, and the emitter writes
// it; a headline that printed "undefined" would be worse than the label.
const { TRIGGERS } = require('../engagement/triggers')
const { headline } = require('../engagement/emit')
for (const t of TRIGGERS) {
const minimal = Object.fromEntries(t.variables.filter((v) => v.required).map((v) => [v.name, v.example]))
const h = headline(t.id, minimal)
assert.ok(h.title && h.intro, `${t.id} has a headline`)
assert.ok(!/undefined|null/.test(h.title + h.intro), `${t.id}: ${h.title} / ${h.intro}`)
}
const online = headline('rust.server.online', { server: 'EU 2' })
assert.match(online.title, /EU 2/, 'the notice says WHICH server')
})
// ── The seeds ──────────────────────────────────────────────────────────────
test('every seeded rule is ours, off, and names bodies that exist', () => {
const { TRIGGERS } = require('../engagement/triggers')
const { TEMPLATES, RULE_GROUPS } = require('../engagement/seeds')
const triggerIds = new Set(TRIGGERS.map((t) => t.id))
const own = new Set(TEMPLATES.map((t) => t.key))
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
const groupKeys = new Set()
for (const group of RULE_GROUPS) {
assert.ok(!groupKeys.has(group.key), `group ${group.key} is unique`)
groupKeys.add(group.key)
for (const r of group.rules) {
assert.ok(triggerIds.has(r.trigger_id), `${r.trigger_id} is one of ours`)
assert.strictEqual(r.enabled, undefined, 'enabled is never a seed parameter')
assert.ok(Number.isInteger(r.max_sends_per_hour) && r.max_sends_per_hour >= 1)
for (const [slot, key] of Object.entries(r.template_keys)) {
assert.ok(own.has(key) || coreKeys.has(key), `${r.trigger_id}: ${key}`)
if (slot !== 'digest') assert.ok(r.channels.includes(slot), `${r.trigger_id}: ${slot} is a channel`)
}
for (const channel of r.channels) {
if (channel !== 'push') assert.ok(r.template_keys[channel], `${r.trigger_id}: a body for ${channel}`)
}
}
}
})
test('push appears only on the rules whose trigger is also a stream (D65)', () => {
const { STREAMS } = require('../engagement/streams')
const { RULE_GROUPS } = require('../engagement/seeds')
const pushable = new Set(STREAMS.map((s) => s.id))
for (const group of RULE_GROUPS) {
for (const r of group.rules) {
if (r.channels.includes('push')) assert.ok(pushable.has(r.trigger_id), r.trigger_id)
}
}
})
test('every body interpolates only what its trigger declares', () => {
const { TRIGGERS } = require('../engagement/triggers')
const { TEMPLATES } = require('../engagement/seeds')
const declared = new Map(TRIGGERS.map((t) => [t.id, new Set(t.variables.map((v) => v.name))]))
// Core's per-delivery and ambient variables — supplied by the renderer.
const ambient = new Set(['unsubscribeUrl', 'siteName', 'siteUrl', 'logoUrl', 'year'])
for (const t of TEMPLATES) {
const vars = declared.get(t.triggerId)
assert.ok(vars, `${t.key}'s trigger exists`)
const text = JSON.stringify([t.subject, t.blocks])
for (const [, name] of text.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) {
assert.ok(vars.has(name) || ambient.has(name), `${t.key} uses {{${name}}}`)
}
assert.ok(t.key.startsWith('rust.'))
if (t.channel === 'email') assert.ok(t.subject)
else assert.strictEqual(t.subject, null)
}
})
test('the seeded raid rule is the OFFLINE raid alert, as a condition (D61)', () => {
const { RULE_GROUPS } = require('../engagement/seeds')
const raid = RULE_GROUPS.find((g) => g.key === 'raid-v1').rules[0]
assert.deepStrictEqual(raid.conditions, { variable: 'ownerOnline', cmp: 'eq', value: false })
assert.strictEqual(raid.audience, 'owner')
})
// ── The raid alert ─────────────────────────────────────────────────────────
test('the raid alert reaches each authorised, linked person, and nobody else', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
try {
const sent = await emit.onEvent(SERVER, { id: 1, kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(sent, 2)
assert.deepStrictEqual(calls.map((c) => c[1].ownerUserId).sort(), [11, 12])
for (const [trigger, env] of calls) {
assert.strictEqual(trigger, 'rust.base.destroyed')
assert.strictEqual(env.recipientUserIds, undefined, 'owner-shaped, never a recipient list')
assert.strictEqual(env.data.building, '8113')
assert.strictEqual(env.data.structure, 'door')
assert.strictEqual(env.data.atGrid, ' in H7')
assert.ok(!JSON.stringify(env.data).includes('Raider'), 'the raider is never named')
assert.ok(!JSON.stringify(env.data).includes('900'))
}
const online = Object.fromEntries(calls.map((c) => [c[1].ownerUserId, c[1].data.ownerOnline]))
assert.deepStrictEqual(online, { 11: false, 12: true })
} finally {
restore()
}
})
test('two Steam accounts held by one person are one alert, online if either is', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][1].data.ownerOnline, true)
} finally {
restore()
}
})
test('an authorised attacker is demolishing their own base: no alert', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ attackerId: '102' }) })
assert.strictEqual(calls.length, 0)
} finally {
restore()
}
})
test('no cupboard, nobody to tell — and a protocol-6 frame is the same (D67)', async () => {
const { emit, calls, restore } = setup({ links: { 100: 10, 101: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ buildingId: undefined, authorized: undefined }) })
// A protocol-6 frame: a BuildingBlock with an owner and no `authorized`.
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: { kind: 'entity.destroyed', t: NOW, ownerId: '100', prefab: 'wall' } })
assert.strictEqual(calls.length, 0, 'the placer is never a fallback')
} finally {
restore()
}
})
test('a replayed raid is still told for a day, and not after (D63)', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(calls.length, 1)
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 25 * 3600 * 1000 }) })
assert.strictEqual(calls.length, 1)
} finally {
restore()
}
})
test('the same frame replayed carries the same dedupe key, within core\'s bound', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11 } })
try {
const item = { id: 7, kind: 'entity.destroyed', frame: raidFrame() }
await emit.onEvent(SERVER, item)
await emit.onEvent(SERVER, { ...item, id: 99 })
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'keyed on the event, not the row id')
assert.ok(calls[0][1].dedupeKey.length <= 190)
} finally {
restore()
}
})
// ── Broadcasts ─────────────────────────────────────────────────────────────
test('a wipe is news for fifteen minutes (D63)', async () => {
const { emit, calls, restore } = setup()
try {
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 60 * 1000, wipeId: 'w2' } })
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 20 * 60 * 1000, wipeId: 'w3' } })
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.wipe.started')
assert.strictEqual(calls[0][1].data.wipeId, 'w2')
} finally {
restore()
}
})
test('online and offline are transitions, and a first sighting is not one', () => {
const { emit, calls, restore } = setup()
try {
emit.serverObserved(SERVER, true)
emit.serverObserved(SERVER, true)
assert.strictEqual(calls.length, 0, 'a restart announces nothing')
emit.serverObserved(SERVER, false)
emit.serverObserved(SERVER, false)
emit.serverObserved(SERVER, true)
assert.deepStrictEqual(calls.map((c) => c[0]), ['rust.server.offline', 'rust.server.online'])
assert.strictEqual(calls[0][1].data.serverId, 'main')
} finally {
restore()
}
})
test('a new leader is announced once; a tie is not a new leader', async () => {
const row = (steamId, kills) => ({ steamId, name: `P${steamId}`, kills })
const { emit, calls, restore } = setup({
state: { wipeId: 'w1' },
board: [
[row('1', 5), row('2', 3)], // first sight: remembered, not announced
[row('2', 5), row('1', 5)], // level on kills: not a change
[row('2', 6), row('1', 5)], // strictly ahead: announced
[row('2', 7), row('1', 5)], // the same leader: nothing
],
})
try {
for (let i = 0; i < 4; i += 1) await emit.checkLeader(SERVER)
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.leaderboard.topped')
assert.strictEqual(calls[0][1].data.leader, 'P2')
assert.strictEqual(calls[0][1].data.kills, 6)
} finally {
restore()
}
})
// ── Clans ──────────────────────────────────────────────────────────────────
test('a disband goes to the roster on the frame, not to the one who did it', async () => {
const { emit, calls, restore } = setup({ links: { 201: 21, 202: 22, 203: 23 } })
try {
await emit.onEvent(SERVER, {
kind: 'clan.disbanded',
frame: { kind: 'clan.disbanded', t: NOW, clanId: 4, clanName: 'Belt', steamId: '201', name: 'Boss', members: ['201', '202', '203'] },
})
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.clan.disbanded')
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [22, 23])
assert.strictEqual(calls[0][1].data.by, 'Boss')
assert.match(calls[0][1].data.clanUrl, RELATIVE_URL)
} finally {
restore()
}
})
test('the one kicked is told; the one who kicked is not', async () => {
const { emit, calls, restore } = setup({
links: { 301: 31, 302: 32, 303: 33 },
members: { 'main:5:1': ['301', '302'] }, // the board already dropped 303
})
try {
await emit.onEvent(SERVER, {
kind: 'clan.member.kicked',
frame: { kind: 'clan.member.kicked', t: NOW, clanId: 5, steamId: '303', name: 'Out', bySteamId: '301', byName: 'Boss' },
})
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [32, 33])
} finally {
restore()
}
})
test('a leaver is not told they left, and a lone leaver tells nobody', async () => {
const { emit, calls, restore } = setup({ links: { 401: 41, 402: 42 }, members: { 'main:6:1': ['401', '402'] } })
try {
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 6, steamId: '402' } })
assert.deepStrictEqual(calls[0][1].recipientUserIds, [41])
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 7, steamId: '402' } })
assert.strictEqual(calls.length, 1)
} finally {
restore()
}
})
// ── Moderation ─────────────────────────────────────────────────────────────
test('a ban never carries the address the frame does', async () => {
const { emit, calls, restore } = setup()
try {
await emit.onEvent(SERVER, {
kind: 'player.banned',
frame: { kind: 'player.banned', t: NOW, steamId: '555', name: 'Cheater', ip: '203.0.113.9', reason: 'aimbot' },
})
assert.strictEqual(calls.length, 1)
assert.ok(!JSON.stringify(calls[0][1]).includes('203.0.113.9'))
assert.strictEqual(calls[0][1].data.reason, 'aimbot')
} finally {
restore()
}
})
test('an unapproved login becomes a staff notice, keyed on the attempt', async () => {
const { emit, calls, restore, eventsDb } = setup()
try {
const asked = []
eventsDb.unapprovedLogins = async (q) => {
asked.push(q)
return [{ steamId: '777', t: NOW - 120000, name: 'Knocker' }]
}
const sent = await emit.sweepLoginDenied([SERVER], NOW)
await emit.sweepLoginDenied([SERVER], NOW)
assert.strictEqual(sent, 1)
assert.strictEqual(asked[0].to, NOW - emit.LOGIN_APPROVAL_WINDOW_MS, 'an attempt waits its minute first')
assert.strictEqual(calls[0][0], 'rust.login.denied')
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'a second sweep is a no-op in core')
} finally {
restore()
}
})
// ── The rest ───────────────────────────────────────────────────────────────
test('a new link tells its owner, and only its owner', () => {
const { emit, calls, restore } = setup()
try {
emit.linked({ userId: 5, steamId: '76561198000000001', name: 'Me' })
assert.strictEqual(calls[0][0], 'rust.player.linked')
assert.strictEqual(calls[0][1].ownerUserId, 5)
assert.strictEqual(emit.linked({ userId: 0, steamId: 'x' }), 0)
} finally {
restore()
}
})
test('a frame the fan-out cannot handle costs one notice, never the caller', async () => {
const { emit, restore } = setup()
try {
const linksDb = require('../model/links/links.db')
linksDb.userIdsForSteamIds = async () => { throw new Error('database gone') }
const sent = await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(sent, 0)
} finally {
restore()
}
})
test('an audience that fails answers nobody, never everybody', async () => {
require('../core')._reset()
const ctx = fakeCtx({ db: { query: spy(() => Promise.reject(new Error('down'))), pool: {} } })
require('../core').init(ctx)
const { AUDIENCES } = require('../engagement/audiences')
for (const a of AUDIENCES) {
assert.deepStrictEqual(await a.resolve({ clan: 'main:1:1', serverId: 'main' }), [], a.id)
assert.deepStrictEqual(await a.resolve({}), [], `${a.id} with no param`)
}
})