Files
Module-Rust/server/test/permissions.test.js
wtclaude a3bcec9cde feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9)
- registerEventActions: rust.zone.open and rust.prefab.place, both
  reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the
  client's 12 s. A location is a monument (kind + instance, carrying its
  server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the
  plugin so a bad step is refused on the form (D95); zone minutes required and
  held by the game (D96).
- registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each
  beside the verb that spends it (D79, D89).
- Option sources rust.options.monuments (live, searchable) and
  rust.options.prefabs (mirrored, answers with every server off), registered in
  the one batch core accepts alongside the lease sources.
- Refs are <serverId>:<id>, since revert and reconcile get no params. The undo
  sends no idempotency key; a lost answer is reverted by key on every server.
  reconcile asks the plugin, and a server that cannot be asked keeps its rows.
- The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart
  or a wipe, never on a first sighting or a reconnect (§11.1).
- The permission mirror keeps the plugin's new notLanded grants out of what it
  records as pushed, and the admin page says so (D85).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 01:26:57 -05:00

323 lines
12 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 grant the store did not hold after the plugin read it back is not recorded as pushed (D85)', 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: 'zonemanager.zone' },
{ kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
],
}
// Protocol 9's read-back. The case it exists for is phase 7's owner bug: a
// grant the plugin made that never reached Oxide's store, which the site had
// been recording as pushed.
const report = {
kind: 'perm.report',
applied: { grants: 1 },
unresolved: [],
pending: [],
notLanded: ['7656001:ZoneManager.Zone', 'vip:kits.vip'],
foreign: [],
}
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'))
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
assert.ok(!recorded.includes('zonemanager.zone'), 'a grant that did not land is not, whatever its case')
assert.ok(!recorded.includes('kits.vip'), 'nor a group permission that did not land')
})
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'])
})