Files
website/server/test/teamRoutes.test.js
wtclaude 57286594e7
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
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>
2026-08-18 07:24:24 -05:00

396 lines
18 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 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 || {}),
})
// ── 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())
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)
})
})