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
651 lines
32 KiB
JavaScript
651 lines
32 KiB
JavaScript
// The forum's access model, its switches, its renderer (phase 4, "5a") and its
|
||
// discussion half (phase 5, "5b") — docs/website/TEAMS.md Part 5.
|
||
//
|
||
// The tests named "acceptance" are the phases' stated acceptance criteria,
|
||
// verbatim. They are the ones to read first, and the ones not to weaken: each
|
||
// names a property that the code around it can lose without any screen looking
|
||
// different.
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||
const settingsDb = require('../src/model/settings/settings.db')
|
||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||
const teamsDb = require('../src/model/teams/teams.db')
|
||
const usersDb = require('../src/model/users/users.db')
|
||
const grants = require('../src/model/teams/teamGrants.model')
|
||
const access = require('../src/model/teams/teamAccess.model')
|
||
const forum = require('../src/model/teams/teamForum.model')
|
||
const forumDb = require('../src/model/teams/teamForum.db')
|
||
const safetyDb = require('../src/model/safety/userSafety.db')
|
||
const uploads = require('../src/model/teams/teamForumUploads.model')
|
||
const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml')
|
||
|
||
const saved = []
|
||
function patch(mod, name, fn) {
|
||
saved.push([mod, name, mod[name]])
|
||
mod[name] = fn
|
||
}
|
||
|
||
// One settings store per test, so a test states the keys it cares about and
|
||
// nothing else. `get` returning undefined is "the row does not exist", which for
|
||
// both forum keys is the default and therefore the OFF state.
|
||
let store = {}
|
||
function stubSettings() {
|
||
store = {}
|
||
patch(settingsDb, 'get', async (key) => store[key])
|
||
patch(settingsDb, 'getRow', async (key) => (key in store
|
||
? { key, value: store[key], updated_by: 1, updated_by_username: 'root', updated_at: new Date() }
|
||
: null))
|
||
patch(settingsDb, 'set', async (key, value) => { store[key] = value })
|
||
}
|
||
|
||
beforeEach(() => { stubSettings() })
|
||
afterEach(() => {
|
||
while (saved.length) {
|
||
const [mod, name, original] = saved.pop()
|
||
mod[name] = original
|
||
}
|
||
})
|
||
|
||
// ── the switch (§5.5.1) ────────────────────────────────────────────────────
|
||
|
||
test('the forum is off until an operator turns it on, and a broken read keeps it off', async () => {
|
||
assert.equal(await forumSettings.forumsEnabled(), false)
|
||
store.teams_forums_enabled = '1'
|
||
assert.equal(await forumSettings.forumsEnabled(), true)
|
||
|
||
// Fail closed. A transient DB fault must not open a feature the operator
|
||
// deliberately turned off — a forum that 404s for a minute is the cheap failure.
|
||
patch(settingsDb, 'get', async () => { throw new Error('db down') })
|
||
assert.equal(await forumSettings.forumsEnabled(), false)
|
||
})
|
||
|
||
test('an unexpected stored image mode reads as disabled rather than as itself', async () => {
|
||
store.teams_forum_images = 'everything'
|
||
assert.equal(await forumSettings.imageMode(), 'disabled')
|
||
})
|
||
|
||
// ── the acknowledgement gate (§5.5.5) ──────────────────────────────────────
|
||
|
||
test('acceptance 4: uploads mode is rejected without a matching acknowledgement', async () => {
|
||
// Server-side, with the admin UI's checkbox bypassed — a checkbox is how the
|
||
// gate is presented and never the gate. Nothing is on record and the stored
|
||
// mode is not uploads, so this is a genuine transition INTO it.
|
||
const refused = await forumSettings.assertAcknowledged('uploads', undefined)
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 400)
|
||
|
||
// A STALE version is not an acknowledgement either.
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', '0')).ok, false)
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION)).ok, true)
|
||
})
|
||
|
||
test('the other two image modes need no acknowledgement', async () => {
|
||
// `remote` gets a non-blocking advisory instead: nothing comes to rest on the
|
||
// operator's disk, which is the thing the acknowledgement is about.
|
||
assert.equal((await forumSettings.assertAcknowledged('remote', undefined)).ok, true)
|
||
assert.equal((await forumSettings.assertAcknowledged('disabled', undefined)).ok, true)
|
||
})
|
||
|
||
test('uploads is not a one-way door — a later save needs no fresh acknowledgement', async () => {
|
||
// Found on the live rig. A settings form sends every field it owns, so with
|
||
// uploads on, unticking "Enable Team forums" re-sends `uploads` and came back
|
||
// 400 — the operator could never change a forum setting again, least of all the
|
||
// one they would reach for in a hurry.
|
||
store.teams_forum_images = 'uploads'
|
||
store.teams_forum_uploads_ack = forumSettings.ACK_VERSION
|
||
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, true)
|
||
|
||
// Still a gate where consent is genuinely absent: a stale acknowledgement means
|
||
// the wording moved, and that DOES need re-consent.
|
||
store.teams_forum_uploads_ack = '0'
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false)
|
||
|
||
// And a transition INTO uploads from another mode still asks.
|
||
store.teams_forum_images = 'remote'
|
||
store.teams_forum_uploads_ack = forumSettings.ACK_VERSION
|
||
assert.equal((await forumSettings.assertAcknowledged('uploads', undefined)).ok, false)
|
||
})
|
||
|
||
test('a reworded notice freezes forum settings but does NOT disable uploads', async () => {
|
||
store.teams_forum_uploads_ack = '0' // accepted an older wording
|
||
store.teams_forum_images = 'uploads'
|
||
|
||
const state = await forumSettings.ackState()
|
||
assert.equal(state.stale, true)
|
||
assert.equal(state.given, true)
|
||
// Uploads keep working: silently downgrading a live feature because a legal
|
||
// text changed would strand users mid-conversation.
|
||
assert.equal(await forumSettings.uploadsEnabled(), true)
|
||
|
||
const frozen = await forumSettings.assertSettingsWritable(['teams_forums_enabled'], undefined)
|
||
assert.equal(frozen.ok, false)
|
||
// Re-acknowledging is the key to its own lock.
|
||
const unlocked = await forumSettings.assertSettingsWritable(
|
||
['teams_forums_enabled'], forumSettings.ACK_VERSION,
|
||
)
|
||
assert.equal(unlocked.ok, true)
|
||
})
|
||
|
||
test('a setting that is not the forum’s is unaffected by a stale acknowledgement', async () => {
|
||
store.teams_forum_uploads_ack = '0'
|
||
const result = await forumSettings.assertSettingsWritable(['site_title'], undefined)
|
||
assert.equal(result.ok, true)
|
||
})
|
||
|
||
// ── the renderer (§5.5.3) ──────────────────────────────────────────────────
|
||
|
||
test('acceptance 3: the stored HTML is identical in every image mode', () => {
|
||
const stored = cleanForumBody('<p>Banner: https://example.com/banner.png</p>')
|
||
|
||
// The author wrote a URL and it was stored as a LINK. No <img> is in the
|
||
// stored body in any mode, which is what makes the policy enforceable and what
|
||
// makes flipping it back a no-op rather than a migration.
|
||
assert.ok(!stored.includes('<img'))
|
||
assert.match(stored, /<a href="https:\/\/example\.com\/banner\.png"/)
|
||
|
||
const disabled = renderForumBody(stored, 'disabled')
|
||
const remote = renderForumBody(stored, 'remote')
|
||
|
||
assert.equal(disabled, stored) // byte-for-byte
|
||
assert.match(remote, /<img [^>]*src="https:\/\/example\.com\/banner\.png"/)
|
||
assert.match(remote, /loading="lazy"/)
|
||
assert.match(remote, /referrerpolicy="no-referrer"/)
|
||
// Carries core's own class, which is what puts the picture BENEATH its link
|
||
// (an <img> is inline) and caps it to the column. Found on the live rig.
|
||
assert.match(remote, /class="forum-embed"/)
|
||
// The link survives in both. A blocked or dead image degrades to the URL the
|
||
// author actually wrote.
|
||
assert.ok(remote.includes('<a href="https://example.com/banner.png"'))
|
||
})
|
||
|
||
test('an author cannot write an img tag, or smuggle attributes through one', () => {
|
||
const stored = cleanForumBody(
|
||
'<p><img src="https://evil.test/x.png" onerror="alert(1)" width="99999" srcset="y"></p>',
|
||
)
|
||
assert.ok(!stored.includes('<img'))
|
||
assert.ok(!stored.includes('onerror'))
|
||
assert.ok(!stored.includes('srcset'))
|
||
// And it stays absent when the policy is at its most permissive: the only code
|
||
// that can emit an <img> is core's renderer.
|
||
assert.ok(!renderForumBody(stored, 'uploads').includes('<img'))
|
||
})
|
||
|
||
test('http URLs and non-image URLs stay plain links', () => {
|
||
// CSP is `img-src 'self' data: https:` — an http: image is blocked by the
|
||
// browser and renders as a broken picture, so it is never embedded. This
|
||
// presents as "images are broken on my forum" with nothing in any log, which is
|
||
// why it is asserted rather than assumed.
|
||
const httpUrl = renderForumBody(cleanForumBody('<p>http://x.test/a.png</p>'), 'remote')
|
||
assert.ok(!httpUrl.includes('<img'))
|
||
|
||
const notAnImage = renderForumBody(cleanForumBody('<p>https://x.test/a.exe</p>'), 'remote')
|
||
assert.ok(!notAnImage.includes('<img'))
|
||
})
|
||
|
||
test('an UPLOADED image becomes a picture — the composer’s own path', () => {
|
||
// Found on the live rig, not by any unit test here. `uploads` mode hands the
|
||
// composer a root-relative path, the composer puts it in the body as TEXT, and
|
||
// the renderer only rewrites ANCHORS — so the write path has to produce one, or
|
||
// an uploaded image can never render. isEmbeddableImageUrl accepted these paths
|
||
// from day one; nothing made an anchor out of them.
|
||
const stored = cleanForumBody('<p>/uploads/1787-abc.png</p>')
|
||
assert.match(stored, /<a href="\/uploads\/1787-abc\.png"/)
|
||
assert.match(renderForumBody(stored, 'uploads'), /<img [^>]*src="\/uploads\/1787-abc\.png"/)
|
||
assert.equal(renderForumBody(stored, 'disabled'), stored)
|
||
})
|
||
|
||
test('ordinary prose containing a slash is not turned into a link', () => {
|
||
// The upload branch is deliberately narrow — `/uploads/` and nothing else.
|
||
assert.ok(!cleanForumBody('<p>meet at /the docks tonight</p>').includes('<a href'))
|
||
})
|
||
|
||
test('a URL inside code or pre is shown, not offered', () => {
|
||
const stored = cleanForumBody('<pre>https://example.com/a.png</pre>')
|
||
assert.ok(!stored.includes('<a href'))
|
||
})
|
||
|
||
test('every link ships with a safe rel, including one the author wrote', () => {
|
||
const stored = cleanForumBody('<a href="https://x.test/" rel="me">x</a>')
|
||
assert.match(stored, /rel="noopener noreferrer nofollow"/)
|
||
assert.ok(!stored.includes('rel="me"'))
|
||
})
|
||
|
||
// ── grants: authority, the cap, and non-contamination (§2.5) ───────────────
|
||
|
||
const team = { id: 1, name: 'Ossuary' }
|
||
const leader = { id: 7, username: 'aldric', role: 'player' }
|
||
const staff = { id: 2, username: 'root', role: 'admin' }
|
||
const guest = { id: 9, username: 'mara', role: 'player' }
|
||
|
||
function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) {
|
||
patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId))
|
||
patch(accessDb, 'activeGrant', async () => existing)
|
||
patch(accessDb, 'activeGrantCount', async () => activeCount)
|
||
patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null))
|
||
patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null)
|
||
}
|
||
|
||
test('acceptance 1: a granted account has forum access and is not a member', async () => {
|
||
stubGrantWorld()
|
||
const written = []
|
||
patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 })
|
||
// The membership projection is stubbed to a table nothing may write. If the
|
||
// grant path touched it, these would be the rows that changed.
|
||
const membersBefore = []
|
||
patch(teamsDb, 'membersByTeam', async () => membersBefore)
|
||
patch(teamsDb, 'activeByUser', async () => undefined)
|
||
|
||
const result = await grants.grant({ team, actor: leader, username: 'mara' })
|
||
assert.equal(result.ok, true)
|
||
assert.equal(written.length, 1)
|
||
assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle
|
||
|
||
// The resolver now says yes, and says WHY separately.
|
||
patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id }))
|
||
const resolved = await access.forumAccess(team.id, guest.id)
|
||
assert.equal(resolved.allowed, true)
|
||
assert.equal(resolved.viaGrant, true)
|
||
assert.equal(resolved.viaMembership, false)
|
||
|
||
// …and path 4 still refuses, because an integration cannot verify that an
|
||
// unlinked, forum-granted account is a real game member.
|
||
assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false)
|
||
})
|
||
|
||
test('a leader is capped; staff are not, and are warned on the way past', async () => {
|
||
stubGrantWorld({ activeCount: 50 })
|
||
patch(accessDb, 'insertGrant', async () => 1)
|
||
|
||
const refused = await grants.grant({ team, actor: leader, username: 'mara' })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 409)
|
||
|
||
const allowed = await grants.grant({ team, actor: staff, username: 'mara' })
|
||
assert.equal(allowed.ok, true)
|
||
assert.match(allowed.warning, /limit of 50/)
|
||
})
|
||
|
||
test('a leader may not revoke a staff-issued grant', async () => {
|
||
stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } })
|
||
patch(accessDb, 'revokeGrant', async () => true)
|
||
|
||
const refused = await grants.revoke({ team, actor: leader, userId: guest.id })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 403)
|
||
|
||
// Staff may. This is what stops a leader undoing a moderation decision.
|
||
const allowed = await grants.revoke({ team, actor: staff, userId: guest.id })
|
||
assert.equal(allowed.ok, true)
|
||
})
|
||
|
||
test('an account that has lost its staff role stops protecting the grants it made', async () => {
|
||
// Checked at REVOKE time against the issuer's current role, not against a flag
|
||
// stored when the grant was made — which is the behaviour an operator demoting
|
||
// someone expects.
|
||
const demoted = { id: 2, username: 'root', role: 'player' }
|
||
stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } })
|
||
patch(usersDb, 'findById', async () => demoted)
|
||
patch(accessDb, 'revokeGrant', async () => true)
|
||
|
||
const result = await grants.revoke({ team, actor: leader, userId: guest.id })
|
||
assert.equal(result.ok, true)
|
||
})
|
||
|
||
test('a member who is also a grantee is listed as a member, not as a guest', async () => {
|
||
patch(accessDb, 'activeGrants', async () => [
|
||
{ user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null },
|
||
{ user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null },
|
||
])
|
||
patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }])
|
||
|
||
const guests = await grants.forumGuests(team.id)
|
||
assert.deepEqual(guests.map((g) => g.username), ['mara'])
|
||
})
|
||
|
||
// ── threads (§5.1, §5.3) ───────────────────────────────────────────────────
|
||
|
||
test('both thread types are creatable, and an invented one is not', async () => {
|
||
patch(forumDb, 'insertThread', async () => 1)
|
||
patch(forumDb, 'insertPost', async () => 1)
|
||
|
||
// Phase 5 opened `discussion`. Neither type needed a migration: both have been
|
||
// in the enum since 5a, which is what §5.1's split-by-layer bought.
|
||
for (const type of ['announcement', 'discussion']) {
|
||
const ok = await forum.createThread({ team, actor: leader, type, title: 'Raid', body: '<p>Hi</p>' })
|
||
assert.equal(ok.ok, true, type)
|
||
}
|
||
|
||
const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: '<p>Hi</p>' })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 400)
|
||
})
|
||
|
||
test('an announcement with only markup for a body is refused', async () => {
|
||
patch(forumDb, 'insertThread', async () => 1)
|
||
patch(forumDb, 'insertPost', async () => 1)
|
||
const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '<p></p>' })
|
||
assert.equal(refused.ok, false)
|
||
})
|
||
|
||
test('moderation records WHICH authority was exercised', async () => {
|
||
const ledger = []
|
||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' }))
|
||
patch(forumDb, 'setThreadFlags', async () => true)
|
||
patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
|
||
|
||
await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' })
|
||
await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' })
|
||
|
||
assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
|
||
assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide'])
|
||
})
|
||
|
||
test('a thread id from another Team reads as not found', async () => {
|
||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' }))
|
||
const result = await forum.getThread(1, 5, { canModerate: true })
|
||
assert.equal(result, null)
|
||
})
|
||
|
||
test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => {
|
||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 }))
|
||
patch(forumDb, 'postsByThread', async () => [])
|
||
assert.equal(await forum.getThread(1, 5, { canModerate: false }), null)
|
||
assert.ok(await forum.getThread(1, 5, { canModerate: true }))
|
||
})
|
||
|
||
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
||
|
||
test('magic bytes decide the type, not the client’s Content-Type header', () => {
|
||
const png = Buffer.concat([
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
Buffer.alloc(8),
|
||
])
|
||
assert.equal(uploads.sniff(png), 'image/png')
|
||
|
||
// A player can send `image/png` with arbitrary bytes. Unrecognised is a
|
||
// rejection, never a fallback to what the header claimed.
|
||
assert.equal(uploads.sniff(Buffer.from('<?php echo 1; ?> ')), null)
|
||
assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge
|
||
})
|
||
|
||
test('a RIFF container that is not WebP is not accepted as one', () => {
|
||
const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)])
|
||
assert.equal(uploads.sniff(wav), null)
|
||
})
|
||
|
||
|
||
// ── phase 5 ("5b"): replies, the edit window, post moderation ──────────────
|
||
|
||
const member = { id: 11, username: 'wanderer', role: 'player' }
|
||
|
||
// A visible discussion thread and one post in it, as the DB layer would return
|
||
// them. Written as a factory rather than a shared constant because half these
|
||
// tests mutate the row they are given.
|
||
const discussion = (over = {}) => ({
|
||
id: 5, team_id: 1, type: 'discussion', title: 'Raid night',
|
||
status: 'visible', locked: 0, pinned: 0, post_count: 1,
|
||
created_by: 11, created_username: 'wanderer', ...over,
|
||
})
|
||
const post = (over = {}) => ({
|
||
id: 80, thread_id: 5, author_user_id: 11, author_username: 'wanderer',
|
||
body_html: '<p>Hi</p>', status: 'visible', created_at: new Date(), edited_at: null,
|
||
edited_by: null, ...over,
|
||
})
|
||
|
||
test('a reply lands on a discussion thread and never on an announcement', async () => {
|
||
patch(forumDb, 'insertPost', async () => 81)
|
||
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
const ok = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Count me in</p>' })
|
||
assert.equal(ok.ok, true)
|
||
assert.equal(ok.postId, 81)
|
||
|
||
// 400, not 404 and not 409: the request is malformed FOR THIS THREAD and no
|
||
// amount of retrying fixes it. An announcement takes no replies by TYPE.
|
||
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
|
||
const refused = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Hi</p>' })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 400)
|
||
})
|
||
|
||
test('a locked thread refuses replies with 409 — and refuses staff too', async () => {
|
||
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
|
||
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
|
||
|
||
for (const actor of [member, leader, staff]) {
|
||
const refused = await forum.createPost({ team, threadId: 5, actor, body: '<p>Hi</p>' })
|
||
assert.equal(refused.ok, false, actor.username)
|
||
// Well-formed request, refusing STATE — which is the distinction a client
|
||
// needs to tell "you cannot" from "not right now".
|
||
assert.equal(refused.status, 409, actor.username)
|
||
}
|
||
})
|
||
|
||
test('a reply to a hidden or foreign thread reads as not found', async () => {
|
||
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
|
||
|
||
patch(forumDb, 'threadById', async () => discussion({ status: 'hidden' }))
|
||
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
|
||
|
||
patch(forumDb, 'threadById', async () => discussion({ team_id: 999 }))
|
||
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
|
||
})
|
||
|
||
test('the edit window is decided on the server, from created_at', () => {
|
||
const fresh = post({ created_at: new Date(Date.now() - 60_000) }) // a minute old
|
||
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) // an hour old
|
||
|
||
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 15 }).canEdit, true)
|
||
assert.equal(forum.editability(stale, { userId: 11, windowMinutes: 15 }).canEdit, false)
|
||
|
||
// Somebody else's post, inside the window, is still not theirs to edit.
|
||
assert.equal(forum.editability(fresh, { userId: 99, windowMinutes: 15 }).canEdit, false)
|
||
|
||
// Staff are not time-bounded, and `editableUntil: null` reads as "no deadline"
|
||
// rather than as "no permission" — canEdit is the permission.
|
||
const asStaff = forum.editability(stale, { userId: 2, isStaff: true, windowMinutes: 15 })
|
||
assert.equal(asStaff.canEdit, true)
|
||
assert.equal(asStaff.editableUntil, null)
|
||
|
||
// A window of zero is a legitimate operator choice: posts immutable once written.
|
||
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 0 }).canEdit, false)
|
||
})
|
||
|
||
test('a hidden post is editable by nobody, staff included', () => {
|
||
const hidden = post({ status: 'hidden', created_at: new Date() })
|
||
assert.equal(forum.editability(hidden, { userId: 11, windowMinutes: 15 }).canEdit, false)
|
||
// Restoring it is a moderation action with a ledger row; quietly rewriting it
|
||
// while it is out of sight is the same act with no record.
|
||
assert.equal(forum.editability(hidden, { userId: 2, isStaff: true, windowMinutes: 15 }).canEdit, false)
|
||
})
|
||
|
||
test('the write path re-derives the window and does not trust the read path', async () => {
|
||
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) })
|
||
patch(forumDb, 'postById', async () => stale)
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
const writes = []
|
||
patch(forumDb, 'updatePostBody', async (...args) => { writes.push(args); return true })
|
||
|
||
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>new</p>' })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 403)
|
||
assert.equal(writes.length, 0)
|
||
|
||
// Staff, same post, same moment.
|
||
const allowed = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>new</p>' })
|
||
assert.equal(allowed.ok, true)
|
||
assert.equal(writes.length, 1)
|
||
// Reported so the controller can write the §5.3 accountability row — a staffer
|
||
// editing someone ELSE's words is an intervention.
|
||
assert.equal(allowed.staffEdit, true)
|
||
})
|
||
|
||
test('a staffer editing their own post is an ordinary edit, not an intervention', async () => {
|
||
patch(forumDb, 'postById', async () => post({ author_user_id: staff.id, author_username: staff.username }))
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'updatePostBody', async () => true)
|
||
|
||
const result = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>x</p>' })
|
||
assert.equal(result.ok, true)
|
||
assert.equal(result.staffEdit, false)
|
||
})
|
||
|
||
test('a member may not edit somebody else’s post at all', async () => {
|
||
patch(forumDb, 'postById', async () => post({ author_user_id: 99, author_username: 'someone' }))
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'updatePostBody', async () => { throw new Error('must not write') })
|
||
|
||
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>x</p>' })
|
||
assert.equal(refused.ok, false)
|
||
assert.equal(refused.status, 403)
|
||
})
|
||
|
||
test('post moderation shares the thread ledger, tagged as a post', async () => {
|
||
const ledger = []
|
||
patch(forumDb, 'postById', async () => post())
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'setPostStatus', async () => true)
|
||
patch(forumDb, 'recountThread', async () => {})
|
||
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
|
||
patch(forumDb, 'restoreUploadsForPost', async () => {})
|
||
patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
|
||
|
||
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: leader, actorRole: 'leader' })
|
||
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
|
||
|
||
// One table, two target kinds — so "everything moderated in this Team" stays
|
||
// one query instead of a union.
|
||
assert.deepEqual(ledger.map((r) => r.targetType), ['post', 'post'])
|
||
assert.deepEqual(ledger.map((r) => r.action), ['hide', 'delete'])
|
||
assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
|
||
})
|
||
|
||
test('pin and lock are refused BY NAME on a post, not as unknown actions', async () => {
|
||
patch(forumDb, 'postById', async () => post())
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
|
||
const wrongObject = await forum.moderatePost({ team, postId: 80, action: 'pin', actor: leader, actorRole: 'leader' })
|
||
assert.equal(wrongObject.status, 400)
|
||
assert.match(wrongObject.error, /applies to a thread/)
|
||
|
||
const nonsense = await forum.moderatePost({ team, postId: 80, action: 'incinerate', actor: leader, actorRole: 'leader' })
|
||
assert.equal(nonsense.status, 400)
|
||
assert.match(nonsense.error, /Unknown/)
|
||
})
|
||
|
||
test('deleting a post takes its images with it, and restoring brings them back', async () => {
|
||
const calls = []
|
||
patch(forumDb, 'postById', async () => post())
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'setPostStatus', async () => true)
|
||
patch(forumDb, 'recountThread', async () => {})
|
||
patch(forumDb, 'insertModeration', async () => {})
|
||
patch(forumDb, 'softDeleteUploadsForPost', async (id) => { calls.push(['soft', id]) })
|
||
patch(forumDb, 'restoreUploadsForPost', async (id) => { calls.push(['restore', id]) })
|
||
|
||
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
|
||
await forum.moderatePost({ team, postId: 80, action: 'restore', actor: staff, actorRole: 'staff' })
|
||
// Without the second half, delete → restore returns the words and loses the
|
||
// pictures a retention window later, silently.
|
||
assert.deepEqual(calls, [['soft', 80], ['restore', 80]])
|
||
|
||
// Hiding is not deleting: a hidden post's images are untouched, because
|
||
// unhiding must be free.
|
||
calls.length = 0
|
||
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: staff, actorRole: 'staff' })
|
||
assert.deepEqual(calls, [])
|
||
})
|
||
|
||
test('post moderation recomputes the thread’s counters rather than nudging them', async () => {
|
||
const recounts = []
|
||
patch(forumDb, 'postById', async () => post())
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'setPostStatus', async () => true)
|
||
patch(forumDb, 'insertModeration', async () => {})
|
||
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
|
||
patch(forumDb, 'restoreUploadsForPost', async () => {})
|
||
patch(forumDb, 'recountThread', async (id) => { recounts.push(id) })
|
||
|
||
// hide → unhide → hide is a cycle a counter kept by deltas gets wrong the
|
||
// first time a step is retried or raced.
|
||
for (const action of ['hide', 'unhide', 'hide']) {
|
||
await forum.moderatePost({ team, postId: 80, action, actor: staff, actorRole: 'staff' })
|
||
}
|
||
assert.deepEqual(recounts, [5, 5, 5])
|
||
})
|
||
|
||
test('a thread reports whether it takes replies, and why not', async () => {
|
||
patch(forumDb, 'postsByThread', async () => [])
|
||
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
assert.equal((await forum.getThread(1, 5, { canModerate: false })).canReply, true)
|
||
|
||
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
|
||
const locked = await forum.getThread(1, 5, { canModerate: false })
|
||
assert.equal(locked.canReply, false)
|
||
assert.equal(locked.locked, true) // the UI can say WHICH half refused
|
||
|
||
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
|
||
const announcement = await forum.getThread(1, 5, { canModerate: false })
|
||
assert.equal(announcement.canReply, false)
|
||
assert.equal(announcement.type, 'announcement')
|
||
})
|
||
|
||
test('every post comes back knowing whether THIS reader may edit it', async () => {
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'postsByThread', async () => [
|
||
post({ id: 80, author_user_id: 11, created_at: new Date() }),
|
||
post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }),
|
||
])
|
||
patch(safetyDb, 'staffAmong', async () => [])
|
||
|
||
const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
|
||
assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false])
|
||
assert.deepEqual(mine.posts.map((p) => p.mine), [true, false])
|
||
|
||
// A caller that does not say who is reading gets the safe answer, which is what
|
||
// keeps every phase-4 call site correct without changing it.
|
||
const anonymous = await forum.getThread(1, 5, {})
|
||
assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false])
|
||
})
|
||
|
||
// USER_SAFETY.md S8: the menu does not offer what the server would refuse.
|
||
test('every post says whether this reader may block its author: not yourself, not staff, not a deleted account', async () => {
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'postsByThread', async () => [
|
||
post({ id: 80, author_user_id: 11, created_at: new Date() }),
|
||
post({ id: 81, author_user_id: 99, created_at: new Date() }),
|
||
post({ id: 82, author_user_id: 2, created_at: new Date() }),
|
||
post({ id: 83, author_user_id: null, author_username: 'gone', created_at: new Date() }),
|
||
])
|
||
patch(safetyDb, 'staffAmong', async (ids) => ids.filter((id) => id === 2))
|
||
|
||
const thread = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
|
||
assert.deepEqual(thread.posts.map((p) => p.canBlock), [false, true, false, false])
|
||
|
||
patch(safetyDb, 'staffAmong', async () => { throw new Error('a signed-out read asks nothing') })
|
||
const anonymous = await forum.getThread(1, 5, {})
|
||
assert.deepEqual(anonymous.posts.map((p) => p.canBlock), [false, false, false, false])
|
||
})
|
||
|
||
test('a reader’s filter leaves out their hidden posts and their blocked authors’ posts', async () => {
|
||
patch(forumDb, 'threadById', async () => discussion())
|
||
patch(forumDb, 'postsByThread', async () => [
|
||
post({ id: 80, author_user_id: 3, created_at: new Date() }),
|
||
post({ id: 81, author_user_id: 99, created_at: new Date() }),
|
||
post({ id: 82, author_user_id: 4, created_at: new Date() }),
|
||
])
|
||
patch(safetyDb, 'staffAmong', async () => [])
|
||
const filter = { blockedUserIds: new Set([99]), hidden: { team_forum_thread: new Set(), team_forum_post: new Set([82]) } }
|
||
|
||
const thread = await forum.getThread(1, 5, { viewer: { userId: 11 }, filter })
|
||
assert.deepEqual(thread.posts.map((p) => p.id), [80])
|
||
|
||
// A thread its reader hid is gone from their view, a link to it included.
|
||
filter.hidden.team_forum_thread.add(5)
|
||
assert.equal(await forum.getThread(1, 5, { viewer: { userId: 11 }, filter }), null)
|
||
})
|