#233 left this open: the forum hid a blocked author's posts from a member, but the member was still pushed, put in the inbox and emailed about them. - teamNotify.forumPost excludes every member who blocks the post's author, or the member who started the thread (the forum 404s that thread for them). One recipient list, so push, the inbox and email skip the same people. userSafety.blockersOf answers it, and a staff author is blocked by nobody (S8). A failed lookup notifies without it. - The daily digest applies each recipient's forumFilter at send time: blocked authors, blocked thread starters, hidden threads and posts. digestPostsSince now also returns the thread's created_by. - A reply's notify payload carries threadAuthorUserId. The Team activity feed needed nothing: forum posts are never written to it (membership, renames and module game events only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
282 lines
14 KiB
JavaScript
282 lines
14 KiB
JavaScript
// 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('blockersOf answers who blocks an author, and nobody for a staff author (S8)', async () => {
|
||
patch(safetyDb, 'blockersOf', async () => [21, 22])
|
||
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
|
||
assert.deepEqual(await safety.blockersOf(12), [21, 22])
|
||
|
||
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role: 'moderator' }))
|
||
assert.deepEqual(await safety.blockersOf(2), [])
|
||
|
||
// A deleted account or no author at all: nothing to look up.
|
||
patch(safetyDb, 'userById', async () => null)
|
||
assert.deepEqual(await safety.blockersOf(99), [])
|
||
assert.deepEqual(await safety.blockersOf(null), [])
|
||
})
|
||
|
||
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()
|
||
}
|
||
})
|