Files
electric-cherry/tests/unit/moderation-case-actions.test.ts
T

332 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 conduct', ...overrides });
const restriction = (overrides: Record<string, unknown> = {}, actor = 'moderator') =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
actor
);
function action(overrides: Record<string, string | null> = {}) {
const record = {
id: randomUUID(),
game: 't5',
guid: '123',
kind: 'warn',
state: 'confirmed',
...overrides
};
handle
.prepare(
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,
kind,reason,actor_id,actor,created_at,expires_at,state)
VALUES(?,?,?,'retired-server','retired-instance',1,'old-session',?,
'Earlier warning','moderator','Former moderator',?,?,?)`
)
.run(record.id, record.game, record.guid, record.kind, clock, clock, record.state);
handle
.prepare(
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES(?,?,'Former moderator',?,?,'Recorded delivery result',?)`
)
.run(record.game, record.guid, `action.${record.state}`, record.id, clock);
return record.id as string;
}
function enforcement() {
return {
sanctions: handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all(),
actions: handle.prepare('SELECT * FROM moderation_actions ORDER BY id').all(),
policy: handle.prepare('SELECT * FROM moderation_policy').all()
};
}
const link = (id: string, revision: number, actionType: string, actionId: string) => ({
op: 'link-case-action',
id,
revision,
actionType,
actionId,
reason: 'This earlier action addresses the incident under review.'
});
it('links a prior restriction to multiple cases without changing enforcement or its original case', async () => {
const original = await create();
const sanction = await restriction({ caseId: original.id, caseRevision: 1 });
const current = await create();
const before = enforcement();
const request = {
...link(current.id, 1, 'restriction', sanction.id),
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
let data = await detail(current.id);
expect(data.case.revision).toBe(2);
expect(data.sanctions).toMatchObject([{ id: sanction.id, caseId: original.id }]);
expect(data.linkedActions).toMatchObject([
{ id: sanction.id, type: 'restriction', state: 'active' }
]);
expect(data.availableActions).toEqual([]);
expect(data.events.map((event) => event.action)).toContain('restriction.created');
expect((await detail(original.id)).linkedActions).toMatchObject([{ id: sanction.id }]);
expect(enforcement()).toEqual(before);
await mutate(link(current.id, 2, 'restriction', sanction.id));
expect(await detail(current.id)).toEqual(data);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_action_links').get()
).toEqual({ count: 1 });
const event = data.events.find((entry) => entry.action === 'case.action_linked')!;
expect(JSON.parse(event.detail)).toEqual({
reason: request.reason,
actionReference: { type: 'restriction', id: sanction.id }
});
await mutate({
op: 'revoke-sanction',
id: sanction.id,
revision: 1,
reason: 'Review completed'
});
data = await detail(current.id);
expect(data.case.revision).toBe(3);
expect((await detail(original.id)).case.revision).toBe(3);
expect(data.linkedActions?.[0].state).toBe('revoked');
expect(data.events[0].action).toBe('restriction.revoked');
});
it('links historical live actions to closed cases and exposes their real delivery state and timeline', async () => {
const warningId = action();
const failedId = action({ state: 'failed' });
action({ game: 't6' });
action({ guid: '456' });
action({ guid: null, kind: 'announce' });
const { id } = await create();
expect((await detail(id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[warningId, failedId].sort()
);
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
const before = enforcement();
await mutate(link(id, 2, 'action', warningId));
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
expect(enforcement()).toEqual(before);
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('rejects unauthorized, stale, missing and wrong-player or game links and permits a matching fleet restriction', async () => {
const { id } = await create();
const right = action();
const otherPlayer = action({ guid: '456' });
const otherGame = action({ game: 't6' });
const wrongRestriction = await restriction({ guid: '456' });
for (const actor of ['super_viewer', 'viewer'])
await expect(mutate(link(id, 1, 'action', right), actor)).rejects.toMatchObject({
status: 403
});
await expect(mutate(link(id, 2, 'action', right))).rejects.toMatchObject({ status: 409 });
for (const [type, source] of [
['action', otherPlayer],
['action', otherGame],
['action', 'missing'],
['restriction', wrongRestriction.id]
])
await expect(mutate(link(id, 1, type, source))).rejects.toMatchObject({ status: 404 });
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
for (const game of ['t5', 't6'])
handle
.prepare('INSERT INTO moderation_aliases VALUES(?,?,?,?,?)')
.run(game, '123', 'Player', clock, clock);
const fleet = await restriction({ scope: 'fleet' }, 'admin');
const t6 = await create({ game: 't6' });
expect((await detail(t6.id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[fleet.id, otherGame].sort()
);
await mutate(link(t6.id, 1, 'restriction', fleet.id));
expect((await detail(t6.id)).sanctions).toMatchObject([
{ id: fleet.id, scope: 'fleet', game: 't5' }
]);
});
it('requires a linked confirmed warning for a cited warning outcome and preserves the actual reference', async () => {
const { id } = await create();
const confirmed = action();
const failed = action({ state: 'failed' });
const kick = action({ kind: 'kick' });
const sanction = await restriction();
const resolve = (
revision: number,
type: string,
actionId: string,
status = 'warning_issued'
) => ({
op: 'resolve-case',
id,
revision,
status,
reason: 'Decision after evidence review',
actionReference: { type, id: actionId }
});
await expect(mutate(resolve(1, 'action', confirmed))).rejects.toThrow(
'Link the recorded action'
);
await mutate(link(id, 1, 'action', failed));
await mutate(link(id, 2, 'action', kick));
await mutate(link(id, 3, 'restriction', sanction.id));
for (const [type, source] of [
['action', failed],
['action', kick],
['restriction', sanction.id]
])
await expect(mutate(resolve(4, type, source))).rejects.toThrow('confirmed warning');
await mutate(link(id, 4, 'action', confirmed));
await expect(mutate(resolve(5, 'action', confirmed, 'restriction_applied'))).rejects.toThrow(
'recorded restriction'
);
const before = enforcement();
await mutate(resolve(5, 'action', confirmed));
const data = await detail(id);
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
expect(JSON.parse(data.events[0].detail)).toEqual({
reason: 'Decision after evidence review',
actionReference: { type: 'action', id: confirmed }
});
expect(enforcement()).toEqual(before);
});
it('allows a historical expired restriction created from the original case to support its closure', async () => {
const { id } = await create();
const sanction = await restriction({ caseId: id, caseRevision: 1 });
vi.setSystemTime(new Date(Date.parse(clock) + 120 * 60_000));
expect((await detail(id)).linkedActions).toMatchObject([{ id: sanction.id, state: 'expired' }]);
const before = enforcement();
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'restriction_applied',
reason: 'Earlier mute addressed the incident',
actionReference: { type: 'restriction', id: sanction.id }
});
expect((await detail(id)).case.status).toBe('restriction_applied');
expect(enforcement()).toEqual(before);
});
it('rolls back a link, case revision and idempotency record if its audit write fails', async () => {
const { id } = await create();
const warning = action();
const before = await detail(id);
handle.exec(`CREATE TRIGGER reject_case_action_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.action_linked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(id, 1, 'action', warning), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER reject_case_action_audit');
await mutate(request);
expect((await detail(id)).linkedActions).toMatchObject([{ id: warning }]);
});
it('upgrades existing databases without changing original case restrictions and enforces link identity', 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, 30));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Prior case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
VALUES('restriction','t5','123','mute','game','Prior restriction','','former','Former staff',?,'case')`
)
.run(clock);
const before = legacy.prepare('SELECT * FROM moderation_sanctions').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_sanctions').all()).toEqual(before);
const insert = legacy.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)');
insert.run('case', 'restriction', 'restriction', 'Moderator', clock);
expect(() => insert.run('case', 'restriction', 'restriction', 'Moderator', clock)).toThrow(
'UNIQUE'
);
expect(() => insert.run('missing', 'action', 'action', 'Moderator', clock)).toThrow(
'FOREIGN KEY'
);
expect(() => insert.run('case', 'other', 'action', 'Moderator', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});