Files
website/server/test/teamForum.test.js
wtclaude 57286594e7
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 32s
test(teams): the four acceptance criteria, and regenerate the API artifacts
Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names
a property the code around it can lose without any screen looking different:

1. A granted, unlinked account reads the forum, is absent from the member rows, and
   is still refused external-platform eligibility. The membership projection is
   asserted byte-identical across a grant, which is what "non-contamination" means
   in practice.
2. With the switch off every forum route 404s AND nothing is read or written on the
   way there — a guard that 404s after loading the thread is one that still bumped
   a counter.
3. The stored HTML is byte-identical between `disabled` and `remote`; only the
   rendered output differs. That is the property the renderer-owned design exists
   to give, and it is what makes flipping the policy back a no-op rather than a
   migration.
4. Selecting `uploads` without a matching acknowledgement is refused server-side,
   with the admin checkbox bypassed.

Plus the ones that are not criteria but are the same kind of claim: an author
cannot smuggle an <img> or its attributes through in any mode, http and non-image
URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted
account stops protecting the grants it made, moderation records which authority was
exercised, and a RIFF container that is not WebP is not accepted as one.

Twelve new routes in the manifest, all annotated and in the OpenAPI spec.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:24 -05:00

333 lines
15 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.

// 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 forums 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('<p>Banner: https://example.com/banner.png</p>')
// The author wrote a URL and it was stored as a LINK. No <img> is in the
// stored body in any mode, which is what makes the policy enforceable and what
// makes flipping it back a no-op rather than a migration.
assert.ok(!stored.includes('<img'))
assert.match(stored, /<a href="https:\/\/example\.com\/banner\.png"/)
const disabled = renderForumBody(stored, 'disabled')
const remote = renderForumBody(stored, 'remote')
assert.equal(disabled, stored) // byte-for-byte
assert.match(remote, /<img src="https:\/\/example\.com\/banner\.png"/)
assert.match(remote, /loading="lazy"/)
assert.match(remote, /referrerpolicy="no-referrer"/)
// The link survives in both. A blocked or dead image degrades to the URL the
// author actually wrote.
assert.ok(remote.includes('<a href="https://example.com/banner.png"'))
})
test('an author cannot write an img tag, or smuggle attributes through one', () => {
const stored = cleanForumBody(
'<p><img src="https://evil.test/x.png" onerror="alert(1)" width="99999" srcset="y"></p>',
)
assert.ok(!stored.includes('<img'))
assert.ok(!stored.includes('onerror'))
assert.ok(!stored.includes('srcset'))
// And it stays absent when the policy is at its most permissive: the only code
// that can emit an <img> is core's renderer.
assert.ok(!renderForumBody(stored, 'uploads').includes('<img'))
})
test('http URLs and non-image URLs stay plain links', () => {
// CSP is `img-src 'self' data: https:` — an http: image is blocked by the
// browser and renders as a broken picture, so it is never embedded. This
// presents as "images are broken on my forum" with nothing in any log, which is
// why it is asserted rather than assumed.
const httpUrl = renderForumBody(cleanForumBody('<p>http://x.test/a.png</p>'), 'remote')
assert.ok(!httpUrl.includes('<img'))
const notAnImage = renderForumBody(cleanForumBody('<p>https://x.test/a.exe</p>'), 'remote')
assert.ok(!notAnImage.includes('<img'))
})
test('a URL inside code or pre is shown, not offered', () => {
const stored = cleanForumBody('<pre>https://example.com/a.png</pre>')
assert.ok(!stored.includes('<a href'))
})
test('every link ships with a safe rel, including one the author wrote', () => {
const stored = cleanForumBody('<a href="https://x.test/" rel="me">x</a>')
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: '<p>Hi</p>' })
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: '<p>Hi</p>' })
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: '<p></p>' })
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 clients 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('<?php echo 1; ?> ')), 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)
})