316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
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<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 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: 'no_action', 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()).toEqual(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();
|
|
}
|
|
});
|