Files
website/server/test/teamForum.test.js
wtclaude 128de0ff2e test(teams): phase 5's server surface, and the negative property under it
1008 pass (972 before). The tests worth reading first are the ones that pin a
property no screen would look different without:

  * **The edit window is decided on the server, twice.** One test proves the read
    path stamps `canEdit` per post per viewer; another proves the WRITE path
    re-derives it from `created_at` and refuses a stale edit even though the
    client was told it could — because a time-bounded permission must not take its
    clock from the party it bounds.

  * **A locked thread refuses staff too**, asserted over member, leader and staff
    in one loop, at 409 rather than 403: well-formed request, refusing state.

  * **delete → restore is reversible for images.** Without the second half of the
    pair a restored post returns its words and loses its pictures a retention
    window later, silently — the test asserts both calls and that `hide` makes
    neither.

  * **Post moderation recomputes the thread's counters** rather than nudging them;
    the test runs hide → unhide → hide, which is the cycle a delta gets wrong.

  * **acceptance: nothing in the report model is reachable by a Team leader.** The
    negative property is the whole point of §5.6 and negatives are what nobody
    notices going, so it is asserted directly — the module's function surface is
    pinned, and `queue`/`handle` are checked not to mention leadership at all. If
    a leader-facing queue is ever wanted it is the org lead's decision, and this
    test is what makes somebody ask.

  * **A report never changes the content it is about**, proved by stubbing every
    mutation the forum has to throw. If filing a report touched a status then
    "report" would BE moderation, and the first person to work that out would have
    found a way to hide anything on the site.

The test suite caught one real defect: `describeTarget` returned `undefined` for a
hard-deleted target, and `undefined` is dropped by JSON.stringify — so the
documented `target: null` would have reached clients as an absent key.

Two phase-4 tests were updated rather than added to, both because phase 5 changed
what they describe: `canPost` split into `canPost` (open a discussion, everyone)
and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type.
Phase 5's four new player routes are added to acceptance criterion 2's list, so
"with the forum off every forum route 404s" keeps covering the whole surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 12:58:15 -05:00

612 lines
29 KiB
JavaScript
Raw Permalink 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, its renderer (phase 4, "5a") and its
// discussion half (phase 5, "5b") — docs/website/TEAMS.md Part 5.
//
// The tests named "acceptance" are the phases' stated 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 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"/)
// 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 composers 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('both thread types are creatable, and an invented one is not', async () => {
patch(forumDb, 'insertThread', async () => 1)
patch(forumDb, 'insertPost', async () => 1)
// Phase 5 opened `discussion`. Neither type needed a migration: both have been
// in the enum since 5a, which is what §5.1's split-by-layer bought.
for (const type of ['announcement', 'discussion']) {
const ok = await forum.createThread({ team, actor: leader, type, title: 'Raid', body: '<p>Hi</p>' })
assert.equal(ok.ok, true, type)
}
const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', 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)
})
// ── phase 5 ("5b"): replies, the edit window, post moderation ──────────────
const member = { id: 11, username: 'wanderer', role: 'player' }
// A visible discussion thread and one post in it, as the DB layer would return
// them. Written as a factory rather than a shared constant because half these
// tests mutate the row they are given.
const discussion = (over = {}) => ({
id: 5, team_id: 1, type: 'discussion', title: 'Raid night',
status: 'visible', locked: 0, pinned: 0, post_count: 1,
created_by: 11, created_username: 'wanderer', ...over,
})
const post = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: 11, author_username: 'wanderer',
body_html: '<p>Hi</p>', status: 'visible', created_at: new Date(), edited_at: null,
edited_by: null, ...over,
})
test('a reply lands on a discussion thread and never on an announcement', async () => {
patch(forumDb, 'insertPost', async () => 81)
patch(forumDb, 'threadById', async () => discussion())
const ok = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Count me in</p>' })
assert.equal(ok.ok, true)
assert.equal(ok.postId, 81)
// 400, not 404 and not 409: the request is malformed FOR THIS THREAD and no
// amount of retrying fixes it. An announcement takes no replies by TYPE.
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
const refused = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Hi</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 400)
})
test('a locked thread refuses replies with 409 — and refuses staff too', async () => {
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
for (const actor of [member, leader, staff]) {
const refused = await forum.createPost({ team, threadId: 5, actor, body: '<p>Hi</p>' })
assert.equal(refused.ok, false, actor.username)
// Well-formed request, refusing STATE — which is the distinction a client
// needs to tell "you cannot" from "not right now".
assert.equal(refused.status, 409, actor.username)
}
})
test('a reply to a hidden or foreign thread reads as not found', async () => {
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
patch(forumDb, 'threadById', async () => discussion({ status: 'hidden' }))
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
patch(forumDb, 'threadById', async () => discussion({ team_id: 999 }))
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
})
test('the edit window is decided on the server, from created_at', () => {
const fresh = post({ created_at: new Date(Date.now() - 60_000) }) // a minute old
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) // an hour old
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 15 }).canEdit, true)
assert.equal(forum.editability(stale, { userId: 11, windowMinutes: 15 }).canEdit, false)
// Somebody else's post, inside the window, is still not theirs to edit.
assert.equal(forum.editability(fresh, { userId: 99, windowMinutes: 15 }).canEdit, false)
// Staff are not time-bounded, and `editableUntil: null` reads as "no deadline"
// rather than as "no permission" — canEdit is the permission.
const asStaff = forum.editability(stale, { userId: 2, isStaff: true, windowMinutes: 15 })
assert.equal(asStaff.canEdit, true)
assert.equal(asStaff.editableUntil, null)
// A window of zero is a legitimate operator choice: posts immutable once written.
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 0 }).canEdit, false)
})
test('a hidden post is editable by nobody, staff included', () => {
const hidden = post({ status: 'hidden', created_at: new Date() })
assert.equal(forum.editability(hidden, { userId: 11, windowMinutes: 15 }).canEdit, false)
// Restoring it is a moderation action with a ledger row; quietly rewriting it
// while it is out of sight is the same act with no record.
assert.equal(forum.editability(hidden, { userId: 2, isStaff: true, windowMinutes: 15 }).canEdit, false)
})
test('the write path re-derives the window and does not trust the read path', async () => {
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) })
patch(forumDb, 'postById', async () => stale)
patch(forumDb, 'threadById', async () => discussion())
const writes = []
patch(forumDb, 'updatePostBody', async (...args) => { writes.push(args); return true })
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>new</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 403)
assert.equal(writes.length, 0)
// Staff, same post, same moment.
const allowed = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>new</p>' })
assert.equal(allowed.ok, true)
assert.equal(writes.length, 1)
// Reported so the controller can write the §5.3 accountability row — a staffer
// editing someone ELSE's words is an intervention.
assert.equal(allowed.staffEdit, true)
})
test('a staffer editing their own post is an ordinary edit, not an intervention', async () => {
patch(forumDb, 'postById', async () => post({ author_user_id: staff.id, author_username: staff.username }))
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'updatePostBody', async () => true)
const result = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>x</p>' })
assert.equal(result.ok, true)
assert.equal(result.staffEdit, false)
})
test('a member may not edit somebody elses post at all', async () => {
patch(forumDb, 'postById', async () => post({ author_user_id: 99, author_username: 'someone' }))
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'updatePostBody', async () => { throw new Error('must not write') })
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>x</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 403)
})
test('post moderation shares the thread ledger, tagged as a post', async () => {
const ledger = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'recountThread', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
patch(forumDb, 'restoreUploadsForPost', async () => {})
patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: leader, actorRole: 'leader' })
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
// One table, two target kinds — so "everything moderated in this Team" stays
// one query instead of a union.
assert.deepEqual(ledger.map((r) => r.targetType), ['post', 'post'])
assert.deepEqual(ledger.map((r) => r.action), ['hide', 'delete'])
assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
})
test('pin and lock are refused BY NAME on a post, not as unknown actions', async () => {
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
const wrongObject = await forum.moderatePost({ team, postId: 80, action: 'pin', actor: leader, actorRole: 'leader' })
assert.equal(wrongObject.status, 400)
assert.match(wrongObject.error, /applies to a thread/)
const nonsense = await forum.moderatePost({ team, postId: 80, action: 'incinerate', actor: leader, actorRole: 'leader' })
assert.equal(nonsense.status, 400)
assert.match(nonsense.error, /Unknown/)
})
test('deleting a post takes its images with it, and restoring brings them back', async () => {
const calls = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'recountThread', async () => {})
patch(forumDb, 'insertModeration', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async (id) => { calls.push(['soft', id]) })
patch(forumDb, 'restoreUploadsForPost', async (id) => { calls.push(['restore', id]) })
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
await forum.moderatePost({ team, postId: 80, action: 'restore', actor: staff, actorRole: 'staff' })
// Without the second half, delete → restore returns the words and loses the
// pictures a retention window later, silently.
assert.deepEqual(calls, [['soft', 80], ['restore', 80]])
// Hiding is not deleting: a hidden post's images are untouched, because
// unhiding must be free.
calls.length = 0
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: staff, actorRole: 'staff' })
assert.deepEqual(calls, [])
})
test('post moderation recomputes the threads counters rather than nudging them', async () => {
const recounts = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'insertModeration', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
patch(forumDb, 'restoreUploadsForPost', async () => {})
patch(forumDb, 'recountThread', async (id) => { recounts.push(id) })
// hide → unhide → hide is a cycle a counter kept by deltas gets wrong the
// first time a step is retried or raced.
for (const action of ['hide', 'unhide', 'hide']) {
await forum.moderatePost({ team, postId: 80, action, actor: staff, actorRole: 'staff' })
}
assert.deepEqual(recounts, [5, 5, 5])
})
test('a thread reports whether it takes replies, and why not', async () => {
patch(forumDb, 'postsByThread', async () => [])
patch(forumDb, 'threadById', async () => discussion())
assert.equal((await forum.getThread(1, 5, { canModerate: false })).canReply, true)
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
const locked = await forum.getThread(1, 5, { canModerate: false })
assert.equal(locked.canReply, false)
assert.equal(locked.locked, true) // the UI can say WHICH half refused
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
const announcement = await forum.getThread(1, 5, { canModerate: false })
assert.equal(announcement.canReply, false)
assert.equal(announcement.type, 'announcement')
})
test('every post comes back knowing whether THIS reader may edit it', async () => {
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'postsByThread', async () => [
post({ id: 80, author_user_id: 11, created_at: new Date() }),
post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }),
])
const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false])
assert.deepEqual(mine.posts.map((p) => p.mine), [true, false])
// A caller that does not say who is reading gets the safe answer, which is what
// keeps every phase-4 call site correct without changing it.
const anonymous = await forum.getThread(1, 5, {})
assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false])
})