test(teams): the four acceptance criteria, and regenerate the API artifacts
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user