// 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() } })