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
This commit is contained in:
345
server/test/permRewards.test.js
Normal file
345
server/test/permRewards.test.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// ── 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' },
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user