274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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<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 = () =>
|
|
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<string, unknown> = {}) => ({
|
|
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: 'no_action' });
|
|
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<typeof POST>[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);
|
|
runMigrations(legacy, migrations);
|
|
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toEqual(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();
|
|
}
|
|
});
|