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'; 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 = (overrides: Record = {}) => mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review incident', ...overrides }); const link = (id: string, revision: number, relatedCaseId: string, relation = 'same_incident') => ({ op: 'link-case', id, revision, relatedCaseId, relation, reason: 'Evidence connects these reviews.' }); const unlink = (id: string, revision: number, relationshipId: string) => ({ op: 'unlink-case', id, revision, relationshipId, reason: 'The investigations concern different incidents.' }); async function linkCurrent(id: string, related: string, relation: string) { return mutate(link(id, (await detail(id)).case.revision, related, relation)); } it('records symmetric incident links once, preserving both players and advancing both case revisions', async () => { const a = await create(); const b = await create({ game: 't6', guid: '456', title: 'Connected player' }); const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() }; await mutate(request); await mutate(request); const first = await detail(a.id); const second = await detail(b.id, 'super_viewer'); expect(first.case).toMatchObject({ revision: 2, game: 't5', guid: '123', status: 'open' }); expect(second.case).toMatchObject({ revision: 2, game: 't6', guid: '456', status: 'open' }); expect(first.relatedCases).toMatchObject([ { relation: 'same_incident', case: { id: b.id, title: 'Connected player' } } ]); expect(second.relatedCases).toMatchObject([ { id: first.relatedCases![0].id, case: { id: a.id } } ]); expect(new Set([first.relatedCases![0].direction, second.relatedCases![0].direction])).toEqual( new Set(['incoming', 'outgoing']) ); await mutate(link(b.id, 2, a.id)); expect(await detail(a.id)).toEqual(first); expect(await detail(b.id)).toEqual(second); expect( handle.prepare('SELECT case_id,target_case_id FROM moderation_case_relationships').all() ).toEqual([{ case_id: [a.id, b.id].sort()[0], target_case_id: [a.id, b.id].sort()[1] }]); expect(JSON.parse(first.events[0].detail)).toMatchObject({ relation: 'same_incident', relatedCaseId: b.id, reason: request.reason }); await expect( mutate({ op: 'case-note', id: b.id, revision: 1, text: 'Stale note' }) ).rejects.toMatchObject({ status: 409 }); }); it('retains directed appeal and duplicate relationships while rejecting cycles within each relation', async () => { const a = await create(); const b = await create(); const c = await create(); await linkCurrent(a.id, b.id, 'duplicate_of'); await linkCurrent(b.id, c.id, 'duplicate_of'); await expect(linkCurrent(c.id, a.id, 'duplicate_of')).rejects.toThrow('cycle'); await expect(linkCurrent(b.id, a.id, 'duplicate_of')).rejects.toThrow('cycle'); await linkCurrent(b.id, a.id, 'appeal_of'); await expect(linkCurrent(a.id, b.id, 'appeal_of')).rejects.toThrow('cycle'); expect((await detail(a.id)).relatedCases).toEqual( expect.arrayContaining([ expect.objectContaining({ relation: 'duplicate_of', direction: 'outgoing', case: expect.objectContaining({ id: b.id }) }), expect.objectContaining({ relation: 'appeal_of', direction: 'incoming', case: expect.objectContaining({ id: b.id }) }) ]) ); const relationship = (await detail(b.id)).relatedCases!.find( (entry) => entry.relation === 'duplicate_of' && entry.direction === 'outgoing' )!; await mutate(unlink(b.id, (await detail(b.id)).case.revision, relationship.id)); await linkCurrent(c.id, a.id, 'duplicate_of'); expect((await detail(c.id)).relatedCases).toMatchObject([ { relation: 'duplicate_of', direction: 'outgoing', case: { id: a.id } } ]); }); it('enforces case existence, staff access, revisions, self-link rejection and subject boundaries', async () => { const a = await create(); const b = await create({ guid: '456' }); const c = await create({ game: 't6' }); for (const actor of ['viewer', 'super_viewer']) await expect(mutate(link(a.id, 1, b.id), actor)).rejects.toMatchObject({ status: 403 }); await expect(mutate(link(a.id, 2, b.id))).rejects.toMatchObject({ status: 409 }); await expect(mutate(link(a.id, 1, 'missing'))).rejects.toMatchObject({ status: 404 }); await expect(mutate(link(a.id, 1, a.id))).rejects.toThrow('itself'); await expect(mutate(link(a.id, 1, b.id, 'unknown'))).rejects.toMatchObject({ status: 400 }); for (const relation of ['duplicate_of', 'appeal_of']) for (const other of [b.id, c.id]) await expect(mutate(link(a.id, 1, other, relation))).rejects.toThrow( 'same game and player' ); expect((await detail(a.id)).case.revision).toBe(1); expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]); await mutate({ op: 'resolve-case', id: a.id, revision: 1, status: 'no_action', reason: 'Review complete' }); await mutate(link(a.id, 2, b.id)); expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 }); await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 }); }); it('removes relationships from either endpoint without erasing history and permits a fresh link later', async () => { const a = await create(); const b = await create(); const unrelated = await create(); await mutate(link(a.id, 1, b.id, 'appeal_of')); const relationship = (await detail(a.id)).relatedCases![0]; await expect(mutate(unlink(unrelated.id, 1, relationship.id))).rejects.toMatchObject({ status: 404 }); await expect(mutate(unlink(b.id, 1, relationship.id))).rejects.toMatchObject({ status: 409 }); await mutate(unlink(b.id, 2, relationship.id)); for (const item of [a, b]) { const data = await detail(item.id); expect(data.case.revision).toBe(3); expect(data.relatedCases).toEqual([]); expect(data.events.map((entry) => entry.action)).toEqual([ 'case.unlinked', 'case.linked', 'case.opened' ]); } expect( handle .prepare('SELECT removed_at FROM moderation_case_relationships WHERE id=?') .get(relationship.id) ).toEqual({ removed_at: clock }); await mutate(unlink(a.id, 3, relationship.id)); expect((await detail(a.id)).case.revision).toBe(3); await mutate(link(a.id, 3, b.id, 'appeal_of')); expect((await detail(a.id)).relatedCases![0].id).not.toBe(relationship.id); expect( handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_relationships').get() ).toEqual({ count: 2 }); }); it('rolls back both case revisions, relationship changes and receipts when either audit fails', async () => { const a = await create(); const b = await create(); handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events WHEN NEW.action IN ('case.linked','case.unlinked') BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`); const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() }; await expect(mutate(request)).rejects.toThrow('audit unavailable'); expect((await detail(a.id)).case.revision).toBe(1); expect((await detail(b.id)).case.revision).toBe(1); expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]); expect( handle .prepare('SELECT * FROM moderation_requests WHERE request_key=?') .all(request.idempotencyKey) ).toEqual([]); handle.exec('DROP TRIGGER fail_relationship_audit'); await mutate(request); const relationship = (await detail(a.id)).relatedCases![0]; handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events WHEN NEW.action='case.unlinked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`); await expect(mutate(unlink(a.id, 2, relationship.id))).rejects.toThrow('audit unavailable'); expect((await detail(a.id)).case.revision).toBe(2); expect((await detail(b.id)).case.revision).toBe(2); expect(handle.prepare('SELECT removed_at FROM moderation_case_relationships').get()).toEqual({ removed_at: null }); }); it('suggests recent same-player cases and treats valid shared archived matches only as review hints', async () => { const a = await create(); const shared = await create(); const different = await create(); const malformed = await create(); const wrongSubject = await create(); await create({ guid: '456' }); await create({ game: 't6' }); const snapshot = (matchId: number, guid = '123') => ({ matchId, match: { game: 't5', server: 'removed-server', started_at: clock }, entry: { type: 'message', message: { guid, message: 'Preserved evidence' } } }); const add = (caseId: string, value: string) => handle .prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)') .run( randomUUID(), caseId, 'Preserved evidence', 'Archived chat', value, 'moderator', 'moderator', clock ); add(a.id, JSON.stringify(snapshot(12))); add(shared.id, JSON.stringify(snapshot(12))); add(different.id, JSON.stringify(snapshot(13))); add(malformed.id, '{broken JSON'); add(malformed.id, JSON.stringify({ matchId: 12 })); add(wrongSubject.id, JSON.stringify(snapshot(12, '456'))); const candidates = (await detail(a.id)).candidateCases!; expect(candidates).toHaveLength(4); expect(candidates.find((item) => item.id === shared.id)?.sharedMatch).toBe(true); expect( candidates.filter((item) => item.id !== shared.id).every((item) => !item.sharedMatch) ).toBe(true); expect((await detail(a.id)).relatedCases).toEqual([]); for (let index = 0; index < 30; index++) { vi.setSystemTime(new Date(Date.parse(clock) + (index + 1) * 60_000)); await create({ title: `Later case ${index}` }); } const recent = (await detail(a.id)).candidateCases!; expect(recent).toHaveLength(25); expect(recent[0].title).toBe('Later case 29'); expect(recent[24].title).toBe('Later case 5'); }); it('upgrades relationship storage with foreign keys, symmetric identity and active-only uniqueness', 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, 32)); const insertCase = legacy.prepare(`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,'t5','123','Existing case','open','former','Former staff',?,?)`); insertCase.run('a', clock, clock); insertCase.run('b', clock, clock); const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all(); runMigrations(legacy, migrations); runMigrations(legacy, migrations); expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject( before ); const insert = legacy.prepare( 'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)' ); insert.run('first', 'a', 'b', 'same_incident', 'former', 'Former staff', clock); expect(() => insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock) ).toThrow('UNIQUE'); expect(() => insert.run('reverse', 'b', 'a', 'same_incident', 'former', 'Former staff', clock) ).toThrow('CHECK'); expect(() => insert.run('self', 'a', 'a', 'appeal_of', 'former', 'Former staff', clock) ).toThrow('CHECK'); expect(() => insert.run('missing', 'a', 'missing', 'appeal_of', 'former', 'Former staff', clock) ).toThrow('FOREIGN KEY'); legacy .prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?') .run(clock, 'first'); insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock); expect(legacy.pragma('foreign_key_check')).toEqual([]); } finally { legacy.close(); } });