// ── 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']) })