feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155
@@ -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,
|
||||
|
||||
296
server/test/contentReports.test.js
Normal file
296
server/test/contentReports.test.js
Normal 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 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: '<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()))
|
||||
})
|
||||
@@ -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 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: '<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 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])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user