Files
website/server/test/contentReports.test.js
wtclaude 41a1e84b51
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s
feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 05:41:00 -05:00

510 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 forum = require('../src/model/teams/teamForum.model')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const registries = require('../src/modules/registries')
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', role: 'player' }
// The world a report is filed into: one thread, one post in it, one upload, all
// in team 1, in a forum that is on and that the reporter can read.
function stubTargets({ teamId = 1, allowed = true } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5
? { id: 5, team_id: teamId, title: 'Raid night', status: 'visible', created_by: 12, created_username: 'someone', created_at: new Date() }
: null))
patch(forumDb, 'postById', async (id) => (id === 80
? { id: 80, thread_id: 5, status: 'visible', author_user_id: 12, author_username: 'someone', body_html: '<p>Rude words</p>', created_at: new Date() }
: null))
patch(forumDb, 'firstPostOf', async () => ({ id: 79, body_html: '<p>Opening words</p>' }))
patch(forumDb, 'uploadById', async (id) => (id === 3
? { id: 3, team_id: teamId, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png' }
: null))
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDb, 'findById', async (id) => ({ id, name: id === 1 ? 'Ossuary' : 'Elsewhere' }))
patch(access, 'forumAccess', async () => ({ allowed, viaMembership: allowed, viaGrant: false, isLeader: false }))
}
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)
})
// ── 1.21.0: one route for every target, and a snapshot (USER_SAFETY.md §4.1) ──
test('a report stores what the item said, so a later edit cannot change what staff read', async () => {
stubTargets()
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(result.ok, true)
const [w] = written
assert.equal(w.teamId, 1, 'the Team comes from the item when no Team route was used')
assert.equal(w.snapshot.text, 'Rude words')
assert.equal(w.snapshot.label, 'Forum post')
assert.deepEqual(w.snapshot.author, { name: 'someone', userId: 12, identity: null })
})
test('a reporter who cannot see the item gets the same 404 as an item that is not there', async () => {
// Otherwise /player/reports would answer "does thread 5 exist" for a private
// Team's forum to anybody signed in.
stubTargets({ allowed: false })
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse' })
assert.equal(result.status, 404)
assert.equal(written.length, 0)
})
test('a module target is reported through the module’s snapshot, for this viewer', async () => {
stubInsert()
const asked = []
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? {
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async (q) => {
asked.push(q)
return q.targetId === 4410
? { author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } }, text: 'gg ez', at: '2026-10-30T10:00:00Z', where: { server: 'main' } }
: null
},
hide: async () => {},
unhide: async () => {},
}
: null))
const ok = await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 4410, reason: 'abuse' })
assert.equal(ok.ok, true)
assert.deepEqual(asked[0], { targetId: 4410, viewer: { userId: 11, role: 'player' } })
assert.equal(written[0].teamId, null)
assert.equal(written[0].snapshot.module, 'demo')
assert.equal(written[0].snapshot.text, 'gg ez')
assert.deepEqual(written[0].snapshot.author.identity, { kind: 'steam', id: '765611980000001' })
// The module said null: gone, or this viewer may not see it. One answer.
assert.equal((await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 1, reason: 'abuse' })).status, 404)
})
test('a module snapshot is clipped to known fields before it reaches the queue', async () => {
stubInsert()
patch(registries, 'reportTarget', () => ({
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async () => ({
author: { name: 'x'.repeat(200), userId: 'nope' },
text: 'y'.repeat(5000),
frame: { huge: 'z'.repeat(10000) },
where: { server: 'main', nested: { no: true } },
}),
hide: async () => {},
unhide: async () => {},
}))
await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 2, reason: 'spam' })
const snap = written[0].snapshot
assert.equal(snap.author.name.length, 64)
assert.equal(snap.author.userId, null)
assert.equal(snap.text.length, 500)
assert.equal(snap.frame, undefined)
assert.deepEqual(snap.where, { server: 'main' })
})
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_user_id: 12, 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.uploaderDeleted, false)
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 with no snapshot 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({ viewer: { role: 'moderator' } })
assert.equal(item.reporter, '[deleted player]')
assert.equal(item.reporterDeleted, true)
})
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
// moderation, and the flag says which it is. Every snapshot in a report row goes
// through the one rule — reporter, handler, and the target's author.
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({
reporter_user_id: null, reporter_username: 'Ravenna',
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
for (const role of ['admin', 'editor', 'moderator']) {
// eslint-disable-next-line no-await-in-loop
const [item] = await reports.queue({ viewer: { role } })
assert.equal(item.reporter, 'Ravenna', role)
assert.equal(item.reporterDeleted, true)
assert.equal(item.handledBy, 'OldMod')
assert.equal(item.handledByDeleted, true)
assert.equal(item.target.author, 'Thorne')
assert.equal(item.target.authorDeleted, true)
}
// Nothing but a staff viewer sees those names, so a caller that forgot to say
// who is reading cannot leak them.
const [blind] = await reports.queue({})
assert.equal(blind.reporter, '[deleted player]')
assert.equal(blind.handledBy, '[deleted player]')
assert.equal(blind.target.author, '[deleted player]')
})
test('an open report has no handler, which is not a deleted handler', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'admin' } })
assert.equal(item.handledBy, null)
assert.equal(item.handledByDeleted, false)
assert.equal(item.reporter, 'wanderer')
assert.equal(item.reporterDeleted, false)
})
// ── 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.',
}]])
})
// ── Hide for everyone (USER_SAFETY.md §4.1) ────────────────────────────────
test('hide for everyone goes through the forum’s own moderation, as staff, and actions the report', async () => {
const moderated = []
const updates = []
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_post', target_id: 80 }))
patch(reportsDb, 'handle', async (id, p) => { updates.push(p); return true })
patch(forumDb, 'postById', async () => ({ id: 80, thread_id: 5 }))
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1 }))
patch(forum, 'moderatePost', async (args) => { moderated.push(args); return { ok: true } })
const staff = { id: 2, username: 'root', role: 'admin' }
const result = await reports.hideForEveryone({ id: 41, actor: staff, note: 'Slur.' })
assert.equal(result.ok, true)
assert.equal(moderated[0].action, 'hide')
assert.equal(moderated[0].actorRole, 'staff', 'a staff hide is a staff action in the forum’s ledger')
assert.equal(moderated[0].postId, 80)
assert.deepEqual(updates[0], { status: 'actioned', handledBy: 2, handledUsername: 'root', note: 'Slur.', actionedWith: 'hide' })
})
test('hide for everyone on a module item calls the module’s hide', async () => {
const hidden = []
patch(reportsDb, 'byId', async () => row({ target_type: 'demo.chat', target_id: 4410, team_id: null }))
patch(reportsDb, 'handle', async () => true)
patch(registries, 'reportTarget', () => ({
owner: 'demo', id: 'demo.chat', label: 'Chat line',
snapshot: async () => null,
hide: async (q) => { hidden.push(q) },
unhide: async () => {},
}))
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root', role: 'admin' } })
assert.equal(result.ok, true)
assert.deepEqual(hidden, [{ targetId: 4410, actor: { id: 2, username: 'root', role: 'admin' } }])
})
test('an upload cannot be hidden from the queue, and nothing is recorded', async () => {
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_upload', target_id: 3 }))
patch(reportsDb, 'handle', async () => { throw new Error('must not action') })
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root' } })
assert.equal(result.status, 400)
assert.match(result.error, /post it is in/)
})
test('the queue shows a module target from its stored snapshot', async () => {
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? { owner: 'demo', id: 'demo.chat', label: 'Chat line' }
: null))
patch(reportsDb, 'list', async () => [row({
target_type: 'demo.chat', target_id: 4410, team_id: null, actioned_with: 'hide',
snapshot: JSON.stringify({
label: 'Chat line', text: 'gg ez', at: 't', where: { server: 'main' }, module: 'demo',
author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } },
}),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.target.kind, 'module')
assert.equal(item.target.text, 'gg ez')
assert.equal(item.actionedWith, 'hide')
// An unlinked chat speaker has no account, which is not a deleted account.
assert.equal(item.snapshot.author, 'Rusty')
assert.equal(item.snapshot.authorDeleted, false)
assert.deepEqual(item.snapshot.authorIdentity, { kind: 'steam', id: '765611980000001' })
})
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.
// `hideForEveryone` (USER_SAFETY.md §4.1) is a staff verb like `handle`, and is
// mounted only under /admin/moderation beside it.
const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function')
assert.deepEqual(surface.sort(), ['file', 'handle', 'hideForEveryone', '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()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.hideForEveryone.toString()))
})