// ── Report, block and hide, for chat lines (USER_SAFETY.md §4.2) ─────────── // // What this module promises core and the reader: // // • a chat line is answered only to a reader who could have seen it — a line // on a server whose presence is hidden from them, or one staff hid when they // are not staff, is the same `null` as a line that is gone; // • a speaker is known by their linked account first, by Steam id only when // there is none (S12); // • the feed leaves out what this reader blocked or hid, IN the query, and // marks each line `canBlock` for a signed-in reader (S11, S13). const test = require('node:test') const assert = require('node:assert') const { fakeCtx, spy } = require('./_fakes') function withCore(overrides = {}) { require('../core')._reset() const ctx = fakeCtx(overrides) require('../core').init(ctx) return ctx } /** Swap named exports on a required file for the length of `fn`. */ async function stubbing(mod, stubs, fn) { const saved = {} for (const [name, value] of Object.entries(stubs)) { saved[name] = mod[name] mod[name] = value } try { return await fn() } finally { Object.assign(mod, saved) } } const LINE = { id: 4410, serverId: 'main', wipeId: 'w1', t: Date.UTC(2026, 9, 30, 10, 0, 0), steamId: '76561198000000001', raw: JSON.stringify({ name: 'Rusty', message: 'gg ez', channel: 'Global' }), hiddenAt: null, } const SEEN = { visible: true, level: 'signed_in', required: 'signed_in', userId: 7 } function deps() { return { eventsDb: require('../model/events/events.db'), linksDb: require('../model/links/links.db'), serversDb: require('../model/servers/servers.db'), visibility: require('../model/visibility/visibility.model'), safety: require('../safety'), } } // ── registration ────────────────────────────────────────────────────────── test('rust.chat and the steam identity are registered, in core’s shape', () => { require('../core')._reset() const { fakeApi } = require('./_fakes') const api = fakeApi() require('../index')(fakeCtx(), api) const [target] = api.record.reportTargets assert.strictEqual(api.record.reportTargets.length, 1) assert.strictEqual(target.id, 'rust.chat') // Core's rule: namespaced `.`, at most 32 characters, a label of 40. assert.match(target.id, /^rust\.[a-z0-9._-]+$/) assert.ok(target.id.length <= 32 && target.label.length <= 40) for (const name of ['snapshot', 'hide', 'unhide']) assert.strictEqual(typeof target[name], 'function') const [identity] = api.record.gameIdentities assert.strictEqual(api.record.gameIdentities.length, 1) assert.strictEqual(identity.kind, 'steam') assert.strictEqual(typeof identity.ownersOf, 'function') }) // ── snapshot ────────────────────────────────────────────────────────────── test('a line is described for a reader who could see it, with the linked account', async () => { withCore() const { eventsDb, linksDb, serversDb, visibility, safety } = deps() await stubbing(eventsDb, { chatLineById: async () => LINE }, () => stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () => stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: LINE.steamId, userId: 12 }] }, () => stubbing(serversDb, { getServer: async () => ({ name: 'Main' }) }, async () => { const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } }) assert.deepStrictEqual(snap, { author: { name: 'Rusty', userId: 12, identity: { kind: 'steam', id: LINE.steamId } }, text: 'gg ez', at: '2026-10-30T10:00:00.000Z', where: { server: 'main', serverName: 'Main', channel: 'Global', wipe: 'w1' }, }) })))) }) test('an unlinked speaker has no account, only their Steam id', async () => { withCore() const { eventsDb, linksDb, serversDb, visibility, safety } = deps() await stubbing(eventsDb, { chatLineById: async () => LINE }, () => stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () => stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () => stubbing(serversDb, { getServer: async () => null }, async () => { const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } }) assert.strictEqual(snap.author.userId, null) assert.deepStrictEqual(snap.author.identity, { kind: 'steam', id: LINE.steamId }) })))) }) test('a line the reader could not have seen is the same null as a missing one', async () => { withCore() const { eventsDb, visibility, safety } = deps() const viewer = { userId: 7, role: 'player' } await stubbing(eventsDb, { chatLineById: async () => null }, async () => { assert.strictEqual(await safety.snapshot({ targetId: 1, viewer }), null) }) // Presence hidden from this reader on the line's server. await stubbing(eventsDb, { chatLineById: async () => LINE }, () => stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, visible: false }) }, async () => { assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null) })) // Hidden by staff, and the reader is not staff (S10). const hidden = { ...LINE, hiddenAt: new Date() } await stubbing(eventsDb, { chatLineById: async () => hidden }, () => stubbing(visibility, { canSeePresenceAs: async () => SEEN }, async () => { assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null) })) }) test('staff still get a line staff hid, so the report can be read', async () => { withCore() const { eventsDb, linksDb, serversDb, visibility, safety } = deps() const hidden = { ...LINE, hiddenAt: new Date() } await stubbing(eventsDb, { chatLineById: async () => hidden }, () => stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, level: 'staff' }) }, () => stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () => stubbing(serversDb, { getServer: async () => null }, async () => { const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 1, role: 'moderator' } }) assert.strictEqual(snap.text, 'gg ez') })))) }) test('the presence check is made for the account core names, not the session role', async () => { withCore() const { eventsDb, visibility, safety } = deps() const asked = [] await stubbing(eventsDb, { chatLineById: async () => LINE }, () => stubbing(visibility, { canSeePresenceAs: async (userId, serverId) => { asked.push([userId, serverId]); return { ...SEEN, visible: false } } }, async () => { await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'admin' } }) })) assert.deepStrictEqual(asked, [[7, 'main']]) }) // ── hide for everyone ───────────────────────────────────────────────────── test('hide sets the flag with who did it; a pruned line is left alone', async () => { withCore() const { eventsDb, safety } = deps() const set = spy(Promise.resolve()) await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => { await safety.hide({ targetId: 4410, actor: { id: 3, username: 'moderator', role: 'moderator' } }) await safety.unhide({ targetId: 4410, actor: { id: 3 } }) }) assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false]]) const none = spy(Promise.resolve()) await stubbing(eventsDb, { chatLineById: async () => null, setChatHidden: none }, async () => { await safety.hide({ targetId: 9, actor: { id: 3 } }) }) assert.deepStrictEqual(none.calls, []) }) function fakeRes() { const res = { statusCode: 200, body: null } res.status = (code) => { res.statusCode = code; return res } res.json = (body) => { res.body = body; return res } res.end = () => res return res } test('the line’s own menu hides and unhides through admin routes, logged', async () => { const ctx = withCore() const { eventsDb } = deps() const admin = require('../router/admin/rust.controller') const set = spy(Promise.resolve()) await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => { const res = fakeRes() await admin.hideChat({ params: { id: '4410' }, user: { id: 3 } }, res) assert.strictEqual(res.statusCode, 204) await admin.unhideChat({ params: { id: '4410' }, user: { id: 3 } }, fakeRes()) }) assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false, 3]]) assert.deepStrictEqual(ctx.activity.log.calls.map(([e]) => e.action), ['rust.chat.hide', 'rust.chat.unhide']) await stubbing(eventsDb, { chatLineById: async () => null }, async () => { const res = fakeRes() await admin.hideChat({ params: { id: '9' }, user: { id: 3 } }, res) assert.strictEqual(res.statusCode, 404) }) }) // ── the steam identity ──────────────────────────────────────────────────── test('ownersOf answers linked ids only, and asks nothing for an empty list', async () => { withCore() const { linksDb, safety } = deps() const asked = spy(async () => [{ steamId: '1', userId: 5 }]) await stubbing(linksDb, { userIdsForSteamIds: asked }, async () => { assert.deepStrictEqual(await safety.ownersOf(['1', '2', '1', null]), { 1: 5 }) assert.deepStrictEqual(await safety.ownersOf([]), {}) }) assert.deepStrictEqual(asked.calls, [[['1', '2']]]) }) // ── the feed ────────────────────────────────────────────────────────────── test('a signed-out reader is filtered by nothing but staff’s hides', async () => { const ctx = withCore() const { safety } = deps() assert.deepStrictEqual(await safety.feedFilter({ userId: null, staff: false }), { showHidden: false, steamIds: [], ids: [] }) assert.deepStrictEqual(ctx.safety.forViewer.calls, []) }) test('a reader’s blocks become Steam ids: an account’s linked ids, and unlinked Steam blocks', async () => { withCore({ safety: { forViewer: async () => ({ blockedUserIds: [12], blockedIdentities: { steam: ['900'] }, hidden: { 'rust.chat': [4410, 4411] }, }), staffAmong: async () => [], }, }) const { linksDb, safety } = deps() const asked = spy(async () => ['100', '101']) await stubbing(linksDb, { steamIdsForUsers: asked }, async () => { assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: true }), { showHidden: true, steamIds: ['100', '101', '900'], ids: [4410, 4411], }) }) assert.deepStrictEqual(asked.calls, [[[12]]]) }) test('a feed whose blocks cannot be read is shown unfiltered, not failed', async () => { withCore({ safety: { forViewer: async () => { throw new Error('db down') }, staffAmong: async () => [] } }) const { safety } = deps() assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: false }), { showHidden: false, steamIds: [], ids: [] }) }) test('canBlock: not your own lines, not a staff-linked player, not a line with no Steam id', async () => { withCore({ safety: { forViewer: async () => ({}), staffAmong: async (ids) => ids.filter((id) => id === 2) } }) const { linksDb, safety } = deps() const rows = [ { id: 1, kind: 'player.chat', steamId: 'mine' }, { id: 2, kind: 'player.chat', steamId: 'staff' }, { id: 3, kind: 'player.chat', steamId: 'linked' }, { id: 4, kind: 'player.chat', steamId: 'unlinked' }, { id: 5, kind: 'player.chat', steamId: null }, { id: 6, kind: 'player.death', steamId: 'linked' }, ] await stubbing(linksDb, { userIdsForSteamIds: async () => [ { steamId: 'mine', userId: 7 }, { steamId: 'staff', userId: 2 }, { steamId: 'linked', userId: 12 }, ], }, async () => { const out = await safety.annotate(rows, { userId: 7 }) assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false, true, true, false, undefined]) // Signed out: untouched. assert.strictEqual(await safety.annotate(rows, { userId: null }), rows) }) }) test('when who is staff cannot be read, no line offers Block', async () => { withCore({ safety: { forViewer: async () => ({}), staffAmong: async () => { throw new Error('db down') } } }) const { linksDb, safety } = deps() await stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: 'a', userId: 12 }] }, async () => { const out = await safety.annotate([{ id: 1, kind: 'player.chat', steamId: 'a' }, { id: 2, kind: 'player.chat', steamId: 'b' }], { userId: 7 }) assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false]) }) }) // ── the query ───────────────────────────────────────────────────────────── test('the feed’s query leaves hidden, blocked and hidden-for-me chat out, and only chat', async () => { const ctx = withCore() const db = require('../model/events/events.db') await db.recentEvents({ serverId: 'main', kinds: ['player.chat', 'player.death'], limit: 50 }) let [sql, params] = ctx.db.query.calls.at(-1) assert.match(sql, /NOT \(kind = 'player\.chat' AND hidden_at IS NOT NULL\)/) assert.deepStrictEqual(params, ['main', 'player.chat', 'player.death', 50]) await db.recentEvents({ serverId: 'main', kinds: ['player.chat'], limit: 50, chat: { showHidden: true, steamIds: ['100', '900'], ids: [4410] }, }); [sql, params] = ctx.db.query.calls.at(-1) assert.doesNotMatch(sql, /hidden_at IS NOT NULL/, 'staff get hidden lines') assert.match(sql, /NOT \(kind = 'player\.chat' AND steam_id IN \(\?, \?\)\)/) assert.match(sql, /NOT \(kind = 'player\.chat' AND id IN \(\?\)\)/) assert.deepStrictEqual(params, ['main', 'player.chat', '100', '900', 4410, 50]) }) test('the model passes the reader’s chat filter through, narrow by default, and marks staff’s hidden lines', async () => { withCore() const db = require('../model/events/events.db') const model = require('../model/events/events.model') let asked = null await stubbing(db, { recentEvents: async (args) => { asked = args return [{ id: 1, kind: 'player.chat', t: 1, steamId: 'a', raw: '{}', hiddenAt: new Date() }] }, }, async () => { await model.recent({ serverId: 'main', presence: true }) assert.deepStrictEqual(asked.chat, { showHidden: false, steamIds: [], ids: [] }) const rows = await model.recent({ serverId: 'main', presence: true, chat: { showHidden: true, steamIds: ['x'], ids: [2] } }) assert.deepStrictEqual(asked.chat, { showHidden: true, steamIds: ['x'], ids: [2] }) assert.strictEqual(rows[0].hidden, true) }) })