diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index d099b33..c18a888 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -125,18 +125,44 @@ resetting its cache or credentials. ## Cases, evidence and review -Create a case for a report, investigation or appeal. Staff notes are explicitly -labeled as notes. Selecting archived chat verifies the target GUID and original -message against the recorded match and saves an independent snapshot, including -the original timing context. Unknown or estimated timestamps remain such; the -snapshot does not turn them into exact observations. Unarchived or already -deleted chat cannot be attached as verified evidence. +Create a case for a report, investigation or appeal. Each case has a dedicated, +shareable page with the player identity, evidence, staff notes, linked restrictions, +and an activity timeline. These pages require moderation access. Give a case a +priority and assign it to an eligible administrator or moderator. Filter the case +queue by status, priority, or assignment; active cases appear first, ordered by +priority and most recent update. -Cases can be resolved or dismissed with a recorded reason. Evidence, restrictions -and append-only moderation events survive source-match cleanup and server -deletion. They currently have no automatic retention cleanup. Authentication -audit cleanup does not remove moderation history. Correct decisions by adding -notes or revoking restrictions; there is no history-erasing delete endpoint. +Move active cases between **Open**, **In review**, and **Escalated**, recording a +reason for workflow changes. Close a case with one of these outcomes: **Resolved**, +**Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**, +**Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a +reason. Reopen a closed case when new evidence or an appeal needs review; earlier +decisions remain in its history. Editing case details does not reopen a case. + +Apply a ban or mute directly from an active case. The case fixes the target game +and GUID and records the resulting restriction alongside the evidence. The same +role, scope, and duration limits apply as elsewhere in Moderation. Applying a +restriction leaves the case active so staff can finish reviewing it. Selecting a +closure outcome records a decision; it does not itself send a warning, create a +restriction, or revoke one. Linked restrictions can be revoked separately with +the required permission and reason, including after a case closes. Reopening a +case also leaves its restrictions in place. Check enforcement coverage after +applying or revoking a restriction. + +Staff notes are explicitly labeled as notes. Selecting archived chat when opening +a case, or attaching a match ID and exact message within an existing case, verifies +the target GUID and original message against the recorded match and saves an +independent snapshot, including the original timing context. Unknown or estimated +timestamps remain such; the snapshot does not turn them into exact observations. +Unarchived or already deleted chat cannot be attached as verified evidence. + +Case decisions, edits, and restrictions check the current revision; refresh before +retrying a decision if another staff member has changed the case. Evidence, +restrictions and append-only moderation events survive source-match cleanup and +server deletion. They currently have no automatic retention cleanup. +Authentication audit cleanup does not remove moderation history. Correct decisions +by reopening, adding notes, or revoking restrictions; there is no history-erasing +delete endpoint. Leaderboard disqualification, bank balance changes, automatic escalation and a public report/appeal portal are separate features. This workflow supports staff diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 5f22742..652d5fd 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -6,6 +6,41 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce'; export type ModerationState = 'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown'; +export const moderationCaseOutcomes = [ + 'resolved', + 'dismissed', + 'warning_issued', + 'restriction_applied', + 'no_action', + 'duplicate', + 'appeal_upheld', + 'appeal_denied' +] as const; +export const moderationCaseStatuses = [ + 'open', + 'in_review', + 'escalated', + ...moderationCaseOutcomes +] as const; +export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number]; +export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent'; +export const moderationCaseStatusLabels: Record = { + open: 'Open', + in_review: 'In review', + escalated: 'Escalated', + resolved: 'Resolved', + dismissed: 'Dismissed', + warning_issued: 'Warning issued', + restriction_applied: 'Restriction applied', + no_action: 'No action needed', + duplicate: 'Duplicate', + appeal_upheld: 'Appeal upheld', + appeal_denied: 'Appeal denied' +}; +export function isModerationCaseOpen(status: ModerationCaseStatus): boolean { + return status === 'open' || status === 'in_review' || status === 'escalated'; +} + export interface ModerationPlayer { game: ModerationGame; guid: string; @@ -18,6 +53,7 @@ export interface ModerationPlayer { } export interface ModerationSanction { id: string; + caseId?: string | null; game: ModerationGame; guid: string; kind: 'ban' | 'mute'; @@ -68,12 +104,31 @@ export interface ModerationCase { game: ModerationGame; guid: string; title: string; - status: 'open' | 'resolved' | 'dismissed'; + status: ModerationCaseStatus; + priority: ModerationCasePriority; + assigneeId: string | null; + assignee: string | null; actor: string; createdAt: string; updatedAt: string; revision: number; - evidence: { id: string; text: string; source: string; createdAt: string; actor: string }[]; + evidence: { + id: string; + text: string; + source: string; + snapshot?: string | null; + createdAt: string; + actor: string; + }[]; +} +export interface ModerationCaseDetail { + case: ModerationCase; + player: ModerationPlayer; + sanctions: ModerationSanction[]; + events: ModerationEvent[]; + coverage: ModerationCoverage[]; + assignees: { id: string; username: string }[]; + checkedAt: string; } export interface ModerationEvent { id: number; @@ -100,6 +155,9 @@ export interface ModerationQuery { game: '' | ModerationGame; guid: string; offset: number; + caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus; + priority?: '' | ModerationCasePriority; + assignedTo?: string; } export interface ModerationOverview { query: ModerationQuery; diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 8ac7a86..8402adf 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -27,7 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js'; import { migrateBank } from './bank.js'; import { migrateBankCredentials } from './bank-credentials.js'; import { migrateTemperatures } from '../hosts/temperatures.js'; -import { migrateModeration } from '../moderation/schema.js'; +import { migrateModeration, migrateModerationCases } from '../moderation/schema.js'; import type { Migration } from './migration-runner.js'; // Append only: never renumber entries or change an applied migration to add an @@ -162,7 +162,13 @@ export const migrations: readonly Migration[] = [ ); } }, - { version: 28, name: 'known login devices', run: migrateLoginDevices } + { version: 28, name: 'known login devices', run: migrateLoginDevices }, + { + version: 29, + name: 'moderation case workflow and linked restrictions', + mode: 'foreign-keys-off', + run: migrateModerationCases + } ]; export const databaseVersion = migrations.length; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 48b7331..0cd0eb6 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -4,7 +4,12 @@ import { AuthError } from '../auth/index.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { canRevokeModerationRestriction, + isModerationCaseOpen, + moderationCaseOutcomes, + moderationCaseStatusLabels, moderationGuid, + type ModerationCaseStatus, + type ModerationCasePriority, type ModerationGame, type ModerationScope } from '../../moderation.js'; @@ -34,8 +39,18 @@ type SanctionRow = { expires_at: string | null; revision: number; revoked_at: string | null; + case_id: string | null; +}; +type CaseRow = { + id: string; + game: ModerationGame; + guid: string; + revision: number; + status: ModerationCaseStatus; + title: string; + priority: ModerationCasePriority; + assignee_id: string | null; }; -type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string }; function serverTarget(name: unknown, game?: ModerationGame) { const server = textField(name, 'Server', 255); @@ -61,6 +76,15 @@ function serverTarget(name: unknown, game?: ModerationGame) { function createSanction(actor: AuthUser, input: Record, now: string) { const target = subject(input); + const linkedCase = + input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId); + if (linkedCase) { + checkCaseRevision(linkedCase, input.caseRevision); + if (!isModerationCaseOpen(linkedCase.status)) + throw new AuthError('Reopen this case before applying a restriction.', 409); + if (linkedCase.game !== target.game || linkedCase.guid !== target.guid) + throw new AuthError('A case restriction must target the case’s game and player.'); + } if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.'); if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope)) throw new AuthError('Choose a restriction scope.'); @@ -117,8 +141,8 @@ function createSanction(actor: AuthUser, input: Record, now: st ensureSubject(target.game, target.guid); db() .prepare( - `INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)` + `INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at,case_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)` ) .run( id, @@ -133,7 +157,8 @@ function createSanction(actor: AuthUser, input: Record, now: st actor.id, actor.username, now, - expires + expires, + linkedCase?.id ?? null ); advancePolicy(); moderationEvent( @@ -147,10 +172,22 @@ function createSanction(actor: AuthUser, input: Record, now: st server: server?.name ?? null, reason, expiresAt: expires, - note + note, + caseId: linkedCase?.id ?? null }), now ); + if (linkedCase) { + touchCase(linkedCase.id, now); + moderationEvent( + actor, + 'case.restriction', + linkedCase, + linkedCase.id, + JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }), + now + ); + } return { id }; } function revokeSanction(actor: AuthUser, input: Record, now: string) { @@ -183,6 +220,7 @@ function revokeSanction(actor: AuthUser, input: Record, now: st .run(now, id); advancePolicy(); moderationEvent(actor, 'restriction.revoked', row, id, reason, now); + if (row.case_id) touchCase(row.case_id, now); return { id }; } function liveAction(actor: AuthUser, input: Record, now: string) { @@ -267,15 +305,43 @@ function caseRow(value: unknown): CaseRow { if (!row) throw new AuthError('Case not found.', 404); return row; } +function checkCaseRevision(row: CaseRow, revision: unknown) { + if (row.revision !== revision) + throw new AuthError('This case changed. Refresh before trying again.', 409); +} +function touchCase(id: string, now: string) { + db() + .prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?') + .run(now, id); +} +function casePriority(value: unknown): ModerationCasePriority { + if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent') + throw new AuthError('Choose low, normal, high, or urgent priority.'); + return value; +} +function caseAssignee(value: unknown): { id: string | null; username: string | null } { + if (value === null) return { id: null, username: null }; + const id = textField(value, 'Assignee', 128); + const user = db() + .prepare( + `SELECT id,username FROM auth_users + WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0` + ) + .get(id) as { id: string; username: string } | undefined; + if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.'); + return user; +} function evidence( actor: AuthUser, id: string, text: string, source: string, snapshot: string | null, - now: string + now: string, + decision = false ) { if ( + !decision && ( db() .prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?') @@ -291,93 +357,206 @@ function createCase(actor: AuthUser, input: Record, now: string const target = subject(input); const title = textField(input.title, 'Case title', 160); const note = textField(input.note, 'Case note', 4000, true); + const priority = input.priority === undefined ? 'normal' : casePriority(input.priority); + const assignee = + input.assigneeId === undefined + ? { id: null, username: null } + : caseAssignee(input.assigneeId); const id = randomUUID(); ensureSubject(target.game, target.guid); db() .prepare( - `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)` + `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)` ) - .run(id, target.game, target.guid, title, actor.id, actor.username, now, now); + .run( + id, + target.game, + target.guid, + title, + actor.id, + actor.username, + now, + now, + priority, + assignee.id + ); if (note) evidence(actor, id, note, 'Staff note', null, now); - if (input.evidence !== undefined) { - const requested = input.evidence as { matchId: unknown; message: unknown }; - if (!requested || !Number.isSafeInteger(requested.matchId)) - throw new AuthError('Choose a recorded chat message.'); - const message = textField(requested.message, 'Chat message', 4096); - const match = db() - .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') - .get(requested.matchId) as - { game: string; server: string; started_at: string } | undefined; - if (!match || match.game !== target.game) - throw new AuthError('The source match is unavailable.', 404); - const entries = db() - .prepare('SELECT entry FROM match_chat WHERE match_id=?') - .all(requested.matchId) as { entry: string }[]; - const found = entries - .filter((row) => { - const entry = JSON.parse(row.entry); - return ( - entry.type === 'message' && - moderationGuid(target.game, entry.message.guid) === target.guid && - entry.message.message === message - ); - }) - .slice(0, 10); - if (!found.length) - throw new AuthError( - 'The original chat message could not be verified in the archive.', - 409 - ); - for (const row of found) - evidence( - actor, - id, - message, - `Archived chat · ${match.server} · match ${requested.matchId}`, - JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }), - now - ); - } + if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now); moderationEvent(actor, 'case.opened', target, id, title, now); + if (priority !== 'normal' || assignee.id) + moderationEvent( + actor, + 'case.updated', + target, + id, + JSON.stringify({ + priority: { from: 'normal', to: priority }, + assignee: { from: null, to: assignee.username } + }), + now + ); return { id }; } +function archivedEvidence( + actor: AuthUser, + id: string, + target: { game: ModerationGame; guid: string }, + value: unknown, + now: string +): number { + const requested = value as { matchId: unknown; message: unknown }; + if (!requested || !Number.isSafeInteger(requested.matchId)) + throw new AuthError('Choose a recorded chat message.'); + textField(requested.message, 'Chat message', 4096); + // Evidence must match the archived bytes, including intentional whitespace. + const message = requested.message as string; + const match = db() + .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') + .get(requested.matchId) as { game: string; server: string; started_at: string } | undefined; + if (!match || match.game !== target.game) + throw new AuthError('The source match is unavailable.', 404); + const entries = db() + .prepare('SELECT entry FROM match_chat WHERE match_id=?') + .all(requested.matchId) as { entry: string }[]; + const found = entries + .filter((row) => { + const entry = JSON.parse(row.entry); + return ( + entry.type === 'message' && + moderationGuid(target.game, entry.message.guid) === target.guid && + entry.message.message === message + ); + }) + .slice(0, 10); + if (!found.length) + throw new AuthError('The original chat message could not be verified in the archive.', 409); + for (const row of found) + evidence( + actor, + id, + message, + `Archived chat · ${match.server} · match ${requested.matchId}`, + JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }), + now + ); + return found.length; +} +function updateCase(actor: AuthUser, input: Record, now: string) { + const row = caseRow(input.id); + checkCaseRevision(row, input.revision); + const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160); + const priority = input.priority === undefined ? row.priority : casePriority(input.priority); + const assignee = + input.assigneeId === undefined || input.assigneeId === row.assignee_id + ? null + : caseAssignee(input.assigneeId); + const assigneeId = assignee ? assignee.id : row.assignee_id; + const status = input.status === undefined ? row.status : input.status; + if ( + input.status !== undefined && + status !== 'open' && + status !== 'in_review' && + status !== 'escalated' + ) + throw new AuthError('Choose an active case status, or close the case with an outcome.'); + if (input.status !== undefined && !isModerationCaseOpen(row.status)) + throw new AuthError('Use reopen to reactivate a closed case.', 409); + const reason = textField(input.reason, 'Status change reason', 4000, status === row.status); + const changes: Record = {}; + if (title !== row.title) changes.title = { from: row.title, to: title }; + if (priority !== row.priority) changes.priority = { from: row.priority, to: priority }; + if (status !== row.status) changes.status = { from: row.status, to: status }; + if (assigneeId !== row.assignee_id) { + const previous = row.assignee_id + ? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as + { username: string } | undefined) + : null; + changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null }; + changes.assigneeId = { from: row.assignee_id, to: assigneeId }; + } + if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.'); + db() + .prepare( + `UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?` + ) + .run(title, priority, assigneeId, status, now, row.id); + moderationEvent( + actor, + 'case.updated', + row, + row.id, + JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }), + now + ); + return { id: row.id }; +} export function mutateModeration(actorId: string, input: Record) { return moderationMutation(actorId, input, (actor, now) => { if (input.op === 'create-sanction') return createSanction(actor, input, now); if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now); if (input.op === 'action') return liveAction(actor, input, now); if (input.op === 'create-case') return createCase(actor, input, now); + if (input.op === 'update-case') return updateCase(actor, input, now); if (input.op === 'case-note') { const row = caseRow(input.id); + if (input.revision !== undefined) checkCaseRevision(row, input.revision); const text = textField(input.text, 'Note', 4000); evidence(actor, row.id, text, 'Staff note', null, now); - db() - .prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?') - .run(now, row.id); + touchCase(row.id, now); moderationEvent(actor, 'case.note', row, row.id, text, now); return { id: row.id }; } if (input.op === 'resolve-case') { const row = caseRow(input.id); - if (row.revision !== input.revision || row.status !== 'open') + if (row.revision !== input.revision || !isModerationCaseOpen(row.status)) throw new AuthError('This case changed. Refresh before resolving it.', 409); - if (input.status !== 'resolved' && input.status !== 'dismissed') - throw new AuthError('Choose resolved or dismissed.'); + const outcome = moderationCaseOutcomes.find((status) => status === input.status); + if (!outcome) throw new AuthError('Choose a case outcome.'); const reason = textField(input.reason, 'Decision reason', 4000); - evidence( - actor, - row.id, - reason, - input.status === 'resolved' ? 'Resolution' : 'Dismissal', - null, - now - ); + evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true); db() .prepare( 'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?' ) - .run(input.status, now, row.id); - moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now); + .run(outcome, now, row.id); + moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now); + return { id: row.id }; + } + if (input.op === 'reopen-case') { + const row = caseRow(input.id); + checkCaseRevision(row, input.revision); + if (isModerationCaseOpen(row.status)) + throw new AuthError('This case is already active.', 409); + const reason = textField(input.reason, 'Reopening reason', 4000); + evidence(actor, row.id, reason, 'Reopened', null, now, true); + db() + .prepare( + "UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?" + ) + .run(now, row.id); + moderationEvent( + actor, + 'case.reopened', + row, + row.id, + JSON.stringify({ from: row.status, reason }), + now + ); + return { id: row.id }; + } + if (input.op === 'attach-evidence') { + const row = caseRow(input.id); + checkCaseRevision(row, input.revision); + const count = archivedEvidence(actor, row.id, row, input.evidence, now); + touchCase(row.id, now); + moderationEvent( + actor, + 'case.evidence_attached', + row, + row.id, + JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }), + now + ); return { id: row.id }; } if (input.op === 'note') { diff --git a/src/lib/server/moderation/cases.ts b/src/lib/server/moderation/cases.ts new file mode 100644 index 0000000..f8b39f5 --- /dev/null +++ b/src/lib/server/moderation/cases.ts @@ -0,0 +1,86 @@ +import { db } from '../db/index.js'; +import { AuthError } from '../auth/index.js'; +import { moderationActor, textField } from './core.js'; +import { moderationOverview } from './overview.js'; +import type { + ModerationCase, + ModerationCaseDetail, + ModerationEvent, + ModerationSanction +} from '../../moderation.js'; + +/** A case owns its evidence and timeline independently of the paginated queues. */ +export function moderationCaseDetail(actorId: string, id: string): ModerationCaseDetail { + moderationActor(actorId); + const caseId = textField(id, 'Case ID', 128); + return db().transaction(() => { + const handle = db(); + const row = handle + .prepare( + `SELECT c.id,c.game,c.guid,c.title,c.status,c.priority, + c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt, + c.updated_at AS updatedAt,c.revision FROM moderation_cases c + LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?` + ) + .get(caseId) as Omit | undefined; + if (!row) throw new AuthError('Case not found.', 404); + const overview = moderationOverview(actorId, { + view: 'players', + q: '', + game: row.game, + guid: row.guid, + offset: 0 + }); + const evidence = handle + .prepare( + `SELECT id,text,source,snapshot,actor,created_at AS createdAt + FROM moderation_evidence WHERE case_id=? ORDER BY created_at,rowid` + ) + .all(caseId) as ModerationCase['evidence']; + const sanctions = ( + handle + .prepare( + `SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note, + created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision + FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id` + ) + .all(caseId) as Omit[] + ).map((restriction) => ({ + ...restriction, + active: + !restriction.revokedAt && + (!restriction.expiresAt || restriction.expiresAt > overview.checkedAt) + })); + const events = handle + .prepare( + `SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt + FROM moderation_events WHERE subject_id=? OR subject_id IN + (SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC` + ) + .all(caseId, caseId) as ModerationEvent[]; + const assignees = handle + .prepare( + `SELECT id,username FROM auth_users WHERE role IN ('admin','moderator') + AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id` + ) + .all() as ModerationCaseDetail['assignees']; + return { + case: { ...row, evidence }, + player: overview.players[0] ?? { + game: row.game, + guid: row.guid, + name: row.guid, + aliases: [], + servers: [], + restrictions: 0, + firstSeen: null, + lastSeen: null + }, + sanctions, + events, + coverage: overview.coverage, + assignees, + checkedAt: overview.checkedAt + }; + })(); +} diff --git a/src/lib/server/moderation/overview.ts b/src/lib/server/moderation/overview.ts index 4a0178f..bd1e89f 100644 --- a/src/lib/server/moderation/overview.ts +++ b/src/lib/server/moderation/overview.ts @@ -3,6 +3,7 @@ import { moderationActor, moderationNow, policyRevision } from './core.js'; import { runtimeFreshMs } from './actions.js'; import { refreshActionStates } from './runtime.js'; import { + moderationCaseStatuses, moderationGuid, type ModerationOverview, type ModerationQuery, @@ -19,12 +20,21 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery { const game = params.get('game'); const guid = (params.get('guid') ?? '').slice(0, 128); const offset = Number(params.get('offset') ?? 0); + const caseStatus = params.get('caseStatus') ?? ''; + const priority = params.get('priority') ?? ''; return { view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players', q: (params.get('q') ?? '').trim().slice(0, 200), game: game === 't5' || game === 't6' ? game : '', guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid, - offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0 + offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, + caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus) + ? (caseStatus as ModerationQuery['caseStatus']) + : '', + priority: ['low', 'normal', 'high', 'urgent'].includes(priority) + ? (priority as ModerationQuery['priority']) + : '', + assignedTo: (params.get('assignedTo') ?? '').slice(0, 128) }; } export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview { @@ -93,6 +103,25 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod where.push('p.guid=?'); values.push(query.guid); } + if (query.view === 'cases') { + if (query.caseStatus === 'active' || query.caseStatus === 'closed') { + where.push( + `p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')` + ); + } else if (query.caseStatus) { + where.push('p.status=?'); + values.push(query.caseStatus); + } + if (query.priority) { + where.push('p.priority=?'); + values.push(query.priority); + } + if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL'); + else if (query.assignedTo) { + where.push('p.assignee_id=?'); + values.push(query.assignedTo === 'me' ? actorId : query.assignedTo); + } + } if (query.q) { const extra = query.view === 'restrictions' @@ -161,14 +190,14 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod } else if (query.view === 'restrictions') { result.sanctions = rows>( 'moderation_sanctions', - 'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision', + 'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision,p.case_id AS caseId', 'p.created_at DESC,p.id' ).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) })); } else if (query.view === 'cases') { result.cases = rows>( 'moderation_cases', - 'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision', - 'p.updated_at DESC,p.id' + 'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision', + "CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id" ).map((c) => ({ ...c, evidence: handle diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index a688933..9259434 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -109,3 +109,43 @@ export function migrateModeration(handle: Database.Database): void { cursor = [last.game, last.guid]; } } + +/** The migration runner disables foreign keys before opening this transaction. */ +export function migrateModerationCases(handle: Database.Database): void { + const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[]; + if (!columns.some((column) => column.name === 'priority')) { + if (handle.pragma('foreign_keys', { simple: true })) + throw new Error('The moderation case migration requires foreign keys to be disabled'); + // Do not rename the existing parent: archived evidence must keep its + // original case reference through the rebuild. + handle.exec(` + CREATE TABLE moderation_cases_expanded ( + id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('open','in_review','escalated','resolved','dismissed', + 'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')), + actor_id TEXT NOT NULL, actor TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, + priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')), + assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL + ); + INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision) + SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases; + DROP TABLE moderation_cases; + ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases; + `); + } + if ( + !(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some( + (column) => column.name === 'case_id' + ) + ) + handle.exec( + 'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL' + ); + handle.exec(` + CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at); + CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at); + CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id); + CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id); + `); +} diff --git a/src/lib/ui/components/moderation/ModerationDialog.svelte b/src/lib/ui/components/moderation/ModerationDialog.svelte index 5269ee2..59d5427 100644 --- a/src/lib/ui/components/moderation/ModerationDialog.svelte +++ b/src/lib/ui/components/moderation/ModerationDialog.svelte @@ -1,6 +1,14 @@ + +{item.title} · Moderation · Electric Cherry + +
+ + {#snippet crumbs()}Moderation cases{/snippet} + {#snippet actions()} + + + {#if permitted} + {#if active} + {:else}{/if} + {/if} + {/snippet} + + + {#if !permitted}

+ Read-only moderation access. You can inspect this case, its evidence, and linked + restrictions. +

{/if} + {#if notice}

{notice}

{/if} + {#if refreshError}{/if} + +
+
+ + {item.priority[0].toUpperCase() + item.priority.slice(1)} priority + {active ? 'Active case' : 'Closed case'} + {#if permitted}{/if} +
+ + +
+ +
+
+
+
+

+ Archived evidence ({archivedEvidence.length}) +

+ {#if permitted}{/if} +
+ {#if archivedEvidence.length} +
    + {#each archivedEvidence as evidence (evidence.id)} + {@const archive = archiveContext(evidence.snapshot)} +
  1. +
    + {archive?.player ?? 'Archived message'}{#if archive?.round !== null && archive?.round !== undefined}Round {archive.round}{/if} +
    +
    {evidence.text}
    +

    {evidence.source}

    + {#if archive}
    + {#if archive.matchId}View match #{archive.matchId}{/if} + {#if archive.realTime}Estimated message time · {$timeFormat.dateTime( + archive.realTime + )}{/if} + {#if archive.time}Original log time · {archive.time}{/if} + {#if archive.startedAt}Match started · {$timeFormat.dateTime( + archive.startedAt + )}{/if} +
    {/if} +

    + Preserved by {evidence.actor} · +

    + {#if archive}
    + Preserved archive snapshot +
    {archive.original}
    +
    {/if} +
  2. + {/each} +
+ {:else}{/if} +
+ +
+
+

+ Staff notes and decisions ({notes.length}) +

+ {#if permitted}{/if} +
+ {#if notes.length}
    + {#each notes as evidence (evidence.id)}
  1. +
    + {evidence.source} +
    +

    {evidence.text}

    +

    {evidence.actor}

    +
  2. {/each} +
{:else}

No staff notes or decisions recorded.

{/if} +
+ +
+
+

Case timeline

+ Newest first +
+ {#if report.events.length}
    + {#each report.events as event (event.id)}
  1. +
    + {moderationActionLabels[event.action] ?? + event.action} +
    +

    + {moderationEventDetail(event.detail, event.action)} +

    +

    {event.actor}

    +
  2. {/each} +
{:else}

No case activity recorded yet.

{/if} +
+
+ + +
+
+ +{#if permitted}{#key dialogKey}{/key}{/if} + + diff --git a/tests/client/moderation-case-page.test.ts b/tests/client/moderation-case-page.test.ts new file mode 100644 index 0000000..21c6908 --- /dev/null +++ b/tests/client/moderation-case-page.test.ts @@ -0,0 +1,500 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { flushSync, mount, tick, unmount } from 'svelte'; +import { fromStore, writable } from 'svelte/store'; +import CasePage from '../../src/routes/moderation/cases/[id]/+page.svelte'; +import type { ModerationCaseDetail } from '../../src/lib/moderation.js'; +import type { UserRole } from '../../src/lib/auth.js'; + +const navigation = vi.hoisted(() => ({ before: [] as (() => void)[] })); +const currentPage = writable({ + url: new URL('http://localhost/moderation/cases/case-1'), + data: { user: { id: 'staff-1', role: 'moderator' as UserRole } } +}); +vi.mock('$app/stores', () => ({ + page: { + subscribe: (run: Parameters[0]) => currentPage.subscribe(run) + }, + navigating: writable(null) +})); +vi.mock('$app/navigation', () => ({ + beforeNavigate: (fn: () => void) => navigation.before.push(fn) +})); +vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} })); + +let component: ReturnType | undefined; +beforeEach(() => { + navigation.before.length = 0; +}); +afterEach(async () => { + if (component) await unmount(component); + component = undefined; + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +const guid = '18446744073709551615'; +function detail(): ModerationCaseDetail { + return { + case: { + id: 'case-1', + game: 't5', + guid, + title: 'Repeated chat abuse', + status: 'in_review', + priority: 'high', + assigneeId: 'staff-1', + assignee: 'Moderator One', + actor: 'Reporter', + createdAt: '2026-10-15T12:00:00Z', + updatedAt: '2026-10-16T12:00:00Z', + revision: 4, + evidence: [ + { + id: 'evidence-1', + text: 'abusive message', + source: 'Archived chat · theater · match 17', + actor: 'Reporter', + createdAt: '2026-10-16T12:00:00Z', + snapshot: JSON.stringify({ + matchId: 17, + match: { + game: 't5', + server: 't5_zombie_theater_1', + started_at: '2026-10-15T12:00:00Z' + }, + entry: { + type: 'message', + message: { + guid, + player: 'Tank', + time: '12:34', + message: 'abusive message' + }, + context: { realTime: '2026-10-15T12:12:34Z', round: 9 } + } + }) + }, + { + id: 'note-1', + text: 'Check the earlier warning.', + source: 'Staff note', + actor: 'Moderator One', + createdAt: '2026-10-16T12:00:00Z' + } + ] + }, + player: { + game: 't5', + guid, + name: 'Dempsey', + aliases: ['Tank'], + firstSeen: null, + lastSeen: null, + servers: [], + restrictions: 1 + }, + sanctions: [ + { + id: 'restriction-1', + game: 't5', + guid, + kind: 'mute', + scope: 'game', + server: null, + reason: 'Prior warning ignored', + note: '', + createdAt: '2026-10-15T12:00:00Z', + expiresAt: '2026-10-16T12:00:00Z', + revokedAt: null, + actorId: 'staff-1', + actor: 'Moderator One', + revision: 2, + active: true, + caseId: 'case-1' + } + ], + events: [ + { + id: 1, + game: 't5', + guid, + action: 'case.opened', + subjectId: 'case-1', + actor: 'Reporter', + detail: 'Repeated chat abuse', + createdAt: '2026-10-15T12:00:00Z' + } + ], + coverage: [ + { + server: 't5_zombie_theater_1', + game: 't5', + connectedAt: null, + revision: 1, + desiredRevision: 2, + state: 'pending', + capabilities: ['ban', 'mute'] + } + ], + assignees: [ + { id: 'staff-1', username: 'Moderator One' }, + { id: 'staff-2', username: 'Moderator Two' } + ], + checkedAt: '2026-10-16T12:00:00Z' + }; +} +function show(initial = detail(), role: UserRole = 'moderator') { + const data = writable({ moderationCase: initial }); + const state = fromStore(data); + currentPage.set({ + url: new URL(`http://localhost/moderation/cases/${initial.case.id}`), + data: { user: { id: 'staff-1', role } } + }); + component = mount(CasePage, { + target: document.body, + props: { + get data() { + return state.current; + } + } + }); + flushSync(); + return (next: ModerationCaseDetail) => { + data.set({ moderationCase: next }); + flushSync(); + }; +} +const button = (label: string) => + [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; +function field( + label: string +): T { + const labelNode = [...document.querySelectorAll('dialog label')].find( + (node) => node.textContent?.replace('*', '').trim() === label + )!; + return document.querySelector(`[aria-labelledby="${labelNode.id}"]`)! as T; +} +function enter(node: HTMLInputElement | HTMLTextAreaElement, value: string) { + node.value = value; + node.dispatchEvent(new Event('input', { bubbles: true })); + flushSync(); +} +function select(label: string, value: string) { + const node = field(label); + // happy-dom's :checked selector matches inputs but not selected options. + const originalQuery = node.querySelector.bind(node); + node.querySelector = ((selector: string) => + selector === ':checked' + ? (node.selectedOptions[0] ?? null) + : originalQuery(selector)) as typeof node.querySelector; + node.value = value; + node.dispatchEvent(new Event('change', { bubbles: true })); + flushSync(); +} +function submit() { + document + .querySelector('dialog form')! + .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +} +function response(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { status }); +} + +it('preserves original evidence context, linked restrictions, and staff history for read-only viewers', () => { + show(detail(), 'super_viewer'); + expect(document.body.textContent).toContain('Read-only moderation access'); + expect(document.body.textContent).toContain('Round 9'); + expect(document.body.textContent).toContain('Original log time · 12:34'); + expect(document.body.textContent).toContain('Check the earlier warning.'); + expect(document.body.textContent).toContain('Prior warning ignored'); + expect(document.body.textContent).toContain('Policy pending'); + expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull(); + for (const label of [ + 'Edit case', + 'Review case', + 'Reopen case', + 'Restrict player', + 'Revoke restriction', + 'Add case note', + 'Attach evidence' + ]) + expect(button(label)).toBeUndefined(); + expect(document.querySelector('dialog')).toBeNull(); +}); + +it('locks case restrictions to the recorded identity and revision without closing the case', async () => { + const updated = detail(); + updated.case.revision = 5; + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'new-restriction' })) + .mockResolvedValue(response(updated)); + vi.stubGlobal('fetch', fetch); + show(); + button('Restrict player').click(); + await tick(); + expect(field('Game').disabled).toBe(true); + expect(field('Player GUID').readOnly).toBe(true); + expect(field('Player GUID').value).toBe(guid); + expect(document.querySelector('dialog')!.textContent).toContain('does not close the case'); + enter(field('Player-visible reason'), 'Repeated abuse'); + submit(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'create-sanction', + game: 't5', + guid, + caseId: 'case-1', + caseRevision: 4, + reason: 'Repeated abuse', + durationMinutes: 60 + }); + expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1'); + expect(button('Review case')).toBeDefined(); +}); + +it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => { + const closed = detail(); + closed.case.status = 'warning_issued'; + closed.case.revision = 5; + const reopened = detail(); + reopened.case.status = 'open'; + reopened.case.revision = 6; + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValueOnce(response(closed)) + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValueOnce(response(reopened)); + vi.stubGlobal('fetch', fetch); + show(); + button('Review case').click(); + await tick(); + expect([...field('Outcome').options].map((option) => option.value)).toEqual([ + 'resolved', + 'dismissed', + 'warning_issued', + 'restriction_applied', + 'no_action', + 'duplicate', + 'appeal_upheld', + 'appeal_denied' + ]); + select('Outcome', 'warning_issued'); + enter(field('Review reason'), 'Warning was sufficient'); + submit(); + await vi.waitFor(() => expect(button('Reopen case')).toBeDefined()); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'resolve-case', + id: 'case-1', + revision: 4, + status: 'warning_issued', + reason: 'Warning was sufficient' + }); + expect(button('Restrict player')).toBeUndefined(); + button('Reopen case').click(); + await tick(); + enter(field('Reopening reason'), 'New evidence received'); + submit(); + await vi.waitFor(() => expect(button('Review case')).toBeDefined()); + expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({ + op: 'reopen-case', + id: 'case-1', + revision: 5, + reason: 'New evidence received' + }); + expect(document.body.textContent).toContain('Check the earlier warning.'); +}); + +it('edits priority, assignment, and review workflow with a required transition reason', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValue(response(detail())); + vi.stubGlobal('fetch', fetch); + show(); + button('Edit case').click(); + await tick(); + enter(field('Case title'), 'Urgent report'); + select('Priority', 'urgent'); + select('Assigned to', 'staff-2'); + select('Workflow status', 'escalated'); + expect(field('Change reason').required).toBe(true); + enter(field('Change reason'), 'Needs administrator review'); + submit(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'update-case', + id: 'case-1', + revision: 4, + title: 'Urgent report', + priority: 'urgent', + assigneeId: 'staff-2', + status: 'escalated', + reason: 'Needs administrator review' + }); +}); + +it('preserves an unavailable assignee when editing metadata on a closed case', async () => { + const initial = detail(); + initial.case.status = 'no_action'; + initial.case.assigneeId = 'disabled-staff'; + initial.case.assignee = 'Former staff'; + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValue(response(initial)); + vi.stubGlobal('fetch', fetch); + show(initial); + button('Edit case').click(); + await tick(); + expect(field('Assigned to').value).toBe('disabled-staff'); + expect(document.querySelector('dialog')!.textContent).toContain('Former staff (unavailable)'); + enter(field('Case title'), 'Clarified title'); + submit(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + const payload = JSON.parse(fetch.mock.calls[0][1].body); + expect(payload.title).toBe('Clarified title'); + expect(payload).not.toHaveProperty('assigneeId'); + expect(payload).not.toHaveProperty('status'); +}); + +it('attaches archived evidence by match and exact message with revision checks', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValue(response(detail())); + vi.stubGlobal('fetch', fetch); + show(); + button('Attach evidence').click(); + await tick(); + enter(field('Match ID'), '17'); + enter(field('Exact archived message'), ' abusive message '); + submit(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'attach-evidence', + id: 'case-1', + revision: 4, + evidence: { matchId: 17, message: ' abusive message ' } + }); +}); + +it('retains a conflicted review and lets staff refresh before making another decision', async () => { + const updated = detail(); + updated.case.revision = 8; + updated.case.status = 'escalated'; + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ error: 'This case changed. Reload it before making another change.' }, 409) + ) + .mockResolvedValue(response(updated)); + vi.stubGlobal('fetch', fetch); + show(); + button('Review case').click(); + await tick(); + select('Outcome', 'appeal_denied'); + enter(field('Review reason'), 'Evidence supports the restriction'); + submit(); + await vi.waitFor(() => + expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain( + 'This case changed.' + ) + ); + expect(field('Outcome').value).toBe('appeal_denied'); + expect(document.querySelector('dialog')!.open).toBe(true); + expect(button('Reopen case')).toBeUndefined(); + button('Cancel').click(); + await tick(); + button('Refresh').click(); + await vi.waitFor(() => + expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated') + ); + button('Review case').click(); + await tick(); + enter(field('Review reason'), 'Reviewed new context'); + submit(); + await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3)); + expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8); +}); + +it('retries uncertain case notes with the exact same idempotent request', async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('Connection lost')) + .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) + .mockResolvedValue(response(detail())); + vi.stubGlobal('fetch', fetch); + show(); + button('Add case note').click(); + await tick(); + enter(field('Private staff note'), 'Follow up on appeal'); + submit(); + await vi.waitFor(() => + expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true) + ); + button('Retry same request').click(); + await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'case-note', + id: 'case-1', + revision: 4, + text: 'Follow up on appeal', + idempotencyKey: expect.any(String) + }); +}); + +it('keeps last loaded records when refresh fails and rejects an old response after changing cases', async () => { + let resolve!: (response: Response) => void; + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('Offline')) + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }) + ); + vi.stubGlobal('fetch', fetch); + const update = show(); + button('Refresh').click(); + await vi.waitFor(() => expect(document.body.textContent).toContain('Case updates paused.')); + expect(document.querySelector('h1')!.textContent).toBe('Repeated chat abuse'); + button('Retry').click(); + navigation.before.forEach((fn) => fn()); + const next = detail(); + next.case.id = 'case-2'; + next.case.title = 'Another case'; + update(next); + resolve(response(detail())); + await tick(); + await tick(); + expect(document.querySelector('h1')!.textContent).toBe('Another case'); +}); + +it('enforces role and ownership limits on linked restriction revocation', async () => { + const initial = detail(); + initial.sanctions[0].actorId = 'staff-2'; + const fetch = vi + .fn() + .mockResolvedValueOnce(response({ ok: true, id: 'restriction-1' })) + .mockResolvedValue(response(initial)); + vi.stubGlobal('fetch', fetch); + show(initial); + expect(button('Revoke restriction')).toBeUndefined(); + currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } })); + flushSync(); + button('Revoke restriction').click(); + await tick(); + enter(field('Review reason'), 'Appeal upheld'); + submit(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + op: 'revoke-sanction', + id: 'restriction-1', + revision: 2, + reason: 'Appeal upheld' + }); +}); diff --git a/tests/client/moderation-page.test.ts b/tests/client/moderation-page.test.ts index 4bd94a3..8a86c8b 100644 --- a/tests/client/moderation-page.test.ts +++ b/tests/client/moderation-page.test.ts @@ -303,3 +303,60 @@ it('copies selected archived chat evidence separately from staff notes', async ( evidence: { matchId: 17, message: 'abusive message' } }); }); + +it('opens a newly created case page after preserving selected evidence', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true, id: 'new-case' }))); + vi.stubGlobal('fetch', fetch); + show(); + button('Open case').click(); + await tick(); + const inputs = document.querySelectorAll('dialog input'); + enter(inputs[inputs.length - 1], 'Appeal review'); + submitDialog(); + await vi.waitFor(() => + expect(navigation.goto).toHaveBeenCalledWith('/moderation/cases/new-case', { + invalidateAll: true + }) + ); +}); + +it('links case records to workspaces and preserves case filters while paging', () => { + const data = overview('cases'); + data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' }; + data.cases = [ + { + id: 'case-1', + game: 't5', + guid, + title: 'Chat investigation', + status: 'escalated', + priority: 'high', + assigneeId: 'staff-1', + assignee: 'Moderator', + actor: 'Reporter', + createdAt: '2026-10-15T12:00:00Z', + updatedAt: '2026-10-16T12:00:00Z', + revision: 3, + evidence: [] + } + ]; + data.total = 40; + show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me'); + expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe( + 'Chat investigation' + ); + expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated'); + expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator'); + button('Next').click(); + const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); + expect(destination.searchParams.get('caseStatus')).toBe('active'); + expect(destination.searchParams.get('priority')).toBe('high'); + expect(destination.searchParams.get('assignedTo')).toBe('me'); + expect(destination.searchParams.get('offset')).toBe('25'); + button('Reset').click(); + const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); + for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset']) + expect(reset.searchParams.has(filter)).toBe(false); +}); diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index b487be0..62d3c1f 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -21,7 +21,9 @@ describe('management route permissions', () => { it.each([ '/moderation', '/moderation/players/[guid]', + '/moderation/cases/[id]', '/api/moderation', + '/api/moderation/cases/[id]', '/api/moderation/players/[guid]', '/api/moderation/sanctions/[id]/revoke' ])('limits private moderation access at %s without changing fleet privileges', (route) => { diff --git a/tests/unit/auth-guard.test.ts b/tests/unit/auth-guard.test.ts index 82c34a8..99b7ce8 100644 --- a/tests/unit/auth-guard.test.ts +++ b/tests/unit/auth-guard.test.ts @@ -290,21 +290,38 @@ describe('browser authentication guard', () => { } } ); - it('protects moderation pages and Svelte page-data requests with the same role checks', async () => { - for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) { - signIn(role); - for (const path of ['/moderation', '/moderation/__data.json']) { - const call = requestFor('/moderation', { path }); - if (role === 'viewer' || role === 'operator') { - await expect(call.invoke()).rejects.toMatchObject({ status: 403 }); - expect(call.resolve).not.toHaveBeenCalled(); - } else { - expect((await call.invoke()).status).toBe(200); - expect(call.resolve).toHaveBeenCalledOnce(); + it.each([ + ['/moderation', '/moderation'], + ['/moderation/cases/[id]', '/moderation/cases/case-123'] + ])( + 'protects %s pages and Svelte page-data requests with the same role checks', + async (route, path) => { + for (const role of [ + 'viewer', + 'super_viewer', + 'operator', + 'moderator', + 'admin' + ] as const) { + const session = signIn(role); + for (const requestPath of [path, `${path}/__data.json`]) { + for (const method of ['GET', 'HEAD']) { + const call = requestFor(route, { path: requestPath, method }); + if (role === 'viewer' || role === 'operator') { + await expect(call.invoke()).rejects.toMatchObject({ status: 403 }); + expect(call.resolve).not.toHaveBeenCalled(); + } else { + const response = await call.invoke(); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(call.resolve).toHaveBeenCalledOnce(); + expect(call.event.locals.user).toEqual(session.user); + } + } } } } - }); + ); it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])( 'permits only administrators to write settings and Super Viewers to read them', async (method) => { diff --git a/tests/unit/moderation-case-overview.test.ts b/tests/unit/moderation-case-overview.test.ts new file mode 100644 index 0000000..46f066d --- /dev/null +++ b/tests/unit/moderation-case-overview.test.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture(); + const { db } = await import('../../src/lib/server/db/index.js'); + for (const id of ['first', 'second']) + db() + .prepare( + `INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) + VALUES(?,?,?,'unused','moderator',0,0)` + ) + .run(id, id, id); +}); +afterEach(async () => fixture.cleanup()); + +it('filters case queues by workflow, priority and ownership while prioritizing active work', async () => { + const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js'); + const { moderationOverview, moderationQuery } = + await import('../../src/lib/server/moderation/overview.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const ids: string[] = []; + for (const [title, priority, status, owner] of [ + ['Normal report', 'normal', 'open', null], + ['Urgent review', 'urgent', 'in_review', 'first'], + ['High escalation', 'high', 'escalated', 'second'], + ['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first'] + ]) { + const { id } = mutateModeration('first', { + op: 'create-case', + game: 't5', + guid: '123', + title, + idempotencyKey: randomUUID() + }); + ids.push(id); + db() + .prepare('UPDATE moderation_cases SET priority=?,status=?,assignee_id=? WHERE id=?') + .run(priority, status, owner, id); + } + const read = (extra = '') => + moderationOverview('first', moderationQuery(new URLSearchParams(`view=cases&${extra}`))); + expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]); + expect(read('caseStatus=active').total).toBe(3); + expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]); + expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([ + ids[2] + ]); + expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]); + expect(read('assignedTo=unassigned').cases.map((item) => item.id)).toEqual([ids[0]]); + expect(read('assignedTo=second').cases[0].assignee).toBe('second'); + expect( + read('caseStatus=active&assignedTo=me&priority=urgent&game=t5&guid=123&q=review&offset=100') + .query.offset + ).toBe(0); + expect(read('caseStatus=active&assignedTo=me&priority=urgent&game=t6').total).toBe(0); + expect(read('caseStatus=invalid&priority=invalid').total).toBe(4); + expect( + moderationOverview( + 'first', + moderationQuery( + new URLSearchParams('caseStatus=closed&assignedTo=unassigned&priority=urgent') + ) + ).total + ).toBe(1); +}); diff --git a/tests/unit/moderation-case-routes.test.ts b/tests/unit/moderation-case-routes.test.ts new file mode 100644 index 0000000..2eded7c --- /dev/null +++ b/tests/unit/moderation-case-routes.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { AuthUser } from '../../src/lib/auth.js'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([]); + const { db } = await import('../../src/lib/server/db/index.js'); + for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) { + db() + .prepare( + `INSERT INTO auth_users + (id,username,display_name,password_hash,role,created_at,updated_at) + VALUES(?,?,?,'unused',?,0,0)` + ) + .run(role, role, role, role); + } + db() + .prepare( + `INSERT INTO moderation_cases + (id,game,guid,title,status,actor_id,actor,created_at,updated_at) + VALUES('case-123','t5','123','Private investigation','open','moderator','moderator',?,?)` + ) + .run('2026-10-17T12:00:00.000Z', '2026-10-17T12:00:00.000Z'); +}); + +afterEach(async () => fixture.cleanup()); + +async function api(user: AuthUser | null, id = 'case-123') { + const { GET } = await import('../../src/routes/api/moderation/cases/[id]/+server.js'); + return GET({ locals: { user }, params: { id } } as Parameters[0]); +} + +async function page(user: AuthUser | null, id = 'case-123') { + const { load } = await import('../../src/routes/moderation/cases/[id]/+page.server.js'); + const setHeaders = vi.fn(); + const result = await load({ + locals: { user }, + params: { id }, + setHeaders + } as unknown as Parameters[0]); + return { result, setHeaders }; +} + +it.each(['admin', 'moderator', 'super_viewer'])( + 'serves private case detail without caching to %s', + async (role) => { + const { getUser } = await import('../../src/lib/server/auth/index.js'); + const user = getUser(role)!; + const response = await api(user); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toMatchObject({ + case: { id: 'case-123', title: 'Private investigation' } + }); + const { result, setHeaders } = await page(user); + expect(result).toMatchObject({ + moderationCase: { case: { id: 'case-123', title: 'Private investigation' } } + }); + expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); + } +); + +it.each(['operator', 'viewer'])( + 'rejects %s reads before revealing case existence or content', + async (role) => { + const { getUser } = await import('../../src/lib/server/auth/index.js'); + const user = getUser(role)!; + for (const id of ['case-123', 'missing']) { + const response = await api(user, id); + expect(response.status).toBe(403); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ error: 'Moderation access is required.' }); + await expect(page(user, id)).rejects.toMatchObject({ + status: 403, + body: { message: 'Moderation access is required.' } + }); + } + } +); + +it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])( + 'rechecks current staff access when stale session locals have %s', + async (change) => { + const { getUser } = await import('../../src/lib/server/auth/index.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const user = getUser('moderator')!; + db().prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run(); + const response = await api(user); + expect(response.status).toBe(403); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(page(user)).rejects.toMatchObject({ status: 403 }); + } +); + +it('preserves private not-found responses in the API and page loader', async () => { + const { getUser } = await import('../../src/lib/server/auth/index.js'); + const user = getUser('moderator')!; + const response = await api(user, 'missing'); + expect(response.status).toBe(404); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ error: 'Case not found.' }); + await expect(page(user, 'missing')).rejects.toMatchObject({ + status: 404, + body: { message: 'Case not found.' } + }); +}); + +it('requires a signed-in user before looking up a case', async () => { + const response = await api(null); + expect(response.status).toBe(401); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ error: 'Sign in to continue.' }); + await expect(page(null)).rejects.toMatchObject({ + status: 401, + body: { message: 'Sign in to continue.' } + }); +}); diff --git a/tests/unit/moderation-cases.test.ts b/tests/unit/moderation-cases.test.ts new file mode 100644 index 0000000..7b56b87 --- /dev/null +++ b/tests/unit/moderation-cases.test.ts @@ -0,0 +1,455 @@ +import { randomUUID } from 'node:crypto'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { moderationCaseOutcomes } from '../../src/lib/moderation.js'; + +let fixture: Fixture; +let handle: Database.Database; +const clock = '2026-10-17T12:00:00.000Z'; +const server = 't5_zombie_theater_1'; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(clock)); + fixture = await newFixture([server]); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + const { db } = await import('../../src/lib/server/db/index.js'); + handle = db(); + for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_viewer']) + handle + .prepare( + `INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) + VALUES(?,?,?,'unused',?,0,0)` + ) + .run(role, role, role, role); +}); +afterEach(async () => { + vi.useRealTimers(); + await fixture.cleanup(); +}); + +async function mutate(input: Record, actor = 'moderator') { + const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js'); + return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input }); +} +async function detail(id: string, actor = 'moderator') { + const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js'); + return moderationCaseDetail(actor, id); +} +const create = (overrides: Record = {}) => + mutate({ + op: 'create-case', + game: 't5', + guid: '123', + title: 'Review chat conduct', + ...overrides + }); +const restriction = ( + caseId: string, + caseRevision: number, + overrides: Record = {} +) => ({ + op: 'create-sanction', + game: 't5', + guid: '123', + kind: 'mute', + scope: 'game', + reason: 'Repeated chat abuse', + durationMinutes: 60, + caseId, + caseRevision, + ...overrides +}); + +it('upgrades legacy cases without changing evidence, events, requests, or restriction records', async () => { + const { migrations } = await import('../../src/lib/server/db/migrations.js'); + const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js'); + const { migrateModerationCases } = await import('../../src/lib/server/moderation/schema.js'); + const legacy = new Database(':memory:'); + try { + legacy.pragma('foreign_keys = ON'); + runMigrations(legacy, migrations.slice(0, 28)); + for (const status of ['open', 'resolved', 'dismissed']) { + legacy + .prepare('INSERT INTO moderation_cases VALUES(?,?,?,?,?,?,?,?,?,?)') + .run( + status, + 't5', + '123', + `Legacy ${status}`, + status, + 'retired-user', + 'Former staff', + clock, + clock, + 7 + ); + legacy + .prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)') + .run( + `evidence-${status}`, + status, + 'Original evidence', + 'Archived chat', + '{"original":true}', + 'retired-user', + 'Former staff', + clock + ); + legacy + .prepare( + `INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at) + VALUES('t5','123','Former staff','case.opened',?,'Original history',?)` + ) + .run(status, clock); + } + legacy + .prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)') + .run( + 'retired-user', + 'original-request', + 'fingerprint', + '{"ok":true,"id":"open"}', + clock + ); + legacy + .prepare( + `INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at) + VALUES('old-sanction','t5','123','ban','game','Original reason','','retired-user','Former staff',?)` + ) + .run(clock); + runMigrations(legacy, migrations); + runMigrations(legacy, migrations); + migrateModerationCases(legacy); + expect(legacy.pragma('foreign_keys', { simple: true })).toBe(1); + expect(legacy.pragma('foreign_key_check')).toEqual([]); + expect( + legacy + .prepare( + 'SELECT status,revision,priority,assignee_id FROM moderation_cases ORDER BY id' + ) + .all() + ).toEqual( + ['dismissed', 'open', 'resolved'].map((status) => ({ + status, + revision: 7, + priority: 'normal', + assignee_id: null + })) + ); + expect( + legacy + .prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE snapshot=?') + .get('{"original":true}') + ).toEqual({ count: 3 }); + expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({ + count: 3 + }); + expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({ + result: '{"ok":true,"id":"open"}' + }); + expect(legacy.prepare('SELECT id,case_id FROM moderation_sanctions').get()).toEqual({ + id: 'old-sanction', + case_id: null + }); + legacy.prepare("UPDATE moderation_cases SET status='in_review' WHERE id='open'").run(); + expect(() => + legacy.prepare("UPDATE moderation_sanctions SET case_id='missing'").run() + ).toThrow('FOREIGN KEY'); + expect(() => legacy.prepare("DELETE FROM moderation_cases WHERE id='open'").run()).toThrow( + 'FOREIGN KEY' + ); + } finally { + legacy.close(); + } +}); + +it('requires private read access and returns a complete case outside list pagination', async () => { + const { id } = await create({ note: 'Initial context' }); + for (const actor of ['viewer', 'operator']) + await expect(detail(id, actor)).rejects.toThrow('Moderation access'); + await expect(detail('missing')).rejects.toMatchObject({ status: 404 }); + for (let i = 0; i < 30; i++) await mutate({ op: 'case-note', id, text: `Follow-up ${i}` }); + const data = await detail(id, 'super_viewer'); + expect(data.case).toMatchObject({ + id, + priority: 'normal', + assigneeId: null, + assignee: null, + revision: 31 + }); + expect(data.case.evidence).toHaveLength(31); + expect(data.events).toHaveLength(31); + expect(data.player).toMatchObject({ game: 't5', guid: '123' }); + expect(data.assignees).toEqual([ + { id: 'admin', username: 'admin' }, + { id: 'moderator', username: 'moderator' } + ]); + await expect( + mutate({ op: 'case-note', id, text: 'Forbidden write' }, 'super_viewer') + ).rejects.toThrow('Moderation access'); + handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run(); + await expect(detail(id)).rejects.toThrow('Moderation access'); +}); + +it('audits title, ownership, priority and active state updates with optimistic revisions', async () => { + const { id } = await create(); + await expect( + mutate({ op: 'update-case', id, revision: 1, status: 'in_review' }) + ).rejects.toThrow('Status change reason'); + await mutate({ + op: 'update-case', + id, + revision: 1, + title: 'Escalated chat report', + priority: 'urgent', + assigneeId: 'admin', + status: 'in_review', + reason: 'Reviewing archive' + }); + const data = await detail(id); + expect(data.case).toMatchObject({ + title: 'Escalated chat report', + priority: 'urgent', + assigneeId: 'admin', + assignee: 'admin', + status: 'in_review', + revision: 2 + }); + expect(JSON.parse(data.events[0].detail)).toMatchObject({ + title: { from: 'Review chat conduct', to: 'Escalated chat report' }, + assignee: { from: null, to: 'admin' }, + status: { from: 'open', to: 'in_review' }, + reason: 'Reviewing archive' + }); + await expect(mutate({ op: 'update-case', id, revision: 1, priority: 'low' })).rejects.toThrow( + 'changed' + ); + for (const assigneeId of ['viewer', 'super_viewer', 'operator', 'missing']) + await expect(mutate({ op: 'update-case', id, revision: 2, assigneeId })).rejects.toThrow( + 'active administrator or moderator' + ); + handle.prepare("UPDATE auth_users SET must_change_password=1 WHERE id='admin'").run(); + await mutate({ + op: 'update-case', + id, + revision: 2, + assigneeId: 'admin', + title: 'Existing ownership retained' + }); + await mutate({ + op: 'update-case', + id, + revision: 3, + assigneeId: null, + status: 'escalated', + reason: 'Needs administrator review' + }); + expect((await detail(id)).case).toMatchObject({ + revision: 4, + status: 'escalated', + assigneeId: null + }); + await expect( + mutate({ op: 'update-case', id, revision: 4, assigneeId: 'admin' }) + ).rejects.toThrow('active administrator or moderator'); +}); + +it('supports every outcome and reopening while preserving each prior decision', async () => { + const { id } = await create(); + let revision = 1; + for (const status of moderationCaseOutcomes) { + await expect(mutate({ op: 'resolve-case', id, revision, status })).rejects.toThrow( + 'Decision reason' + ); + await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` }); + revision++; + expect((await detail(id)).case.status).toBe(status); + await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow( + 'Use reopen' + ); + await expect( + mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' }) + ).rejects.toThrow('changed'); + await expect(mutate({ op: 'reopen-case', id, revision })).rejects.toThrow( + 'Reopening reason' + ); + await mutate({ op: 'reopen-case', id, revision, reason: `New evidence after ${status}` }); + revision++; + } + const data = await detail(id); + expect(data.case).toMatchObject({ revision, status: 'open' }); + for (const status of moderationCaseOutcomes) { + expect(data.case.evidence.some((item) => item.text === `Decision ${status}`)).toBe(true); + expect(data.events.some((event) => event.action === `case.${status}`)).toBe(true); + } + await expect( + mutate({ op: 'reopen-case', id, revision, reason: 'Already open' }) + ).rejects.toThrow('already active'); + await expect( + mutate({ op: 'resolve-case', id, revision, status: 'escalated', reason: 'Wrong operation' }) + ).rejects.toThrow('case outcome'); +}); + +it('links restrictions atomically with case revisions and idempotent retries', async () => { + const { id } = await create(); + const input = { ...restriction(id, 1), idempotencyKey: randomUUID() }; + for (const override of [ + { game: 't6' }, + { guid: '456' }, + { durationMinutes: null }, + { caseRevision: 0 } + ]) + await expect(mutate({ ...input, ...override })).rejects.toThrow(); + expect((await detail(id)).case.revision).toBe(1); + expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({ + count: 0 + }); + const applied = await mutate(input); + expect(await mutate(input)).toEqual(applied); + let data = await detail(id); + expect(data.case.revision).toBe(2); + expect(data.sanctions).toMatchObject([{ id: applied.id, caseId: id, active: true }]); + expect(data.events.filter((event) => event.action === 'case.restriction')).toHaveLength(1); + await expect(mutate(restriction(id, 1))).rejects.toThrow('changed'); + await mutate({ op: 'revoke-sanction', id: applied.id, revision: 1, reason: 'Review revised' }); + data = await detail(id); + expect(data.case.revision).toBe(3); + expect(data.sanctions[0].active).toBe(false); + expect(data.events[0]).toMatchObject({ + action: 'restriction.revoked', + subjectId: applied.id, + detail: 'Review revised' + }); + await mutate({ + op: 'resolve-case', + id, + revision: 3, + status: 'restriction_applied', + reason: 'Finished review' + }); + await expect(mutate(restriction(id, 4))).rejects.toThrow('Reopen this case'); +}); + +it('rolls back restriction, policy and case writes when a linked audit write fails', async () => { + const { id } = await create(); + const policy = handle.prepare('SELECT revision FROM moderation_policy').get(); + handle.exec(`CREATE TRIGGER fail_case_audit BEFORE INSERT ON moderation_events + WHEN NEW.action='case.restriction' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`); + const input = { ...restriction(id, 1), idempotencyKey: randomUUID() }; + await expect(mutate(input)).rejects.toThrow('audit unavailable'); + expect((await detail(id)).case.revision).toBe(1); + expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual(policy); + expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({ + count: 0 + }); + expect( + handle + .prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?') + .get(input.idempotencyKey) + ).toEqual({ count: 0 }); + handle.exec('DROP TRIGGER fail_case_audit'); + await mutate(input); + expect((await detail(id)).sanctions).toHaveLength(1); +}); + +it('attaches only archived evidence for the exact player and retains snapshots after cleanup', async () => { + const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + saveTelemetry({ + server, + map: 'zombie_theater', + event: 'game_start', + players: [{ guid: '123', name: 'Player' }], + receivedAt: clock + }); + const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as { + id: number; + }; + for (const [guid, message] of [ + ['123', 'Verified message'], + ['123', ' Evidence with padding '], + ['456', 'Another player'] + ]) + handle + .prepare('INSERT INTO match_chat VALUES(?,?,?,?)') + .run( + matchId, + message, + 0, + JSON.stringify({ type: 'message', message: { guid, message, player: 'Player' } }) + ); + const { id } = await create(); + for (const message of ['Forged text', 'Another player', 'Evidence with padding']) + await expect( + mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, message } }) + ).rejects.toThrow('could not be verified'); + const { id: otherGame } = await create({ game: 't6' }); + await expect( + mutate({ + op: 'attach-evidence', + id: otherGame, + revision: 1, + evidence: { matchId, message: 'Verified message' } + }) + ).rejects.toThrow('source match is unavailable'); + const input = { + op: 'attach-evidence', + id, + revision: 1, + evidence: { matchId, message: 'Verified message' }, + idempotencyKey: randomUUID() + }; + await mutate(input); + await mutate(input); + await expect(mutate({ ...input, idempotencyKey: randomUUID() })).rejects.toThrow('changed'); + await mutate({ + op: 'attach-evidence', + id, + revision: 2, + evidence: { matchId, message: ' Evidence with padding ' } + }); + handle.prepare('DELETE FROM leaderboard_matches').run(); + const data = await detail(id); + expect(data.case.revision).toBe(3); + expect(data.case.evidence).toMatchObject([ + { text: 'Verified message', snapshot: expect.stringContaining('Verified message') }, + { + text: ' Evidence with padding ', + snapshot: expect.stringContaining(' Evidence with padding ') + } + ]); + expect(data.events[0].action).toBe('case.evidence_attached'); +}); + +it('keeps decision history writable when a case has reached its evidence limit', async () => { + const { id } = await create(); + for (let index = 0; index < 100; index++) + handle + .prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)') + .run( + randomUUID(), + id, + `Evidence ${index}`, + 'Staff note', + null, + 'moderator', + 'moderator', + clock + ); + await expect( + mutate({ op: 'case-note', id, revision: 1, text: 'Over the limit' }) + ).rejects.toThrow('100 evidence entries'); + await mutate({ + op: 'resolve-case', + id, + revision: 1, + status: 'no_action', + reason: 'Review complete' + }); + await mutate({ op: 'reopen-case', id, revision: 2, reason: 'Reconsider the decision' }); + expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 }); + expect((await detail(id)).case.evidence).toHaveLength(102); +}); diff --git a/tests/unit/ui-moderation.test.ts b/tests/unit/ui-moderation.test.ts index 6e1de92..c1252ef 100644 --- a/tests/unit/ui-moderation.test.ts +++ b/tests/unit/ui-moderation.test.ts @@ -3,7 +3,7 @@ import { render } from 'svelte/server'; import { writable } from 'svelte/store'; import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte'; -import { moderationHref } from '../../src/lib/ui/moderation.js'; +import { moderationEventDetail, moderationHref } from '../../src/lib/ui/moderation.js'; import type { UserRole } from '../../src/lib/auth.js'; const currentPage = writable({ @@ -72,3 +72,41 @@ it('does not invent a game namespace for an unscoped profile', () => { expect(target.searchParams.get('guid')).toBe('42'); expect(target.searchParams.has('game')).toBe(false); }); + +it('presents case decisions and ownership changes as readable history while preserving staff text', () => { + expect( + moderationEventDetail( + JSON.stringify({ + title: { from: 'open', to: 'constructor' }, + assignee: { from: 'open', to: 'constructor' } + }), + 'case.updated' + ) + ).toBe('Title: open → constructor\nAssigned to: open → constructor'); + expect( + moderationEventDetail( + JSON.stringify({ + title: { from: 'Report', to: 'Appeal review' }, + assignee: { from: null, to: 'Moderator' }, + assigneeId: { from: null, to: 'private-internal-user-id' }, + status: { from: 'open', to: 'in_review' }, + reason: 'Reviewing additional evidence' + }), + 'case.updated' + ) + ).toBe( + 'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence' + ); + expect( + moderationEventDetail( + JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }), + 'case.reopened' + ) + ).toBe('Previous outcome: Appeal denied\nNew evidence'); + expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe( + 'Decision with {unusual text}' + ); + expect( + moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached') + ).toBe('2 archived chat entries attached from match #42.'); +});