feat(teams): the Team read API, the moderation routes, and Admin -> Teams
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
304
server/test/teamRoutes.test.js
Normal file
304
server/test/teamRoutes.test.js
Normal file
@@ -0,0 +1,304 @@
|
||||
// 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 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user