Files
website/server/test/userSafety.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

268 lines
14 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.

// Block and "Hide for me" (docs/website/USER_SAFETY.md §1, §4.1).
//
// The rules under test are the org lead's, each named where it is checked:
// S8 (staff cannot be blocked), S12 (a block lands on the ACCOUNT whenever there
// is one), S14 (a game block becomes an account block once the identity is
// linked) and S11 (a module filters its own feeds with `ctx.safety.forViewer`).
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const safety = require('../src/model/safety/userSafety.model')
const safetyDb = require('../src/model/safety/userSafety.db')
const targets = require('../src/model/safety/targets')
const registries = require('../src/modules/registries')
const forum = require('../src/model/teams/teamForum.model')
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 alice = { id: 11, username: 'alice', role: 'player' }
// A pretend module: `demo.chat` lines, spoken by Steam ids, some linked.
function stubModule({ links = {}, ownersOf = null } = {}) {
const target = {
owner: 'demo', id: 'demo.chat', label: 'Chat line',
snapshot: async () => null, hide: async () => {}, unhide: async () => {},
}
const ident = {
owner: 'demo', kind: 'steam', label: 'Steam account',
ownersOf: ownersOf || (async (ids) => Object.fromEntries(ids.filter((id) => links[id]).map((id) => [id, links[id]]))),
}
patch(registries, 'reportTarget', (type) => (type === 'demo.chat' ? target : null))
patch(registries, 'allReportTargets', () => [target])
patch(registries, 'gameIdentity', (owner, kind) => (owner === 'demo' && kind === 'steam' ? ident : null))
}
// The item a block names, as the resolver would answer it.
const chatLine = (author) => ({ type: 'demo.chat', targetId: 4410, teamId: null, module: 'demo', author, snapshot: {} })
const forumPost = (author) => ({ type: 'team_forum_post', targetId: 80, teamId: 1, author, snapshot: {} })
function stubWrites() {
const writes = []
patch(safetyDb, 'insertAccountBlock', async (row) => { writes.push(['account', row]); return 7 })
patch(safetyDb, 'insertGameBlock', async (row) => { writes.push(['game', row]); return 8 })
return writes
}
// ── S12: the account first ─────────────────────────────────────────────────
test('blocking a forum author blocks their account', async () => {
patch(targets, 'resolve', async () => forumPost({ userId: 12, name: 'rusty', identity: null }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'account')
assert.deepEqual(writes, [['account', { blockerUserId: 11, blockedUserId: 12, displayName: 'rusty' }]])
})
test('blocking a chat speaker whose Steam id is linked blocks the ACCOUNT, not the Steam id (S12)', async () => {
stubModule({ links: { 7656: 12 } })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Rusty', identity: { kind: 'steam', id: '7656' } }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'account')
assert.equal(result.block.name, 'rusty', 'the website username, not the in-game name')
assert.equal(writes[0][0], 'account')
})
test('only an unlinked speaker is blocked by their game identity', async () => {
stubModule({ links: {} })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Rusty', identity: { kind: 'steam', id: '7656' } }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'game')
assert.deepEqual(writes, [['game', { blockerUserId: 11, moduleId: 'demo', kind: 'steam', externalId: '7656', displayName: 'Rusty' }]])
})
// ── S8: never staff ────────────────────────────────────────────────────────
test('staff cannot be blocked, through a forum post or through a linked Steam id (S8)', async () => {
for (const role of ['admin', 'editor', 'moderator']) {
patch(targets, 'resolve', async () => forumPost({ userId: 2, name: 'mod', identity: null }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role }))
const writes = stubWrites()
// eslint-disable-next-line no-await-in-loop
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })
assert.equal(result.status, 403, role)
assert.equal(result.code, 'staff')
assert.equal(writes.length, 0)
}
stubModule({ links: { 7656: 2 } })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Mod', identity: { kind: 'steam', id: '7656' } }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role: 'moderator' }))
stubWrites()
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })).code, 'staff')
})
test('a block on someone who later became staff is kept but stops applying, and says why (S8)', async () => {
patch(safetyDb, 'blocksFor', async () => [
{ id: 1, blocked_user_id: 12, blocked_username: 'rusty', blocked_role: 'moderator', created_at: new Date() },
{ id: 2, blocked_user_id: 13, blocked_username: 'grim', blocked_role: 'player', created_at: new Date() },
])
const blocks = await safety.listBlocks(alice)
assert.deepEqual(blocks.map((b) => [b.name, b.applies, b.reason]), [['rusty', false, 'staff'], ['grim', true, null]])
patch(safetyDb, 'hiddenFor', async () => [])
const filter = await safety.forumFilter(alice.id)
assert.deepEqual([...filter.blockedUserIds], [13])
})
test('you cannot block yourself, or a deleted account', async () => {
patch(targets, 'resolve', async () => forumPost({ userId: 11, name: 'alice', identity: null }))
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).code, 'self')
patch(targets, 'resolve', async () => forumPost({ userId: null, name: 'gone', identity: null }))
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).code, 'no_author')
})
test('an item the member cannot see is a 404, and an upload is not blockable from', async () => {
patch(targets, 'resolve', async () => null)
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).status, 404)
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_upload', targetId: 3 })).code, 'unknown_target')
})
// ── S14: a game block becomes an account block ─────────────────────────────
test('a game block whose Steam id has since been linked is rewritten as an account block (S14)', async () => {
stubModule({ links: { 7656: 12 } })
let rows = [{ id: 5, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656', display_name: 'Rusty', created_at: new Date() }]
patch(safetyDb, 'blocksFor', async () => rows)
const converted = []
patch(safetyDb, 'convertToAccountBlock', async (id, userId) => {
converted.push([id, userId])
rows = [{ id: 5, blocked_user_id: userId, blocked_username: 'rusty', blocked_role: 'player', created_at: new Date() }]
return 'converted'
})
const blocks = await safety.listBlocks(alice)
assert.deepEqual(converted, [[5, 12]])
assert.equal(blocks[0].kind, 'account')
assert.equal(blocks[0].name, 'rusty')
// From then on it hides the account's forum posts too.
patch(safetyDb, 'hiddenFor', async () => [])
assert.deepEqual([...(await safety.forumFilter(alice.id)).blockedUserIds], [12])
})
test('a module that does not answer leaves game blocks as they are, still applying', async () => {
stubModule({ ownersOf: async () => { throw new Error('down') } })
patch(safetyDb, 'blocksFor', async () => [
{ id: 5, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656', display_name: 'Rusty', created_at: new Date() },
])
patch(safetyDb, 'convertToAccountBlock', async () => { throw new Error('must not convert') })
const [block] = await safety.listBlocks(alice)
assert.equal(block.kind, 'game')
assert.equal(block.applies, true)
})
// ── S11: what a module's feed leaves out ───────────────────────────────────
test('forViewer gives a module the accounts, its own game blocks and its own hides', async () => {
stubModule({ links: {} })
patch(safetyDb, 'blocksFor', async () => [
{ id: 1, blocked_user_id: 12, blocked_role: 'player' },
{ id: 2, blocked_user_id: 2, blocked_role: 'admin' },
{ id: 3, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656' },
{ id: 4, blocked_user_id: null, module_id: 'other', identity_kind: 'steam', external_id: '9999' },
])
patch(safetyDb, 'hiddenFor', async (userId, types) => {
assert.deepEqual(types, ['demo.chat'], 'only this module’s target types are read')
return [{ target_type: 'demo.chat', target_id: 4410 }]
})
const view = await safety.forModuleViewer('demo', alice.id)
assert.deepEqual(view, {
blockedUserIds: [12],
blockedIdentities: { steam: ['7656'] },
hidden: { 'demo.chat': [4410] },
})
})
test('a signed-out reader has no blocks and no hides', async () => {
patch(safetyDb, 'blocksFor', async () => { throw new Error('must not read') })
assert.deepEqual(await safety.forModuleViewer('demo', null), { blockedUserIds: [], blockedIdentities: {}, hidden: {} })
assert.equal((await safety.forumFilter(null)).blockedUserIds.size, 0)
})
test('staffAmong takes only positive integer ids', async () => {
let asked
patch(safetyDb, 'staffAmong', async (ids) => { asked = ids; return [] })
await safety.staffAmong([3, '4', 3, -1, 'x', null, 2.5])
assert.deepEqual(asked, [3, 4])
})
// ── hide for me, and the forum's read ──────────────────────────────────────
test('hide for me names a type a member can hide, and never looks the item up', async () => {
const inserted = []
patch(safetyDb, 'insertHidden', async (...args) => { inserted.push(args) })
patch(targets, 'resolve', async () => { throw new Error('must not look up') })
assert.equal((await safety.hide(alice, 'team_forum_post', 80)).ok, true)
assert.equal((await safety.hide(alice, 'wiki_page', 1)).code, 'unknown_target')
assert.deepEqual(inserted, [[11, 'team_forum_post', 80]])
})
test('the forum leaves out a reader’s hidden items and blocked authors, and nothing else', () => {
const filter = {
blockedUserIds: new Set([12]),
hidden: { team_forum_thread: new Set([5]), team_forum_post: new Set([81]) },
}
assert.equal(forum.leftOutThread({ id: 5, created_by: 3 }, filter), true, 'hidden')
assert.equal(forum.leftOutThread({ id: 6, created_by: 12 }, filter), true, 'blocked author')
assert.equal(forum.leftOutThread({ id: 7, created_by: 3 }, filter), false)
assert.equal(forum.leftOutThread({ id: 8, created_by: null }, filter), false, 'a deleted author is not a blocked one')
assert.equal(forum.leftOutPost({ id: 81, author_user_id: 3 }, filter), true)
assert.equal(forum.leftOutPost({ id: 82, author_user_id: 12 }, filter), true)
assert.equal(forum.leftOutPost({ id: 83, author_user_id: 3 }, filter), false)
assert.equal(forum.leftOutPost({ id: 81, author_user_id: 3 }, null), false, 'no filter, nothing left out')
})
// ── the registry ───────────────────────────────────────────────────────────
test('a module registers report targets and game identities, namespaced and shape-checked', () => {
registries._reset()
try {
const api = registries.stage('demo')
api.registerReportTargets([{ id: 'demo.chat', label: 'Chat line', snapshot() {}, hide() {}, unhide() {} }])
api.registerGameIdentities([{ kind: 'steam', label: 'Steam account', ownersOf() {} }])
registries.apply(api.staged)
assert.equal(registries.reportTarget('demo.chat').owner, 'demo')
assert.equal(registries.gameIdentity('demo', 'steam').label, 'Steam account')
assert.equal(registries.gameIdentity('other', 'steam'), null, 'each module answers only for its own links')
const other = registries.stage('other')
other.registerReportTargets([{ id: 'demo.post', label: 'Post', snapshot() {}, hide() {}, unhide() {} }])
assert.throws(() => registries.apply(other.staged), /not namespaced "other\."/)
const core = registries.stage('sneaky')
assert.throws(
() => core.registerReportTargets([{ id: 'team_forum_post', label: 'x', snapshot() {}, hide() {}, unhide() {} }]),
/bad id/,
'a module can never claim one of core’s forum types',
)
assert.throws(
() => registries.stage('demo2').registerReportTargets([{ id: 'demo2.chat', label: 'x', snapshot() {} }]),
/hide\(\) is missing/,
)
} finally {
registries._reset()
}
})