// 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) })