import { randomUUID } from 'node:crypto'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { ModerationRuntimeIdentity } from '../../src/lib/server/moderation/credentials.js'; const t5 = 't5_zombie_theater_1', t6 = 't6_zm_transit_1'; let fixture: Fixture; let instanceId: string; let credential: string; let identity: ModerationRuntimeIdentity; const clock = '2026-10-15T12:00:00.000Z'; beforeEach(async () => { vi.resetModules(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(clock)); fixture = await newFixture([t5, t6]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); const { db } = await import('../../src/lib/server/db/index.js'); const 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); const { createHost } = await import('../../src/lib/server/hosts/index.js'); const host = createHost({ name: 'Moderation host' }); instanceId = randomUUID(); handle .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'library','image',28960,?)` ) .run(instanceId, t5, host.id, clock); handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t5', t5); handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t6', t6); const { moderationCredential, authenticateModerationRuntime } = await import('../../src/lib/server/moderation/credentials.js'); credential = moderationCredential(instanceId, 1); identity = authenticateModerationRuntime( new Request('https://test/api/moderation/runtime', { headers: { Authorization: `Bearer ${credential}` } }) )!; expect(identity).toMatchObject({ instanceId, game: 't5', generation: 1 }); }); 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 }); } const restriction = (overrides: Record = {}) => ({ op: 'create-sanction', game: 't5', guid: '123', kind: 'ban', scope: 'game', reason: 'Repeated abuse', durationMinutes: 60, ...overrides }); async function sync(overrides: Record = {}) { const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js'); return syncModerationRuntime(identity, { protocol: 1, server: t5, generation: 1, session: 'boot-1', sessionSequence: 1, players: [{ guid: '123', name: 'Player', connection: 'boot-1:1' }], capabilities: ['warn', 'message', 'kick', 'announce', 'ban', 'mute'], appliedRevision: 0, receipts: [], ...overrides }); } it('enforces private read/write access, duration limits, and explicit fleet identity', async () => { const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); const query = moderationQuery(new URLSearchParams()); for (const role of ['viewer', 'operator']) expect(() => moderationOverview(role, query)).toThrow('Moderation access'); expect(moderationOverview('super_viewer', query).total).toBe(0); for (const role of ['viewer', 'operator', 'super_viewer']) await expect(mutate(restriction(), role)).rejects.toThrow('Moderation access'); for (const input of [{ durationMinutes: null }, { durationMinutes: 10081 }, { scope: 'fleet' }]) await expect(mutate(restriction(input))).rejects.toThrow('up to 7 days'); await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games'); expect(await mutate(restriction({ durationMinutes: 10080 }))).toHaveProperty('id'); }); it('applies current moderator limits when revoking restrictions created before an administrator downgrade', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js'); for (const game of ['t5', 't6'] as const) observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock); db().prepare("UPDATE auth_users SET role='admin' WHERE id='moderator'").run(); const restricted = await Promise.all( [ { scope: 'fleet', durationMinutes: 10080 }, { durationMinutes: null }, { durationMinutes: 11520 } ].map((input) => mutate(restriction(input))) ); const allowed = await Promise.all( [{ scope: 'server', server: t5 }, { scope: 'game' }].map((input) => mutate(restriction({ ...input, durationMinutes: 10080 })) ) ); db().prepare("UPDATE auth_users SET role='moderator' WHERE id='moderator'").run(); // The eight-day restriction now has only six days left, but its original duration governs. vi.setSystemTime(new Date(Date.parse(clock) + 2 * 24 * 60 * 60 * 1000)); const revocation = (id: string) => ({ op: 'revoke-sanction', id, revision: 1, reason: 'Appeal accepted' }); for (const { id } of restricted) await expect(mutate(revocation(id))).rejects.toThrow('originally lasting up to 7 days'); expect( db() .prepare( 'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NOT NULL' ) .get() ).toEqual({ count: 0 }); for (const { id } of allowed) expect(await mutate(revocation(id))).toMatchObject({ id }); for (const { id } of restricted) expect(await mutate(revocation(id), 'admin')).toMatchObject({ id }); }); it('applies game and exact server registration scopes and revisioned revocation', async () => { const game = await mutate(restriction()); const server = await mutate(restriction({ scope: 'server', server: t5, kind: 'mute' })); const initial = await sync(); expect(initial.sanctions).toHaveLength(2); await expect( mutate({ op: 'revoke-sanction', id: game.id, revision: 99, reason: 'Correction' }) ).rejects.toThrow('changed'); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( "INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES('other','other','Other','unused','moderator',0,0)" ) .run(); await expect( mutate({ op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Correction' }, 'other') ).rejects.toThrow('another moderator'); await mutate( { op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Appeal accepted' }, 'admin' ); const next = await sync({ appliedRevision: initial.revision }); expect(next.revision).toBeGreaterThan(initial.revision); expect(next.sanctions).toMatchObject([{ id: server.id }]); const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js'); const differentRegistration = { ...identity, serverCreatedAt: '2099-01-01T00:00:00.000Z' }; db() .prepare('UPDATE servers SET created_at=? WHERE name=?') .run(differentRegistration.serverCreatedAt, t5); expect( syncModerationRuntime(differentRegistration, { protocol: 1, server: t5, generation: 1, session: 'boot-1', sessionSequence: 1, players: [], capabilities: [], appliedRevision: 0, receipts: [] }).sanctions ).toEqual([]); }); it('excludes expired restrictions without deleting their history', async () => { await mutate(restriction({ durationMinutes: 1 })); expect((await sync()).sanctions).toHaveLength(1); vi.setSystemTime(new Date(Date.parse(clock) + 60_000)); expect((await sync()).sanctions).toEqual([]); const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); const data = moderationOverview( 'moderator', moderationQuery(new URLSearchParams('view=restrictions')) ); expect(data.sanctions).toMatchObject([{ active: false, reason: 'Repeated abuse' }]); }); it('queues a single live action and preserves exact delivery across duplicate polling', async () => { await sync(); const request = { op: 'action', kind: 'kick', game: 't5', guid: '123', server: t5, reason: 'Repeated abuse', idempotencyKey: 'same-action' }; const created = await mutate(request); expect(await mutate(request)).toEqual(created); const first = await sync(); expect(first.actions).toMatchObject([ { id: created.id, guid: '123', connection: 'boot-1:1', kind: 'kick' } ]); expect((await sync()).actions).toEqual(first.actions); expect( ( await sync({ receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }] }) ).actions ).toEqual([]); await sync({ receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }] }); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare( "SELECT COUNT(*) AS count FROM moderation_events WHERE action='action.confirmed'" ) .get() ).toEqual({ count: 1 }); }); it('does not deliver to a reused connection or after permission revocation', async () => { await sync(); await mutate({ op: 'action', kind: 'warn', game: 't5', guid: '123', server: t5, reason: 'Warning' }); expect( (await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions ).toEqual([]); await mutate({ op: 'action', kind: 'warn', game: 't5', guid: '123', server: t5, reason: 'Warning again' }); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run(); expect( (await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions ).toEqual([]); expect(db().prepare('SELECT state FROM moderation_actions').all()).toEqual([ { state: 'failed' }, { state: 'failed' } ]); }); it('keeps unacknowledged delivery uncertain and accepts durable late receipts without replay', async () => { await sync(); const { id } = await mutate({ op: 'action', kind: 'kick', game: 't5', guid: '123', server: t5, reason: 'Kick' }); await sync(); vi.setSystemTime(new Date(Date.parse(clock) + 31_000)); expect((await sync()).actions).toEqual([]); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({ state: 'unknown' }); await sync({ session: 'boot-2', sessionSequence: 2, players: [], receipts: [{ id, state: 'confirmed', detail: 'Observed disconnect' }] }); expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({ state: 'confirmed' }); }); it('expires undelivered actions and refuses stale or unsupported live targeting', async () => { await sync({ capabilities: ['warn'] }); await expect( mutate({ op: 'action', kind: 'kick', game: 't5', guid: '123', server: t5, reason: 'Kick' }) ).rejects.toThrow('does not support'); await expect( mutate({ op: 'action', kind: 'warn', game: 't5', guid: '999', server: t5, reason: 'Warning' }) ).rejects.toThrow('current connection'); await mutate({ op: 'action', kind: 'warn', game: 't5', guid: '123', server: t5, reason: 'Warning' }); vi.setSystemTime(new Date(Date.parse(clock) + 31_000)); await expect( mutate({ op: 'action', kind: 'warn', game: 't5', guid: '123', server: t5, reason: 'Warning' }) ).rejects.toThrow('not connected'); await sync(); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT state FROM moderation_actions').get()).toEqual({ state: 'expired' }); }); it('binds machine credentials to the active generation and authenticates before body parsing', async () => { const { POST } = await import('../../src/routes/api/moderation/runtime/+server.js'); const invoke = (token: string) => POST({ request: new Request('https://test/api/moderation/runtime', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: 'bad json' }) } as Parameters[0]); expect((await invoke('ec_bank_' + 'a'.repeat(43))).status).toBe(401); const { db } = await import('../../src/lib/server/db/index.js'); const { moderationCredential, authenticateModerationRuntime } = await import('../../src/lib/server/moderation/credentials.js'); const future = moderationCredential(instanceId, 2); expect( authenticateModerationRuntime( new Request('https://test', { headers: { Authorization: `Bearer ${future}` } }) ) ).toBeNull(); db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId); expect((await invoke(credential)).status).toBe(401); expect( authenticateModerationRuntime( new Request('https://test', { headers: { Authorization: `Bearer ${future}` } }) ) ).toMatchObject({ generation: 2 }); db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(clock, instanceId); expect((await invoke(future)).status).toBe(401); }); it('preserves verified chat evidence after source match cleanup and rejects forged evidence', async () => { const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); saveTelemetry({ server: t5, map: 'zombie_theater', event: 'game_start', players: [{ guid: '123', name: 'Player' }], receivedAt: clock }); const { db } = await import('../../src/lib/server/db/index.js'); const { id: matchId } = db().prepare('SELECT id FROM leaderboard_matches').get() as { id: number; }; const entry = { type: 'message', message: { guid: '123', slot: 0, player: 'Player', message: 'Evidence message', time: '1:20' }, context: { realTime: null, round: null, matchElapsedMs: null } }; db() .prepare('INSERT INTO match_chat VALUES(?,?,?,?)') .run(matchId, 'key', 0, JSON.stringify(entry)); await expect( mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review', evidence: { matchId, message: 'Forged message' } }) ).rejects.toThrow('could not be verified'); const { id } = await mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review', note: 'Staff context', evidence: { matchId, message: 'Evidence message' } }); db().prepare('DELETE FROM leaderboard_matches').run(); expect( db() .prepare( 'SELECT text,source,snapshot FROM moderation_evidence WHERE case_id=? ORDER BY source' ) .all(id) ).toEqual([ expect.objectContaining({ text: 'Evidence message', snapshot: expect.stringContaining('Evidence message') }), expect.objectContaining({ text: 'Staff context', source: 'Staff note', snapshot: null }) ]); await mutate({ op: 'resolve-case', id, revision: 1, status: 'resolved', reason: 'Appeal accepted' }); await expect( mutate({ op: 'resolve-case', id, revision: 1, status: 'dismissed', reason: 'Stale decision' }) ).rejects.toThrow('changed'); }); it('retains paginated player search and reads pending policy coverage honestly', async () => { await sync(); await mutate(restriction()); const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); const data = moderationOverview( 'moderator', moderationQuery(new URLSearchParams('q=Player&offset=999')) ); expect(data.players).toMatchObject([ { guid: '123', name: 'Player', servers: [t5], restrictions: 1 } ]); expect(data.query.offset).toBe(0); expect(data.coverage.find((c) => c.server === t5)?.state).toBe('pending'); const snapshot = await sync(); await sync({ appliedRevision: snapshot.revision }); expect( moderationOverview('moderator', data.query).coverage.find((c) => c.server === t5)?.state ).toBe('current'); }); it('does not treat manually entered identities as cross-game observations', async () => { for (const game of ['t5', 't6']) await mutate({ op: 'note', game, guid: '123', text: 'Manual lookup' }); await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games'); const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); expect(moderationOverview('admin', moderationQuery(new URLSearchParams())).players).toEqual([ expect.objectContaining({ firstSeen: null, lastSeen: null }), expect.objectContaining({ firstSeen: null, lastSeen: null }) ]); await sync(); const { db } = await import('../../src/lib/server/db/index.js'); const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js'); observeModerationPlayers(db(), 't6', [{ guid: '123', name: 'Verified player' }], clock); const created = await mutate(restriction({ scope: 'fleet' }), 'admin'); expect((await sync()).sanctions).toMatchObject([{ id: created.id }]); }); it('shows applicable fleet restrictions in either game without including other game restrictions', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js'); for (const game of ['t5', 't6'] as const) observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock); const fleet = await mutate(restriction({ scope: 'fleet' }), 'admin'); const local = { t5: await mutate(restriction()), t6: await mutate(restriction({ game: 't6' })) }; await mutate(restriction({ game: 't6', guid: '456' })); const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); for (const game of ['t5', 't6'] as const) { const data = moderationOverview( 'moderator', moderationQuery(new URLSearchParams({ view: 'restrictions', game, guid: '123' })) ); expect(data.total).toBe(2); expect(data.sanctions.map((row) => row.id).sort()).toEqual( [fleet.id, local[game].id].sort() ); } }); it('rejects oversized UTF-8 game messages before policy or queue changes', async () => { await expect(mutate(restriction({ reason: '界'.repeat(100) }))).rejects.toThrow('UTF-8 bytes'); await sync(); await expect( mutate({ op: 'action', kind: 'message', game: 't5', guid: '123', server: t5, reason: '界'.repeat(100) }) ).rejects.toThrow('UTF-8 bytes'); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({ count: 0 }); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_actions').get()).toEqual({ count: 0 }); }); it('rechecks runtime authorization after a request body could have yielded', async () => { const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId); await expect(sync()).rejects.toThrow('no longer active'); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_runtimes').get()).toEqual({ count: 0 }); db().prepare('UPDATE native_instances SET generation=1 WHERE id=?').run(instanceId); db().prepare('DELETE FROM moderation_credentials WHERE instance_id=?').run(instanceId); await expect(sync()).rejects.toThrow('no longer active'); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_players').get()).toEqual({ count: 0 }); }); it('rejects coerced enum values before moderator scope checks or SQL binding', async () => { await expect(mutate(restriction({ scope: ['fleet'] }))).rejects.toThrow('restriction scope'); await sync(); await expect( mutate({ op: 'action', kind: ['announce'], server: t5, reason: 'Message' }) ).rejects.toThrow('supported live action'); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({ count: 0 }); expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({ count: 0 }); }); it('rejects delayed polls from superseded game sessions without replacing current connections', async () => { await sync({ session: 'new-boot', sessionSequence: 2, players: [{ guid: '123', name: 'Current player', connection: 'new-boot:1' }] }); await expect(sync()).rejects.toThrow('superseded'); await expect(sync({ session: 'different-boot', sessionSequence: 2 })).rejects.toThrow( 'superseded' ); const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare('SELECT session,session_sequence,players FROM moderation_runtimes').get() ).toMatchObject({ session: 'new-boot', session_sequence: 2, players: expect.stringContaining('Current player') }); }); it('does not advertise current restriction coverage for a runtime lacking ban or mute support', async () => { const first = await sync(); await sync({ capabilities: ['warn'], appliedRevision: first.revision }); const { moderationOverview, moderationQuery } = await import('../../src/lib/server/moderation/overview.js'); expect( moderationOverview('admin', moderationQuery(new URLSearchParams())).coverage.find( (row) => row.server === t5 ) ).toMatchObject({ state: 'pending', capabilities: ['warn'] }); });