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>
396 lines
18 KiB
JavaScript
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)
|
|
})
|
|
})
|