// 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())) })