feat(teams): the four-path access resolver and staff leadership overrides
The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they stay four: four tables answering four questions, and no resolver reads another path's table. 1. Is this account a member? module team_members 2. Does this account lead the Team? module team_members.is_leader + override 3. May it use the Team forum? CORE team_forum_grants OR path 1 4. May it get external access? CORE derived, nothing of its own The temptation this resists is collapsing 1 and 3 into one boolean. They answer different questions about different populations: a forum grant may name any Runic Gateway account, including one with no game identity at all -- that is the point of it, since letting an unlinked guildmate into a forum must not require a staff ticket. Reading "has forum access" as "is a member" would put that person on the public roster, into every membership count, and into the external-platform grant, which is where a modelling preference becomes an impersonation risk. Path 4 is deliberately blind to path 3, and the reason is written down so nobody "fixes" it: an integration cannot verify that an unlinked, forum-granted account corresponds to a real game member, so it must not hand that account a privilege on a platform where impersonation has consequences. A forum is a room on the operator's own site with a known moderator; a Discord role is an identity claim in someone else's space. Leadership overrides are applied ON TOP of the synced value at read time, never written into the projection. The sync owns that column and rewrites it every interval, so an override stored there would be undone fifteen minutes after staff set it -- which is the whole reason §2.5.1 is a separate table. The roster carries both the resolved answer and `is_leader_synced`, so an admin sees that a decision was made rather than being shown it as fact. Three tests are named INVARIANT rather than for behaviour, because what they protect is structural and a reasonable-looking refactor destroys it silently: a grant never writes the membership projection, a granted user is absent from the roster, and a grant does not confer external eligibility. None of those failures appears on a screen as a bug -- the first shows up as a stranger on a public roster, the second as a Discord role handed to an account nobody can tie to a real player. Every unit test here stubs the db layer, so the SQL itself was verified separately: all 44 statements across teams.db.js and teamAccess.db.js were run against MariaDB 11 with a throwaway module id and cleaned up after. That run also confirmed live what the reconciler's tests could only assert against a stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique key for a new one while the ledger keeps both, and an archived team stays resolvable at its old slug while its external_id is free for the successor row. 19 tests. Full suite 828 passed, 0 failed. Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
209
server/test/teamAccess.test.js
Normal file
209
server/test/teamAccess.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// The four authority paths, and the rule that they stay four
|
||||
// (docs/website/TEAMS.md §2.5).
|
||||
//
|
||||
// Two of these tests are named for invariants rather than for behaviour, because
|
||||
// what they protect is a structural property that a perfectly reasonable-looking
|
||||
// refactor destroys: "has forum access" is never read as "is a member", and a
|
||||
// grant never writes the membership projection. Both are one `||` away from being
|
||||
// wrong, and neither failure is visible on any screen — the first shows up as a
|
||||
// stranger on a public roster, the second as a Discord role handed to an account
|
||||
// nobody can tie to a real player.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const identities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const access = require('../src/model/teams/teamAccess.model')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
// Every read the four paths can make, stubbed to "nothing there". Each test then
|
||||
// states only the fact it is about, which is what makes a crossed path obvious:
|
||||
// a resolver reading a table it should not would come back empty and the
|
||||
// assertion would say so.
|
||||
function stubAll() {
|
||||
patch(accessDb, 'activeGrant', async () => undefined)
|
||||
patch(accessDb, 'overrideFor', async () => undefined)
|
||||
patch(accessDb, 'overridesForTeam', async () => [])
|
||||
patch(teamsDb, 'activeByUser', async () => undefined)
|
||||
patch(teamsDb, 'membersByTeam', async () => [])
|
||||
patch(identities, 'listForUser', async () => [])
|
||||
}
|
||||
|
||||
const memberRow = (extra = {}) => ({
|
||||
team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra,
|
||||
})
|
||||
const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra })
|
||||
|
||||
beforeEach(stubAll)
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [mod, name, fn] = saved.pop()
|
||||
mod[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
// ── Path 3: forum access is membership OR a grant ──────────────────────────
|
||||
|
||||
test('a member has forum access via membership', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false })
|
||||
})
|
||||
|
||||
test('a granted non-member has forum access via the grant', async () => {
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false })
|
||||
})
|
||||
|
||||
test('both reasons are reported when both hold', async () => {
|
||||
// Not collapsed into one boolean: both facts are true, membership is what the
|
||||
// UI shows as the current reason, and the grant stays as the record of who let
|
||||
// this person in before they were a member.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.viaMembership, true)
|
||||
assert.equal(result.viaGrant, true)
|
||||
})
|
||||
|
||||
test('a revoked grant and no membership is no access', async () => {
|
||||
// activeGrant returns nothing for a revoked row — the resolver never sees one.
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, false)
|
||||
})
|
||||
|
||||
test('an anonymous caller is refused without touching a table', async () => {
|
||||
let reads = 0
|
||||
patch(accessDb, 'activeGrant', async () => { reads += 1 })
|
||||
patch(teamsDb, 'activeByUser', async () => { reads += 1 })
|
||||
const result = await access.forumAccess(1, null)
|
||||
assert.equal(result.allowed, false)
|
||||
assert.equal(reads, 0)
|
||||
})
|
||||
|
||||
// ── Invariant 3: non-contamination ─────────────────────────────────────────
|
||||
|
||||
test('INVARIANT — a grant never writes the membership projection', async () => {
|
||||
// The grant path reads its own table and nothing else. Asserted by making every
|
||||
// membership WRITE explode: if resolving a grant ever wrote a member row, this
|
||||
// is where it would surface.
|
||||
for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) {
|
||||
patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) })
|
||||
}
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, true)
|
||||
assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction')
|
||||
})
|
||||
|
||||
test('INVARIANT — a granted, unlinked user is not on the roster', async () => {
|
||||
// The roster is path 1's table alone. A granted user with no membership row
|
||||
// appears nowhere in it, which is what keeps them out of every membership count.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 }))
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow()])
|
||||
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster.length, 1)
|
||||
assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member')
|
||||
})
|
||||
|
||||
// ── Path 4: external access is blind to path 3 ─────────────────────────────
|
||||
|
||||
test('INVARIANT — a forum grant does not make an account externally eligible', async () => {
|
||||
// The named test from §2.5. An integration cannot verify that an unlinked,
|
||||
// forum-granted account corresponds to a real game member, so it must not hand
|
||||
// that account a privilege on a platform where impersonation has consequences.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
test('a linked member with a linked Discord identity is eligible', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 }))
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), true)
|
||||
})
|
||||
|
||||
test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
test('eligibility is per platform, not "linked to anything"', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'matrix'), false)
|
||||
})
|
||||
|
||||
test('a non-member is never eligible', async () => {
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
// ── Path 2: leadership, and the staff override on top ──────────────────────
|
||||
|
||||
test('leadership follows the synced value when no override exists', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), true)
|
||||
})
|
||||
|
||||
test('a deny override outranks a synced leader', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), false)
|
||||
})
|
||||
|
||||
test('a grant override promotes someone the game does not call a leader', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), true)
|
||||
})
|
||||
|
||||
test('an override survives a resync, because it is never written into the projection', async () => {
|
||||
// The projection keeps saying what the game says; the override keeps saying what
|
||||
// staff decided. Applied at READ time, so a sync fifteen minutes later cannot
|
||||
// undo it — which is the entire point of §2.5.1.
|
||||
patch(accessDb, 'overridesForTeam', async () => [
|
||||
{ member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' },
|
||||
])
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
|
||||
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster[0].is_leader, false, 'the resolved answer is the override')
|
||||
assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible')
|
||||
assert.equal(roster[0].leader_override.reason, 'harassment')
|
||||
assert.equal(roster[0].leader_override.by, 'mod1')
|
||||
})
|
||||
|
||||
test('a member with no override carries no override field', async () => {
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster[0].leader_override, null)
|
||||
assert.equal(roster[0].is_leader, true)
|
||||
})
|
||||
|
||||
test('leadership resolves through forumAccess too, override included', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.isLeader, true)
|
||||
})
|
||||
|
||||
test('a granted non-member is never a leader', async () => {
|
||||
// isLeader is path 2, which is a property of a MEMBER row. Someone with only a
|
||||
// forum grant has no member row, so there is nothing to promote.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, true)
|
||||
assert.equal(result.isLeader, false)
|
||||
})
|
||||
Reference in New Issue
Block a user