This commit is contained in:
@@ -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<string, unknown>, 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<string, unknown> = {}) =>
|
||||
mutate({
|
||||
op: 'create-case',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title: 'Review chat conduct',
|
||||
...overrides
|
||||
});
|
||||
const restriction = (
|
||||
caseId: string,
|
||||
caseRevision: number,
|
||||
overrides: Record<string, unknown> = {}
|
||||
) => ({
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user