From 57286594e7319b7c13ee53e6042d50e622192d31 Mon Sep 17 00:00:00 2001
From: wtclaude Banner: https://example.com/banner.png http://x.test/a.png or its attributes through in any mode, http and non-image
URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted
account stops protecting the grants it made, moderation records which authority was
exercised, and a RIFF container that is not WebP is not accepted as one.
Twelve new routes in the manifest, all annotated and in the OpenAPI spec.
Co-Authored-By: Claude
beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The thread id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The thread",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumThread"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Forum off, no such thread, or no access",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/threads/{id}/moderate": {
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Pin, lock, hide or delete a thread",
+ "description": "Leader or staff. Every action writes the Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s mod_actions/appeals pair, which is Discord-sanction-shaped.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The thread id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Applied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "action": {
+ "type": "string"
+ },
+ "threadId": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader of this Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "action"
+ ],
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "pin",
+ "unpin",
+ "lock",
+ "unlock",
+ "hide",
+ "unhide",
+ "delete",
+ "restore"
+ ]
+ },
+ "reason": {
+ "type": "string",
+ "maxLength": 255
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/uploads": {
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Upload an image to a Team forum",
+ "description": "Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload path’s 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a client’s Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stored",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "url": {
+ "type": "string"
+ },
+ "bytes": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Not the image type it claims to be",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "429": {
+ "description": "Daily upload quota reached",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/uploads/{id}": {
+ "delete": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Remove an uploaded image",
+ "description": "The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The upload id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Removed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not your upload",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/teams/{slug}/grants": {
+ "get": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "The Team’s forum guests, and the per-Team cap",
+ "description": "Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Forum guests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumGuestList"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader of this Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Grant forum access to an account",
+ "description": "A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Granted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "grantee": {
+ "type": "string"
+ },
+ "warning": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "409": {
+ "description": "Already granted, or the Team is at its cap",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "userId": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string",
+ "maxLength": 255
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/grants/{userId}": {
+ "delete": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Revoke forum access",
+ "description": "The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuer’s role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The grantee’s account id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "grantee": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader, or the grant was staff-issued",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "example": "any"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/public/contact": {
"post": {
"tags": [
diff --git a/server/test/teamForum.test.js b/server/test/teamForum.test.js
new file mode 100644
index 0000000..fea740d
--- /dev/null
+++ b/server/test/teamForum.test.js
@@ -0,0 +1,332 @@
+// The forum's access model, its switches, and its renderer
+// (docs/website/TEAMS.md Part 5, phase 4 "5a").
+//
+// The four tests named "acceptance" are §Phase 4's four 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 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', () => {
+ // Server-side, with the admin UI's checkbox bypassed — a checkbox is how the
+ // gate is presented and never the gate.
+ const refused = forumSettings.assertAcknowledged('uploads', undefined)
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 400)
+
+ // A STALE version is not an acknowledgement either.
+ assert.equal(forumSettings.assertAcknowledged('uploads', '0').ok, false)
+ assert.equal(forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION).ok, true)
+})
+
+test('the other two image modes need no acknowledgement', () => {
+ // `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(forumSettings.assertAcknowledged('remote', undefined).ok, true)
+ assert.equal(forumSettings.assertAcknowledged('disabled', undefined).ok, true)
+})
+
+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('
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('
{
+ const stored = cleanForumBody(
+ '
is core's renderer.
+ assert.ok(!renderForumBody(stored, 'uploads').includes('
{
+ // 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('
https://x.test/a.exe
https://example.com/a.png') + assert.ok(!stored.includes(' { + const stored = cleanForumBody('x') + 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('5a creates announcements and refuses discussion threads', async () => { + patch(forumDb, 'insertThread', async () => 1) + patch(forumDb, 'insertPost', async () => 1) + + const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '
Hi
' }) + assert.equal(ok.ok, true) + + // The type exists in the enum from day one so 5b adds no migration — but + // nothing creates one yet. + const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: 'Hi
' }) + 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: '' }) + 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(' ')), 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) +}) diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js index 4987ed2..7f9b68a 100644 --- a/server/test/teamRoutes.test.js +++ b/server/test/teamRoutes.test.js @@ -24,6 +24,10 @@ const moderation = require('../src/model/teams/teamModeration.model') const teamSync = require('../src/model/teams/teamSync.model') const activity = require('../src/model/activity/activity.model') const settings = require('../src/model/settings/settings.model') +const forumSettings = require('../src/model/teams/teamForumSettings.model') +const forum = require('../src/model/teams/teamForum.model') +const grants = require('../src/model/teams/teamGrants.model') +const access = require('../src/model/teams/teamAccess.model') const db = require('../src/utils/db') after(() => db.close()) @@ -302,3 +306,90 @@ test('an empty display name is routed to the CLEAR action, not published as blan assert.equal(action, 'display_name_override') }) }) + +// ── The forum's switch, at the route level (§5.5.1, phase 4) ─────────────── + +test('acceptance 2: with the forum off every forum route 404s, and nothing is touched', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => false) + // Everything the forum would read or write if the guard failed. None of these + // may run: "off means guarded, never destroyed" is a claim about writes as much + // as about reads, and a guard that 404s AFTER loading the thread is one that + // still bumped a counter on the way. + let touched = false + const mark = () => { touched = true; return null } + patch(teamsDbModule, 'findBySlug', async () => { touched = true; return { id: 1, name: 'A' } }) + patch(forum, 'listThreads', async () => mark()) + patch(forum, 'getThread', async () => mark()) + patch(forum, 'createThread', async () => mark()) + patch(forum, 'moderateThread', async () => mark()) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404) + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404) + }) + assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404') +}) + +test('with the forum ON, the same routes answer — the switch is the only difference', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(forumSettings, 'imageMode', async () => 'disabled') + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false })) + patch(forum, 'listThreads', async () => []) + + await withApp('/api/v1/player', playerRouter, async (app) => { + const res = await get(app, '/api/v1/player/teams/a/forum/threads') + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer') + }) +}) + +test('a caller with no access gets 404, never 403', async () => { + // 403 says "this exists and you may not have it", which advertises a private + // room to someone outside it. In a forum the contents and the existence are the + // same secret. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false })) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404) + }) +}) + +test('the upload routes 404 in every image mode but uploads', async () => { + // The same guard at a second level, for the same reason. An upload control the + // client offers and the server refuses is worse than no control — which is why + // the mode is published, and why the SERVER is still what enforces it. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(forumSettings, 'uploadsEnabled', async () => false) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true })) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await post(app, '/api/v1/player/teams/a/forum/uploads')).status, 404) + }) +}) + +test('the grant routes answer even while the forum is switched off', async () => { + // Deliberate (§5.5.1): a toggle-off revokes no grant and the rows stay + // authoritative, so the access list must stay manageable. What the switch + // guards is the forum's CONTENT, not its access list. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => false) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(grants, 'authorityFor', async () => ({ may: true, as: 'leader' })) + patch(grants, 'forumGuests', async () => []) + patch(grants, 'grantCap', async () => 50) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/grants')).status, 200) + }) +})