None of these could fail a unit test, and three of them break the feature for the operator rather than for the code. **The uploads acknowledgement was a one-way door.** A settings form sends every field it owns, so once `teams_forum_images` was `uploads`, every later save re-sent `uploads` — and the gate fired on the VALUE being present rather than on the mode being SELECTED. The operator could never change a forum setting again, and the thing they would reach for in a hurry, switching the forum off, was exactly what came back 400. The gate now passes when an acknowledgement for the version in force is already on record AND uploads is already the stored mode: there is no new consent to take. A transition INTO uploads still asks, and a reworded notice is still caught by assertSettingsWritable. **An uploaded image could never become a picture.** `uploads` mode hands the composer `/uploads/<name>.png`, the composer puts it in the body as text — the author never writes markup, which is the whole design — and the renderer only rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write path could not produce the anchor the read path looks for, even though `isEmbeddableImageUrl` had accepted those paths since the first commit. The two halves disagreed and only a real upload showed it. **The embed sat beside its link, not beneath it**, because an <img> is inline, and nothing capped a remote image to the column — one post from a host serving a 4000px file would have blown the layout out. Core now emits `class="forum-embed"` and the stylesheet owns both. A class rather than an inline style because the style would then have to survive the client's DOMPurify pass, and its CSS sanitiser is a larger thing to reason about than one class name. **The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every other call site in this codebase pairs it with the base `btn` — so alone it contributed colours and no geometry, and the controls rendered as bare boxes. Small inline actions use `pill`, which is what the rest of the admin surface uses for exactly these. Same class of mistake as the Material one in the Android M12 phase: the modifier carries no base. Also: the post body now re-sanitises client-side like every other body-HTML surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is load-bearing — DOMPurify's default allowlist carries `loading` but not `referrerpolicy`, so a plain sanitize() call silently strips the one attribute limiting what a remote embed leaks to the host serving it, which is the privacy property the admin help text promises. Co-Authored-By: Claude <noreply@anthropic.com>
375 lines
18 KiB
JavaScript
375 lines
18 KiB
JavaScript
// 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', async () => {
|
||
// Server-side, with the admin UI's checkbox bypassed — a checkbox is how the
|
||
// gate is presented and never the gate. Nothing is on record and the stored
|
||
// mode is not uploads, so this is a genuine transition INTO it.
|
||
const refused = await forumSettings.assertAcknowledged('uploads', undefined)
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 400)
|
||
|
||
// A STALE version is not an acknowledgement either.
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', '0')).ok, false)
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION)).ok, true)
|
||
})
|
||
|
||
test('the other two image modes need no acknowledgement', async () => {
|
||
// `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((await forumSettings.assertAcknowledged('remote', undefined)).ok, true)
|
||
assert.equal((await forumSettings.assertAcknowledged('disabled', undefined)).ok, true)
|
||
})
|
||
|
||
test('uploads is not a one-way door — a later save needs no fresh acknowledgement', async () => {
|
||
// Found on the live rig. A settings form sends every field it owns, so with
|
||
// uploads on, unticking "Enable Team forums" re-sends `uploads` and came back
|
||
// 400 — the operator could never change a forum setting again, least of all the
|
||
// one they would reach for in a hurry.
|
||
store.teams_forum_images = 'uploads'
|
||
store.teams_forum_uploads_ack = forumSettings.ACK_VERSION
|
||
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, true)
|
||
|
||
// Still a gate where consent is genuinely absent: a stale acknowledgement means
|
||
// the wording moved, and that DOES need re-consent.
|
||
store.teams_forum_uploads_ack = '0'
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false)
|
||
|
||
// And a transition INTO uploads from another mode still asks.
|
||
store.teams_forum_images = 'remote'
|
||
store.teams_forum_uploads_ack = forumSettings.ACK_VERSION
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false)
|
||
})
|
||
|
||
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('<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"/)
|
||
// Carries core's own class, which is what puts the picture BENEATH its link
|
||
// (an <img> is inline) and caps it to the column. Found on the live rig.
|
||
assert.match(remote, /class="forum-embed"/)
|
||
// 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('an UPLOADED image becomes a picture — the composer’s own path', () => {
|
||
// Found on the live rig, not by any unit test here. `uploads` mode hands the
|
||
// composer a root-relative path, the composer puts it in the body as TEXT, and
|
||
// the renderer only rewrites ANCHORS — so the write path has to produce one, or
|
||
// an uploaded image can never render. isEmbeddableImageUrl accepted these paths
|
||
// from day one; nothing made an anchor out of them.
|
||
const stored = cleanForumBody('<p>/uploads/1787-abc.png</p>')
|
||
assert.match(stored, /<a href="\/uploads\/1787-abc\.png"/)
|
||
assert.match(renderForumBody(stored, 'uploads'), /<img [^>]*src="\/uploads\/1787-abc\.png"/)
|
||
assert.equal(renderForumBody(stored, 'disabled'), stored)
|
||
})
|
||
|
||
test('ordinary prose containing a slash is not turned into a link', () => {
|
||
// The upload branch is deliberately narrow — `/uploads/` and nothing else.
|
||
assert.ok(!cleanForumBody('<p>meet at /the docks tonight</p>').includes('<a href'))
|
||
})
|
||
|
||
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 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('<?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)
|
||
})
|