diff --git a/server/src/model/reports/contentReports.model.js b/server/src/model/reports/contentReports.model.js index 47bd817..49fd435 100644 --- a/server/src/model/reports/contentReports.model.js +++ b/server/src/model/reports/contentReports.model.js @@ -135,11 +135,21 @@ async function queue({ status, teamId, limit, offset } = {}) { return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) })) } +/** + * The reported content, resolved. + * + * **Every miss returns `null`, never `undefined`.** They look interchangeable in + * JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so + * a hard-deleted target would reach the client as an ABSENT `target` key rather + * than as an explicit null, and the queue's own contract says nullable. A client + * distinguishing "gone" from "not resolved yet" would get it wrong. + */ function describeTarget(report, { threadMap, postMap, uploadMap }) { const id = Number(report.target_id) if (report.target_type === 'team_forum_thread') { const t = threadMap.get(id) - return t && { + if (!t) return null + return { kind: 'thread', threadId: t.id, title: t.title, @@ -150,7 +160,8 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) { } if (report.target_type === 'team_forum_post') { const p = postMap.get(id) - return p && { + if (!p) return null + return { kind: 'post', postId: p.id, threadId: p.thread_id, @@ -163,10 +174,11 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) { } if (report.target_type === 'team_forum_upload') { const u = uploadMap.get(id) + if (!u) return null // §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting. // This is the payoff for §5.5.4's attribution table being load-bearing rather // than bookkeeping. - return u && { + return { kind: 'upload', uploadId: u.id, postId: u.post_id, diff --git a/server/test/contentReports.test.js b/server/test/contentReports.test.js new file mode 100644 index 0000000..f905398 --- /dev/null +++ b/server/test/contentReports.test.js @@ -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 row’s 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: '

Rude words

', + 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: `

${'x'.repeat(500)}

link`, + 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())) +}) diff --git a/server/test/teamForum.test.js b/server/test/teamForum.test.js index da8914f..ebe123f 100644 --- a/server/test/teamForum.test.js +++ b/server/test/teamForum.test.js @@ -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: '

Hi

' }) - 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: '

Hi

' }) + 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: '

Hi

' }) + const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: '

Hi

' }) 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: '

Hi

', 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: '

Count me in

' }) + 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: '

Hi

' }) + 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: '

Hi

' }) + 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: '

x

' })).status, 404) + + patch(forumDb, 'threadById', async () => discussion({ team_id: 999 })) + assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '

x

' })).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: '

new

' }) + 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: '

new

' }) + 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: '

x

' }) + assert.equal(result.ok, true) + assert.equal(result.staffEdit, false) +}) + +test('a member may not edit somebody else’s 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: '

x

' }) + 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 thread’s 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]) +}) diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js index 7f9b68a..293fc83 100644 --- a/server/test/teamRoutes.test.js +++ b/server/test/teamRoutes.test.js @@ -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) }) })