test(teams): phase 5's server surface, and the negative property under it

1008 pass (972 before). The tests worth reading first are the ones that pin a
property no screen would look different without:

  * **The edit window is decided on the server, twice.** One test proves the read
    path stamps `canEdit` per post per viewer; another proves the WRITE path
    re-derives it from `created_at` and refuses a stale edit even though the
    client was told it could — because a time-bounded permission must not take its
    clock from the party it bounds.

  * **A locked thread refuses staff too**, asserted over member, leader and staff
    in one loop, at 409 rather than 403: well-formed request, refusing state.

  * **delete → restore is reversible for images.** Without the second half of the
    pair a restored post returns its words and loses its pictures a retention
    window later, silently — the test asserts both calls and that `hide` makes
    neither.

  * **Post moderation recomputes the thread's counters** rather than nudging them;
    the test runs hide → unhide → hide, which is the cycle a delta gets wrong.

  * **acceptance: nothing in the report model is reachable by a Team leader.** The
    negative property is the whole point of §5.6 and negatives are what nobody
    notices going, so it is asserted directly — the module's function surface is
    pinned, and `queue`/`handle` are checked not to mention leadership at all. If
    a leader-facing queue is ever wanted it is the org lead's decision, and this
    test is what makes somebody ask.

  * **A report never changes the content it is about**, proved by stubbing every
    mutation the forum has to throw. If filing a report touched a status then
    "report" would BE moderation, and the first person to work that out would have
    found a way to hide anything on the site.

The test suite caught one real defect: `describeTarget` returned `undefined` for a
hard-deleted target, and `undefined` is dropped by JSON.stringify — so the
documented `target: null` would have reached clients as an absent key.

Two phase-4 tests were updated rather than added to, both because phase 5 changed
what they describe: `canPost` split into `canPost` (open a discussion, everyone)
and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type.
Phase 5's four new player routes are added to acceptance criterion 2's list, so
"with the forum off every forum route 404s" keeps covering the whole surface.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:58:15 -05:00
parent fff14848f1
commit 128de0ff2e
4 changed files with 660 additions and 13 deletions

View File

@@ -28,6 +28,7 @@ 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 reports = require('../src/model/reports/contentReports.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -75,6 +76,11 @@ const get = (app, path, init) => fetch(`${app.url}${path}`, init)
const post = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// Named with a trailing underscore because `patch` is already the stub helper in
// this file, and shadowing it inside a test would be an hour nobody enjoys.
const patch_ = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// ── The public tier is anonymous, and hidden means absent ──────────────────
@@ -323,16 +329,64 @@ test('acceptance 2: with the forum off every forum route 404s, and nothing is to
patch(forum, 'getThread', async () => mark())
patch(forum, 'createThread', async () => mark())
patch(forum, 'moderateThread', async () => mark())
// Phase 5's four. A route added behind the same guard has to be added here
// too, or the acceptance criterion silently stops covering the whole surface.
patch(forum, 'createPost', async () => mark())
patch(forum, 'editPost', async () => mark())
patch(forum, 'moderatePost', async () => mark())
patch(reports, 'file', 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((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404')
})
test('replying, editing and reporting all run through the same access resolver', async () => {
// A caller with no access sees 404 on every write too, not only on the reads.
// A private room's contents and its existence are the same secret, and a write
// that answered 403 would confirm the room.
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 }))
patch(forum, 'createPost', async () => { throw new Error('must not run') })
patch(forum, 'editPost', async () => { throw new Error('must not run') })
patch(reports, 'file', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
})
test('post moderation is refused to a participant who is neither leader nor staff', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'moderatePost', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
// 403 and not 404 here, deliberately: this caller can SEE the forum, so
// nothing is being concealed — they are simply not allowed to moderate it.
const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })
assert.equal(res.status, 403)
})
})
test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
@@ -345,7 +399,55 @@ test('with the forum ON, the same routes answer — the switch is the only diffe
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')
// Phase 5 split one capability into two. `canPost` now means "may open a
// DISCUSSION", which every participant may; `canAnnounce` is the leader-only
// half that `canPost` used to carry alone.
assert.equal(body.canPost, true, 'an ordinary member may open a discussion')
assert.equal(body.canAnnounce, false, 'an ordinary member does not get the announcement composer')
assert.equal(body.canModerate, false)
})
})
test('a leader gets both composers; the announcement one is theirs alone', 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: true }))
patch(forum, 'listThreads', async () => [])
await withApp('/api/v1/player', playerRouter, async (app) => {
const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json()
assert.equal(body.canPost, true)
assert.equal(body.canAnnounce, true)
assert.equal(body.canModerate, true)
})
})
test('an ordinary member is refused an announcement and allowed a discussion', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'createThread', async ({ type }) => ({ ok: true, threadId: 1, postId: 1, type }))
await withApp('/api/v1/player', playerRouter, async (app) => {
// The check splits by TYPE — phase 4's comment said it would happen here
// rather than the leader gate being widened.
const announcement = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'announcement', title: 'x', body: 'y',
})
assert.equal(announcement.status, 403)
const discussion = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'discussion', title: 'x', body: 'y',
})
assert.equal(discussion.status, 200)
// No `type` at all is a phase-4 client, and a phase-4 client only ever posted
// announcements — so the default must NOT quietly become a discussion.
const untyped = await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })
assert.equal(untyped.status, 403)
})
})