From 8fe2e014664d32380f0d9d41bd2c578ad5618ed0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 15:08:58 -0500 Subject: [PATCH] feat(teams): reserved-name screening, auto-hide, and the admin-approval gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- server/src/model/teams/teamModeration.db.js | 123 ++++++++ .../src/model/teams/teamModeration.model.js | 239 ++++++++++++++ server/src/model/teams/teamSync.model.js | 31 +- server/src/utils/reservedNames.js | 212 +++++++++++++ server/test/reservedNames.test.js | 192 ++++++++++++ server/test/teamModeration.test.js | 293 ++++++++++++++++++ server/test/teamSync.test.js | 65 ++++ 7 files changed, 1147 insertions(+), 8 deletions(-) create mode 100644 server/src/model/teams/teamModeration.db.js create mode 100644 server/src/model/teams/teamModeration.model.js create mode 100644 server/src/utils/reservedNames.js create mode 100644 server/test/reservedNames.test.js create mode 100644 server/test/teamModeration.test.js diff --git a/server/src/model/teams/teamModeration.db.js b/server/src/model/teams/teamModeration.db.js new file mode 100644 index 0000000..f69174f --- /dev/null +++ b/server/src/model/teams/teamModeration.db.js @@ -0,0 +1,123 @@ +// SQL for the reserved-name review queue and the §2.9 approval queue. + +const { query } = require('../../utils/db') + +// ── The hide/display state on `teams` ────────────────────────────────────── + +async function setHidden(teamId, { hidden, reason, term }) { + await query( + 'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?', + [hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId], + ) +} + +/** + * Record that a human has decided about this name. + * + * What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync, + * and without this stamp an operator adding a reserved term — or simply renaming + * the deployment — would re-hide a Team staff had already allowed, every fifteen + * minutes, forever. + */ +async function markNameReviewed(teamId) { + await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId]) +} + +async function setDisplayNameOverride(teamId, displayName) { + await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId]) +} + +/** Active teams whose name has never been screened by a human. */ +async function unreviewedActive(moduleId) { + return query( + `SELECT id, name, hidden, hidden_reason FROM teams + WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`, + [moduleId], + ) +} + +/** The reserved-name review queue (§2.8.3). */ +async function reviewQueue() { + return query( + `SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at + FROM teams + WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL + ORDER BY created_at DESC`, + ) +} + +// ── team_moderation_requests (§2.9) ──────────────────────────────────────── + +const REQUEST_COLUMNS = ` + id, team_id, action, payload, reason, requested_by, requested_username, requested_at, + status, decided_by, decided_username, decided_at, decision_note` + +async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) { + const res = await query( + `INSERT INTO team_moderation_requests + (team_id, action, payload, reason, requested_by, requested_username) + VALUES (?, ?, ?, ?, ?, ?)`, + [teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername], + ) + return res.insertId +} + +async function findRequest(id) { + const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id]) + return rows[0] +} + +/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */ +async function listRequests({ status = 'pending', limit = 100 } = {}) { + const params = [] + let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')}, + t.name AS team_name, t.slug AS team_slug + FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id` + if (status !== 'all') { + sql += ' WHERE r.status = ?' + params.push(status) + } + sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?' + params.push(limit) + return query(sql, params) +} + +/** + * Decide a request, but only if it is still pending. + * + * The `status = 'pending'` guard is the concurrency control: two admins opening + * the same queue and both clicking approve would otherwise each apply the action, + * and the second would overwrite the first's record of who decided it. The caller + * applies the effect only when this reports a row was actually moved. + */ +async function decideRequest(id, { status, decidedBy, decidedUsername, note }) { + const res = await query( + `UPDATE team_moderation_requests + SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ? + WHERE id = ? AND status = 'pending'`, + [status, decidedBy, decidedUsername, note, id], + ) + return res.affectedRows > 0 +} + +/** Pending requests for one team — shown on its admin page so a second is not filed. */ +async function pendingForTeam(teamId) { + return query( + `SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests + WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`, + [teamId], + ) +} + +module.exports = { + setHidden, + markNameReviewed, + setDisplayNameOverride, + unreviewedActive, + reviewQueue, + insertRequest, + findRequest, + listRequests, + decideRequest, + pendingForTeam, +} diff --git a/server/src/model/teams/teamModeration.model.js b/server/src/model/teams/teamModeration.model.js new file mode 100644 index 0000000..991073b --- /dev/null +++ b/server/src/model/teams/teamModeration.model.js @@ -0,0 +1,239 @@ +// ── Impersonation controls, and the approval gate on them ────────────────── +// +// TEAMS.md §2.8–§2.9. Two things live here: +// +// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team +// and a review queue entry rather than into a refusal. Core cannot refuse a +// name — the guild exists in the game and core is a mirror of it. +// +// 2. **The approval gate**, which is scoped to the three actions that RELEASE +// untrusted game-sourced strings onto public surfaces, and to nothing else. +// +// **The gate's scope is the part most likely to be misread.** It is not a general +// staff-approval workflow. Ordinary forum grants, leadership overrides, archives +// and forum moderation all still apply immediately and are audited, exactly as +// before. Three actions are gated, and the question that admits a fourth is +// always the same one: *does this publish untrusted game data?* +// +// - clearing a reserved_name hide — publishes a name that tripped the list +// - setting a display_name_override — substitutes free text into the same +// public surfaces +// - un-hiding a staff-hidden Team — reverses a deliberate suppression +// +// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role` +// defaults to admin and `npm run seed` creates exactly one, so most deployments +// have precisely one admin. A rule requiring a second would wedge them with no +// way out, which is a worse failure than the one it guards against. + +const moderationDb = require('./teamModeration.db') +const teamsDb = require('./teams.db') +const reservedNames = require('../../utils/reservedNames') +const activity = require('../activity/activity.model') +const log = require('../../utils/logger')('teams') + +const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override'] + +const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin' + +/** + * Screen a name and return the columns a create should carry. + * + * Never throws: screening reads settings, and a database hiccup during a + * reconcile must not stop a Team being created. It fails OPEN on the create — the + * Team appears — because the re-screen on the next sync will catch it, and a + * reconcile that aborts halfway is worse than a name that is public for one + * interval. That is a deliberate trade and it is the reason re-screening exists + * at all rather than being a create-time-only check. + */ +async function screenForCreate(name) { + try { + const { reserved, term } = await reservedNames.screen(name) + if (!reserved) return { hidden: false } + log.warn('team auto-hidden: its name matched a reserved term', { name, term }) + return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term } + } catch (err) { + log.error('reserved-name screening failed; the team is created unscreened', { + name, message: err.message, + }) + return { hidden: false } + } +} + +/** + * Re-screen every active Team whose name no human has ruled on. + * + * Names are immutable per row, so this only ever changes an outcome when the TERM + * LIST changed — an operator adding a term, or the deployment being renamed. That + * is precisely the case a create-time-only check would miss forever. + * + * A Team staff have already decided about is skipped, and that stickiness is the + * point: without it, an override would be undone on the next sweep. + */ +async function rescreen(moduleId) { + let hidden = 0 + try { + const rows = await moderationDb.unreviewedActive(moduleId) + for (const row of rows) { + if (row.hidden) continue + // eslint-disable-next-line no-await-in-loop + const { reserved, term } = await reservedNames.screen(row.name) + if (!reserved) continue + // eslint-disable-next-line no-await-in-loop + await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term }) + hidden += 1 + log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term }) + } + } catch (err) { + log.error('re-screening failed', { message: err.message }) + } + return hidden +} + +// ── The three gated actions ──────────────────────────────────────────────── + +/** + * Apply a gated action, or file it for approval. + * + * The role check is answered LIVE against the database on every request by core's + * admin middleware, so "is this caller an admin" is not read from a token claim + * that a demotion would not have invalidated. + */ +async function requestOrApply({ req, actor, teamId, action, payload, reason }) { + if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`) + const team = await teamsDb.findById(teamId) + if (!team) return { ok: false, status: 404, error: 'team not found' } + + if (!isAdmin(actor)) { + const id = await moderationDb.insertRequest({ + teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username, + }) + await activity.log({ + req, + action: 'team.moderation.request', + detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})` + + `${reason ? `: "${reason}"` : ''}`, + }) + return { ok: true, pending: true, requestId: id } + } + + await applyAction({ req, actor, team, action, payload, reason }) + return { ok: true, pending: false } +} + +/** The effect itself. Reached by an admin directly, or by an approval. */ +async function applyAction({ req, actor, team, action, payload, reason }) { + switch (action) { + case 'unhide': + await moderationDb.setHidden(team.id, { hidden: false }) + // A human has now ruled on this name, so no later sweep re-hides it. + await moderationDb.markNameReviewed(team.id) + break + case 'display_name_override': + await moderationDb.setDisplayNameOverride(team.id, payload.displayName) + await moderationDb.markNameReviewed(team.id) + break + case 'clear_display_name_override': + await moderationDb.setDisplayNameOverride(team.id, null) + break + default: + throw new Error(`not a gated action: "${action}"`) + } + + await activity.log({ + req, + action: `team.${action}`, + detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})` + + `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}` + + `${reason ? `: "${reason}"` : ''}`, + }) +} + +/** + * Hide a Team. NOT gated — suppression is always safe (§2.11). + * + * The asymmetry is the whole design: publishing untrusted data needs a second + * pair of eyes, and withdrawing it needs to be possible at once, by whoever is + * on duty. + */ +async function hide({ req, actor, teamId, reason }) { + const team = await teamsDb.findById(teamId) + if (!team) return { ok: false, status: 404, error: 'team not found' } + await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' }) + await activity.log({ + req, + action: 'team.hide', + detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})` + + `${reason ? `: "${reason}"` : ''}`, + }) + return { ok: true } +} + +/** + * Decide a pending request. Admin only. + * + * The effect is applied only when the row actually moved out of `pending`, so two + * admins deciding the same request race safely: the second is told it was already + * decided rather than applying the action a second time. + */ +async function decide({ req, actor, requestId, status, note }) { + if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' } + if (!['approved', 'rejected'].includes(status)) { + return { ok: false, status: 400, error: 'status must be approved or rejected' } + } + + const request = await moderationDb.findRequest(requestId) + if (!request) return { ok: false, status: 404, error: 'request not found' } + if (request.status !== 'pending') { + return { ok: false, status: 409, error: `request is already ${request.status}` } + } + + const moved = await moderationDb.decideRequest(requestId, { + status, decidedBy: actor.id, decidedUsername: actor.username, note, + }) + if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' } + + const team = await teamsDb.findById(request.team_id) + if (status === 'approved' && team) { + await applyAction({ + req, + actor, + team, + action: request.action, + payload: parsePayload(request.payload), + reason: request.reason, + }) + } + + await activity.log({ + req, + action: `team.moderation.${status}`, + detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} ` + + `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})` + + `${note ? `: "${note}"` : ''}`, + }) + return { ok: true, applied: status === 'approved' } +} + +// The driver returns JSON columns already parsed on some versions and as a string +// on others, so this normalises rather than assuming either. +function parsePayload(payload) { + if (payload == null) return {} + if (typeof payload === 'object') return payload + try { + return JSON.parse(payload) + } catch { + return {} + } +} + +module.exports = { + screenForCreate, + rescreen, + requestOrApply, + hide, + decide, + reviewQueue: moderationDb.reviewQueue, + listRequests: moderationDb.listRequests, + pendingForTeam: moderationDb.pendingForTeam, + GATED_ACTIONS, +} diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index 4d5f764..1847470 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -31,6 +31,7 @@ const teamsDb = require('./teams.db') const teamProvider = require('./teamProvider') +const moderation = require('./teamModeration.model') const { slugify, uniqueSlug } = require('./teamSlug') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') @@ -95,17 +96,23 @@ function backoffSeconds(consecutiveFailures, intervalS) { // ── Applying one Team ────────────────────────────────────────────────────── /** - * Create the row for a Team core has not seen, deriving its slug. + * Create the row for a Team core has not seen, deriving its slug and screening + * its name against the reserved list (§2.8). * - * Screening the name against the reserved list happens here in a later commit; - * the row is created either way, because core cannot refuse a name — the guild - * already exists in the game and core is a mirror of it, not an authority over it. + * The row is created whatever the screening says, and hidden if it matched. 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 hidden Team is absent from public surfaces + * and completely functional for its own members — the people in it are not being + * punished for a name their leader chose. */ async function createTeam(moduleId, team) { const taken = await teamsDb.slugsLike(slugify(team.name) || 'team') const slug = uniqueSlug(team.name, taken) - const id = await teamsDb.insertTeam({ moduleId, slug, ...team }) - log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug }) + const screened = await moderation.screenForCreate(team.name) + const id = await teamsDb.insertTeam({ moduleId, slug, ...team, ...screened }) + log.info('team created', { + moduleId, externalId: team.externalId, name: team.name, slug, hidden: Boolean(screened.hidden), + }) return id } @@ -291,9 +298,17 @@ async function runOnce(reason) { } } + // Re-screen the names no human has ruled on. Names are immutable per row, so + // this only changes an outcome when the reserved TERMS changed — an operator + // adding one, or the deployment being renamed — which is exactly the case a + // create-time-only check would miss forever. + const rehidden = await moderation.rescreen(moduleId) + await teamsDb.recordSuccess(moduleId) - log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, total: answer.teams.length }) - return { ok: true, created, renamed, archived, rosters } + log.info('reconcile complete', { + trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length, + }) + return { ok: true, created, renamed, archived, rosters, rehidden } } // ── The public entry points ──────────────────────────────────────────────── diff --git a/server/src/utils/reservedNames.js b/server/src/utils/reservedNames.js new file mode 100644 index 0000000..0d4c5b7 --- /dev/null +++ b/server/src/utils/reservedNames.js @@ -0,0 +1,212 @@ +// ── Reserved-name screening ──────────────────────────────────────────────── +// +// The one place untrusted game data becomes a public page (TEAMS.md §2.8). +// +// A Team's name is written by a player, inside the game, with no review, and the +// platform then turns it into a public page, a URL, a nav-reachable entity and +// eventually a Discord channel name. Someone naming their guild "Admin", +// "Moderator" or " Staff" gets an official-looking page on the operator's +// own site for free, by typing a name into a guild stone. +// +// **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 puts it in a review queue, and it keeps +// working completely for its own members — the people in it are not being +// punished for a name their leader chose. +// +// That asymmetry is what lets this matcher be conservative without being clever: +// **a false positive costs a human glance, a false negative costs an impersonated +// staff page.** +// +// NOT `filter_words`. That table exists but is bot-owned (its own pool, never +// read by the website — MODERATION_APPEALS.md §2), and it is a profanity filter, +// which is a different question with a different answer. Reusing it would cross +// an ownership boundary to get the wrong list. +// +// Also NOT `auth/usernamePolicy.js`'s RESERVED_USERNAMES. That list answers +// "may someone register under this handle", matched exactly against a whole +// username; this one answers "does this phrase impersonate authority", matched +// word by word inside a name that is usually several words long. Sharing them +// would give each question the other's answer — "Support" is a fine guild name +// and an unacceptable username. + +const brand = require('../config/brand') +const settings = require('../model/settings/settings.model') +const log = require('./logger')('teams') + +// The `users.role` enum plus the words people actually use for those roles. Kept +// here rather than derived from the enum alone, because 'gm' and 'staff' are not +// roles in the database and are exactly what a would-be impersonator reaches for. +const ROLE_TERMS = [ + 'admin', 'editor', 'moderator', 'player', + 'staff', 'administrator', 'mod', 'owner', 'gm', +] + +// Impersonating the software project is as much a problem as impersonating the +// operator. Stored in its correct two-word form; §2.8.2's whitespace-insensitive +// comparison is what also catches RunicGateway, runic-gateway and Runic_Gateway. +const PROJECT_TERMS = ['Runic Gateway'] + +const OPERATOR_TERMS_KEY = 'teams_reserved_terms' + +/** + * Case-fold, strip punctuation, collapse repeats and whitespace. + * + * Repeated characters are squeezed so "Adminnn" folds to "admin". Deliberately + * NO leet-speak folding in v1 (`4dm1n`): it multiplies false positives, and the + * consequence of a miss is a Team hidden by a human rather than a breach. + */ +function normalise(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\s]+/g, ' ') + .replace(/(.)\1{1,}/g, '$1') + .replace(/\s+/g, ' ') + .trim() +} + +const words = (value) => (value ? value.split(' ') : []) + +/** + * The words of a name, plus the acronyms its punctuation was hiding. + * + * "G.M." normalises to `g m`, and neither token is the reserved term `gm` — so a + * run of two or more single-letter words is ALSO offered as one joined token. + * "GM" is a live impersonation vector on a game server, and spelling it with dots + * is the obvious way around a word-level check. + * + * The individual letters are kept as well as the joined form, so this only ever + * adds matches. And the join is deliberately not the whole-name condensation used + * for multi-word terms: condensing every name would let a single-word term match + * inside an ordinary word again, which is the substring matching this whole design + * refuses. + */ +function tokens(normalised) { + const list = words(normalised) + const out = [...list] + let run = [] + const flush = () => { + if (run.length > 1) out.push(run.join('')) + run = [] + } + for (const word of list) { + if (word.length === 1) run.push(word) + else flush() + } + flush() + return out +} + +/** + * A single-word term matches a name word, or that word's singular. + * + * A guild called "Moderators" impersonates staff exactly as much as one called + * "Moderator", and a check that misses the plural misses the more natural name of + * the two. Only a trailing `s` is stripped, and only when the remainder is the + * whole term — so "Nomads" still does not match "mod" and "Playerless" still does + * not match "player". + */ +const wordMatches = (word, term) => + word === term || (word.length > 1 && word.endsWith('s') && word.slice(0, -1) === term) + +/** + * Every reserved term for this deployment, resolved AT CHECK TIME. + * + * Never baked in: the brand is runtime configuration, so a deployment that + * renames itself must be protected under its new name without a redeploy. + * + * A settings read that fails must not open the gate, so a failure falls back to + * the static terms rather than to an empty list — screening fewer terms is bad, + * screening none is the whole hole. + */ +async function reservedTerms() { + const terms = [...ROLE_TERMS, ...PROJECT_TERMS] + + try { + const instanceName = await settings.getInstanceName() + if (instanceName) terms.push(instanceName) + } catch (err) { + log.warn('could not resolve the instance name for reserved-name screening', { message: err.message }) + } + + if (brand.name) terms.push(brand.name) + if (brand.shortName) terms.push(brand.shortName) + + try { + const extra = await settings.get(OPERATOR_TERMS_KEY) + if (extra) terms.push(...String(extra).split(',').map((t) => t.trim()).filter(Boolean)) + } catch (err) { + log.warn('could not read operator reserved terms', { message: err.message }) + } + + // De-duplicated on the normalised form: the brand and an operator term are + // frequently the same word, and reporting the same match twice is noise in a + // review queue. + const seen = new Set() + return terms.filter((term) => { + const key = normalise(term) + if (!key || seen.has(key)) return false + seen.add(key) + return true + }) +} + +/** + * Does `name` contain `term`? + * + * Whole WORDS, after normalisation — never substrings. Core already has the + * precedent and the scar tissue for this: scripts/checkModuleIdentifiers.js + * tokenises and compares word by word precisely so `defaultImage` does not match + * "ultIma". The same discipline applies for the same reason — a substring match + * flags "Badminton" for containing "admin", and a check that cries wolf is a + * check people switch off. + * + * A MULTI-WORD term is additionally compared with the whitespace removed on both + * sides, so "Runic Gateway" matches "RunicGateway". Without that the whole-word + * rule fails on exactly the case that matters: the condensed form is a SINGLE + * word and could never match a two-word term — and it is the form an impersonator + * would reach for, because it is what the Gitea org and every URL already use. + * + * The widening applies only to terms containing whitespace, which keeps it away + * from the single-word terms where whole-word matching is doing the false-positive + * work. A two-word term is specific enough that running its letters together + * cannot collide with ordinary vocabulary. + */ +function matches(nameWords, condensedName, term) { + const normalisedTerm = normalise(term) + if (!normalisedTerm) return false + const termWords = words(normalisedTerm) + + if (termWords.length === 1) return nameWords.some((w) => wordMatches(w, termWords[0])) + + // A multi-word term matches as a consecutive run of words … + for (let i = 0; i + termWords.length <= nameWords.length; i++) { + if (termWords.every((w, j) => nameWords[i + j] === w)) return true + } + // … or as its condensed form appearing as a whole word in the condensed name. + const condensedTerm = termWords.join('') + return condensedName.includes(condensedTerm) +} + +/** + * Screen a name. Returns `{ reserved, term }` — `term` is the term that matched, + * in its stored form, which is what the review queue shows a human. + */ +async function screen(name) { + const normalised = normalise(name) + if (!normalised) return { reserved: false, term: null } + + const nameWords = tokens(normalised) + // The condensed name is the whole thing with spaces removed, so a multi-word + // term can be found inside a run-together name. + const condensed = words(normalised).join('') + + for (const term of await reservedTerms()) { + if (matches(nameWords, condensed, term)) return { reserved: true, term } + } + return { reserved: false, term: null } +} + +module.exports = { screen, normalise, reservedTerms, ROLE_TERMS, PROJECT_TERMS, OPERATOR_TERMS_KEY } diff --git a/server/test/reservedNames.test.js b/server/test/reservedNames.test.js new file mode 100644 index 0000000..91c4477 --- /dev/null +++ b/server/test/reservedNames.test.js @@ -0,0 +1,192 @@ +// Reserved-name screening (docs/website/TEAMS.md §2.8). +// +// Two failure modes with very different costs, and the tests are split along +// that line: +// +// - a FALSE NEGATIVE puts an official-looking staff page on the operator's own +// site, written by whoever typed a name into a guild stone; +// - a FALSE POSITIVE hides a legitimate guild until a human glances at a queue. +// +// The second is cheap and recoverable, which is what lets the matcher be +// conservative. It is not licence to be sloppy in the other direction: a check +// that fires on "Badminton" is a check the operator switches off, and then the +// first cost is paid in full. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const settings = require('../src/model/settings/settings.model') +const brand = require('../src/config/brand') +const reserved = require('../src/utils/reservedNames') + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} + +beforeEach(() => { + // A deployment with a two-word brand and no operator additions, which is the + // shape that exercises the condensed-form rule. + patch(settings, 'getInstanceName', async () => 'UO Mysticmoon') + patch(settings, 'get', async () => null) +}) + +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +const isReserved = async (name) => (await reserved.screen(name)).reserved +const termFor = async (name) => (await reserved.screen(name)).term + +// ── The names this exists to catch ───────────────────────────────────────── + +test('bare role names are reserved', async () => { + for (const name of ['Admin', 'admin', 'ADMIN', 'Moderator', 'Staff', 'Owner', 'GM', 'Administrator']) { + assert.equal(await isReserved(name), true, `"${name}" must not become a public page`) + } +}) + +test('a role word inside a longer name is caught', async () => { + for (const name of ['The Admin Team', 'Server Staff', 'GM Council', 'Guild of Moderators']) { + assert.equal(await isReserved(name), true, `"${name}" is the impersonation this exists for`) + } +}) + +test('the deployment brand is reserved, in both presentations', async () => { + assert.equal(await isReserved('UO Mysticmoon'), true) + assert.equal(await isReserved('UOMysticmoon'), true, 'the condensed form is what an impersonator types') + assert.equal(await isReserved('uo-mysticmoon'), true) + assert.equal(await isReserved('UO_MYSTICMOON'), true) + assert.equal(await isReserved('UOMysticmoon Staff'), true) +}) + +test('the project name is reserved, in both of its legitimate presentations', async () => { + // "Runic Gateway" is correct; "RunicGateway" is what the Gitea org and every + // URL segment use, so it is the form someone would copy. + assert.equal(await isReserved('Runic Gateway'), true) + assert.equal(await isReserved('RunicGateway'), true) + assert.equal(await isReserved('runic-gateway'), true) + assert.equal(await isReserved('Runic_Gateway'), true) + assert.equal(await isReserved('RUNIC GATEWAY'), true) +}) + +test('operator additions are honoured', async () => { + patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'Council, Arbiter' : null)) + assert.equal(await isReserved('The Council'), true) + assert.equal(await isReserved('Arbiter'), true) +}) + +test('repeated characters are squeezed', async () => { + assert.equal(await isReserved('Adminnn'), true) + assert.equal(await isReserved('Staaaff'), true) +}) + +test('punctuation between words does not evade the check', async () => { + assert.equal(await isReserved('[Admin]'), true) + assert.equal(await isReserved('~*~ Staff ~*~'), true) + assert.equal(await isReserved('G.M.'), true) +}) + +test('the matched term is reported, for the review queue', async () => { + assert.equal(await termFor('The Admin Team'), 'admin') + assert.equal(await termFor('UOMysticmoon'), 'UO Mysticmoon', 'shown in its stored form, not the input') +}) + +// ── The names it must NOT catch ──────────────────────────────────────────── + +test('a word merely CONTAINING a reserved term is not reserved', async () => { + // The scar tissue this rule comes from: checkModuleIdentifiers.js tokenises + // precisely so `defaultImage` does not match "ultIma". + for (const name of ['Badminton', 'Badminton Club', 'Modest Proposal', 'Gmork', 'Playerless']) { + assert.equal(await isReserved(name), false, `"${name}" is a false positive that would discredit the check`) + } +}) + +test('ordinary guild names pass', async () => { + for (const name of [ + 'The Silver Hand', 'Knights of the Round', 'Dread Pirates', 'Moonlight Traders', + 'The Guardians', 'Iron Wolves', + ]) { + assert.equal(await isReserved(name), false, `"${name}" is an ordinary guild`) + } +}) + +test('the condensed-form widening applies only to multi-word terms', async () => { + // Running the letters together is safe for a two-word term because it is + // specific; doing it for single-word terms is what would re-introduce + // substring matching through the back door. + assert.equal(await isReserved('Badminton'), false) + assert.equal(await isReserved('Grandmaster'), false, 'contains "gm" only as a substring') + assert.equal(await isReserved('Nomads'), false, 'contains "mod" only as a substring') +}) + +test('an empty or unusable name is not reserved', async () => { + for (const name of ['', ' ', null, undefined, '★☆★']) { + assert.equal(await isReserved(name), false) + } +}) + +// ── Resolution is at check time, and fails safe ──────────────────────────── + +test('the brand is resolved at CHECK time, so a rename protects the new name', async () => { + patch(settings, 'getInstanceName', async () => 'Dragonspire') + assert.equal(await isReserved('Dragonspire'), true) + + patch(settings, 'getInstanceName', async () => 'Emberfall') + assert.equal(await isReserved('Emberfall'), true, 'no redeploy should be needed to protect a new brand') +}) + +test('a failed settings read falls back to the static terms rather than to none', async () => { + // Screening fewer terms is bad; screening none is the entire hole. + patch(settings, 'getInstanceName', async () => { throw new Error('db down') }) + patch(settings, 'get', async () => { throw new Error('db down') }) + + assert.equal(await isReserved('Admin'), true, 'the role list must survive a database outage') + assert.equal(await isReserved('Runic Gateway'), true) +}) + +test('BRAND_NAME is covered even when no site_title is set', async () => { + patch(settings, 'getInstanceName', async () => null) + assert.equal(await isReserved(brand.name), true) +}) + +test('the same term resolved twice is listed once', async () => { + // The brand and an operator term are frequently the same word, and reporting + // one match twice is noise in a queue a human reads. + patch(settings, 'getInstanceName', async () => 'Dragonspire') + patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'dragonspire' : null)) + const terms = await reserved.reservedTerms() + const normalised = terms.map((t) => reserved.normalise(t)) + assert.equal(new Set(normalised).size, normalised.length) +}) + +test('normalise folds case, diacritics and punctuation', () => { + assert.equal(reserved.normalise('Ünderdärk!'), 'underdark') + assert.equal(reserved.normalise(' The Silver Hand '), 'the silver hand') + assert.equal(reserved.normalise('Adminnn'), 'admin', 'repeats are squeezed on both sides') +}) + +test('plurals are caught, and near-misses are not', async () => { + // "Moderators" is the more natural guild name of the two, so missing it would + // miss the likelier case. + for (const name of ['Moderators', 'The Admins', 'Guild of Moderators', 'Owners']) { + assert.equal(await isReserved(name), true, `"${name}" impersonates as much as its singular`) + } + // Only a trailing s off the WHOLE term, so an ordinary word whose stem merely + // contains one does not fire. + for (const name of ['Nomads', 'Playerless', 'Gods']) { + assert.equal(await isReserved(name), false, `"${name}" is not a plural of a reserved term`) + } +}) + +test('an acronym spelled with punctuation is caught', async () => { + // "G.M." normalises to two single-letter words, neither of which is the term. + assert.equal(await isReserved('G.M.'), true) + assert.equal(await isReserved('G M Council'), true) + // …but joining single letters must not condense whole names, which would let a + // single-word term match inside an ordinary word again. + assert.equal(await isReserved('Badminton'), false) +}) diff --git a/server/test/teamModeration.test.js b/server/test/teamModeration.test.js new file mode 100644 index 0000000..77455b2 --- /dev/null +++ b/server/test/teamModeration.test.js @@ -0,0 +1,293 @@ +// 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 moderator’s 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 requester’s 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/) +}) diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js index b2cdfe4..614f986 100644 --- a/server/test/teamSync.test.js +++ b/server/test/teamSync.test.js @@ -11,6 +11,7 @@ const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const teamsDb = require('../src/model/teams/teams.db') +const moderation = require('../src/model/teams/teamModeration.model') const settings = require('../src/model/settings/settings.model') const teamSync = require('../src/model/teams/teamSync.model') @@ -169,6 +170,18 @@ function stubDb() { s.pending_empty_since = since store.sync.set(moduleId, s) }) + + // Reserved-name screening is its own unit (teamModeration.test.js). Stubbed + // here so these tests stay about the reconciler — and because the real calls + // read settings, which means a live database connection this suite must never + // make. `screened` records that the reconciler asked, which is the integration + // point worth asserting from this side. + store.screened = [] + patch(moderation, 'screenForCreate', async (name) => { + store.screened.push(name) + return { hidden: false } + }) + patch(moderation, 'rescreen', async () => 0) } // A provider whose answers the test controls. Defaults are authoritative and @@ -612,6 +625,58 @@ test('a name with nothing URL-safe in it still gets an address', async () => { assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed') }) +// ── Screening is on the create path, and on every run ────────────────────── + +test('every newly created team has its name screened', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) }) + await teamSync.reconcileNow('test') + assert.deepEqual(store.screened, ['Admin', 'The Silver Hand']) +}) + +test('a renamed team is screened again under its new name', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) }) + await teamSync.reconcileNow('rename') + assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens') +}) + +test('a hidden team is still created and still syncs its roster', async () => { + // Hide, never reject: the Team works completely for its own members. The people + // in it are not being punished for a name their leader chose. + patch(moderation, 'screenForCreate', async () => ({ + hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin', + })) + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + }) + await teamSync.reconcileNow('test') + + assert.equal(store.teams[0].hidden, 1) + assert.equal(store.teams[0].hidden_term, 'admin') + assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown') + assert.equal(store.teams[0].member_count, 2) +}) + +test('a successful run re-screens the names no human has ruled on', async () => { + let called = 0 + patch(moderation, 'rescreen', async () => { called += 1; return 0 }) + provide() + await teamSync.reconcileNow('test') + assert.equal(called, 1) +}) + +test('a refused run does not re-screen — it does nothing at all', async () => { + let called = 0 + patch(moderation, 'rescreen', async () => { called += 1; return 0 }) + provide({ getTeams: async () => ({ ok: false, reason: 'down' }) }) + await teamSync.reconcileNow('test') + assert.equal(called, 0) +}) + // ── Events (§2.3) ────────────────────────────────────────────────────────── test('an unknown event kind is rejected', async () => {