Files
Module-Rust/server/test/permRewards.test.js
wtclaude 63c82d5a2f
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
feat(rust): permissions as event rewards — lend or give any registered permission (PLAN_REDESIGNS §12)
D342-D346 (docs#342). Two steps over the permission mirror's own tables, so
no plugin, sidecar or protocol change:

- rust.perm.lend, "Permissions until the event ends": a kit reward's lease,
  one rust_perm_run_grants row per recipient per permission, the message on
  one row each; change/ledger with rust.kit.entitle's revert and reconcile;
  budget rust.permissions.
- rust.perm.give, "Permissions for good": a standing rust_perm_grants row per
  website user per permission, scoped to the step's server, source `event`,
  note "event run <run>, step <step>"; a grant already there is left alone,
  and a retry's own grants are counted as given; the message rides a
  notice-only run-grant row; irreversible/none; budget rust.permissions.kept.
- Both: several permissions through core's `multiple` param (MODULE_API
  1.14.0, at most 10); a required "I am sure" toggle refused unticked in the
  dry run and at run time, naming the permissions (D343); a permission the
  server's catalogue lacks refused by name; a blank message tells nobody.
- rust.options.permissions: the enabled servers' catalogues, grouped by the
  registering plugin (by prefix when none), sorted plugin then permission
  without case, a row noted "Only on X" when not on every server.
- Permission manager: an event-given grant shows "given by event run N,
  step M"; staff turn it off like any grant.
- coreApi ^1.14.0; ci/core-ref.json pinned to website#213's head until it
  merges; engagement-triggers.json regenerated; routes.manifest.json
  unchanged (no new routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 15:49:44 -05:00

346 lines
18 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.

// ── Permissions as event rewards (PLAN_REDESIGNS §12, D342–D346) ──────────
//
// Each test is one way the feature could look right and be wrong:
//
// the picker groups by the plugin that registered a permission, by prefix
// when none did, sorts both levels without case, and says where one is missing
// several permissions arrive as core's list (MODULE_API 1.14.0); a bare value
// is a list of one; more than ten is refused
// unticked, both steps refuse in the dry run, naming what they would give (D343)
// a permission the server's catalogue lacks is refused by name, never skipped
// lend writes one row per recipient per permission, the message on one row
// give writes standing grants, source `event`, scoped to the server, leaves one
// already there alone, and tells a retry from a staff member's grant by note
// give's message rides a notice-only row the push grants nothing for
// a for-good grant carries its note to the permission manager
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { statements }
}
withRecorder()
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const linksDb = require('../model/links/links.db')
const permDb = require('../model/permissions/permissions.db')
const model = require('../model/permissions/permissions.model')
const rewards = require('../eventRewards')
const perms = require('../eventPermRewards')
const ok = (data) => ({ ok: true, status: 'ok', data })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const lend = perms.ACTIONS.find((a) => a.id === 'rust.perm.lend')
const give = perms.ACTIONS.find((a) => a.id === 'rust.perm.give')
const CATALOGUE = [
{ serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
{ serverId: 'main', permission: 'betterchat.color', owner: 'BetterChat' },
{ serverId: 'main', permission: 'kits.vip', owner: 'Kits' },
]
function stub(t, { catalogue = CATALOGUE, people = [], links = {}, existing = [], held = [] } = {}) {
const calls = { inserted: [], grants: [], dirty: [], deleted: [], heldAsked: [] }
const saved = {
getServer: serversDb.getServer,
tallySnapshot: client.tallySnapshot,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
listCatalogue: permDb.listCatalogue,
listRunGrantsForStep: permDb.listRunGrantsForStep,
insertRunGrants: permDb.insertRunGrants,
insertGrants: permDb.insertGrants,
listGrantsForUsers: permDb.listGrantsForUsers,
markDirty: permDb.markDirty,
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
}
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
permDb.listCatalogue = async () => catalogue
permDb.listRunGrantsForStep = async () => existing
permDb.insertRunGrants = async (rows) => {
calls.inserted.push(...rows)
return rows.length
}
permDb.insertGrants = async (rows) => {
calls.grants.push(...rows)
return rows.length
}
permDb.listGrantsForUsers = async (userIds, scope) => {
calls.heldAsked.push({ userIds, scope })
return held
}
permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
calls.deleted.push(`${runId}:${stepId}`)
return ['main']
}
t.after(() => {
Object.assign(serversDb, { getServer: saved.getServer })
Object.assign(client, { tallySnapshot: saved.tallySnapshot })
Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds })
Object.assign(permDb, {
listCatalogue: saved.listCatalogue,
listRunGrantsForStep: saved.listRunGrantsForStep,
insertRunGrants: saved.insertRunGrants,
insertGrants: saved.insertGrants,
listGrantsForUsers: saved.listGrantsForUsers,
markDirty: saved.markDirty,
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
})
})
return calls
}
const PARAMS = { server: 'main', permissions: ['nightvision.allowed', 'betterchat.color'], recipients: 'everyone', confirm: true }
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
// ── The declarations ───────────────────────────────────────────────────────
test('lend is a ledgered change and give is irreversible, each on its own budget (D346)', () => {
assert.strictEqual(lend.risk, 'change')
assert.strictEqual(lend.reversible, 'ledger')
assert.strictEqual(typeof lend.revert, 'function')
assert.strictEqual(give.risk, 'irreversible')
assert.strictEqual(give.reversible, 'none')
assert.strictEqual(give.revert, undefined)
// People times permissions: the count for a count, the bound for every other mode.
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['a.x', 'b.y'] }), { 'rust.permissions': 6 })
assert.deepStrictEqual(give.cost({ recipients: 'linked', permissions: ['a.x'] }), { 'rust.permissions.kept': rewards.MAX_RECIPIENTS })
// The switchboard prices the example as a list of one.
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['nightvision.allowed'] }), { 'rust.permissions': 3 })
assert.deepStrictEqual(perms.BUDGETS.map((b) => b.id), ['rust.permissions', 'rust.permissions.kept'])
})
test('the permissions param is core\'s list, at most ten, and confirm is a required toggle', () => {
for (const action of [lend, give]) {
const list = action.params.find((p) => p.name === 'permissions')
assert.strictEqual(list.multiple, true)
assert.strictEqual(list.max, perms.MAX_PERMISSIONS)
assert.strictEqual(list.source, 'rust.options.permissions')
assert.strictEqual(Array.isArray(list.example), false)
const confirm = action.params.find((p) => p.name === 'confirm')
assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false])
}
// Each step's own message example says how long the perk lasts.
assert.match(lend.params.find((p) => p.name === 'message').example, /until the event ends/)
assert.doesNotMatch(give.params.find((p) => p.name === 'message').example, /until the event ends/)
})
test('a list is lower case, once each, a bare value is a list of one, and eleven is refused', () => {
assert.deepStrictEqual(perms.checkPermissions(['NightVision.Allowed', ' kits.vip ', 'nightvision.allowed']).value, ['nightvision.allowed', 'kits.vip'])
assert.deepStrictEqual(perms.checkPermissions('kits.vip').value, ['kits.vip'])
assert.match(perms.checkPermissions([]).error, /at least one/)
assert.match(perms.checkPermissions(['two words']).error, /not a permission name/)
const eleven = Array.from({ length: 11 }, (_, i) => `p.n${i}`)
assert.match(perms.checkPermissions(eleven).error, /at most 10 permissions, not 11/)
})
// ── The picker (§12.2) ─────────────────────────────────────────────────────
test('the picker groups by plugin, by prefix when none registered it, sorted both levels without case', () => {
const enabled = [{ id: 'main', name: 'Main' }, { id: 'pvp', name: 'PvP' }]
const rows = perms.permissionRows(
[
{ serverId: 'main', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
{ serverId: 'pvp', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
{ serverId: 'main', permission: 'Betterchat.Color', owner: 'BetterChat' },
{ serverId: 'pvp', permission: 'betterchat.color', owner: 'BetterChat' },
{ serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
{ serverId: 'main', permission: 'admin.teleport', owner: null },
{ serverId: 'pvp', permission: 'admin.teleport', owner: null },
{ serverId: 'main', permission: 'betterchat.admin', owner: 'BetterChat' },
{ serverId: 'pvp', permission: 'betterchat.admin', owner: 'BetterChat' },
// A disabled server's catalogue is not offered.
{ serverId: 'off', permission: 'secret.thing', owner: 'Secret' },
],
enabled,
)
assert.deepStrictEqual(rows.map((r) => [r.group, r.value]), [
['admin (not registered by a plugin)', 'admin.teleport'],
['BetterChat', 'betterchat.admin'],
['BetterChat', 'Betterchat.Color'],
['NightVision', 'nightvision.allowed'],
['ZoneManager', 'zonemanager.ignoreflag.nokits'],
])
// One server only: the row says which.
assert.strictEqual(rows.find((r) => r.value === 'nightvision.allowed').note, 'Only on Main')
assert.strictEqual('note' in rows.find((r) => r.value === 'admin.teleport'), false)
})
// ── What both steps refuse ─────────────────────────────────────────────────
test('unticked, both steps refuse in the dry run, naming what they would give (D343)', async (t) => {
stub(t)
for (const action of [lend, give]) {
for (const confirm of [false, undefined, 'true']) {
const result = await action.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, confirm }, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.strictEqual(result.error, 'Tick "I am sure" to make nightvision.allowed, betterchat.color a reward')
}
}
})
test('a permission the server did not register is refused by name, in the dry run too', async (t) => {
stub(t)
const result = await give.perform({ runId: 7, stepId: 4, params: { ...PARAMS, permissions: ['nightvision.allowed', 'removertool.remove'] }, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.strictEqual(result.error, 'Main has no permission removertool.remove — its plugins did not register it')
})
test('a server that has reported no catalogue yet is a wait, not a refusal', async (t) => {
stub(t, { catalogue: [] })
const result = await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true })
assert.strictEqual(result.ok, false)
assert.notStrictEqual(result.retry, false)
assert.match(result.error, /Main has reported no permissions yet/)
})
test('a dry run that passes writes nothing', async (t) => {
const calls = stub(t)
assert.deepStrictEqual(await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
assert.deepStrictEqual(await give.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
assert.deepStrictEqual([calls.inserted, calls.grants, calls.dirty], [[], [], []])
})
// ── Until the run ends (§12.4) ─────────────────────────────────────────────
test('lend writes a row per recipient per permission, the message on one row each', async (t) => {
const calls = stub(t, { people: [person(A, 10), person(B, 5), person(C, 0)], links: { [A]: 1, [B]: 2, [C]: 3 } })
const result = await lend.perform({
runId: 7, stepId: 4, idempotencyKey: 'k-1',
params: { ...PARAMS, message: 'You have {permissions} until the end', delivery: 'popup' },
})
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.lent, 2)
assert.deepStrictEqual(result.resources, [{ kind: 'permission-lease', ref: 'main:7:4', payload: { serverId: 'main', permissions: ['nightvision.allowed', 'betterchat.color'] } }])
assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice || null]), [
[1, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
[1, 'betterchat.color', '', false, null],
[2, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
[2, 'betterchat.color', '', false, null],
])
assert.strictEqual(calls.inserted[0].noticeDelivery, 'popup')
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('a blank message tells nobody (§12.6)', async (t) => {
const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' } })
assert.strictEqual(result.ok, true)
assert.strictEqual(calls.inserted.some((r) => r.notice), false)
assert.strictEqual('told' in result.detail, false)
})
test('a repeated key finds the rows and lends nothing new; the revert is a kit reward\'s', async (t) => {
const calls = stub(t, { existing: [{ userId: 1 }, { userId: 1 }, { userId: 2 }] })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.strictEqual(result.detail.repeat, true)
assert.strictEqual(result.detail.lent, 2)
assert.deepStrictEqual(calls.inserted, [])
assert.deepStrictEqual(await lend.revert({ runId: 7, resources: result.resources, idempotencyKey: 'k-1' }), { ok: true })
assert.deepStrictEqual(calls.deleted, ['7:4'])
assert.deepStrictEqual(calls.dirty, ['main'])
})
// ── For good (§12.5) ───────────────────────────────────────────────────────
test('give writes standing grants scoped to the server, source event, with the step in the note', async (t) => {
const calls = stub(t, { people: [person(A, 10), person(B, 5)], links: { [A]: 1, [B]: 2 } })
const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.resources, undefined)
assert.deepStrictEqual(calls.heldAsked, [{ userIds: [1, 2], scope: 'main' }])
assert.deepStrictEqual(calls.grants, [
{ userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 1, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
])
assert.strictEqual(result.detail.given, 4)
// No message: no notice row at all.
assert.deepStrictEqual(calls.inserted, [])
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('a grant already there is left alone; this step\'s own is counted as given on a retry', async (t) => {
const calls = stub(t, {
people: [person(A, 10), person(B, 5)],
links: { [A]: 1, [B]: 2 },
held: [
{ userId: 1, permission: 'nightvision.allowed', source: 'admin', note: null },
{ userId: 2, permission: 'nightvision.allowed', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'betterchat.color', source: 'event', note: 'event run 7, step 4' },
],
})
const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.deepStrictEqual(calls.grants.map((g) => [g.userId, g.permission]), [[1, 'betterchat.color']])
assert.strictEqual(result.detail.given, 3)
assert.strictEqual(result.detail.alreadyHeld, 1)
assert.strictEqual(result.detail.repeat, true)
})
test('give\'s message rides a notice-only row the push grants nothing for', async (t) => {
const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: 'You keep {permissions}' } })
assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice, r.noticeDelivery]), [
[1, '', '', false, 'You keep nightvision.allowed, betterchat.color', 'chat'],
])
const { payload } = model.buildDesired('main', {
groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
grants: [], steamGrants: [], exceptions: [], groupChat: [],
steamIdsByUser: new Map([[1, [A]]]),
runGrants: calls.inserted.map((r) => ({ ...r, noticeToldAt: null })),
})
assert.deepStrictEqual(payload.grants, [])
assert.deepStrictEqual(payload.notices.map((n) => [n.key, n.steamId, n.message]), [['7:4:1', A, 'You keep nightvision.allowed, betterchat.color']])
})
test('a for-good grant carries its note to the permission manager; a staff grant does not grow one', () => {
const { sources } = model.buildDesired('main', {
groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
grants: [
{ id: 9, userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ id: 10, userId: 1, permission: 'kits.vip', scope: 'main', source: 'admin', note: null },
],
steamGrants: [], exceptions: [], groupChat: [], runGrants: [],
steamIdsByUser: new Map([[1, [A]]]),
})
assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'nightvision.allowed' })), [
{ type: 'userGrant', id: 9, userId: 1, scope: 'main', origin: 'event', note: 'event run 7, step 4' },
])
assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'kits.vip' })), [
{ type: 'userGrant', id: 10, userId: 1, scope: 'main' },
])
})