Found on the live rig. `moderatePost` answers `pin` with «"pin" applies to a thread, not to a post» and an invented action with "Unknown moderation action" — the distinction exists because they are different mistakes and a caller who made the first one has a bug worth naming precisely. The route's validator listed only the four actions a post accepts, so `pin` never got there: it came back as a generic "Validation failed". The precise message was written, documented, unit-tested — and unreachable through the API, which is the worst of both, because the branch reads as live code and is only exercised by its own test. The validator now lists all eight and lets the model discriminate. Both answers are 400, neither is a security boundary, and widening the list is not removing it — an action outside the enum still stops at the validator, which the added route test asserts alongside the `pin` case. Nothing else the walk exercised needed changing. The whole phase 5 surface was driven against a real server, real MariaDB and real sessions across four identities — an ordinary member, a granted non-member guest, a Team leader and a staffer — plus a browser pass over the forum panel, the reports queue, the per-Team forum ledger and the settings screen. Notably confirmed live: a locked thread refuses replies from all four identities at 409; a hidden post renders for the leader and staff with Unhide and **no Edit control for anyone**; the report queue answers 200 to staff and 403 to the leader, the member and the guest alike; and turning the edit window down to 0 stops the author while leaving staff unbounded. Co-Authored-By: Claude <noreply@anthropic.com>
523 lines
25 KiB
JavaScript
523 lines
25 KiB
JavaScript
// The Team API's access boundaries, exercised through the real routers
|
|
// (docs/website/TEAMS.md §2.11).
|
|
//
|
|
// The models are stubbed; what is under test is the wiring — which tier a route
|
|
// sits behind, what a hidden Team does to a public caller, and the one route that
|
|
// is admin-only inside a staff-wide group. Those are the properties a reviewer
|
|
// cannot check by reading a controller in isolation, because they are decided by
|
|
// the mount table and by a role read at request time.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, after, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const { startApp } = require('./_helper')
|
|
const publicRouter = require('../src/router/v1/public')
|
|
const playerRouter = require('../src/router/v1/player')
|
|
const adminRouter = require('../src/router/v1/admin')
|
|
const sessionService = require('../src/auth/session.service')
|
|
const users = require('../src/model/users/users.model')
|
|
const teams = require('../src/model/teams/teams.model')
|
|
const teamsDbModule = require('../src/model/teams/teams.db')
|
|
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 reports = require('../src/model/reports/contentReports.model')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const saved = []
|
|
function patch(mod, name, fn) {
|
|
saved.push([mod, name, mod[name]])
|
|
mod[name] = fn
|
|
}
|
|
afterEach(() => {
|
|
while (saved.length) {
|
|
const [mod, name, fn] = saved.pop()
|
|
mod[name] = fn
|
|
}
|
|
})
|
|
|
|
function signInAs(user) {
|
|
patch(sessionService, 'validateSession', () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' }))
|
|
patch(sessionService, 'isSessionRevoked', async () => false)
|
|
patch(sessionService, 'sessionMeta', () => ({}))
|
|
patch(users, 'getById', async () => user)
|
|
// Every staff action writes the audit log, and the real one inserts a row. It
|
|
// swallows its own errors, so an unstubbed call is not a failure — it is ten
|
|
// seconds of connection retries against the dead pool, per test.
|
|
patch(activity, 'log', async () => {})
|
|
}
|
|
|
|
// siteMode reads settings; keep the public tier out of maintenance.
|
|
const liveSite = () => patch(settings, 'get', async () => 'live')
|
|
|
|
const admin = { id: 1, username: 'root', role: 'admin', status: 'active' }
|
|
const moderator = { id: 2, username: 'mod1', role: 'moderator', status: 'active' }
|
|
const player = { id: 3, username: 'ada', role: 'player', status: 'active' }
|
|
|
|
async function withApp(mountPath, router, fn) {
|
|
const app = await startApp((a) => a.use(mountPath, router))
|
|
try {
|
|
return await fn(app)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
|
|
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 ──────────────────
|
|
|
|
test('the public Team routes need no session', async () => {
|
|
liveSite()
|
|
patch(teams, 'listPublic', async () => ({ teams: [{ slug: 'a' }], total: 1, stale: false, lastSyncAt: null }))
|
|
await withApp('/api/v1/public', publicRouter, async (app) => {
|
|
const res = await get(app, '/api/v1/public/teams')
|
|
assert.equal(res.status, 200)
|
|
const body = await res.json()
|
|
assert.equal(body.total, 1)
|
|
assert.equal(body.stale, false, 'freshness travels with every public payload')
|
|
})
|
|
})
|
|
|
|
test('a hidden Team is a 404 to the public, indistinguishable from a missing one', async () => {
|
|
liveSite()
|
|
// The model returns null for hidden and for missing alike; the route must not
|
|
// tell them apart either, or "absent from every public surface" leaks the fact
|
|
// that the Team exists.
|
|
patch(teams, 'getPublic', async () => null)
|
|
patch(teams, 'rosterPublic', async () => null)
|
|
await withApp('/api/v1/public', publicRouter, async (app) => {
|
|
assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404)
|
|
assert.equal((await get(app, '/api/v1/public/teams/admin/members')).status, 404)
|
|
})
|
|
})
|
|
|
|
test('the index and the by-slug lookup agree about what exists', async () => {
|
|
// Found live: the index was keyed on a registered provider while the lookup
|
|
// goes by slug, so with the module uninstalled `/teams` was empty while
|
|
// `/teams/:slug/members` served a full roster — the index denying a Team that
|
|
// direct URLs answered for. The rows are core's and outlive the module that
|
|
// filled them; `configured: false` is how a client learns the projection is no
|
|
// longer maintained.
|
|
const rows = [
|
|
{ id: 1, slug: 'the-silver-hand', name: 'The Silver Hand', status: 'active', hidden: 0, member_count: 2 },
|
|
{ id: 2, slug: 'admin', name: 'Admin', status: 'active', hidden: 1, member_count: 1 },
|
|
]
|
|
patch(teamsDbModule, 'allActive', async () => rows)
|
|
patch(teamsDbModule, 'findBySlug', async (slug) => rows.find((r) => r.slug === slug))
|
|
patch(teamsDbModule, 'syncState', async () => null)
|
|
|
|
await withApp('/api/v1/public', publicRouter, async (app) => {
|
|
liveSite()
|
|
const list = await (await get(app, '/api/v1/public/teams')).json()
|
|
assert.equal(list.total, 1, 'the hidden Team is absent from the index')
|
|
assert.equal(list.configured, false, 'with no provider, the projection is reported unmaintained')
|
|
assert.equal(list.teams[0].slug, 'the-silver-hand')
|
|
|
|
// Everything the index lists resolves, and nothing it omits does.
|
|
assert.equal((await get(app, '/api/v1/public/teams/the-silver-hand')).status, 200)
|
|
assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404)
|
|
})
|
|
})
|
|
|
|
test('a public roster never carries a member key or a user id', async () => {
|
|
liveSite()
|
|
patch(teams, 'rosterPublic', async () => ({
|
|
members: [teams.publicMember({
|
|
display_name: 'Aldric', rank_label: 'Warlord', is_leader: 1, online: 1, user_id: 7, member_key: '0x1',
|
|
})],
|
|
stale: false,
|
|
lastSyncAt: null,
|
|
}))
|
|
await withApp('/api/v1/public', publicRouter, async (app) => {
|
|
const body = await (await get(app, '/api/v1/public/teams/x/members')).json()
|
|
const [member] = body.members
|
|
assert.equal(member.displayName, 'Aldric')
|
|
assert.equal(member.linked, true)
|
|
assert.equal('userId' in member, false, 'a site account id is not public')
|
|
assert.equal('memberKey' in member, false, 'a game-internal identifier is not public')
|
|
})
|
|
})
|
|
|
|
// ── The player tier is authenticated, and role-agnostic ───────────────────
|
|
|
|
test('the player Team routes reject an anonymous caller', async () => {
|
|
await withApp('/api/v1/player', playerRouter, async (app) => {
|
|
assert.equal((await get(app, '/api/v1/player/teams')).status, 401)
|
|
})
|
|
})
|
|
|
|
test('staff are a superset of players — an admin reaches their own Teams', async () => {
|
|
// The mistake this guards against has been made in this group once already: a
|
|
// requireRole('player') here 403s an admin off their own characters.
|
|
patch(teams, 'listForUser', async (userId) => ({ teams: [], forUser: userId, stale: false }))
|
|
for (const user of [player, moderator, admin]) {
|
|
signInAs(user)
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await withApp('/api/v1/player', playerRouter, async (app) => {
|
|
const res = await get(app, '/api/v1/player/teams')
|
|
assert.equal(res.status, 200, `${user.role} must reach their own Teams`)
|
|
assert.equal((await res.json()).forUser, user.id, 'the handler is self-scoped to the session')
|
|
})
|
|
}
|
|
})
|
|
|
|
test('the player access route is scoped to the caller, not to a supplied id', async () => {
|
|
signInAs(player)
|
|
let seen = null
|
|
patch(teams, 'accessForUser', async (slug, userId) => { seen = { slug, userId }; return { slug, allowed: true } })
|
|
await withApp('/api/v1/player', playerRouter, async (app) => {
|
|
await get(app, '/api/v1/player/teams/the-silver-hand/access?userId=1')
|
|
assert.deepEqual(seen, { slug: 'the-silver-hand', userId: player.id }, 'the query string is not an identity')
|
|
})
|
|
})
|
|
|
|
// ── The admin tier is staff-wide, with one admin-only action ──────────────
|
|
|
|
test('a player is refused the admin Team surface', async () => {
|
|
signInAs(player)
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
assert.equal((await get(app, '/api/v1/admin/teams')).status, 403)
|
|
})
|
|
})
|
|
|
|
test('a moderator reaches the review queue — that is who runs it', async () => {
|
|
signInAs(moderator)
|
|
patch(moderation, 'reviewQueue', async () => [{ id: 1, name: 'Admin', hidden_term: 'admin' }])
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
const res = await get(app, '/api/v1/admin/teams/review')
|
|
assert.equal(res.status, 200)
|
|
assert.equal((await res.json()).teams[0].hidden_term, 'admin')
|
|
})
|
|
})
|
|
|
|
test('literal admin paths are not captured by /:id', async () => {
|
|
// Express is first-match-wins, and a /:id declared ahead of /review would turn
|
|
// the queue into a lookup for a Team whose id is "review" — a 400 from the
|
|
// validator, on a route that should have worked.
|
|
signInAs(admin)
|
|
patch(moderation, 'reviewQueue', async () => [])
|
|
patch(moderation, 'listRequests', async () => [])
|
|
patch(teamSync, 'reconcileNow', async () => ({ ok: true, created: 0 }))
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
assert.equal((await get(app, '/api/v1/admin/teams/review')).status, 200)
|
|
assert.equal((await get(app, '/api/v1/admin/teams/requests')).status, 200)
|
|
assert.equal((await post(app, '/api/v1/admin/teams/resync')).status, 200)
|
|
})
|
|
})
|
|
|
|
test('a non-numeric team id is rejected by the validator', async () => {
|
|
signInAs(admin)
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
assert.equal((await get(app, '/api/v1/admin/teams/not-a-number')).status, 400)
|
|
})
|
|
})
|
|
|
|
// ── The §2.9 gate, as the route sees it ───────────────────────────────────
|
|
|
|
test('a moderator un-hiding gets a pending result; an admin gets an applied one', async () => {
|
|
// The gate is decided from the caller's live role, so this asserts on the ACTOR
|
|
// the route handed the model — the thing that actually decides — rather than on
|
|
// two sessions swapped mid-test.
|
|
const calls = []
|
|
patch(moderation, 'requestOrApply', async ({ actor, action }) => {
|
|
calls.push({ role: actor.role, action })
|
|
return actor.role === 'admin' ? { ok: true, pending: false } : { ok: true, pending: true, requestId: 5 }
|
|
})
|
|
|
|
signInAs(moderator)
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
const body = await (await post(app, '/api/v1/admin/teams/1/unhide', { reason: 'legit' })).json()
|
|
assert.equal(body.pending, true)
|
|
assert.equal(body.requestId, 5)
|
|
})
|
|
assert.deepEqual(calls, [{ role: 'moderator', action: 'unhide' }])
|
|
})
|
|
|
|
test('an admin un-hiding applies at once', async () => {
|
|
const calls = []
|
|
patch(moderation, 'requestOrApply', async ({ actor, action }) => {
|
|
calls.push({ role: actor.role, action })
|
|
return { ok: true, pending: false }
|
|
})
|
|
|
|
signInAs(admin)
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
const body = await (await post(app, '/api/v1/admin/teams/1/unhide', {})).json()
|
|
assert.equal(body.pending, false)
|
|
})
|
|
assert.deepEqual(calls, [{ role: 'admin', action: 'unhide' }])
|
|
})
|
|
|
|
test('deciding a request is admin-only, inside a staff-wide group', async () => {
|
|
// The route is reachable by any staff member; the refusal comes from the model
|
|
// checking the role live, which is the design (§2.9) — a demoted moderator
|
|
// loses this the moment they are demoted, not when their token expires.
|
|
signInAs(moderator)
|
|
patch(moderation, 'decide', async ({ actor }) => (actor.role === 'admin'
|
|
? { ok: true, applied: true }
|
|
: { ok: false, status: 403, error: 'only an admin may decide a request' }))
|
|
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
const res = await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'approved' })
|
|
assert.equal(res.status, 403)
|
|
})
|
|
})
|
|
|
|
test('an invalid decision status never reaches the model', async () => {
|
|
signInAs(admin)
|
|
let called = false
|
|
patch(moderation, 'decide', async () => { called = true; return { ok: true } })
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
assert.equal((await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'maybe' })).status, 400)
|
|
})
|
|
assert.equal(called, false)
|
|
})
|
|
|
|
test('a leadership override requires both a member key and an effect', async () => {
|
|
signInAs(admin)
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { effect: 'grant' })).status, 400)
|
|
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1' })).status, 400)
|
|
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1', effect: 'maybe' })).status, 400)
|
|
})
|
|
})
|
|
|
|
test('an empty display name is routed to the CLEAR action, not published as blank', async () => {
|
|
signInAs(admin)
|
|
let action = null
|
|
patch(moderation, 'requestOrApply', async (args) => { action = args.action; return { ok: true, pending: false } })
|
|
await withApp('/api/v1/admin', adminRouter, async (app) => {
|
|
await post(app, '/api/v1/admin/teams/1/display-name', { displayName: '' })
|
|
assert.equal(action, 'clear_display_name_override', 'an audit line must not read as publishing a blank name')
|
|
|
|
await post(app, '/api/v1/admin/teams/1/display-name', { displayName: 'The Old Guard' })
|
|
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())
|
|
// 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)
|
|
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()
|
|
// 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)
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
test('pin on a POST reaches the model, so the caller is told which mistake they made', async () => {
|
|
// The route's validator deliberately accepts all eight actions. Narrowing it to
|
|
// the four a post takes would turn "that applies to a thread, not to a post"
|
|
// into a generic "Validation failed" — the precise message would exist, be
|
|
// unit-tested, and be unreachable through the API. Found on the live rig.
|
|
signInAs(admin)
|
|
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 ({ action }) => ({
|
|
ok: false, status: 400, error: `"${action}" applies to a thread, not to a post`,
|
|
}))
|
|
|
|
await withApp('/api/v1/player', playerRouter, async (app) => {
|
|
const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'pin' })
|
|
assert.equal(res.status, 400)
|
|
assert.match((await res.json()).message, /applies to a thread/)
|
|
|
|
// An action that is not in the enum at all still stops at the validator —
|
|
// widening the list is not the same as removing it.
|
|
const nonsense = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'incinerate' })
|
|
assert.equal(nonsense.status, 400)
|
|
})
|
|
})
|