Files
website/server/test/teamModeration.test.js
wtclaude 8fe2e01466 feat(teams): reserved-name screening, auto-hide, and the admin-approval gate
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).

A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.

Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.

That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.

Three matcher gaps found by writing the tests, all real impersonation vectors:

  - "Guild of Moderators" did not match `moderator`. Only a trailing s off the
    WHOLE term is stripped, so "Nomads" still does not match `mod`.
  - "G.M." normalises to two single-letter words and matched nothing. A run of
    two or more single-letter words is now also offered joined. Deliberately not
    a whole-name condensation, which would re-admit substring matching.
  - The multi-word condensed form was already handled and is what makes
    "RunicGateway" match the two-word term -- the form an impersonator would
    reach for, since it is what the Gitea org and every URL use.

Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.

Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.

The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.

Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.

Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.

Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.

44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.

Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:08:58 -05:00

294 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Auto-hide and the §2.9 approval gate (docs/website/TEAMS.md §2.8§2.9).
//
// The gate's SCOPE is what these tests pin down, and it is the thing most likely
// to be widened by accident. Three actions are gated because they publish
// untrusted game-sourced strings; everything else staff can do still applies at
// once. Gating more would make this a general staff-approval workflow, which is a
// different and much larger idea — and gating admins would wedge the
// single-admin deployments `npm run seed` creates.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const moderationDb = require('../src/model/teams/teamModeration.db')
const teamsDb = require('../src/model/teams/teams.db')
const activity = require('../src/model/activity/activity.model')
const reservedNames = require('../src/utils/reservedNames')
const moderation = require('../src/model/teams/teamModeration.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
let db
let logged
const admin = { id: 1, username: 'root', role: 'admin' }
const mod = { id: 2, username: 'mod1', role: 'moderator' }
function stub() {
db = {
teams: new Map([[1, { id: 1, name: 'Admin', slug: 'admin', hidden: 1, hidden_reason: 'reserved_name' }]]),
requests: new Map(),
nextRequestId: 1,
}
logged = []
patch(teamsDb, 'findById', async (id) => db.teams.get(id))
patch(moderationDb, 'setHidden', async (id, { hidden, reason, term }) => {
const t = db.teams.get(id)
Object.assign(t, { hidden: hidden ? 1 : 0, hidden_reason: hidden ? reason : null, hidden_term: hidden ? term : null })
})
patch(moderationDb, 'markNameReviewed', async (id) => { db.teams.get(id).name_reviewed_at = 'now' })
patch(moderationDb, 'setDisplayNameOverride', async (id, value) => {
db.teams.get(id).display_name_override = value
})
patch(moderationDb, 'insertRequest', async (row) => {
const id = db.nextRequestId++
db.requests.set(id, { id, status: 'pending', ...row, payload: row.payload, team_id: row.teamId, requested_username: row.requestedUsername })
return id
})
patch(moderationDb, 'findRequest', async (id) => db.requests.get(id))
patch(moderationDb, 'decideRequest', async (id, { status, decidedUsername }) => {
const r = db.requests.get(id)
if (!r || r.status !== 'pending') return false
Object.assign(r, { status, decided_username: decidedUsername })
return true
})
patch(activity, 'log', async (entry) => { logged.push(entry) })
}
beforeEach(stub)
afterEach(() => {
while (saved.length) {
const [m, name, fn] = saved.pop()
m[name] = fn
}
})
const actions = () => logged.map((l) => l.action)
// ── Auto-hide at create ────────────────────────────────────────────────────
test('a reserved name produces the hide columns a create should carry', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.deepEqual(await moderation.screenForCreate('Admin'), {
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
})
})
test('an ordinary name carries nothing', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: false, term: null }))
assert.deepEqual(await moderation.screenForCreate('The Silver Hand'), { hidden: false })
})
test('a screening failure creates the team unscreened rather than aborting the reconcile', async () => {
// A deliberate trade: the re-screen on the next sync catches it, and a
// reconcile that dies halfway through is worse than a name public for one
// interval. It is also why re-screening exists rather than being create-only.
patch(reservedNames, 'screen', async () => { throw new Error('settings unavailable') })
assert.deepEqual(await moderation.screenForCreate('Admin'), { hidden: false })
})
// ── Re-screening ───────────────────────────────────────────────────────────
test('a re-screen hides a team whose name became reserved', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Council', hidden: 0 }])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'Council' }))
assert.equal(await moderation.rescreen('uo'), 1)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_term, 'Council')
})
test('a re-screen never re-hides a team staff have already ruled on', async () => {
// unreviewedActive excludes them by definition — the stamp is the mechanism,
// and without it an override would be undone on every sweep.
patch(moderationDb, 'unreviewedActive', async () => [])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a re-screen skips a team that is already hidden', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Admin', hidden: 1 }])
patch(reservedNames, 'screen', async () => { throw new Error('should not be screened again') })
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a failing re-screen does not break the reconcile that called it', async () => {
patch(moderationDb, 'unreviewedActive', async () => { throw new Error('db down') })
assert.equal(await moderation.rescreen('uo'), 0)
})
// ── The gate: moderator asks, admin applies ────────────────────────────────
test('a moderator un-hiding files a pending request and changes nothing public', async () => {
const result = await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'unhide', reason: 'legitimate guild',
})
assert.equal(result.pending, true)
assert.equal(db.teams.get(1).hidden, 1, 'nothing is published until an admin agrees')
assert.equal(db.requests.get(1).status, 'pending')
assert.deepEqual(actions(), ['team.moderation.request'])
})
test('an admin un-hiding applies at once', async () => {
const result = await moderation.requestOrApply({ actor: admin, teamId: 1, action: 'unhide' })
assert.equal(result.pending, false)
assert.equal(db.teams.get(1).hidden, 0)
assert.equal(db.teams.get(1).name_reviewed_at, 'now', 'a human has now ruled on the name')
assert.deepEqual(actions(), ['team.unhide'])
})
test('an admin approving a moderators request publishes it', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
assert.equal(db.teams.get(1).hidden, 1)
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(result.applied, true)
assert.equal(db.teams.get(1).hidden, 0)
assert.deepEqual(actions(), ['team.moderation.request', 'team.unhide', 'team.moderation.approved'])
})
test('a rejected request changes nothing but is kept', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected', note: 'no' })
assert.equal(result.applied, false)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.requests.get(1).status, 'rejected', 'the record of a refusal is the part worth having')
assert.equal(actions().includes('team.unhide'), false)
})
test('a moderator may not decide a request', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: mod, requestId: 1, status: 'approved' })
assert.equal(result.ok, false)
assert.equal(result.status, 403)
assert.equal(db.teams.get(1).hidden, 1)
})
test('a request already decided cannot be decided again', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
const second = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.equal(second.ok, false)
assert.equal(second.status, 409)
assert.equal(db.teams.get(1).hidden, 0, 'the first decision stands')
})
test('two admins deciding at once — only one applies', async () => {
// The row moves out of `pending` under a guard, and the effect follows only if
// it actually moved. Without that, both would apply the action and the second
// would overwrite the record of who decided it.
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
let applied = 0
patch(moderationDb, 'setHidden', async () => { applied += 1 })
const [a, b] = await Promise.all([
moderation.decide({ actor: admin, requestId: 1, status: 'approved' }),
moderation.decide({ actor: { ...admin, id: 3, username: 'root2' }, requestId: 1, status: 'approved' }),
])
assert.equal([a.ok, b.ok].filter(Boolean).length, 1)
assert.equal(applied, 1)
})
test('an unknown request and an unknown team are refused, not guessed at', async () => {
assert.equal((await moderation.decide({ actor: admin, requestId: 99, status: 'approved' })).status, 404)
assert.equal((await moderation.requestOrApply({ actor: admin, teamId: 99, action: 'unhide' })).status, 404)
})
test('an invalid decision status is refused', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
assert.equal((await moderation.decide({ actor: admin, requestId: 1, status: 'maybe' })).status, 400)
})
// ── The display-name override, through the same gate ───────────────────────
test('a display name set by an admin applies; by a moderator it waits', async () => {
await moderation.requestOrApply({
actor: admin, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
db.teams.get(1).display_name_override = null
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Sneaky' },
})
assert.equal(db.teams.get(1).display_name_override, null, 'free text into a public surface waits for an admin')
})
test('an approved display-name request carries its payload through', async () => {
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
})
test('a payload stored as a JSON string is parsed on approval', async () => {
// The driver hands JSON columns back parsed on some versions and as a string on
// others; an approval that silently applied `undefined` would be a data loss
// that only shows up on one of them.
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Kept' },
})
db.requests.get(1).payload = JSON.stringify({ displayName: 'Kept' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'Kept')
})
test('clearing a display name is gated too', async () => {
db.teams.get(1).display_name_override = 'Something'
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'clear_display_name_override' })
assert.equal(db.teams.get(1).display_name_override, 'Something')
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, null)
})
// ── Hiding is NOT gated ────────────────────────────────────────────────────
test('a moderator may hide immediately — suppression is always safe', async () => {
db.teams.get(1).hidden = 0
const result = await moderation.hide({ actor: mod, teamId: 1, reason: 'impersonation' })
assert.equal(result.ok, true)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_reason, 'staff')
assert.deepEqual(actions(), ['team.hide'])
assert.equal(db.requests.size, 0, 'withdrawing untrusted data must not wait for a second pair of eyes')
})
// ── The gate's scope ───────────────────────────────────────────────────────
test('exactly three actions are gated', () => {
assert.deepEqual(moderation.GATED_ACTIONS, ['unhide', 'display_name_override', 'clear_display_name_override'])
})
test('an action outside the three is rejected rather than quietly gated', async () => {
await assert.rejects(
() => moderation.requestOrApply({ actor: mod, teamId: 1, action: 'archive' }),
/not a gated action/,
)
})
test('every transition writes the audit log', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved', note: 'checked' })
assert.equal(logged.length, 3)
assert.match(logged[0].detail, /mod1 \(#2\) requested "unhide" on team "Admin" \(#1\): "legit"/)
assert.match(logged[2].detail, /root \(#1\) approved request #1/)
assert.match(logged[2].detail, /asked by mod1/)
})
test('the audit trail survives the requesters account being deleted', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
// §2.10: requested_by goes SET NULL and the username snapshot is what keeps the
// record readable.
db.requests.get(1).requested_username = null
await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.match(logged[logged.length - 1].detail, /asked by a deleted user/)
})