Files
Module-Rust/server/test/permissions.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

389 lines
16 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 {
// A group on every server, and one on `creative` alone (D189).
groups: [
{ id: 1, name: 'vip', title: 'VIP', rank: 10, parent: '', allServers: true },
{ id: 2, name: 'builder', title: 'Builder', rank: 0, parent: '', allServers: false },
],
groupServers: [{ groupId: 2, serverId: 'creative', included: true }],
groupPermissions: [
{ groupId: 1, permission: 'kits.vip' },
{ groupId: 2, permission: 'buildtools.use' },
],
members: [
{ groupId: 1, userId: 1 },
{ groupId: 2, userId: 2 },
],
steamMembers: [],
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' },
],
steamGrants: [],
exceptions: [],
groupChat: [],
// 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 reaches nobody, and is not an error', () => {
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, and
// the grant is still the site's: it reaches them the day they link.
assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null))
assert.deepEqual(payload.grants.map((g) => g.steamId).sort(), ['7656001', '7656099'])
// Protocol 13 sends no `managed` namespace: the inventory reads the whole store.
assert.equal(payload.managed, undefined)
})
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.
const holds = (desired, permission) => desired.payload.grants.some((g) => g.permissions.includes(permission))
assert.ok(!holds(main, 'zonemanager.admin'))
assert.ok(holds(creative, '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 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 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'])
})
// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const set = {
...authored(),
runGrants: [
// User 1 won on main with their second account; the grant reaches both
// accounts (D28), the credit only the one that took part (D103).
{ runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
// The same permission an admin already grants user 1: one row in the game.
{ runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
// A kit anybody may redeem: a credit and no permission at all.
{ runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
// A win on another server reaches nothing here (D102).
{ runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
// An account unlinked since the win earns its credit nowhere.
{ runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
],
}
const { payload, rows, hash } = model.buildDesired('main', set)
const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
assert.ok(!payload.grants.some((g) => g.permissions.includes('kits.creative')))
assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
assert.deepEqual(payload.credits, [
{ steamId: '7656001', kit: 'gold', count: 1 },
{ steamId: '7656002', kit: 'starter', count: 1 },
{ steamId: '7656099', kit: 'event', count: 1 },
])
// Credits push but never enter the pushed ledger: a use of a kit is not
// something in the permission store to retire.
assert.ok(!rows.some((r) => r.kind === 'credit'))
// A revert of the last reward still moves the digest, so it is pushed.
const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
assert.notStrictEqual(withdrawn.hash, hash)
})
test('the permission an admin grants survives an event revert of the same one (D84)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
const after = model.buildDesired('main', { ...authored(), runGrants: [] })
// Nothing to retire: the admin grant still desires every row the event did.
assert.deepEqual(model.retirements(before.rows, after.rows), [])
assert.deepEqual(after.payload.credits, [])
})