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
510 lines
23 KiB
JavaScript
510 lines
23 KiB
JavaScript
// 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()))
|
||
})
|