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>
This commit is contained in:
2026-08-18 12:58:15 -05:00
parent fff14848f1
commit 128de0ff2e
4 changed files with 660 additions and 13 deletions

View File

@@ -0,0 +1,296 @@
// Member-raised abuse reports (docs/website/TEAMS.md §5.6).
//
// The property most worth protecting here is a negative one, and negatives are
// what nobody notices going: **reports reach site staff and nobody else.** The
// gap this feature closes is that leaders moderate their own Team's forum and a
// Team's leaders are exactly the people who will not report their own Team — so a
// leader-facing view, even a read-only one scoped to their own Team, would hand a
// complaint about a leader back to that leader. Org lead settled it on 2026-08-18:
// site administration only. The test at the bottom of this file is the one that
// fails if somebody adds one.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const reports = require('../src/model/reports/contentReports.model')
const reportsDb = require('../src/model/reports/contentReports.db')
const forumDb = require('../src/model/teams/teamForum.db')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
afterEach(() => {
while (saved.length) {
const [mod, name, original] = saved.pop()
mod[name] = original
}
})
const team = { id: 1, name: 'Ossuary' }
const reporter = { id: 11, username: 'wanderer' }
// The world a report is filed into: one thread, one post in it, one upload, all
// in team 1.
function stubTargets({ teamId = 1 } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5 ? { id: 5, team_id: teamId } : null))
patch(forumDb, 'postById', async (id) => (id === 80 ? { id: 80, thread_id: 5 } : null))
patch(forumDb, 'uploadById', async (id) => (id === 3 ? { id: 3, team_id: teamId } : null))
}
let written = []
function stubInsert({ duplicate = false } = {}) {
written = []
patch(reportsDb, 'insert', async (row) => {
written.push(row)
return duplicate ? null : 41
})
}
// ── filing ─────────────────────────────────────────────────────────────────
test('a report can be filed against a thread, a post or an upload', async () => {
stubTargets()
stubInsert()
const cases = [
['team_forum_thread', 5],
['team_forum_post', 80],
['team_forum_upload', 3],
]
for (const [targetType, targetId] of cases) {
const result = await reports.file({ team, actor: reporter, targetType, targetId, reason: 'abuse' })
assert.equal(result.ok, true, targetType)
assert.equal(result.reportId, 41)
}
assert.deepEqual(written.map((r) => r.targetType), cases.map((c) => c[0]))
})
test('a report never changes the content it is about', async () => {
stubTargets()
stubInsert()
// Rule 2 of §5.6, made structural: 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.
patch(forumDb, 'setPostStatus', async () => { throw new Error('a report must not moderate') })
patch(forumDb, 'setThreadFlags', async () => { throw new Error('a report must not moderate') })
patch(forumDb, 'insertModeration', async () => { throw new Error('a report is not a ledger entry') })
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'spam',
})
assert.equal(result.ok, true)
})
test('a target in another Team reads as not found', async () => {
// Otherwise a participant in one Team could file reports carrying another
// Team's id, and the queue's per-Team filter would quietly be lying.
stubTargets({ teamId: 999 })
stubInsert()
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse',
})
assert.equal(result.ok, false)
assert.equal(result.status, 404)
assert.equal(written.length, 0)
})
test('a target that does not exist reads as not found, not as a 400', async () => {
stubTargets()
stubInsert()
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 9999, reason: 'abuse',
})
assert.equal(result.status, 404)
})
test('an unknown target type or reason is refused before any lookup', async () => {
patch(forumDb, 'threadById', async () => { throw new Error('must not look up') })
stubInsert()
assert.equal((await reports.file({
team, actor: reporter, targetType: 'wiki_page', targetId: 1, reason: 'abuse',
})).status, 400)
assert.equal((await reports.file({
team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'because',
})).status, 400)
})
test('a second open report on the same target answers 409 rather than pretending', async () => {
stubTargets()
stubInsert({ duplicate: true })
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
})
assert.equal(result.ok, false)
assert.equal(result.status, 409)
// Silently accepting would be friendlier for one tap and dishonest for the
// second: a member who reports twice because nothing seemed to happen deserves
// to be told the first one is already in the queue.
assert.match(result.error, /already reported/i)
})
test('the duplicate is caught by the index, not by a read-then-write', async () => {
stubTargets()
// The DB layer turns ER_DUP_ENTRY into a clean null, so two taps that race
// reach the same answer as two taps that do not. A SELECT-first check would
// give "usually not a duplicate".
patch(reportsDb, 'insert', reportsDb.insert)
const { insert } = require('../src/model/reports/contentReports.db')
assert.equal(typeof insert, 'function')
})
// ── the queue ──────────────────────────────────────────────────────────────
const row = (over = {}) => ({
id: 41, target_type: 'team_forum_post', target_id: 80, team_id: 1,
reporter_user_id: 11, reporter_username: 'wanderer', reason: 'abuse',
detail: null, status: 'open', handled_by: null, handled_username: null,
handled_note: null, handled_at: null, created_at: new Date(), ...over,
})
test('the queue resolves every rows target in batched reads, not one per row', async () => {
const calls = { threads: 0, posts: 0, uploads: 0 }
patch(reportsDb, 'list', async () => [
row({ id: 1, target_type: 'team_forum_post', target_id: 80 }),
row({ id: 2, target_type: 'team_forum_post', target_id: 81 }),
row({ id: 3, target_type: 'team_forum_thread', target_id: 5 }),
row({ id: 4, target_type: 'team_forum_upload', target_id: 3 }),
])
patch(reportsDb, 'postsByIds', async (ids) => {
calls.posts += 1
return ids.map((id) => ({
id, thread_id: 5, author_username: 'someone', body_html: '<p>Rude words</p>',
status: 'visible', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}))
})
patch(reportsDb, 'threadsByIds', async (ids) => {
calls.threads += 1
return ids.map((id) => ({ id, team_id: 1, title: 'Raid night', type: 'discussion', status: 'visible', created_username: 'someone' }))
})
patch(reportsDb, 'uploadsByIds', async (ids) => {
calls.uploads += 1
return ids.map((id) => ({
id, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}))
})
const queue = await reports.queue({})
assert.equal(queue.length, 4)
// Four rows, three reads. The N+1 version is the one that becomes a queue
// staff avoid opening.
assert.deepEqual(calls, { threads: 1, posts: 1, uploads: 1 })
})
test('an upload report carries uploader, size and the SNIFFED type', async () => {
patch(reportsDb, 'list', async () => [row({ target_type: 'team_forum_upload', target_id: 3 })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
const [item] = await reports.queue({})
// §5.6's fourth rule — and the payoff for §5.5.4's attribution table being
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
})
test('a post report carries a plain-text excerpt, capped', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_username: 'someone', status: 'visible',
body_html: `<p>${'x'.repeat(500)}</p><a href="http://x/">link</a>`,
created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
const [item] = await reports.queue({})
assert.equal(item.target.excerpt.length, reports.EXCERPT_CHARS)
assert.ok(!item.target.excerpt.includes('<'), 'the queue triages on text, not markup')
})
test('a report whose target is already gone still lists, with a null target', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => []) // hard-deleted since
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
// Dropping the row would hide the pattern of a member deleting their own
// content the moment it is reported.
assert.equal(item.id, 41)
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
assert.equal(item.reporterDeleted, true)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {
const updates = []
patch(reportsDb, 'byId', async () => row())
patch(reportsDb, 'handle', async (id, patchRow) => { updates.push([id, patchRow]); return true })
const staff = { id: 2, username: 'root' }
const result = await reports.handle({ id: 41, actor: staff, status: 'dismissed', note: 'Nothing in it.' })
assert.equal(result.ok, true)
assert.deepEqual(updates, [[41, {
status: 'dismissed', handledBy: 2, handledUsername: 'root', note: 'Nothing in it.',
}]])
})
test('an unknown status is refused, and an absent report is 404', async () => {
patch(reportsDb, 'byId', async () => null)
patch(reportsDb, 'handle', async () => { throw new Error('must not write') })
assert.equal((await reports.handle({
id: 41, actor: { id: 2, username: 'root' }, status: 'obliterated',
})).status, 400)
assert.equal((await reports.handle({
id: 41, actor: { id: 2, username: 'root' }, status: 'actioned',
})).status, 404)
})
// ── the negative property ──────────────────────────────────────────────────
test('acceptance: nothing in the report model is reachable by a Team leader', () => {
// §5.6's whole point is a path that routes AROUND a Team's own leadership. The
// model exposes exactly three verbs — file, queue, handle — and `queue` and
// `handle` are mounted ONLY under /admin/moderation, which is gated to
// admin+moderator. There is deliberately no leader-scoped variant of either,
// and no `teamId`-scoped authority check that a leader could satisfy: the only
// teamId this model takes is a FILTER on a staff view.
//
// If a leader-facing queue is ever wanted, it is a design decision for the org
// lead and not a refactor — which is what this test is here to make somebody
// notice.
const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function')
assert.deepEqual(surface.sort(), ['file', 'handle', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
// `handle` takes the actor and never a Team: there is no seat at this table for
// "the leader of the Team the report is about".
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.handle.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.queue.toString()))
})

View File

@@ -1,7 +1,7 @@
// The forum's access model, its switches, and its renderer
// (docs/website/TEAMS.md Part 5, phase 4 "5a").
// 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 four tests named "acceptance" are §Phase 4's four acceptance criteria,
// 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.
@@ -306,16 +306,18 @@ test('a member who is also a grantee is listed as a member, not as a guest', asy
// ── threads (§5.1, §5.3) ───────────────────────────────────────────────────
test('5a creates announcements and refuses discussion threads', async () => {
test('both thread types are creatable, and an invented one is not', 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)
// 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)
}
// 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>' })
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)
})
@@ -372,3 +374,238 @@ 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])
})

View File

@@ -28,6 +28,7 @@ const forumSettings = require('../src/model/teams/teamForumSettings.model')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const access = require('../src/model/teams/teamAccess.model')
const reports = require('../src/model/reports/contentReports.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -75,6 +76,11 @@ const get = (app, path, init) => fetch(`${app.url}${path}`, init)
const post = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// Named with a trailing underscore because `patch` is already the stub helper in
// this file, and shadowing it inside a test would be an hour nobody enjoys.
const patch_ = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// ── The public tier is anonymous, and hidden means absent ──────────────────
@@ -323,16 +329,64 @@ test('acceptance 2: with the forum off every forum route 404s, and nothing is to
patch(forum, 'getThread', async () => mark())
patch(forum, 'createThread', async () => mark())
patch(forum, 'moderateThread', async () => mark())
// Phase 5's four. A route added behind the same guard has to be added here
// too, or the acceptance criterion silently stops covering the whole surface.
patch(forum, 'createPost', async () => mark())
patch(forum, 'editPost', async () => mark())
patch(forum, 'moderatePost', async () => mark())
patch(reports, 'file', async () => mark())
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404')
})
test('replying, editing and reporting all run through the same access resolver', async () => {
// A caller with no access sees 404 on every write too, not only on the reads.
// A private room's contents and its existence are the same secret, and a write
// that answered 403 would confirm the room.
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false }))
patch(forum, 'createPost', async () => { throw new Error('must not run') })
patch(forum, 'editPost', async () => { throw new Error('must not run') })
patch(reports, 'file', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
})
test('post moderation is refused to a participant who is neither leader nor staff', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'moderatePost', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
// 403 and not 404 here, deliberately: this caller can SEE the forum, so
// nothing is being concealed — they are simply not allowed to moderate it.
const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })
assert.equal(res.status, 403)
})
})
test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
@@ -345,7 +399,55 @@ test('with the forum ON, the same routes answer — the switch is the only diffe
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
assert.equal(res.status, 200)
const body = await res.json()
assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer')
// Phase 5 split one capability into two. `canPost` now means "may open a
// DISCUSSION", which every participant may; `canAnnounce` is the leader-only
// half that `canPost` used to carry alone.
assert.equal(body.canPost, true, 'an ordinary member may open a discussion')
assert.equal(body.canAnnounce, false, 'an ordinary member does not get the announcement composer')
assert.equal(body.canModerate, false)
})
})
test('a leader gets both composers; the announcement one is theirs alone', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(forumSettings, 'imageMode', async () => 'disabled')
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
patch(forum, 'listThreads', async () => [])
await withApp('/api/v1/player', playerRouter, async (app) => {
const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json()
assert.equal(body.canPost, true)
assert.equal(body.canAnnounce, true)
assert.equal(body.canModerate, true)
})
})
test('an ordinary member is refused an announcement and allowed a discussion', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'createThread', async ({ type }) => ({ ok: true, threadId: 1, postId: 1, type }))
await withApp('/api/v1/player', playerRouter, async (app) => {
// The check splits by TYPE — phase 4's comment said it would happen here
// rather than the leader gate being widened.
const announcement = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'announcement', title: 'x', body: 'y',
})
assert.equal(announcement.status, 403)
const discussion = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'discussion', title: 'x', body: 'y',
})
assert.equal(discussion.status, 200)
// No `type` at all is a phase-4 client, and a phase-4 client only ever posted
// announcements — so the default must NOT quietly become a discussion.
const untyped = await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })
assert.equal(untyped.status, 403)
})
})