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); }); it('preserves all participants and selected authors, distinguishes repeated messages, and deduplicates attachments', async () => { const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.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; }; const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)'); for (let i = 0; i < 22; i++) { const subject = i > 0 && i < 20; insert.run( matchId, subject ? 'repeat' : `context-${i}`, subject ? i : 1, JSON.stringify({ type: 'message', context: { realTime: null, round: null, matchElapsedMs: null }, message: { time: '1:01', guid: subject ? '123' : '456', player: subject ? 'Subject' : 'Witness', slot: subject ? 1 : 2, message: subject ? 'Repeated message' : `Context ${i}` } }) ); } const entries = matchChatTimeline(matchId); expect(new Set(entries.map((entry) => entry.messageId)).size).toBe(22); const { id } = await create(); await mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, messageIds: entries.slice(1, 20).map((entry) => entry.messageId) } }); let data = await detail(id); expect(data.case.evidence).toHaveLength(1); const snapshot = JSON.parse(data.case.evidence[0].snapshot!); expect(snapshot.entries).toHaveLength(22); expect(snapshot.selectedMessageIds).toHaveLength(19); expect(snapshot.highlightedMessageIds).toHaveLength(19); expect(snapshot.entries[0].message.player).toBe('Witness'); await mutate({ op: 'attach-evidence', id, revision: 2, evidence: { matchId, message: 'Repeated message' } }); data = await detail(id); expect(data.case.revision).toBe(2); expect(data.case.evidence).toHaveLength(1); await expect( mutate({ op: 'attach-evidence', id, revision: 2, evidence: { matchId, messageIds: ['forged'] } }) ).rejects.toThrow('identifiers'); const legacy = await create(); await mutate({ op: 'attach-evidence', id: legacy.id, revision: 1, evidence: { matchId, message: 'Repeated message' } }); expect((await detail(legacy.id)).case.evidence).toHaveLength(19); handle.prepare('DELETE FROM leaderboard_matches').run(); expect(JSON.parse((await detail(id)).case.evidence[0].snapshot!).entries).toHaveLength(22); });