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

179 lines
6.6 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.

// ── What one player holds, as that player reads it ────────────────────────
//
// Phase 8's half of R2. The admin surface answers *who holds what* against the
// authored tables; this answers *what do I hold*, and the two differ in three
// ways that are each a test below:
//
// • the scope is RESOLVED here. A client handed `*` would have to know what
// the fleet is to say anything, and then `inScope` exists twice.
// • `live` is the PUSHED ledger, never the authored row. A grant is not a
// privilege in a game until a sync confirmed it, and phase 7 is careful
// never to record a push that silently did nothing — so "waiting" is an
// honest answer and the alternative is the site claiming to have given
// something it has not.
// • an entitlement reaching NOBODY still lists. Authored against the website
// account, it exists before a Steam id does, and hiding it until one turns
// up is the defect the admin user page shipped in phase 7 (PLAN.md §20.5).
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** The model, wired to a db module answering from one fixture. */
function modelWith(fixture) {
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
const db = require('../model/permissions/permissions.db')
const model = require('../model/permissions/permissions.model')
const originals = {}
for (const [name, value] of Object.entries(fixture)) {
originals[name] = db[name]
db[name] = () => Promise.resolve(value)
}
return { model, restore: () => Object.assign(db, originals) }
}
const SERVERS = [
{ id: 'main', name: 'Main' },
{ id: 'creative', name: 'Creative' },
]
/** One person: in a fleet group, holding one server-scoped grant. */
function fixture({ pushed = [] } = {}) {
return {
// A group on every server (D189: `allServers`, where the old model said `scope: '*'`).
listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 10, allServers: true, addedAt: '2026-09-01T00:00:00Z' }],
listGroupServers: [],
listGroupPermissions: [
{ groupId: 1, permission: 'Kits.VIP' },
{ groupId: 2, permission: 'buildtools.use' },
],
listSteamGrants: [],
listExceptions: [],
listGrants: [
{ id: 7, userId: 4, permission: 'zonemanager.admin', scope: 'creative', source: 'admin', note: null, grantedAt: '2026-09-02T00:00:00Z', steamId: '7656119', playerName: 'Wanderer' },
],
listPushedForSteamIds: pushed,
}
}
test('a fleet scope resolves to every server; a server scope to one', async () => {
const { model, restore } = modelWith(fixture())
try {
const held = await model.forPlayer(4, ['7656119'], SERVERS)
assert.deepEqual(held.groups[0].reach.map((s) => s.id), ['main', 'creative'])
assert.deepEqual(held.grants[0].reach.map((s) => s.id), ['creative'])
} finally {
restore()
}
})
test('live is the pushed ledger, per server — not the authored row', async () => {
const { model, restore } = modelWith(
fixture({ pushed: [{ serverId: 'main', kind: 'member', subject: '7656119', object: 'vip' }] }),
)
try {
const held = await model.forPlayer(4, ['7656119'], SERVERS)
const byId = Object.fromEntries(held.groups[0].reach.map((s) => [s.id, s.live]))
assert.equal(byId.main, true, 'the server that confirmed it has it')
assert.equal(byId.creative, false, 'the one that has not is waiting, not live')
// The grant was never pushed anywhere, and an authored row must not imply one.
assert.deepEqual(held.grants[0].reach.map((s) => s.live), [false])
} finally {
restore()
}
})
test('an entitlement is live for the person when it landed on ANY account they hold', async () => {
// Two accounts, one membership pushed against the second. The game sees one
// player with the rank; so does this.
const { model, restore } = modelWith(
fixture({ pushed: [{ serverId: 'main', kind: 'member', subject: '7656120', object: 'vip' }] }),
)
try {
const held = await model.forPlayer(4, ['7656119', '7656120'], SERVERS)
assert.equal(held.groups[0].reach.find((s) => s.id === 'main').live, true)
} finally {
restore()
}
})
test('a grant and a membership are different rows about the same person', async () => {
// `kind` is why the pushed lookup carries it: a membership of `vip` and a
// direct grant named `vip` would otherwise be one entry in the map, and the
// wrong one would light up.
const { model, restore } = modelWith({
listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 0, allServers: true, addedAt: null }],
listGroupServers: [],
listGroupPermissions: [],
listSteamGrants: [],
listExceptions: [],
listGrants: [{ id: 1, userId: 4, permission: 'vip', scope: '*', source: 'admin', note: null, grantedAt: null, steamId: '7656119' }],
listPushedForSteamIds: [{ serverId: 'main', kind: 'grant', subject: '7656119', object: 'vip' }],
})
try {
const held = await model.forPlayer(4, ['7656119'], SERVERS)
assert.equal(held.grants[0].reach.find((s) => s.id === 'main').live, true)
assert.equal(held.groups[0].reach.find((s) => s.id === 'main').live, false)
} finally {
restore()
}
})
test('a player with no linked account still sees what they were given', async () => {
const { model, restore } = modelWith(fixture())
try {
const held = await model.forPlayer(4, [], SERVERS)
assert.equal(held.groups.length, 1)
assert.equal(held.grants.length, 1)
assert.ok(
[...held.groups[0].reach, ...held.grants[0].reach].every((s) => s.live === false),
'authored, and reaching nobody — which is the state worth showing',
)
} finally {
restore()
}
})
test('only the caller’s own groups carry their permissions, lowered as the store lowers them', async () => {
const { model, restore } = modelWith(fixture())
try {
const held = await model.forPlayer(4, ['7656119'], SERVERS)
// `builder`'s permission is in the group-permission table and this caller is
// not in that group; `Kits.VIP` is theirs, and arrives the way a game stores it.
assert.deepEqual(held.groups[0].permissions, ['kits.vip'])
} finally {
restore()
}
})
test('a fleet with no servers configured reaches nothing and does not throw', async () => {
const { model, restore } = modelWith(fixture())
try {
const held = await model.forPlayer(4, ['7656119'], [])
assert.deepEqual(held.groups[0].reach, [])
assert.deepEqual(held.grants[0].reach, [])
} finally {
restore()
}
})