import { randomUUID } from 'node:crypto'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { moderationConcernCategories } from '../../src/lib/moderation.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; let handle: Database.Database; const clock = '2026-10-17T12:00:00.000Z'; beforeEach(async () => { vi.resetModules(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(clock)); fixture = await newFixture([]); const { db } = await import('../../src/lib/server/db/index.js'); handle = db(); for (const role of ['admin', 'moderator', 'super_viewer', '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 = () => mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct' }); const draft = { summary: 'The documented slur is substantiated; the threatening intent remains unclear.', rules: ['Rule 1: No discriminatory abuse', 'Rule 2: No threats'], findings: [ { category: 'hate_speech', finding: 'substantiated', detail: 'The preserved message contains a slur.' }, { category: 'threats', finding: 'inconclusive', detail: 'The context does not establish a threat.' } ] }; const assess = (id: string, revision: number, overrides: Record = {}) => ({ op: 'assess-case', id, revision, ...draft, ...overrides }); it('preserves current and historical assessments with case revisions, including closed cases', async () => { const { id } = await create(); expect(await detail(id)).toMatchObject({ assessment: null, assessmentHistory: [] }); const request = { ...assess(id, 1), idempotencyKey: randomUUID() }; await mutate(request); await mutate(request); const first = (await detail(id)).assessment!; expect(first).toEqual({ ...draft, revision: 2, actor: 'moderator', createdAt: clock }); await mutate({ op: 'resolve-case', id, revision: 2, status: 'no_action', reason: 'Initial review complete' }); vi.setSystemTime(new Date(Date.parse(clock) + 60_000)); const updated = { summary: 'Further context does not support a threat.', rules: [], findings: [ { category: 'threats', finding: 'unsubstantiated', detail: 'The reply was about the game.' } ] }; await mutate(assess(id, 3, updated), 'admin'); const data = await detail(id, 'super_viewer'); expect(data.case).toMatchObject({ revision: 4, status: 'closed' }); expect(data.assessment).toEqual({ ...updated, revision: 4, actor: 'admin', createdAt: '2026-10-17T12:01:00.000Z' }); expect(data.assessmentHistory).toEqual([data.assessment, first]); expect(JSON.parse(data.events[0].detail)).toEqual({ assessmentRevision: 4, summary: updated.summary }); const { currentCaseAssessment } = await import('../../src/lib/server/moderation/assessments.js'); expect(currentCaseAssessment(id)).toEqual(data.assessment); expect(currentCaseAssessment('missing')).toBeNull(); expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual({ revision: 1 }); }); it('checks staff access, case revision and normalized no-change submissions before writing history', async () => { const { id } = await create(); for (const actor of ['viewer', 'super_viewer']) await expect(mutate(assess(id, 1), actor)).rejects.toMatchObject({ status: 403 }); await mutate(assess(id, 1)); const before = await detail(id); await expect(mutate(assess(id, 1, { summary: 'Stale edit' }))).rejects.toMatchObject({ status: 409 }); await expect(mutate(assess(id, 2, { summary: ` ${draft.summary} ` }))).rejects.toThrow( 'Change the assessment' ); expect(await detail(id)).toEqual(before); await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 }); handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run(); await expect(mutate(assess(id, 2, { summary: 'Disabled account edit' }))).rejects.toMatchObject( { status: 403 } ); }); it('rejects invalid summaries, rules and findings without adding assessment or audit rows', async () => { const { id } = await create(); const invalid = [ { summary: '' }, { summary: 'a'.repeat(2001) }, { summary: 'Invalid\u0000summary' }, { rules: null }, { rules: [''] }, { rules: ['a'.repeat(161)] }, { rules: Array(21).fill('Rule') }, { findings: [] }, { findings: null }, { findings: [null] }, { findings: [{ category: 'unknown', finding: 'substantiated', detail: 'Evidence' }] }, { findings: [{ category: 'spam', finding: 'unknown', detail: 'Evidence' }] }, { findings: [{ category: 'spam', finding: 'substantiated', detail: '' }] }, { findings: [{ category: 'spam', finding: 'substantiated', detail: 'a'.repeat(1001) }] }, { findings: [draft.findings[0], draft.findings[0]] }, { findings: Array(8).fill(draft.findings[0]) } ]; const before = await detail(id); for (const input of invalid) await expect(mutate(assess(id, 1, input))).rejects.toMatchObject({ status: 400 }); expect(await detail(id)).toEqual(before); expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]); }); it('rolls back history, revisions and request receipts when assessment auditing fails', async () => { const { id } = await create(); const before = await detail(id); handle.exec(`CREATE TRIGGER fail_assessment_audit BEFORE INSERT ON moderation_events WHEN NEW.action='case.assessed' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`); const request = { ...assess(id, 1), idempotencyKey: randomUUID() }; await expect(mutate(request)).rejects.toThrow('audit unavailable'); expect(await detail(id)).toEqual(before); expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]); expect( handle .prepare('SELECT * FROM moderation_requests WHERE request_key=?') .all(request.idempotencyKey) ).toEqual([]); handle.exec('DROP TRIGGER fail_assessment_audit'); await mutate(request); expect((await detail(id)).assessmentHistory).toHaveLength(1); }); it('keeps assessment revisions after source match, server and author cleanup', async () => { handle .prepare( "INSERT INTO servers(name,game,map) VALUES('t5_zombie_theater_1','t5','zombie_theater')" ) .run(); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); saveTelemetry({ server: 't5_zombie_theater_1', map: 'zombie_theater', event: 'game_start', players: [{ guid: '123', name: 'Player' }], receivedAt: clock }); const { id } = await create(); await mutate(assess(id, 1)); const before = (await detail(id)).assessmentHistory; handle.prepare('DELETE FROM leaderboard_matches').run(); handle.prepare('DELETE FROM servers').run(); handle.prepare("DELETE FROM auth_users WHERE id='moderator'").run(); expect((await detail(id, 'admin')).assessmentHistory).toEqual(before); }); it('accepts a valid detailed multibyte assessment over 16 KiB while retaining the 64 KiB request boundary', async () => { const { id } = await create(); const { POST } = await import('../../src/routes/api/moderation/+server.js'); const { getUser } = await import('../../src/lib/server/auth/index.js'); const input = { ...assess(id, 1, { summary: '界'.repeat(2000), rules: Array.from({ length: 20 }, (_, index) => `${index} ${'界'.repeat(155)}`), findings: moderationConcernCategories.map((category) => ({ category, finding: 'inconclusive', detail: '界'.repeat(1000) })) }), idempotencyKey: randomUUID() }; const body = JSON.stringify(input); expect(Buffer.byteLength(body)).toBeGreaterThan(16 * 1024); const post = (body: string) => POST({ locals: { user: getUser('moderator') }, request: new Request('http://localhost/api/moderation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }) } as Parameters[0]); const response = await post(body); expect(response.status).toBe(200); expect((await detail(id)).assessment?.findings).toHaveLength(7); const oversized = await post( JSON.stringify({ ...input, revision: 2, padding: 'a'.repeat(64 * 1024) }) ); expect(oversized.status).toBe(413); expect((await detail(id)).assessmentHistory).toHaveLength(1); }); it('adds assessment storage to an existing database without altering case records', async () => { const { migrations } = await import('../../src/lib/server/db/migrations.js'); const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js'); const legacy = new Database(':memory:'); legacy.pragma('foreign_keys = ON'); try { runMigrations(legacy, migrations.slice(0, 31)); legacy .prepare( `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision) VALUES('case','t5','123','Existing case','no_action','former','Former staff',?,?,41)` ) .run(clock, clock); const before = legacy.prepare('SELECT * FROM moderation_cases').all(); runMigrations(legacy, migrations.slice(0, 32)); runMigrations(legacy, migrations.slice(0, 32)); expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before); const insert = legacy.prepare( 'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)' ); insert.run('case', 42, JSON.stringify(draft), 'former', 'Former staff', clock); expect(() => insert.run('case', 42, '{}', 'former', 'Former staff', clock)).toThrow( 'UNIQUE' ); expect(() => insert.run('missing', 2, '{}', 'former', 'Former staff', clock)).toThrow( 'FOREIGN KEY' ); expect(legacy.pragma('foreign_key_check')).toEqual([]); } finally { legacy.close(); } });