// ── 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']) }) // ── 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.managed.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, []) })