Link prior moderation actions to case decisions
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user