Files
Module-Rust/server/test/permissions.test.js
wtclaude 43147b796a feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:32 -05:00

286 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 permission mirror ─────────────────────────────────────────────────
//
// The whole of R2's correctness is three set operations and one rule about what
// counts as landed, and every test here is one of those:
//
// desired pushed apply
// pushed desired RETIRE, because the site put it there and withdrew it
// present desired drift, which is reported and never undone
//
// and: a grant naming a permission the server has not registered did NOT land,
// however much the push looked like it worked.
//
// The last one is the one with teeth. `GrantUserPermission` returns void, throws
// nothing and logs nothing for an unregistered name (PLAN.md §12.2 rule 1), so a
// module that recorded it as pushed would believe it had given a privilege it had
// not — and would then RETIRE it from a server that never had it, which is a
// no-op that reads as a success in every log.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(overrides = {}) {
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: {},
},
...overrides,
}),
)
return queries
}
/** One authored set: a fleet group, a server-scoped group, and two grants. */
function authored() {
return {
groups: [
{ name: 'vip', title: 'VIP', rank: 10, scope: '*' },
{ name: 'builder', title: 'Builder', rank: 0, scope: 'creative' },
],
groupPermissions: [
{ groupName: 'vip', permission: 'kits.vip' },
{ groupName: 'builder', permission: 'buildtools.use' },
],
members: [
{ groupName: 'vip', userId: 1 },
{ groupName: 'builder', userId: 2 },
],
grants: [
{ id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' },
{ id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null },
{ id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' },
],
// One person with TWO Steam accounts, one with one, one with none.
steamIdsByUser: new Map([
[1, ['7656001', '7656099']],
[2, ['7656002']],
]),
}
}
test('a grant reaches every Steam account its holder has linked (D28)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload } = model.buildDesired('main', authored())
const holders = payload.grants.map((row) => row.steamId).sort()
// `kits.gold` is authored once, against user 1, who holds two accounts.
assert.deepEqual(holders, ['7656001', '7656099'])
for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold'])
})
test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload, rows } = model.buildDesired('main', authored())
// User 3 holds `kits.gold` and has no account. Nothing is pushed for them…
assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null))
// …and the permission is still MANAGED, which is what makes a hand grant of it
// to somebody else show up as drift rather than as nothing at all.
assert.ok(payload.managed.includes('kits.gold'))
})
test('scope decides what a server is sent at all (D29)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const main = model.buildDesired('main', authored())
const creative = model.buildDesired('creative', authored())
assert.deepEqual(main.payload.groups.map((g) => g.name), ['vip'])
assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip'])
// The server-scoped grant is on `creative` and nowhere else.
assert.ok(!main.payload.managed.includes('zonemanager.admin'))
assert.ok(creative.payload.managed.includes('zonemanager.admin'))
})
test('a group travels as a group: its members and its permissions are separate facts (D30)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload, rows } = model.buildDesired('main', authored())
const vip = payload.groups.find((group) => group.name === 'vip')
assert.deepEqual(vip.permissions, ['kits.vip'])
assert.deepEqual(vip.members.sort(), ['7656001', '7656099'])
// Three distinct row kinds, because the game can fail at each independently: a
// group can exist while a membership does not, which is exactly what happens
// for a player the store has never seen.
assert.ok(rows.some((r) => r.kind === 'group' && r.subject === 'vip'))
assert.ok(rows.some((r) => r.kind === 'group-permission' && r.object === 'kits.vip'))
assert.ok(rows.some((r) => r.kind === 'member' && r.object === 'vip'))
})
test('the digest does not depend on the order rows came out of the database', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const rows = model.buildDesired('main', authored()).rows
const shuffled = [...rows].reverse()
// An unsorted digest would differ between two reads of an unchanged set, and
// the loop would push to every game server on every tick for ever.
assert.equal(model.hashRows(rows), model.hashRows(shuffled))
assert.notEqual(model.hashRows(rows), model.hashRows(rows.slice(1)))
})
test('what this site put there and has withdrawn is the only thing retired (D31)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const desired = [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' },
{ kind: 'member', subject: '7656001', object: 'vip' },
]
const pushed = [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' }, // still wanted
{ kind: 'grant', subject: '7656001', object: 'kits.silver' }, // withdrawn
]
assert.deepEqual(model.retirements(pushed, desired), [
{ kind: 'grant', subject: '7656001', object: 'kits.silver' },
])
// A hand grant is in NEITHER set, so it is never retired by this calculation —
// it reaches the operator as drift instead. That difference is the reason the
// pushed ledger exists at all.
assert.deepEqual(model.retirements([], desired), [])
})
test('a permission the server could not resolve is not recorded as pushed', async () => {
const queries = withCore()
const permSync = require('../permSync')
const desired = {
hash: 'h1',
rows: [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' },
{ kind: 'grant', subject: '7656001', object: 'kits.vip' },
{ kind: 'member', subject: '7656002', object: 'vip' },
{ kind: 'member', subject: '7656003', object: 'vip' },
],
}
const report = {
kind: 'perm.report',
applied: { grants: 1 },
unresolved: ['kits.vip'],
pending: ['7656003:vip'],
foreign: [],
}
// The catalogue refresh is a second call to the game; stubbed so the report
// path is what this test is about.
const sidecar = require('../sidecarClient')
sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
assert.ok(insert, 'the rows that landed must be recorded')
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
assert.ok(!recorded.includes('kits.vip'), 'an unresolved permission never reached the store')
assert.ok(recorded.includes('7656002'), 'a membership that took is pushed')
assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet')
})
test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
withCore()
const permSync = require('../permSync')
const base = {
state: 'ok',
dirty: false,
syncedHash: 'h1',
bootId: 'boot-1',
wipeId: 'w-1',
lastAttemptAt: new Date(),
}
const at = (sync, state = {}) =>
permSync.reasonToSync({
desiredHash: 'h1',
sync,
state: { bootId: 'boot-1', wipeId: 'w-1', ...state },
force: false,
})
assert.equal(at(base), null, 'nothing changed: no push')
assert.equal(at({ ...base, dirty: true }), 'dirty')
assert.equal(permSync.reasonToSync({ desiredHash: 'h2', sync: base, state: {}, force: false }), 'changed')
assert.equal(at(base, { bootId: 'boot-2' }), 'restart')
assert.equal(at(base, { wipeId: 'w-2' }), 'wipe')
assert.equal(at(null), 'first')
// The audit is the backstop that finds drift on a server nobody has touched.
const old = new Date(Date.now() - permSync.AUDIT_MS - 1000)
assert.equal(at({ ...base, lastAttemptAt: old }), 'audit')
})
test('a failing server is left alone for a backoff, unless something changed', () => {
withCore()
const permSync = require('../permSync')
const failing = {
state: 'failed',
dirty: false,
syncedHash: 'h1',
lastAttemptAt: new Date(),
}
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: failing, state: {}, force: false }),
null,
'a server that just failed is not hammered every thirty seconds',
)
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, dirty: true }, state: {}, force: false }),
'retry',
'an operator changing something is a reason to try again at once',
)
const older = new Date(Date.now() - permSync.FAIL_BACKOFF_MS - 1000)
assert.equal(
permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, lastAttemptAt: older }, state: {}, force: false }),
'retry',
)
})
test('names are lowered, because the store lowers them', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const set = {
...authored(),
grants: [{ id: 9, userId: 1, permission: 'Kits.GOLD', scope: '*', steamId: '7656001' }],
}
const { payload } = model.buildDesired('main', set)
// Pushed as `kits.gold`, read back as `kits.gold`. Unlowered, the site would
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})