Files
Module-Rust/server/test/optionalMods.test.js
wtclaude e0d13e73db
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

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

440 lines
21 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 optional mods (PLAN.md §33, protocol 12) ──────────────────────────
//
// BetterChat titles, BetterChat group styles, the voice, and popups. Every test
// here is one of the ways the phase can look right and be wrong:
//
// a title goes to nobody on a fresh wipe; ties follow the leaderboard; a mode keeps what it says
// an operator's title cannot carry markup, and a title made only of markup is refused
// a style is all twelve fields, in the spelling BetterChat's setter takes
// a voice has no sender: no username, and no stray colon where one was
// a style field carries what the site last pushed THERE, and landed only if the game took it
// a withdrawn style is one `chat-group` retirement, never for `default`, and leaves the
// ledger only once BetterChat has removed the group
// titles skip a plugin older than protocol 12, and push again on a restart or a wipe
// a popup refused for want of PopupNotifications is not retried
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore() {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1 })
},
pool: {},
},
}),
)
return queries
}
withCore()
const titles = require('../model/titles/titles')
const chatStyle = require('../model/permissions/chatStyle')
const row = (steamId, stats) => ({ steamId, kills: 0, npcKills: 0, playtimeSec: 0, ...stats })
// ── Titles ─────────────────────────────────────────────────────────────────
test('a title rule counts only a stat above zero, so a fresh wipe gives no titles (§33.4 reading 1)', () => {
const held = titles.evaluate(
[{ stat: 'kills', topN: 3, text: 'Killer', color: '#ff0000' }],
{ kills: [row('1', { kills: 5 }), row('2', { kills: 0 }), row('3', { kills: 0 })] },
)
assert.deepStrictEqual([...held.keys()], ['1'])
const empty = titles.evaluate([{ stat: 'kills', topN: 1, text: 'K', color: '#ff0000' }], { kills: [row('1')] })
assert.strictEqual(empty.size, 0)
})
test('ties follow the leaderboard order: a rule’s top N is the first N rows it was given (reading 2)', () => {
const held = titles.evaluate(
[{ stat: 'playtime', topN: 2, text: 'Regular', color: '#00ff00' }],
{ playtime: [row('9', { playtimeSec: 60 }), row('4', { playtimeSec: 60 }), row('7', { playtimeSec: 60 })] },
)
assert.deepStrictEqual([...held.keys()], ['9', '4'])
})
test('the mode keeps the first rule met, every rule, or up to N — in the operator’s order (D136)', () => {
const rules = [
{ stat: 'kills', topN: 1, text: 'Killer', color: '#ff0000' },
{ stat: 'npckills', topN: 1, text: 'Hunter', color: '#00ff00' },
{ stat: 'playtime', topN: 1, text: 'Regular', color: '#0000ff' },
]
const standings = {
kills: [row('1', { kills: 3 })],
npckills: [row('1', { npcKills: 2 })],
playtime: [row('1', { playtimeSec: 9 })],
}
const texts = (mode, max) => titles.evaluate(rules, standings, { mode, max }).get('1').map((t) => t.text)
assert.deepStrictEqual(texts('first'), ['Killer'])
assert.deepStrictEqual(texts('all'), ['Killer', 'Hunter', 'Regular'])
assert.deepStrictEqual(texts('upto', 2), ['Killer', 'Hunter'])
assert.deepStrictEqual(texts('nonsense'), ['Killer'], 'an unknown mode shows the fewest')
})
test('a title set is BetterChat markup, sorted, and digests the same however it was built', () => {
const held = new Map([
['2', [{ text: 'B', color: '#00ff00' }]],
['1', [{ text: 'A', color: '#ff0000' }, { text: 'C', color: '#0000ff' }]],
])
const set = titles.wireSet(held)
assert.deepStrictEqual(set, [
{ steamId: '1', text: '[#ff0000]A[/#] [#0000ff]C[/#]' },
{ steamId: '2', text: '[#00ff00]B[/#]' },
])
const again = titles.wireSet(new Map([...held.entries()].reverse()))
assert.strictEqual(titles.digest(set), titles.digest(again))
assert.notStrictEqual(titles.digest(set), titles.digest([]))
})
test('an operator’s title cannot carry markup, and one made only of markup is refused (reading 3)', () => {
assert.strictEqual(titles.cleanText(' [#ff0000]<b>Top</b> {Message} '), '#ff0000bTop/b Message')
const ok = titles.validateSettings({ mode: 'upto', max: 3, rules: [{ stat: 'kills', topN: 1, text: '[Top Killer]', color: '#FF8800' }] })
assert.strictEqual(ok.ok, true)
assert.deepStrictEqual(ok.value.rules[0], { stat: 'kills', topN: 1, text: 'Top Killer', color: '#ff8800' })
const bad = titles.validateSettings({
mode: 'most',
max: 9,
rules: [
{ stat: 'deaths', topN: 0, text: '[]<>', color: 'red' },
{ stat: 'kills', topN: 1, text: 'x'.repeat(25), color: '#ffffff' },
],
})
assert.strictEqual(bad.ok, false)
assert.ok(bad.errors.some((e) => /mode/.test(e)))
assert.ok(bad.errors.some((e) => /max/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: stat/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: top/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: the title needs some text/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: colour/.test(e)))
assert.ok(bad.errors.some((e) => /rule 2: a title is at most 24/.test(e)))
const many = titles.validateSettings({ rules: new Array(11).fill({ stat: 'kills', topN: 1, text: 'K', color: '#ffffff' }) })
assert.strictEqual(many.ok, false)
})
// ── Styles and the voice ───────────────────────────────────────────────────
const STYLE = {
Priority: 5,
Title: '[Staff]',
TitleColor: '#FF0000',
TitleSize: '16',
TitleHidden: false,
TitleHiddenIfNotPrimary: 'FALSE',
UsernameColor: '#55aaff',
UsernameSize: 15,
MessageColor: 'white',
MessageSize: '15',
ChatFormat: '{Title} {Username}: {Message}',
ConsoleFormat: '{Title} {Username}: {Message}',
}
test('a style is all twelve fields, each in the spelling BetterChat’s setter takes (D138)', () => {
const checked = chatStyle.validateStyle(STYLE)
assert.strictEqual(checked.ok, true)
assert.strictEqual(checked.fields.Priority, '5')
assert.strictEqual(checked.fields.TitleColor, '#ff0000')
assert.strictEqual(checked.fields.TitleHidden, 'false', 'a boolean is true or false, never True')
assert.strictEqual(checked.fields.TitleHiddenIfNotPrimary, 'false')
assert.strictEqual(checked.fields.UsernameSize, '15')
const partial = chatStyle.validateStyle({ Title: '[x]' })
assert.strictEqual(partial.ok, false)
assert.ok(partial.errors.some((e) => /ChatFormat is missing/.test(e)))
const bad = chatStyle.validateStyle({ ...STYLE, ChatFormat: '{Title}', ConsoleFormat: '{Message} {Message}', TitleColor: 'rgb(1,2,3)', Nope: 1 })
assert.ok(bad.errors.some((e) => /ChatFormat must contain \{Message\} exactly once/.test(e)))
assert.ok(bad.errors.some((e) => /ConsoleFormat must contain/.test(e)))
assert.ok(bad.errors.some((e) => /TitleColor/.test(e)))
assert.ok(bad.errors.some((e) => /Nope is not a BetterChat group field/.test(e)))
assert.strictEqual(chatStyle.defaults('vip').Title, '[vip]')
assert.strictEqual(chatStyle.defaults('default').Title, '[Player]')
})
test('a voice has no sender: no username, and no stray colon where BetterChat’s default puts one (D140)', () => {
const { fields } = chatStyle.validateStyle(STYLE)
assert.strictEqual(chatStyle.voiceFormat(fields), '[#ff0000][+16][Staff][/+][/#] [#white][+15]{message}[/+][/#]')
const hidden = chatStyle.voiceFormat({ ...fields, TitleHidden: 'true', ChatFormat: '<{Time}> {Title} {Username} » {Message}' })
assert.strictEqual(hidden, '<> » [#white][+15]{message}[/+][/#]')
// A title is operator text, and `$&` in a replacement string is a pattern.
const dollars = chatStyle.voiceFormat({ ...fields, Title: '[$&]' })
assert.match(dollars, /\[\$&\]/)
assert.strictEqual(chatStyle.voiceFormat({ ...fields, ChatFormat: '{Title}' }), null)
})
// ── The permission mirror's style half ─────────────────────────────────────
test('the desired set carries a style per field, and its value moves the digest but not the row’s identity', () => {
const model = require('../model/permissions/permissions.model')
const base = {
groups: [{ id: 1, name: 'staff', title: 'Staff', rank: 0, parent: '', allServers: true }],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [],
exceptions: [],
steamIdsByUser: new Map(),
// A style belongs to a group ROW since groups became per server (D189).
groupChat: [
{ groupId: 1, field: 'Title', value: '[Staff]' },
{ groupId: 1, field: 'TitleColor', value: '#ff0000' },
],
}
const a = model.buildDesired('main', base)
assert.deepStrictEqual(a.payload.groups[0].chat, { Title: '[Staff]', TitleColor: '#ff0000' })
assert.deepStrictEqual(
a.rows.filter((r) => r.kind === 'chat-field').map((r) => `${r.object}=${r.value}`),
['Title=[Staff]', 'TitleColor=#ff0000'],
)
const b = model.buildDesired('main', { ...base, groupChat: [{ ...base.groupChat[0] }, { ...base.groupChat[1], value: '#00ff00' }] })
assert.notStrictEqual(a.hash, b.hash, 'a recoloured title must push')
assert.strictEqual(model.retirements(a.rows, b.rows).length, 0, 'a changed value is not a retirement')
const none = model.buildDesired('main', { ...base, groupChat: [] })
assert.strictEqual(none.payload.groups[0].chat, undefined)
})
test('each style field carries what the site last pushed to THAT server, or null (§33.2)', () => {
const permSync = require('../permSync')
const groups = [{ name: 'staff', chat: { Title: '[Staff]', TitleColor: '#ff0000' } }, { name: 'vip' }]
const pushed = [
{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Old]' },
{ kind: 'grant', subject: '1', object: 'x', value: null },
]
assert.deepStrictEqual(permSync.withExpect(groups, pushed), [
{ name: 'staff', chat: { Title: { value: '[Staff]', expect: '[Old]' }, TitleColor: { value: '#ff0000', expect: null } } },
{ name: 'vip' },
])
})
test('a withdrawn style is one chat-group retirement per group, and never for default (D139)', () => {
const permSync = require('../permSync')
const retired = [
{ kind: 'chat-field', subject: 'staff', object: 'Title' },
{ kind: 'chat-field', subject: 'staff', object: 'TitleColor' },
{ kind: 'chat-field', subject: 'default', object: 'Title' },
{ kind: 'grant', subject: '1', object: 'kits.vip' },
]
const { styleRetired, sent } = permSync.styleRetirements(retired)
assert.strictEqual(styleRetired.length, 3)
assert.deepStrictEqual(sent, [{ kind: 'chat-group', subject: 'staff', object: '' }])
})
test('a style field landed only when BetterChat took it; drift keeps the game’s value; a removal clears the ledger', async () => {
const queries = withCore()
const permSync = require('../permSync')
require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
const desired = {
hash: 'h',
rows: [
{ kind: 'group', subject: 'staff', object: '' },
{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' },
{ kind: 'chat-field', subject: 'staff', object: 'TitleColor', value: '#ff0000' },
{ kind: 'chat-field', subject: 'staff', object: 'MessageSize', value: '15' },
],
}
const report = {
kind: 'perm.report',
foreign: [],
chat: {
loaded: true,
applied: 1,
saved: 1,
drift: [{ group: 'staff', field: 'TitleColor', game: '#123456' }],
failed: [{ group: 'staff', field: 'MessageSize', reason: 'InvalidValue' }],
removed: ['old'],
},
}
const styleRetired = [
{ kind: 'chat-field', subject: 'old', object: 'Title' },
{ kind: 'chat-field', subject: 'gone', object: 'Title' },
]
await permSync.applyReport({ id: 'main' }, { desired, retire: [{ kind: 'chat-group', subject: 'old', object: '' }], styleRetired, report })
const insert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed'))
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('Title [Staff]'), 'the field BetterChat took is pushed, with its value')
assert.ok(!recorded.includes('TitleColor'), 'a hand-edited field is not ours')
assert.ok(!recorded.includes('MessageSize'), 'a field BetterChat refused did not land')
const deletes = queries.filter((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed')).map((q) => q.params.join(' '))
assert.ok(deletes.includes('main chat-field old Title'), 'a removed group leaves the ledger')
assert.ok(!deletes.some((d) => d.includes('gone')), 'a group BetterChat has not removed stays, to be retired again')
assert.ok(!deletes.some((d) => d.includes('chat-group')), 'a chat-group retirement is not a ledger row')
// Protocol 13: every "needs a person" row says which way it went.
const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift'))
assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456', 'changed'])
})
test('with BetterChat absent no style field landed, and a withdrawn style is kept for later', async () => {
const queries = withCore()
const permSync = require('../permSync')
require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
await permSync.applyReport(
{ id: 'main' },
{
desired: { hash: 'h', rows: [{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' }] },
retire: [],
styleRetired: [{ kind: 'chat-field', subject: 'old', object: 'Title' }],
report: { kind: 'perm.report', foreign: [], chat: { loaded: false } },
},
)
assert.ok(!queries.some((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed')))
assert.ok(!queries.some((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed') && q.params.includes('old')))
})
// ── The title push ─────────────────────────────────────────────────────────
test('titles push on a change, a restart or a wipe, and not on a quiet tick', () => {
const titleSync = require('../titleSync')
const last = { digest: 'd', bootId: 'b1', wipeId: 'w1' }
const state = { bootId: 'b1', wipeId: 'w1' }
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last: null }), 'first')
assert.strictEqual(titleSync.reasonToPush({ digest: 'e', state, last }), 'changed')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, bootId: 'b2' }, last }), 'restart')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, wipeId: 'w2' }, last }), 'wipe')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last }), null)
})
test('a plugin older than protocol 12, or a server that is off, is not asked', async () => {
withCore()
const titleSync = require('../titleSync')
const client = require('../sidecarClient')
const calls = []
const saved = client.titles
client.titles = async (...args) => {
calls.push(args)
return { ok: true, data: { kind: 'titles.ok', count: 0, betterChat: false } }
}
try {
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 11, wipeId: 'w' }), null)
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 0, protocol: 12, wipeId: 'w' }), null)
assert.strictEqual(calls.length, 0)
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 12, wipeId: 'w', bootId: 'b' }), 'ok')
assert.deepStrictEqual(calls[0][1].titles, [], 'no rules is an empty set, which clears the game')
assert.deepStrictEqual(titleSync.lastPush('main').betterChat, false)
} finally {
client.titles = saved
}
})
// ── Delivery ───────────────────────────────────────────────────────────────
test('a line’s body: a popup carries no format; chat carries the voice; plain chat looks as it did', () => {
const rewards = require('../eventRewards')
const base = { key: 'k', message: 'm' }
assert.deepStrictEqual(rewards.lineBody(base, 'chat', null), base)
assert.deepStrictEqual(rewards.lineBody(base, 'chat', 'F {message}'), { ...base, format: 'F {message}' })
assert.deepStrictEqual(rewards.lineBody(base, 'popup', 'F {message}'), { ...base, delivery: 'popup' })
})
test('rust.announce: a popup where PopupNotifications is missing is refused and not retried (D141, R3)', async () => {
withCore()
const rewards = require('../eventRewards')
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const voice = require('../model/permissions/voice')
const saved = { chat: client.chat, getServer: serversDb.getServer, currentFormat: voice.currentFormat }
const bodies = []
serversDb.getServer = async (id) => ({ id, name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 })
voice.currentFormat = async () => '[#ff0000]S[/#] {message}'
client.chat = async (server, body) => {
bodies.push(body)
return body.delivery === 'popup'
? { ok: true, data: { kind: 'chat.error', reason: 'popup-unavailable', message: 'PopupNotifications is not loaded on this server' } }
: { ok: true, data: { kind: 'chat.ok', said: true } }
}
try {
const announce = rewards.ACTIONS.find((a) => a.id === 'rust.announce')
assert.strictEqual(announce.version, 1, 'a bump would stop every existing step from dispatching')
const refused = await announce.perform({ runId: 1, idempotencyKey: 'k', params: { server: 'main', message: 'hi', delivery: 'popup' } })
assert.strictEqual(refused.ok, false)
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /PopupNotifications/)
assert.strictEqual(bodies[0].format, undefined, 'a popup carries no format')
const said = await announce.perform({ runId: 1, idempotencyKey: 'k2', params: { server: 'main', message: 'hi' } })
assert.strictEqual(said.ok, true)
assert.strictEqual(bodies[1].format, '[#ff0000]S[/#] {message}', 'chat is said in the voice')
const wrong = await announce.perform({ runId: 1, params: { server: 'main', message: 'hi', delivery: 'carrier pigeon' } })
assert.strictEqual(wrong.retry, false)
} finally {
Object.assign(client, { chat: saved.chat })
serversDb.getServer = saved.getServer
voice.currentFormat = saved.currentFormat
}
})
test('the news leg: each server’s own delivery, and one voice for every server that chats (D142)', async () => {
withCore()
const rewards = require('../eventRewards')
const client = require('../sidecarClient')
const servers = require('../model/servers/servers.model')
const voice = require('../model/permissions/voice')
const saved = { chat: client.chat, listForPolling: servers.listForPolling, currentFormat: voice.currentFormat }
const calls = []
servers.listForPolling = async () => [
{ id: 'main', name: 'Main', announceNews: true, newsDelivery: 'chat' },
{ id: 'pve', name: 'PvE', announceNews: true, newsDelivery: 'popup' },
{ id: 'off', name: 'Off', announceNews: false, newsDelivery: 'chat' },
]
voice.currentFormat = async () => 'V {message}'
client.chat = async (server, body) => {
calls.push({ server: server.id, body })
return { ok: true, data: { kind: 'chat.ok', said: true } }
}
try {
const result = await rewards.LEG.dispatch({ id: 3, title: 'Wipe tonight' })
assert.deepStrictEqual(calls, [
{ server: 'main', body: { key: 'news:3', message: 'Wipe tonight', format: 'V {message}' } },
{ server: 'pve', body: { key: 'news:3', message: 'Wipe tonight', delivery: 'popup' } },
])
assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
} finally {
client.chat = saved.chat
servers.listForPolling = saved.listForPolling
voice.currentFormat = saved.currentFormat
}
})
test('the delivery option source offers chat and popup, since core has no enum type', async () => {
const rewards = require('../eventRewards')
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.delivery')
assert.deepStrictEqual((await source.resolve()).map((r) => r.value), ['chat', 'popup'])
})