Files
electric-cherry/tests/unit/moderation-case-relationships.test.ts
2026-10-17 21:30:43 -04:00

318 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: '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();
}
});