diff --git a/server/routes.guards.json b/server/routes.guards.json index 232ca0f..5651a8f 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -774,6 +774,17 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/forum/moderation", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/admin/teams/:id/grants", @@ -829,6 +840,26 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams/forum/settings", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/forum/uploads", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/admin/teams/requests", @@ -1656,6 +1687,100 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/forum/threads", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/forum/threads/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/uploads", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "multerMiddleware" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/player/teams/:slug/forum/uploads/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/grants", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/grants", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/player/teams/:slug/grants/:userId", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "POST", "path": "/api/v1/public/contact", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 8ac8f8b..917214d 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -309,6 +309,10 @@ "method": "POST", "path": "/api/v1/admin/teams/:id/display-name" }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/forum/moderation" + }, { "method": "GET", "path": "/api/v1/admin/teams/:id/grants" @@ -329,6 +333,14 @@ "method": "POST", "path": "/api/v1/admin/teams/:id/unhide" }, + { + "method": "GET", + "path": "/api/v1/admin/teams/forum/settings" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/forum/uploads" + }, { "method": "GET", "path": "/api/v1/admin/teams/requests" @@ -665,6 +677,42 @@ "method": "GET", "path": "/api/v1/player/teams/:slug/access" }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/forum/threads" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads" + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/forum/threads/:id" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/uploads" + }, + { + "method": "DELETE", + "path": "/api/v1/player/teams/:slug/forum/uploads/:id" + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/grants" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/grants" + }, + { + "method": "DELETE", + "path": "/api/v1/player/teams/:slug/grants/:userId" + }, { "method": "POST", "path": "/api/v1/public/contact" diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 919ed8d..6b0f874 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4352,6 +4352,112 @@ ] } }, + "/api/v1/admin/teams/forum/settings": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The forum switch, the image policy, and the acknowledgement’s state", + "description": "The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.", + "responses": { + "200": { + "description": "Forum settings state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamForumSettingsState" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/forum/uploads": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "Upload attribution across every Team forum", + "description": "Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if \"who uploaded this\" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size (default 100)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + }, + { + "name": "deleted", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "0", + "1" + ], + "items": { + "type": "string" + } + } + } + }, + "description": "Include soft-deleted uploads." + } + ], + "responses": { + "200": { + "description": "Uploads with their attribution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamForumUploadList" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/teams/requests": { "get": { "tags": [ @@ -4729,6 +4835,59 @@ } } }, + "/api/v1/admin/teams/{id}/forum/moderation": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "A Team’s forum moderation ledger", + "description": "Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The Team id." + } + ], + "responses": { + "200": { + "description": "The ledger, newest first", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamForumModerationLedger" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/teams/{id}/grants": { "get": { "tags": [ @@ -10093,6 +10252,773 @@ ] } }, + "/api/v1/player/teams/{slug}/forum/threads": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "List a Team forum’s threads", + "description": "Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The thread list, with what this caller may do", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamForumThreadList" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Forum off, no such Team, or no access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Post an announcement", + "description": "Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "Posted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "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": [ + "title", + "body" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "announcement" + ] + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/player/teams/{slug}/forum/threads/{id}": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "Read one thread and its posts", + "description": "Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated 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('

Banner: https://example.com/banner.png

') + + // The author wrote a URL and it was stored as a LINK. No 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( + '

', + ) + assert.ok(!stored.includes(' 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('

http://x.test/a.png

'), 'remote') + assert.ok(!httpUrl.includes('https://x.test/a.exe

'), 'remote') + assert.ok(!notAnImage.includes(' { + const stored = cleanForumBody('
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) + }) +})