test(teams): the four acceptance criteria, and regenerate the API artifacts
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 32s

Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names
a property the code around it can lose without any screen looking different:

1. A granted, unlinked account reads the forum, is absent from the member rows, and
   is still refused external-platform eligibility. The membership projection is
   asserted byte-identical across a grant, which is what "non-contamination" means
   in practice.
2. With the switch off every forum route 404s AND nothing is read or written on the
   way there — a guard that 404s after loading the thread is one that still bumped
   a counter.
3. The stored HTML is byte-identical between `disabled` and `remote`; only the
   rendered output differs. That is the property the renderer-owned design exists
   to give, and it is what makes flipping the policy back a no-op rather than a
   migration.
4. Selecting `uploads` without a matching acknowledgement is refused server-side,
   with the admin checkbox bypassed.

Plus the ones that are not criteria but are the same kind of claim: an author
cannot smuggle an <img> 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:24:24 -05:00
parent cbb7339a3a
commit 57286594e7
5 changed files with 1522 additions and 0 deletions

View File

@@ -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)
})
})