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

311 lines
12 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.

// ── Three sets, and what a change made in the game becomes (PLAN_REDESIGNS §1) ──
//
// The reconciler decides what an in-game change becomes: the site's own
// (auto-adopt), a question for a person (adopt), or undone (revoke) — and the
// first read of a server imports everything (D198). Two rules here protect the
// site's own grants, and each has a test: a permission the server has not
// REGISTERED right now is never judged (a plugin unloaded for a minute is not a
// revocation), and a pair an event lease holds is the lease's.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function load() {
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
return {
reconcile: require('../model/permissions/reconcile'),
model: require('../model/permissions/permissions.model'),
}
}
const REGISTERED = new Set(['kits.vip', 'kits.gold', 'zonemanager.zone'])
test('the inventory becomes ledger-shaped rows, and `default` membership is not judged', () => {
const { reconcile, model } = load()
const present = reconcile.presentRows({
groups: [{ name: 'VIP', title: 'VIP ', rank: 2, parent: 'default', permissions: ['Kits.VIP'] }],
users: [{ steamId: '7656001', permissions: ['kits.gold'], groups: ['vip', 'default'] }],
})
assert.deepEqual(present.map(model.rowKey).sort(), [
'grant 7656001 kits.gold',
'group vip ',
'group-permission vip kits.vip',
'member 7656001 default',
'member 7656001 vip',
])
// The title is kept verbatim — Carbon's own end in a space.
assert.equal(present.find((r) => r.kind === 'group').value, model.groupValue('VIP ', 2, 'default'))
const classes = reconcile.classify({ present, pushed: [], desired: [], registered: REGISTERED })
assert.ok(!classes.added.some((r) => r.kind === 'member' && r.object === 'default'))
})
test('added, removed, changed and landed are told apart by the pushed ledger', () => {
const { reconcile, model } = load()
const v = (t) => model.groupValue(t, 0, '')
const present = [
{ kind: 'grant', subject: 's1', object: 'kits.vip' }, // nobody's: added in the game
{ kind: 'grant', subject: 's2', object: 'kits.gold' }, // wanted, never pushed: landed
{ kind: 'group', subject: 'vip', object: '', value: v('Changed') },
]
const pushed = [
{ kind: 'grant', subject: 's3', object: 'kits.gold' }, // pushed, wanted, gone: removed
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const desired = [
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
{ kind: 'grant', subject: 's3', object: 'kits.gold' },
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const c = reconcile.classify({ present, pushed, desired, registered: REGISTERED })
assert.deepEqual(c.added.map(model.rowKey), ['grant s1 kits.vip'])
assert.deepEqual(c.removed.map(model.rowKey), ['grant s3 kits.gold'])
assert.deepEqual(c.landed.map(model.rowKey), ['grant s2 kits.gold'])
assert.deepEqual(c.changed.map((r) => r.value), [v('Changed')])
})
test('a permission the server does not register right now is never judged — an unloaded plugin is not a revocation', () => {
const { reconcile } = load()
// `kits.vip` was pushed and is wanted, and the inventory does not have it —
// because Kits is unloaded, so the name is not registered.
const c = reconcile.classify({
present: [],
pushed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
desired: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
registered: new Set(['zonemanager.zone']),
})
assert.equal(c.removed.length, 0)
})
test('a pair an event lease holds is the lease’s, not a hand edit', () => {
const { reconcile } = load()
const c = reconcile.classify({
present: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
pushed: [],
desired: [],
registered: REGISTERED,
leased: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
})
assert.equal(c.added.length, 0)
})
test('a group removed in the game takes its permissions and members with it', () => {
const { reconcile, model } = load()
const rows = [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 0, '') },
{ kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
{ kind: 'member', subject: 's1', object: 'vip' },
]
const c = reconcile.classify({ present: [], pushed: rows, desired: rows, registered: REGISTERED })
assert.deepEqual(c.removed.map(model.rowKey), ['group vip '])
})
test('the first inventory imports additions whatever the policy, and pushes removals back (D198)', () => {
const { reconcile } = load()
const classes = {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'vip', object: '', value: '["VIP",1,""]' },
{ kind: 'member', subject: 's1', object: 'vip' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
}
for (const policy of ['auto-adopt', 'adopt', 'revoke']) {
const p = reconcile.plan({ classes, policy, importing: true, sources: new Map() })
// Groups first: a membership is written into a group that must exist.
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGroup', 'adoptGrant', 'adoptMember'], policy)
assert.ok(p.ops.every((o) => o.source === undefined || o.source === 'imported'))
assert.equal(p.ops.find((o) => o.op === 'adoptGroup').title, 'VIP')
assert.equal(p.revocations.length, 0)
assert.equal(p.hold.size, 0)
}
})
test('auto-adopt makes an addition the site’s and a removal the site’s withdrawal (D190)', () => {
const { reconcile } = load()
const sources = new Map([['grant s3 kits.gold', [{ type: 'steamGrant', id: 9, scope: 'main' }]]])
const p = reconcile.plan({
classes: {
added: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'auto-adopt',
importing: false,
sources,
})
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGrant', 'dropGrant'])
assert.equal(p.ops[0].source, 'adopted')
assert.deepEqual(p.ops[1].sources, [{ type: 'steamGrant', id: 9, scope: 'main' }])
})
test('auto-adopt leaves an event’s grant to the event, pushes it back and tells a person', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: { added: [], removed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], changed: [], landed: [] },
policy: 'auto-adopt',
importing: false,
sources: new Map([['grant s1 kits.vip', [{ type: 'runGrant', runId: '4', stepId: '1' }]]]),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift, [{ kind: 'grant', subject: 's1', object: 'kits.vip', direction: 'removed', detail: 'event' }])
assert.equal(p.hold.size, 0, 'pushed back, not held')
})
test('adopt asks a person about every change, and holds removals and changed groups until then', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [{ kind: 'group', subject: 'raid', object: '', value: '["Raiders",0,""]' }],
removed: [{ kind: 'member', subject: 's1', object: 'vip' }],
changed: [{ kind: 'group', subject: 'vip', object: '', value: '["V",0,""]' }],
landed: [],
},
policy: 'adopt',
importing: false,
sources: new Map(),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift.map((d) => d.direction), ['added', 'removed', 'changed'])
assert.equal(p.drift[0].detail, '["Raiders",0,""]', 'an added group keeps its title for adopting later')
assert.deepEqual([...p.hold].sort(), ['group vip ', 'member s1 vip'])
})
test('revoke undoes an addition at this sync, and never a built-in group', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'admin', object: '' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'revoke',
importing: false,
sources: new Map(),
})
assert.deepEqual(p.revocations, [{ kind: 'grant', subject: 's1', object: 'kits.vip' }])
assert.equal(p.ops.length, 0, 'a removal is pushed back by the desired set, with nothing to do')
})
test('a held row is neither pushed nor recorded, and a held group keeps its place without its title', () => {
const { reconcile, model } = load()
const desired = {
hash: 'h',
rows: [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 1, '') },
{ kind: 'member', subject: 's1', object: 'vip' },
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
],
payload: {
groups: [{ name: 'vip', title: 'VIP', rank: 1, parent: '', permissions: [], members: ['s1'] }],
grants: [{ steamId: 's2', permissions: ['kits.gold'] }],
credits: [],
},
}
const held = reconcile.withHold(desired, new Set(['group vip ', 'member s1 vip', 'grant s2 kits.gold']))
assert.deepEqual(held.payload.groups, [{ name: 'vip', permissions: [], members: [] }])
assert.deepEqual(held.payload.grants, [])
assert.deepEqual(held.rows, [])
assert.equal(held.hash, 'h', 'the digest is the whole desired set’s')
// …and a held row is not retired either: it is still wanted.
assert.deepEqual(model.retirements(desired.rows, desired.rows.slice(1), new Set(['group vip '])), [])
})
test('a built-in group is never retired, even when the site no longer has it', () => {
const { model } = load()
const pushed = [
{ kind: 'group', subject: 'default', object: '' },
{ kind: 'group', subject: 'raid', object: '' },
]
assert.deepEqual(model.retirements(pushed, []).map((r) => r.subject), ['raid'])
})
test('a group shared with other servers is marked so in the sources; a server-only one is not (D190)', () => {
const { model } = load()
const desired = model.buildDesired('main', {
groups: [
{ id: 1, name: 'vip', title: 'VIP', rank: 0, parent: '', allServers: true },
{ id: 2, name: 'raid', title: 'Raid', rank: 0, parent: '', allServers: false },
],
groupServers: [{ groupId: 2, serverId: 'main', included: true }],
groupPermissions: [{ groupId: 1, permission: 'kits.vip' }, { groupId: 2, permission: 'kits.gold' }],
members: [],
steamMembers: [{ groupId: 2, steamId: 's1' }],
grants: [],
steamGrants: [],
exceptions: [],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
})
assert.equal(desired.sources.get('group-permission vip kits.vip')[0].shared, true)
assert.equal(desired.sources.get('group-permission raid kits.gold')[0].shared, false)
assert.equal(desired.sources.get('member s1 raid')[0].type, 'steamMember')
})
test('an exception keeps a fleet grant off one server and on every other (D190)', () => {
const { model } = load()
const set = {
groups: [],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [{ id: 5, steamId: 's1', permission: 'kits.vip', scope: '*' }],
exceptions: [{ id: 1, holder: 'steam', grantId: 5, serverId: 'main' }],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
}
assert.ok(!model.buildDesired('main', set).rows.some((r) => r.kind === 'grant'))
assert.ok(model.buildDesired('creative', set).rows.some((r) => r.kind === 'grant' && r.subject === 's1'))
})
test('a group excluded from one server by a split is on every other server', () => {
const { model } = load()
const group = { id: 1, name: 'vip', allServers: true }
const rows = model.serversByGroup([{ groupId: 1, serverId: 'main', included: false }])
assert.equal(model.groupCovers(group, rows, 'main'), false)
assert.equal(model.groupCovers(group, rows, 'creative'), true)
assert.equal(model.isShared(group, rows), true)
})