feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
94
server/src/model/teams/teamAccess.db.js
Normal file
94
server/src/model/teams/teamAccess.db.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// SQL for the two tables the access resolver reads: forum grants (path 3) and
|
||||||
|
// staff leadership overrides (§2.5.1).
|
||||||
|
//
|
||||||
|
// Kept separate from teams.db.js on purpose. The four authority paths are four
|
||||||
|
// tables answering four questions, and the single most important structural rule
|
||||||
|
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
|
||||||
|
// a cheap way to make crossing one visible in a diff.
|
||||||
|
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GRANT_COLUMNS = `
|
||||||
|
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
|
||||||
|
revoked_by, revoked_username, revoked_at, revoke_reason`
|
||||||
|
|
||||||
|
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
|
||||||
|
async function activeGrant(teamId, userId) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
|
||||||
|
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||||
|
[teamId, userId],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole ledger for a team, revoked rows included — the admin grant view. */
|
||||||
|
async function grantLedger(teamId) {
|
||||||
|
return query(
|
||||||
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
|
||||||
|
[teamId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active grants only, for the "Forum guests" list and the per-team cap. */
|
||||||
|
async function activeGrants(teamId) {
|
||||||
|
return query(
|
||||||
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
|
||||||
|
ORDER BY granted_at`,
|
||||||
|
[teamId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||||
|
|
||||||
|
async function overridesForTeam(teamId) {
|
||||||
|
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
|
||||||
|
[teamId])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function overrideFor(teamId, memberKey) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
|
||||||
|
[teamId, memberKey],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or replace one override.
|
||||||
|
*
|
||||||
|
* The projection is never touched by this — `team_members.is_leader` keeps saying
|
||||||
|
* what the game says and this keeps saying what staff decided, which is the entire
|
||||||
|
* point (§2.5.1). An override applied INTO the projection would be clobbered by
|
||||||
|
* the next sync, fifteen minutes later.
|
||||||
|
*/
|
||||||
|
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
|
||||||
|
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
|
||||||
|
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOverride(teamId, memberKey) {
|
||||||
|
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
|
||||||
|
[teamId, memberKey])
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
activeGrant,
|
||||||
|
grantLedger,
|
||||||
|
activeGrants,
|
||||||
|
overridesForTeam,
|
||||||
|
overrideFor,
|
||||||
|
setOverride,
|
||||||
|
clearOverride,
|
||||||
|
}
|
||||||
131
server/src/model/teams/teamAccess.model.js
Normal file
131
server/src/model/teams/teamAccess.model.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// ── The four authority paths ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The single most important structural rule in TEAMS.md (§2.5): these are 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,
|
||||||
|
// plus a staff override
|
||||||
|
// 3. May it use the Team forum? CORE team_forum_grants OR path 1
|
||||||
|
// 4. May it get external-platform CORE, nothing of its own
|
||||||
|
// access? derived
|
||||||
|
//
|
||||||
|
// The temptation this file exists to resist 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 the
|
||||||
|
// forum must not require a staff ticket. Treating "has forum access" as "is a
|
||||||
|
// member" would put that person on the roster, in the member count, and into the
|
||||||
|
// external-platform grant, which is where it stops being a modelling preference
|
||||||
|
// and becomes an impersonation risk (path 4 below).
|
||||||
|
//
|
||||||
|
// Non-contamination is the invariant: a manual grant never writes the membership
|
||||||
|
// projection, in either direction, ever. Both facts coexist and neither migrates
|
||||||
|
// into the other.
|
||||||
|
|
||||||
|
const accessDb = require('./teamAccess.db')
|
||||||
|
const teamsDb = require('./teams.db')
|
||||||
|
const identities = require('../userIdentities/userIdentities.model')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path 3 — forum access. Two reads, OR'd, and nothing else.
|
||||||
|
*
|
||||||
|
* `viaGrant` is reported even when membership also holds, deliberately: both
|
||||||
|
* facts are true, the UI presents membership as the current reason, and the grant
|
||||||
|
* survives as audit history. Collapsing them into one boolean is what loses the
|
||||||
|
* record of who let this person in and why.
|
||||||
|
*/
|
||||||
|
async function forumAccess(teamId, userId) {
|
||||||
|
if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
|
||||||
|
|
||||||
|
const [grant, member] = await Promise.all([
|
||||||
|
accessDb.activeGrant(teamId, userId), // path 3's own table
|
||||||
|
teamsDb.activeByUser(teamId, userId), // path 1
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: Boolean(grant) || Boolean(member),
|
||||||
|
viaMembership: Boolean(member),
|
||||||
|
viaGrant: Boolean(grant),
|
||||||
|
isLeader: member ? await isLeader(teamId, member) : false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path 2 — leadership, with the staff override applied ON TOP of the synced value
|
||||||
|
* at read time (§2.5.1).
|
||||||
|
*
|
||||||
|
* Applied at read rather than written into the projection because the sync owns
|
||||||
|
* that column and rewrites it every interval. An override that lived in
|
||||||
|
* `team_members` would be undone fifteen minutes after staff set it, which is the
|
||||||
|
* whole reason this is a separate table read here.
|
||||||
|
*/
|
||||||
|
async function isLeader(teamId, member) {
|
||||||
|
if (!member) return false
|
||||||
|
const override = await accessDb.overrideFor(teamId, member.member_key)
|
||||||
|
if (override) return override.effect === 'grant'
|
||||||
|
return Boolean(member.is_leader)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leadership for a caller identified by user id rather than by a member row. */
|
||||||
|
async function isLeaderByUser(teamId, userId) {
|
||||||
|
if (!userId) return false
|
||||||
|
const member = await teamsDb.activeByUser(teamId, userId)
|
||||||
|
return isLeader(teamId, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path 4 — external-platform eligibility. Computed, no table of its own, and
|
||||||
|
* deliberately blind to path 3.
|
||||||
|
*
|
||||||
|
* The reason, stated so nobody "fixes" it later: 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.
|
||||||
|
*/
|
||||||
|
async function externalEligible(teamId, userId, platform) {
|
||||||
|
if (!userId || !platform) return false
|
||||||
|
const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
|
||||||
|
if (!member || member.user_id == null) return false // must be a LINKED game member
|
||||||
|
const linked = await identities.listForUser(userId)
|
||||||
|
return linked.some((i) => i.provider === platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A team's roster with overrides folded in, for the admin view and the Team page.
|
||||||
|
*
|
||||||
|
* The rows returned carry `is_leader` as RESOLVED — synced value plus override —
|
||||||
|
* and `is_leader_synced` as what the game actually said, so the admin surface can
|
||||||
|
* show that a decision was made rather than silently presenting it as fact.
|
||||||
|
*/
|
||||||
|
async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
|
||||||
|
const [members, overrides] = await Promise.all([
|
||||||
|
teamsDb.membersByTeam(teamId, { includeDeparted }),
|
||||||
|
accessDb.overridesForTeam(teamId),
|
||||||
|
])
|
||||||
|
const byKey = new Map(overrides.map((o) => [o.member_key, o]))
|
||||||
|
return members.map((m) => {
|
||||||
|
const override = byKey.get(m.member_key)
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
is_leader_synced: Boolean(m.is_leader),
|
||||||
|
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
|
||||||
|
leader_override: override
|
||||||
|
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
forumAccess,
|
||||||
|
isLeader,
|
||||||
|
isLeaderByUser,
|
||||||
|
externalEligible,
|
||||||
|
rosterWithOverrides,
|
||||||
|
setLeaderOverride: accessDb.setOverride,
|
||||||
|
clearLeaderOverride: accessDb.clearOverride,
|
||||||
|
grantLedger: accessDb.grantLedger,
|
||||||
|
activeGrants: accessDb.activeGrants,
|
||||||
|
}
|
||||||
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