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
318 lines
14 KiB
JavaScript
318 lines
14 KiB
JavaScript
import { test, beforeEach, afterEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { api, ApiError } from '../src/api/client.js'
|
|
|
|
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
|
// that matter to the whole app:
|
|
// - it always sends the session cookie (credentials: 'include');
|
|
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
|
// (server body.message → statusText → generic), never a silent bad value;
|
|
// - an empty body resolves to null (not a JSON parse throw);
|
|
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
|
// browser can set the multipart boundary);
|
|
// - query strings and path params are built/encoded correctly.
|
|
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
|
|
|
let calls
|
|
const realFetch = global.fetch
|
|
|
|
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
|
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText,
|
|
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
calls = []
|
|
global.fetch = async (url, opts) => {
|
|
calls.push({ url, opts })
|
|
return calls.nextReply || reply({ body: { ok: true } })
|
|
}
|
|
})
|
|
afterEach(() => {
|
|
global.fetch = realFetch
|
|
})
|
|
|
|
// helper to queue the next response
|
|
function willReply(r) {
|
|
global.fetch = async (url, opts) => {
|
|
calls.push({ url, opts })
|
|
return reply(r)
|
|
}
|
|
}
|
|
|
|
// ── happy path + cookie + base path ─────────────────────────────────────
|
|
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
|
willReply({ body: { user: { id: 1 } } })
|
|
const out = await api.me()
|
|
assert.equal(calls[0].url, '/api/v1/auth/me')
|
|
assert.equal(calls[0].opts.credentials, 'include')
|
|
assert.equal(calls[0].opts.method, 'GET')
|
|
assert.deepEqual(out, { user: { id: 1 } })
|
|
})
|
|
|
|
// ── error mapping ───────────────────────────────────────────────────────
|
|
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
|
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
|
await assert.rejects(
|
|
() => api.login('u', 'bad'),
|
|
(err) => {
|
|
assert.ok(err instanceof ApiError)
|
|
assert.equal(err.status, 401)
|
|
assert.equal(err.message, 'Incorrect username or password.')
|
|
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
test('an error with no JSON message falls back to statusText', async () => {
|
|
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
|
await assert.rejects(
|
|
() => api.status(),
|
|
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
|
)
|
|
})
|
|
|
|
// ── empty body ──────────────────────────────────────────────────────────
|
|
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
|
willReply({ status: 200, body: '' })
|
|
const out = await api.logout()
|
|
assert.equal(out, null)
|
|
})
|
|
|
|
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
|
willReply({ status: 200, body: 'plain text' })
|
|
const out = await api.me()
|
|
assert.equal(out, 'plain text')
|
|
})
|
|
|
|
// ── request body encoding ───────────────────────────────────────────────
|
|
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
|
willReply({ body: { user: { id: 9 } } })
|
|
await api.register('newbie', 'pw', { company: '' })
|
|
const { opts } = calls[0]
|
|
assert.equal(opts.method, 'POST')
|
|
assert.equal(opts.headers['Content-Type'], 'application/json')
|
|
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
|
})
|
|
|
|
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
|
willReply({ body: { url: '/uploads/x.png' } })
|
|
const fakeFile = { name: 'x.png' }
|
|
await api.admin.upload(fakeFile)
|
|
const { opts } = calls[0]
|
|
assert.equal(opts.method, 'POST')
|
|
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
|
assert.ok(opts.body instanceof FormData)
|
|
})
|
|
|
|
// ── query strings + path param encoding ─────────────────────────────────
|
|
test('wiki() builds a query string only from the params that are set', async () => {
|
|
willReply({ body: [] })
|
|
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
|
const url = new URL(calls[0].url, 'http://x')
|
|
assert.equal(url.pathname, '/api/v1/public/wiki')
|
|
assert.equal(url.searchParams.get('category'), 'lore')
|
|
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
|
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
|
})
|
|
|
|
test('wiki() with no options sends no query string at all', async () => {
|
|
willReply({ body: [] })
|
|
await api.wiki()
|
|
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
|
})
|
|
|
|
test('path params are URL-encoded (a token with unsafe characters is escaped)', async () => {
|
|
willReply({ body: {} })
|
|
await api.getInvite('a b/c?d')
|
|
assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
|
|
})
|
|
|
|
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
|
willReply({ body: {} })
|
|
await api.revokeMySession('a b/c')
|
|
assert.equal(calls[0].opts.method, 'DELETE')
|
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
|
})
|
|
|
|
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
|
//
|
|
// These pin the URLs, because the destructive one differs from the harmless one
|
|
// by a query parameter and nothing else.
|
|
|
|
test('module actions hit the right paths and methods', async () => {
|
|
const cases = [
|
|
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
|
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
|
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
|
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
|
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
|
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
|
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
|
]
|
|
for (const [call, method, url] of cases) {
|
|
calls = []
|
|
willReply({ body: {} })
|
|
await call()
|
|
assert.equal(calls[0].url, url)
|
|
assert.equal(calls[0].opts.method || 'GET', method)
|
|
}
|
|
})
|
|
|
|
test('uninstall only asks for a purge when it is told to', async () => {
|
|
// The difference between "remove the module" and "remove the module and drop
|
|
// every table it owns" is this query parameter, so a default that leaned the
|
|
// wrong way would be irreversible.
|
|
willReply({ body: {} })
|
|
await api.admin.uninstallModule('uo')
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
|
assert.equal(calls[0].opts.method, 'DELETE')
|
|
|
|
calls = []
|
|
willReply({ body: {} })
|
|
await api.admin.uninstallModule('uo', { purge: true })
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
|
})
|
|
|
|
test('a module id is URL-encoded on the way into the path', async () => {
|
|
willReply({ body: {} })
|
|
await api.admin.disableModule('a b/c')
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
|
})
|
|
|
|
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
|
//
|
|
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
|
// edits and post moderation hang off a POST; and the report route hangs off the
|
|
// forum rather than off either, because a report can name a thread, a post or an
|
|
// upload and is not moderation of any of them.
|
|
|
|
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
|
assert.equal(calls[0].opts.method, 'POST')
|
|
|
|
calls = []
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
|
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
|
// server's route is mounted on the verb.
|
|
assert.equal(calls[0].opts.method, 'PATCH')
|
|
})
|
|
|
|
test('post moderation is a different route from thread moderation', async () => {
|
|
// Not the same route with a target kind, because the two answer to different
|
|
// rules — `pin` and `lock` mean nothing to a post at all.
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
|
|
|
calls = []
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
|
})
|
|
|
|
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
|
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
|
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
|
})
|
|
|
|
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
|
// should have one place to work, and there is deliberately no leader-facing
|
|
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
|
calls = []
|
|
willReply({ body: { reports: [] } })
|
|
await api.admin.contentReports({ status: 'open' })
|
|
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
|
})
|
|
|
|
test('the report queue defaults to the open work rather than to everything', async () => {
|
|
willReply({ body: { reports: [] } })
|
|
await api.admin.contentReports()
|
|
// No query string at all — the server's default is open + reviewing, and a
|
|
// client that pinned `status=all` here would put the archive in front of a
|
|
// staffer every time they opened the screen.
|
|
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
|
})
|
|
|
|
// USER_SAFETY.md §4.1: a report, a block and a hide name an ITEM, never a
|
|
// person, and the server reads who wrote it.
|
|
test('report, block and hide name an item, on the one route for every target', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.reportItem({ targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
|
assert.equal(calls[0].url, '/api/v1/player/reports')
|
|
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
|
|
|
calls = []
|
|
willReply({ body: { block: {} } })
|
|
await api.blockAuthor({ targetType: 'team_forum_post', targetId: 80 })
|
|
assert.equal(calls[0].url, '/api/v1/auth/me/blocks')
|
|
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'team_forum_post', targetId: 80 })
|
|
|
|
calls = []
|
|
willReply({ body: { ok: true } })
|
|
await api.unhideItem({ targetType: 'rust.chat', targetId: 4410 })
|
|
assert.equal(calls[0].url, '/api/v1/auth/me/hidden/rust.chat/4410')
|
|
assert.equal(calls[0].opts.method, 'DELETE')
|
|
|
|
calls = []
|
|
willReply({ body: {} })
|
|
await api.admin.hideReportedItem(41, { note: 'x' })
|
|
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports/41/hide')
|
|
})
|
|
|
|
test('a Team slug is URL-encoded on every forum path', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
|
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
|
})
|
|
|
|
|
|
// ── Public events (Phase 14a) ───────────────────────────────────────────
|
|
//
|
|
// The one shape worth pinning is `?run=`: it is what an announcement's link
|
|
// carries, and a client that dropped it would make a mail about last Friday's
|
|
// occurrence open next Friday's.
|
|
|
|
test('the public calendar asks for no window at all by default', async () => {
|
|
willReply({ body: { entries: [] } })
|
|
await api.publicEvents()
|
|
// The server defaults to now through a month out, so the first render need
|
|
// not compute two ISO instants before it can ask for anything.
|
|
assert.equal(calls[0].url, '/api/v1/public/events')
|
|
})
|
|
|
|
test('an event page carries the run when one was named, and not when it was not', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.publicEvent('the-yew-invasion')
|
|
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
|
|
|
|
willReply({ body: { ok: true } })
|
|
await api.publicEvent('the-yew-invasion', 3692)
|
|
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
|
|
})
|
|
|
|
test('an event slug is URL-encoded on every public path', async () => {
|
|
willReply({ body: { ok: true } })
|
|
await api.publicEventSeries('a b/c')
|
|
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
|
|
})
|
|
|
|
test('participation history takes a keyset cursor, never an offset', async () => {
|
|
willReply({ body: { entries: [] } })
|
|
await api.player.eventHistory({ limit: 25, before: 900 })
|
|
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
|
|
})
|