Files
Module-Rust/server/test/safety.test.js
wtclaude dd9b8b2711
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s
feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 09:04:08 -05:00

350 lines
15 KiB
JavaScript
Raw Permalink 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.

// ── 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 `<moduleId>.`, 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)
})
})