// The forum's access model, its switches, and its renderer // (docs/website/TEAMS.md Part 5, phase 4 "5a"). // // The four tests named "acceptance" are §Phase 4's four acceptance criteria, // verbatim. They are the ones to read first, and the ones not to weaken: each // names a property that the code around it can lose without any screen looking // different. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const forumSettings = require('../src/model/teams/teamForumSettings.model') const settingsDb = require('../src/model/settings/settings.db') const accessDb = require('../src/model/teams/teamAccess.db') const teamsDb = require('../src/model/teams/teams.db') const usersDb = require('../src/model/users/users.db') const grants = require('../src/model/teams/teamGrants.model') const access = require('../src/model/teams/teamAccess.model') const forum = require('../src/model/teams/teamForum.model') const forumDb = require('../src/model/teams/teamForum.db') const uploads = require('../src/model/teams/teamForumUploads.model') const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml') const saved = [] function patch(mod, name, fn) { saved.push([mod, name, mod[name]]) mod[name] = fn } // One settings store per test, so a test states the keys it cares about and // nothing else. `get` returning undefined is "the row does not exist", which for // both forum keys is the default and therefore the OFF state. let store = {} function stubSettings() { store = {} patch(settingsDb, 'get', async (key) => store[key]) patch(settingsDb, 'getRow', async (key) => (key in store ? { key, value: store[key], updated_by: 1, updated_by_username: 'root', updated_at: new Date() } : null)) patch(settingsDb, 'set', async (key, value) => { store[key] = value }) } beforeEach(() => { stubSettings() }) afterEach(() => { while (saved.length) { const [mod, name, original] = saved.pop() mod[name] = original } }) // ── the switch (§5.5.1) ──────────────────────────────────────────────────── test('the forum is off until an operator turns it on, and a broken read keeps it off', async () => { assert.equal(await forumSettings.forumsEnabled(), false) store.teams_forums_enabled = '1' assert.equal(await forumSettings.forumsEnabled(), true) // Fail closed. A transient DB fault must not open a feature the operator // deliberately turned off — a forum that 404s for a minute is the cheap failure. patch(settingsDb, 'get', async () => { throw new Error('db down') }) assert.equal(await forumSettings.forumsEnabled(), false) }) test('an unexpected stored image mode reads as disabled rather than as itself', async () => { store.teams_forum_images = 'everything' assert.equal(await forumSettings.imageMode(), 'disabled') }) // ── the acknowledgement gate (§5.5.5) ────────────────────────────────────── test('acceptance 4: uploads mode is rejected without a matching acknowledgement', () => { // Server-side, with the admin UI's checkbox bypassed — a checkbox is how the // gate is presented and never the gate. const refused = forumSettings.assertAcknowledged('uploads', undefined) assert.equal(refused.ok, false) assert.equal(refused.status, 400) // A STALE version is not an acknowledgement either. assert.equal(forumSettings.assertAcknowledged('uploads', '0').ok, false) assert.equal(forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION).ok, true) }) test('the other two image modes need no acknowledgement', () => { // `remote` gets a non-blocking advisory instead: nothing comes to rest on the // operator's disk, which is the thing the acknowledgement is about. assert.equal(forumSettings.assertAcknowledged('remote', undefined).ok, true) assert.equal(forumSettings.assertAcknowledged('disabled', undefined).ok, true) }) test('a reworded notice freezes forum settings but does NOT disable uploads', async () => { store.teams_forum_uploads_ack = '0' // accepted an older wording store.teams_forum_images = 'uploads' const state = await forumSettings.ackState() assert.equal(state.stale, true) assert.equal(state.given, true) // Uploads keep working: silently downgrading a live feature because a legal // text changed would strand users mid-conversation. assert.equal(await forumSettings.uploadsEnabled(), true) const frozen = await forumSettings.assertSettingsWritable(['teams_forums_enabled'], undefined) assert.equal(frozen.ok, false) // Re-acknowledging is the key to its own lock. const unlocked = await forumSettings.assertSettingsWritable( ['teams_forums_enabled'], forumSettings.ACK_VERSION, ) assert.equal(unlocked.ok, true) }) test('a setting that is not the forum’s is unaffected by a stale acknowledgement', async () => { store.teams_forum_uploads_ack = '0' const result = await forumSettings.assertSettingsWritable(['site_title'], undefined) assert.equal(result.ok, true) }) // ── the renderer (§5.5.3) ────────────────────────────────────────────────── test('acceptance 3: the stored HTML is identical in every image mode', () => { const stored = cleanForumBody('
Banner: https://example.com/banner.png
') // The author wrote a URL and it was stored as a LINK. No
{
const stored = cleanForumBody(
'
http://x.test/a.png
'), 'remote') assert.ok(!httpUrl.includes('https://example.com/a.png') assert.ok(!stored.includes(' { const stored = cleanForumBody('x') assert.match(stored, /rel="noopener noreferrer nofollow"/) assert.ok(!stored.includes('rel="me"')) }) // ── grants: authority, the cap, and non-contamination (§2.5) ─────────────── const team = { id: 1, name: 'Ossuary' } const leader = { id: 7, username: 'aldric', role: 'player' } const staff = { id: 2, username: 'root', role: 'admin' } const guest = { id: 9, username: 'mara', role: 'player' } function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) { patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId)) patch(accessDb, 'activeGrant', async () => existing) patch(accessDb, 'activeGrantCount', async () => activeCount) patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null)) patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null) } test('acceptance 1: a granted account has forum access and is not a member', async () => { stubGrantWorld() const written = [] patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 }) // The membership projection is stubbed to a table nothing may write. If the // grant path touched it, these would be the rows that changed. const membersBefore = [] patch(teamsDb, 'membersByTeam', async () => membersBefore) patch(teamsDb, 'activeByUser', async () => undefined) const result = await grants.grant({ team, actor: leader, username: 'mara' }) assert.equal(result.ok, true) assert.equal(written.length, 1) assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle // The resolver now says yes, and says WHY separately. patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id })) const resolved = await access.forumAccess(team.id, guest.id) assert.equal(resolved.allowed, true) assert.equal(resolved.viaGrant, true) assert.equal(resolved.viaMembership, false) // …and path 4 still refuses, because an integration cannot verify that an // unlinked, forum-granted account is a real game member. assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false) }) test('a leader is capped; staff are not, and are warned on the way past', async () => { stubGrantWorld({ activeCount: 50 }) patch(accessDb, 'insertGrant', async () => 1) const refused = await grants.grant({ team, actor: leader, username: 'mara' }) assert.equal(refused.ok, false) assert.equal(refused.status, 409) const allowed = await grants.grant({ team, actor: staff, username: 'mara' }) assert.equal(allowed.ok, true) assert.match(allowed.warning, /limit of 50/) }) test('a leader may not revoke a staff-issued grant', async () => { stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } }) patch(accessDb, 'revokeGrant', async () => true) const refused = await grants.revoke({ team, actor: leader, userId: guest.id }) assert.equal(refused.ok, false) assert.equal(refused.status, 403) // Staff may. This is what stops a leader undoing a moderation decision. const allowed = await grants.revoke({ team, actor: staff, userId: guest.id }) assert.equal(allowed.ok, true) }) test('an account that has lost its staff role stops protecting the grants it made', async () => { // Checked at REVOKE time against the issuer's current role, not against a flag // stored when the grant was made — which is the behaviour an operator demoting // someone expects. const demoted = { id: 2, username: 'root', role: 'player' } stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } }) patch(usersDb, 'findById', async () => demoted) patch(accessDb, 'revokeGrant', async () => true) const result = await grants.revoke({ team, actor: leader, userId: guest.id }) assert.equal(result.ok, true) }) test('a member who is also a grantee is listed as a member, not as a guest', async () => { patch(accessDb, 'activeGrants', async () => [ { user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null }, { user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null }, ]) patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }]) const guests = await grants.forumGuests(team.id) assert.deepEqual(guests.map((g) => g.username), ['mara']) }) // ── threads (§5.1, §5.3) ─────────────────────────────────────────────────── test('5a creates announcements and refuses discussion threads', async () => { patch(forumDb, 'insertThread', async () => 1) patch(forumDb, 'insertPost', async () => 1) const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '
Hi
' }) assert.equal(ok.ok, true) // The type exists in the enum from day one so 5b adds no migration — but // nothing creates one yet. const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: 'Hi
' }) assert.equal(refused.ok, false) assert.equal(refused.status, 400) }) test('an announcement with only markup for a body is refused', async () => { patch(forumDb, 'insertThread', async () => 1) patch(forumDb, 'insertPost', async () => 1) const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '' }) assert.equal(refused.ok, false) }) test('moderation records WHICH authority was exercised', async () => { const ledger = [] patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' })) patch(forumDb, 'setThreadFlags', async () => true) patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) }) await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' }) await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' }) assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff']) assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide']) }) test('a thread id from another Team reads as not found', async () => { patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' })) const result = await forum.getThread(1, 5, { canModerate: true }) assert.equal(result, null) }) test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => { patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 })) patch(forumDb, 'postsByThread', async () => []) assert.equal(await forum.getThread(1, 5, { canModerate: false }), null) assert.ok(await forum.getThread(1, 5, { canModerate: true })) }) // ── uploads (§5.5.4) ─────────────────────────────────────────────────────── test('magic bytes decide the type, not the client’s Content-Type header', () => { const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(8), ]) assert.equal(uploads.sniff(png), 'image/png') // A player can send `image/png` with arbitrary bytes. Unrecognised is a // rejection, never a fallback to what the header claimed. assert.equal(uploads.sniff(Buffer.from(' ')), null) assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge }) test('a RIFF container that is not WebP is not accepted as one', () => { const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)]) assert.equal(uploads.sniff(wav), null) })