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
This commit is contained in:
@@ -15,6 +15,11 @@ 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) {
|
||||
@@ -29,14 +34,24 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
const team = { id: 1, name: 'Ossuary' }
|
||||
const reporter = { id: 11, username: 'wanderer' }
|
||||
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.
|
||||
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))
|
||||
// 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 = []
|
||||
@@ -119,6 +134,85 @@ test('an unknown target type or reason is refused before any lookup', async () =
|
||||
})).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 })
|
||||
@@ -308,6 +402,74 @@ test('handling records who decided, when, and why', async () => {
|
||||
}]])
|
||||
})
|
||||
|
||||
// ── 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') })
|
||||
@@ -334,11 +496,14 @@ test('acceptance: nothing in the report model is reachable by a Team leader', ()
|
||||
// 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', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
|
||||
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()))
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||
// assertion that says it does. The number itself is pinned in version.js.
|
||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||
assert.equal(MODULE_API_VERSION, '1.20.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.21.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
|
||||
@@ -552,9 +552,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// hooks on `api`, never by writing an identity itself.
|
||||
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
|
||||
// the module enforces in its own handlers.
|
||||
// API 1.21.0 added `safety` (USER_SAFETY.md §4.1, S11): READ only, one
|
||||
// reader's blocks and hides, so a module filters its own feeds on the server.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
'posts', 'push', 'safety', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
// window and cap and takes the plumbing, so there is one express-rate-limit in
|
||||
|
||||
@@ -17,6 +17,7 @@ const grants = require('../src/model/teams/teamGrants.model')
|
||||
const access = require('../src/model/teams/teamAccess.model')
|
||||
const forum = require('../src/model/teams/teamForum.model')
|
||||
const forumDb = require('../src/model/teams/teamForum.db')
|
||||
const safetyDb = require('../src/model/safety/userSafety.db')
|
||||
const uploads = require('../src/model/teams/teamForumUploads.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml')
|
||||
|
||||
@@ -599,6 +600,7 @@ test('every post comes back knowing whether THIS reader may edit it', 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() }),
|
||||
])
|
||||
patch(safetyDb, 'staffAmong', async () => [])
|
||||
|
||||
const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
|
||||
assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false])
|
||||
@@ -609,3 +611,40 @@ test('every post comes back knowing whether THIS reader may edit it', async () =
|
||||
const anonymous = await forum.getThread(1, 5, {})
|
||||
assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false])
|
||||
})
|
||||
|
||||
// USER_SAFETY.md S8: the menu does not offer what the server would refuse.
|
||||
test('every post says whether this reader may block its author: not yourself, not staff, not a deleted account', 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, created_at: new Date() }),
|
||||
post({ id: 82, author_user_id: 2, created_at: new Date() }),
|
||||
post({ id: 83, author_user_id: null, author_username: 'gone', created_at: new Date() }),
|
||||
])
|
||||
patch(safetyDb, 'staffAmong', async (ids) => ids.filter((id) => id === 2))
|
||||
|
||||
const thread = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
|
||||
assert.deepEqual(thread.posts.map((p) => p.canBlock), [false, true, false, false])
|
||||
|
||||
patch(safetyDb, 'staffAmong', async () => { throw new Error('a signed-out read asks nothing') })
|
||||
const anonymous = await forum.getThread(1, 5, {})
|
||||
assert.deepEqual(anonymous.posts.map((p) => p.canBlock), [false, false, false, false])
|
||||
})
|
||||
|
||||
test('a reader’s filter leaves out their hidden posts and their blocked authors’ posts', async () => {
|
||||
patch(forumDb, 'threadById', async () => discussion())
|
||||
patch(forumDb, 'postsByThread', async () => [
|
||||
post({ id: 80, author_user_id: 3, created_at: new Date() }),
|
||||
post({ id: 81, author_user_id: 99, created_at: new Date() }),
|
||||
post({ id: 82, author_user_id: 4, created_at: new Date() }),
|
||||
])
|
||||
patch(safetyDb, 'staffAmong', async () => [])
|
||||
const filter = { blockedUserIds: new Set([99]), hidden: { team_forum_thread: new Set(), team_forum_post: new Set([82]) } }
|
||||
|
||||
const thread = await forum.getThread(1, 5, { viewer: { userId: 11 }, filter })
|
||||
assert.deepEqual(thread.posts.map((p) => p.id), [80])
|
||||
|
||||
// A thread its reader hid is gone from their view, a link to it included.
|
||||
filter.hidden.team_forum_thread.add(5)
|
||||
assert.equal(await forum.getThread(1, 5, { viewer: { userId: 11 }, filter }), null)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 safety = require('../src/model/safety/userSafety.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -394,6 +395,7 @@ test('with the forum ON, the same routes answer — the switch is the only diffe
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
|
||||
patch(forum, 'listThreads', async () => [])
|
||||
patch(safety, 'forumFilter', async () => null)
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
|
||||
@@ -415,6 +417,7 @@ test('a leader gets both composers; the announcement one is theirs alone', async
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
|
||||
patch(forum, 'listThreads', async () => [])
|
||||
patch(safety, 'forumFilter', async () => null)
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json()
|
||||
|
||||
267
server/test/userSafety.test.js
Normal file
267
server/test/userSafety.test.js
Normal file
@@ -0,0 +1,267 @@
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user