feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
4 changed files with 660 additions and 13 deletions
Showing only changes of commit 128de0ff2e - Show all commits

View File

@@ -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,

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