test(teams): the four acceptance criteria, and regenerate the API artifacts
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

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>
This commit is contained in:
2026-08-18 07:24:24 -05:00
parent cbb7339a3a
commit 57286594e7
5 changed files with 1522 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
// 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)
})