This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { fromStore, writable } from 'svelte/store';
|
||||
import CasePage from '../../src/routes/moderation/cases/[id]/+page.svelte';
|
||||
import type { ModerationCaseDetail } from '../../src/lib/moderation.js';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
const navigation = vi.hoisted(() => ({ before: [] as (() => void)[] }));
|
||||
const currentPage = writable({
|
||||
url: new URL('http://localhost/moderation/cases/case-1'),
|
||||
data: { user: { id: 'staff-1', role: 'moderator' as UserRole } }
|
||||
});
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||
},
|
||||
navigating: writable(null)
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({
|
||||
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
|
||||
}));
|
||||
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
|
||||
|
||||
let component: ReturnType<typeof mount> | undefined;
|
||||
beforeEach(() => {
|
||||
navigation.before.length = 0;
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (component) await unmount(component);
|
||||
component = undefined;
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const guid = '18446744073709551615';
|
||||
function detail(): ModerationCaseDetail {
|
||||
return {
|
||||
case: {
|
||||
id: 'case-1',
|
||||
game: 't5',
|
||||
guid,
|
||||
title: 'Repeated chat abuse',
|
||||
status: 'in_review',
|
||||
priority: 'high',
|
||||
assigneeId: 'staff-1',
|
||||
assignee: 'Moderator One',
|
||||
actor: 'Reporter',
|
||||
createdAt: '2026-10-15T12:00:00Z',
|
||||
updatedAt: '2026-10-16T12:00:00Z',
|
||||
revision: 4,
|
||||
evidence: [
|
||||
{
|
||||
id: 'evidence-1',
|
||||
text: 'abusive message',
|
||||
source: 'Archived chat · theater · match 17',
|
||||
actor: 'Reporter',
|
||||
createdAt: '2026-10-16T12:00:00Z',
|
||||
snapshot: JSON.stringify({
|
||||
matchId: 17,
|
||||
match: {
|
||||
game: 't5',
|
||||
server: 't5_zombie_theater_1',
|
||||
started_at: '2026-10-15T12:00:00Z'
|
||||
},
|
||||
entry: {
|
||||
type: 'message',
|
||||
message: {
|
||||
guid,
|
||||
player: 'Tank',
|
||||
time: '12:34',
|
||||
message: 'abusive message'
|
||||
},
|
||||
context: { realTime: '2026-10-15T12:12:34Z', round: 9 }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'note-1',
|
||||
text: 'Check the earlier warning.',
|
||||
source: 'Staff note',
|
||||
actor: 'Moderator One',
|
||||
createdAt: '2026-10-16T12:00:00Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
player: {
|
||||
game: 't5',
|
||||
guid,
|
||||
name: 'Dempsey',
|
||||
aliases: ['Tank'],
|
||||
firstSeen: null,
|
||||
lastSeen: null,
|
||||
servers: [],
|
||||
restrictions: 1
|
||||
},
|
||||
sanctions: [
|
||||
{
|
||||
id: 'restriction-1',
|
||||
game: 't5',
|
||||
guid,
|
||||
kind: 'mute',
|
||||
scope: 'game',
|
||||
server: null,
|
||||
reason: 'Prior warning ignored',
|
||||
note: '',
|
||||
createdAt: '2026-10-15T12:00:00Z',
|
||||
expiresAt: '2026-10-16T12:00:00Z',
|
||||
revokedAt: null,
|
||||
actorId: 'staff-1',
|
||||
actor: 'Moderator One',
|
||||
revision: 2,
|
||||
active: true,
|
||||
caseId: 'case-1'
|
||||
}
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
game: 't5',
|
||||
guid,
|
||||
action: 'case.opened',
|
||||
subjectId: 'case-1',
|
||||
actor: 'Reporter',
|
||||
detail: 'Repeated chat abuse',
|
||||
createdAt: '2026-10-15T12:00:00Z'
|
||||
}
|
||||
],
|
||||
coverage: [
|
||||
{
|
||||
server: 't5_zombie_theater_1',
|
||||
game: 't5',
|
||||
connectedAt: null,
|
||||
revision: 1,
|
||||
desiredRevision: 2,
|
||||
state: 'pending',
|
||||
capabilities: ['ban', 'mute']
|
||||
}
|
||||
],
|
||||
assignees: [
|
||||
{ id: 'staff-1', username: 'Moderator One' },
|
||||
{ id: 'staff-2', username: 'Moderator Two' }
|
||||
],
|
||||
checkedAt: '2026-10-16T12:00:00Z'
|
||||
};
|
||||
}
|
||||
function show(initial = detail(), role: UserRole = 'moderator') {
|
||||
const data = writable({ moderationCase: initial });
|
||||
const state = fromStore(data);
|
||||
currentPage.set({
|
||||
url: new URL(`http://localhost/moderation/cases/${initial.case.id}`),
|
||||
data: { user: { id: 'staff-1', role } }
|
||||
});
|
||||
component = mount(CasePage, {
|
||||
target: document.body,
|
||||
props: {
|
||||
get data() {
|
||||
return state.current;
|
||||
}
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
return (next: ModerationCaseDetail) => {
|
||||
data.set({ moderationCase: next });
|
||||
flushSync();
|
||||
};
|
||||
}
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
label: string
|
||||
): T {
|
||||
const labelNode = [...document.querySelectorAll('dialog label')].find(
|
||||
(node) => node.textContent?.replace('*', '').trim() === label
|
||||
)!;
|
||||
return document.querySelector(`[aria-labelledby="${labelNode.id}"]`)! as T;
|
||||
}
|
||||
function enter(node: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
function select(label: string, value: string) {
|
||||
const node = field<HTMLSelectElement>(label);
|
||||
// happy-dom's :checked selector matches inputs but not selected options.
|
||||
const originalQuery = node.querySelector.bind(node);
|
||||
node.querySelector = ((selector: string) =>
|
||||
selector === ':checked'
|
||||
? (node.selectedOptions[0] ?? null)
|
||||
: originalQuery(selector)) as typeof node.querySelector;
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
function submit() {
|
||||
document
|
||||
.querySelector('dialog form')!
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
function response(value: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(value), { status });
|
||||
}
|
||||
|
||||
it('preserves original evidence context, linked restrictions, and staff history for read-only viewers', () => {
|
||||
show(detail(), 'super_viewer');
|
||||
expect(document.body.textContent).toContain('Read-only moderation access');
|
||||
expect(document.body.textContent).toContain('Round 9');
|
||||
expect(document.body.textContent).toContain('Original log time · 12:34');
|
||||
expect(document.body.textContent).toContain('Check the earlier warning.');
|
||||
expect(document.body.textContent).toContain('Prior warning ignored');
|
||||
expect(document.body.textContent).toContain('Policy pending');
|
||||
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
||||
for (const label of [
|
||||
'Edit case',
|
||||
'Review case',
|
||||
'Reopen case',
|
||||
'Restrict player',
|
||||
'Revoke restriction',
|
||||
'Add case note',
|
||||
'Attach evidence'
|
||||
])
|
||||
expect(button(label)).toBeUndefined();
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
|
||||
const updated = detail();
|
||||
updated.case.revision = 5;
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'new-restriction' }))
|
||||
.mockResolvedValue(response(updated));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Restrict player').click();
|
||||
await tick();
|
||||
expect(field<HTMLSelectElement>('Game').disabled).toBe(true);
|
||||
expect(field<HTMLInputElement>('Player GUID').readOnly).toBe(true);
|
||||
expect(field<HTMLInputElement>('Player GUID').value).toBe(guid);
|
||||
expect(document.querySelector('dialog')!.textContent).toContain('does not close the case');
|
||||
enter(field('Player-visible reason'), 'Repeated abuse');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'create-sanction',
|
||||
game: 't5',
|
||||
guid,
|
||||
caseId: 'case-1',
|
||||
caseRevision: 4,
|
||||
reason: 'Repeated abuse',
|
||||
durationMinutes: 60
|
||||
});
|
||||
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
|
||||
expect(button('Review case')).toBeDefined();
|
||||
});
|
||||
|
||||
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
|
||||
const closed = detail();
|
||||
closed.case.status = 'warning_issued';
|
||||
closed.case.revision = 5;
|
||||
const reopened = detail();
|
||||
reopened.case.status = 'open';
|
||||
reopened.case.revision = 6;
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValueOnce(response(closed))
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValueOnce(response(reopened));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Review case').click();
|
||||
await tick();
|
||||
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
|
||||
'resolved',
|
||||
'dismissed',
|
||||
'warning_issued',
|
||||
'restriction_applied',
|
||||
'no_action',
|
||||
'duplicate',
|
||||
'appeal_upheld',
|
||||
'appeal_denied'
|
||||
]);
|
||||
select('Outcome', 'warning_issued');
|
||||
enter(field('Review reason'), 'Warning was sufficient');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'resolve-case',
|
||||
id: 'case-1',
|
||||
revision: 4,
|
||||
status: 'warning_issued',
|
||||
reason: 'Warning was sufficient'
|
||||
});
|
||||
expect(button('Restrict player')).toBeUndefined();
|
||||
button('Reopen case').click();
|
||||
await tick();
|
||||
enter(field('Reopening reason'), 'New evidence received');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
|
||||
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
|
||||
op: 'reopen-case',
|
||||
id: 'case-1',
|
||||
revision: 5,
|
||||
reason: 'New evidence received'
|
||||
});
|
||||
expect(document.body.textContent).toContain('Check the earlier warning.');
|
||||
});
|
||||
|
||||
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValue(response(detail()));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Edit case').click();
|
||||
await tick();
|
||||
enter(field('Case title'), 'Urgent report');
|
||||
select('Priority', 'urgent');
|
||||
select('Assigned to', 'staff-2');
|
||||
select('Workflow status', 'escalated');
|
||||
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
|
||||
enter(field('Change reason'), 'Needs administrator review');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'update-case',
|
||||
id: 'case-1',
|
||||
revision: 4,
|
||||
title: 'Urgent report',
|
||||
priority: 'urgent',
|
||||
assigneeId: 'staff-2',
|
||||
status: 'escalated',
|
||||
reason: 'Needs administrator review'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
||||
const initial = detail();
|
||||
initial.case.status = 'no_action';
|
||||
initial.case.assigneeId = 'disabled-staff';
|
||||
initial.case.assignee = 'Former staff';
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValue(response(initial));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show(initial);
|
||||
button('Edit case').click();
|
||||
await tick();
|
||||
expect(field<HTMLSelectElement>('Assigned to').value).toBe('disabled-staff');
|
||||
expect(document.querySelector('dialog')!.textContent).toContain('Former staff (unavailable)');
|
||||
enter(field('Case title'), 'Clarified title');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
const payload = JSON.parse(fetch.mock.calls[0][1].body);
|
||||
expect(payload.title).toBe('Clarified title');
|
||||
expect(payload).not.toHaveProperty('assigneeId');
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('attaches archived evidence by match and exact message with revision checks', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValue(response(detail()));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Attach evidence').click();
|
||||
await tick();
|
||||
enter(field('Match ID'), '17');
|
||||
enter(field('Exact archived message'), ' abusive message ');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'attach-evidence',
|
||||
id: 'case-1',
|
||||
revision: 4,
|
||||
evidence: { matchId: 17, message: ' abusive message ' }
|
||||
});
|
||||
});
|
||||
|
||||
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
||||
const updated = detail();
|
||||
updated.case.revision = 8;
|
||||
updated.case.status = 'escalated';
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
response({ error: 'This case changed. Reload it before making another change.' }, 409)
|
||||
)
|
||||
.mockResolvedValue(response(updated));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Review case').click();
|
||||
await tick();
|
||||
select('Outcome', 'appeal_denied');
|
||||
enter(field('Review reason'), 'Evidence supports the restriction');
|
||||
submit();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
||||
'This case changed.'
|
||||
)
|
||||
);
|
||||
expect(field<HTMLSelectElement>('Outcome').value).toBe('appeal_denied');
|
||||
expect(document.querySelector('dialog')!.open).toBe(true);
|
||||
expect(button('Reopen case')).toBeUndefined();
|
||||
button('Cancel').click();
|
||||
await tick();
|
||||
button('Refresh').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated')
|
||||
);
|
||||
button('Review case').click();
|
||||
await tick();
|
||||
enter(field('Review reason'), 'Reviewed new context');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
||||
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
||||
});
|
||||
|
||||
it('retries uncertain case notes with the exact same idempotent request', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||
.mockResolvedValue(response(detail()));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Add case note').click();
|
||||
await tick();
|
||||
enter(field('Private staff note'), 'Follow up on appeal');
|
||||
submit();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true)
|
||||
);
|
||||
button('Retry same request').click();
|
||||
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'case-note',
|
||||
id: 'case-1',
|
||||
revision: 4,
|
||||
text: 'Follow up on appeal',
|
||||
idempotencyKey: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps last loaded records when refresh fails and rejects an old response after changing cases', async () => {
|
||||
let resolve!: (response: Response) => void;
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Offline'))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const update = show();
|
||||
button('Refresh').click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('Case updates paused.'));
|
||||
expect(document.querySelector('h1')!.textContent).toBe('Repeated chat abuse');
|
||||
button('Retry').click();
|
||||
navigation.before.forEach((fn) => fn());
|
||||
const next = detail();
|
||||
next.case.id = 'case-2';
|
||||
next.case.title = 'Another case';
|
||||
update(next);
|
||||
resolve(response(detail()));
|
||||
await tick();
|
||||
await tick();
|
||||
expect(document.querySelector('h1')!.textContent).toBe('Another case');
|
||||
});
|
||||
|
||||
it('enforces role and ownership limits on linked restriction revocation', async () => {
|
||||
const initial = detail();
|
||||
initial.sanctions[0].actorId = 'staff-2';
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ok: true, id: 'restriction-1' }))
|
||||
.mockResolvedValue(response(initial));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show(initial);
|
||||
expect(button('Revoke restriction')).toBeUndefined();
|
||||
currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } }));
|
||||
flushSync();
|
||||
button('Revoke restriction').click();
|
||||
await tick();
|
||||
enter(field('Review reason'), 'Appeal upheld');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'revoke-sanction',
|
||||
id: 'restriction-1',
|
||||
revision: 2,
|
||||
reason: 'Appeal upheld'
|
||||
});
|
||||
});
|
||||
@@ -303,3 +303,60 @@ it('copies selected archived chat evidence separately from staff notes', async (
|
||||
evidence: { matchId: 17, message: 'abusive message' }
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a newly created case page after preserving selected evidence', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ ok: true, id: 'new-case' })));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Open case').click();
|
||||
await tick();
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
|
||||
enter(inputs[inputs.length - 1], 'Appeal review');
|
||||
submitDialog();
|
||||
await vi.waitFor(() =>
|
||||
expect(navigation.goto).toHaveBeenCalledWith('/moderation/cases/new-case', {
|
||||
invalidateAll: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('links case records to workspaces and preserves case filters while paging', () => {
|
||||
const data = overview('cases');
|
||||
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' };
|
||||
data.cases = [
|
||||
{
|
||||
id: 'case-1',
|
||||
game: 't5',
|
||||
guid,
|
||||
title: 'Chat investigation',
|
||||
status: 'escalated',
|
||||
priority: 'high',
|
||||
assigneeId: 'staff-1',
|
||||
assignee: 'Moderator',
|
||||
actor: 'Reporter',
|
||||
createdAt: '2026-10-15T12:00:00Z',
|
||||
updatedAt: '2026-10-16T12:00:00Z',
|
||||
revision: 3,
|
||||
evidence: []
|
||||
}
|
||||
];
|
||||
data.total = 40;
|
||||
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me');
|
||||
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
|
||||
'Chat investigation'
|
||||
);
|
||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
|
||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
|
||||
button('Next').click();
|
||||
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||
expect(destination.searchParams.get('caseStatus')).toBe('active');
|
||||
expect(destination.searchParams.get('priority')).toBe('high');
|
||||
expect(destination.searchParams.get('assignedTo')).toBe('me');
|
||||
expect(destination.searchParams.get('offset')).toBe('25');
|
||||
button('Reset').click();
|
||||
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset'])
|
||||
expect(reset.searchParams.has(filter)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('management route permissions', () => {
|
||||
it.each([
|
||||
'/moderation',
|
||||
'/moderation/players/[guid]',
|
||||
'/moderation/cases/[id]',
|
||||
'/api/moderation',
|
||||
'/api/moderation/cases/[id]',
|
||||
'/api/moderation/players/[guid]',
|
||||
'/api/moderation/sanctions/[id]/revoke'
|
||||
])('limits private moderation access at %s without changing fleet privileges', (route) => {
|
||||
|
||||
@@ -290,21 +290,38 @@ describe('browser authentication guard', () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => {
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
for (const path of ['/moderation', '/moderation/__data.json']) {
|
||||
const call = requestFor('/moderation', { path });
|
||||
if (role === 'viewer' || role === 'operator') {
|
||||
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect((await call.invoke()).status).toBe(200);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
it.each([
|
||||
['/moderation', '/moderation'],
|
||||
['/moderation/cases/[id]', '/moderation/cases/case-123']
|
||||
])(
|
||||
'protects %s pages and Svelte page-data requests with the same role checks',
|
||||
async (route, path) => {
|
||||
for (const role of [
|
||||
'viewer',
|
||||
'super_viewer',
|
||||
'operator',
|
||||
'moderator',
|
||||
'admin'
|
||||
] as const) {
|
||||
const session = signIn(role);
|
||||
for (const requestPath of [path, `${path}/__data.json`]) {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
const call = requestFor(route, { path: requestPath, method });
|
||||
if (role === 'viewer' || role === 'operator') {
|
||||
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
} else {
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toContain('no-store');
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(call.event.locals.user).toEqual(session.user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
|
||||
'permits only administrators to write settings and Super Viewers to read them',
|
||||
async (method) => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const id of ['first', 'second'])
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES(?,?,?,'unused','moderator',0,0)`
|
||||
)
|
||||
.run(id, id, id);
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
it('filters case queues by workflow, priority and ownership while prioritizing active work', async () => {
|
||||
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||
const { moderationOverview, moderationQuery } =
|
||||
await import('../../src/lib/server/moderation/overview.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const ids: string[] = [];
|
||||
for (const [title, priority, status, owner] of [
|
||||
['Normal report', 'normal', 'open', null],
|
||||
['Urgent review', 'urgent', 'in_review', 'first'],
|
||||
['High escalation', 'high', 'escalated', 'second'],
|
||||
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
|
||||
]) {
|
||||
const { id } = mutateModeration('first', {
|
||||
op: 'create-case',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title,
|
||||
idempotencyKey: randomUUID()
|
||||
});
|
||||
ids.push(id);
|
||||
db()
|
||||
.prepare('UPDATE moderation_cases SET priority=?,status=?,assignee_id=? WHERE id=?')
|
||||
.run(priority, status, owner, id);
|
||||
}
|
||||
const read = (extra = '') =>
|
||||
moderationOverview('first', moderationQuery(new URLSearchParams(`view=cases&${extra}`)));
|
||||
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||||
expect(read('caseStatus=active').total).toBe(3);
|
||||
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
|
||||
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
|
||||
ids[2]
|
||||
]);
|
||||
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
||||
expect(read('assignedTo=unassigned').cases.map((item) => item.id)).toEqual([ids[0]]);
|
||||
expect(read('assignedTo=second').cases[0].assignee).toBe('second');
|
||||
expect(
|
||||
read('caseStatus=active&assignedTo=me&priority=urgent&game=t5&guid=123&q=review&offset=100')
|
||||
.query.offset
|
||||
).toBe(0);
|
||||
expect(read('caseStatus=active&assignedTo=me&priority=urgent&game=t6').total).toBe(0);
|
||||
expect(read('caseStatus=invalid&priority=invalid').total).toBe(4);
|
||||
expect(
|
||||
moderationOverview(
|
||||
'first',
|
||||
moderationQuery(
|
||||
new URLSearchParams('caseStatus=closed&assignedTo=unassigned&priority=urgent')
|
||||
)
|
||||
).total
|
||||
).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { AuthUser } from '../../src/lib/auth.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) {
|
||||
db()
|
||||
.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);
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_cases
|
||||
(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||
VALUES('case-123','t5','123','Private investigation','open','moderator','moderator',?,?)`
|
||||
)
|
||||
.run('2026-10-17T12:00:00.000Z', '2026-10-17T12:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
async function api(user: AuthUser | null, id = 'case-123') {
|
||||
const { GET } = await import('../../src/routes/api/moderation/cases/[id]/+server.js');
|
||||
return GET({ locals: { user }, params: { id } } as Parameters<typeof GET>[0]);
|
||||
}
|
||||
|
||||
async function page(user: AuthUser | null, id = 'case-123') {
|
||||
const { load } = await import('../../src/routes/moderation/cases/[id]/+page.server.js');
|
||||
const setHeaders = vi.fn();
|
||||
const result = await load({
|
||||
locals: { user },
|
||||
params: { id },
|
||||
setHeaders
|
||||
} as unknown as Parameters<typeof load>[0]);
|
||||
return { result, setHeaders };
|
||||
}
|
||||
|
||||
it.each(['admin', 'moderator', 'super_viewer'])(
|
||||
'serves private case detail without caching to %s',
|
||||
async (role) => {
|
||||
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||
const user = getUser(role)!;
|
||||
const response = await api(user);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toMatchObject({
|
||||
case: { id: 'case-123', title: 'Private investigation' }
|
||||
});
|
||||
const { result, setHeaders } = await page(user);
|
||||
expect(result).toMatchObject({
|
||||
moderationCase: { case: { id: 'case-123', title: 'Private investigation' } }
|
||||
});
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['operator', 'viewer'])(
|
||||
'rejects %s reads before revealing case existence or content',
|
||||
async (role) => {
|
||||
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||
const user = getUser(role)!;
|
||||
for (const id of ['case-123', 'missing']) {
|
||||
const response = await api(user, id);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual({ error: 'Moderation access is required.' });
|
||||
await expect(page(user, id)).rejects.toMatchObject({
|
||||
status: 403,
|
||||
body: { message: 'Moderation access is required.' }
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
|
||||
'rechecks current staff access when stale session locals have %s',
|
||||
async (change) => {
|
||||
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const user = getUser('moderator')!;
|
||||
db().prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
|
||||
const response = await api(user);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
await expect(page(user)).rejects.toMatchObject({ status: 403 });
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves private not-found responses in the API and page loader', async () => {
|
||||
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||
const user = getUser('moderator')!;
|
||||
const response = await api(user, 'missing');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual({ error: 'Case not found.' });
|
||||
await expect(page(user, 'missing')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
body: { message: 'Case not found.' }
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a signed-in user before looking up a case', async () => {
|
||||
const response = await api(null);
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual({ error: 'Sign in to continue.' });
|
||||
await expect(page(null)).rejects.toMatchObject({
|
||||
status: 401,
|
||||
body: { message: 'Sign in to continue.' }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
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';
|
||||
import { moderationCaseOutcomes } from '../../src/lib/moderation.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
let handle: Database.Database;
|
||||
const clock = '2026-10-17T12:00:00.000Z';
|
||||
const server = 't5_zombie_theater_1';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(clock));
|
||||
fixture = await newFixture([server]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
handle = db();
|
||||
for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_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 chat conduct',
|
||||
...overrides
|
||||
});
|
||||
const restriction = (
|
||||
caseId: string,
|
||||
caseRevision: number,
|
||||
overrides: Record<string, unknown> = {}
|
||||
) => ({
|
||||
op: 'create-sanction',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
kind: 'mute',
|
||||
scope: 'game',
|
||||
reason: 'Repeated chat abuse',
|
||||
durationMinutes: 60,
|
||||
caseId,
|
||||
caseRevision,
|
||||
...overrides
|
||||
});
|
||||
|
||||
it('upgrades legacy cases without changing evidence, events, requests, or restriction records', async () => {
|
||||
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||
const { migrateModerationCases } = await import('../../src/lib/server/moderation/schema.js');
|
||||
const legacy = new Database(':memory:');
|
||||
try {
|
||||
legacy.pragma('foreign_keys = ON');
|
||||
runMigrations(legacy, migrations.slice(0, 28));
|
||||
for (const status of ['open', 'resolved', 'dismissed']) {
|
||||
legacy
|
||||
.prepare('INSERT INTO moderation_cases VALUES(?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(
|
||||
status,
|
||||
't5',
|
||||
'123',
|
||||
`Legacy ${status}`,
|
||||
status,
|
||||
'retired-user',
|
||||
'Former staff',
|
||||
clock,
|
||||
clock,
|
||||
7
|
||||
);
|
||||
legacy
|
||||
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||
.run(
|
||||
`evidence-${status}`,
|
||||
status,
|
||||
'Original evidence',
|
||||
'Archived chat',
|
||||
'{"original":true}',
|
||||
'retired-user',
|
||||
'Former staff',
|
||||
clock
|
||||
);
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||
VALUES('t5','123','Former staff','case.opened',?,'Original history',?)`
|
||||
)
|
||||
.run(status, clock);
|
||||
}
|
||||
legacy
|
||||
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
|
||||
.run(
|
||||
'retired-user',
|
||||
'original-request',
|
||||
'fingerprint',
|
||||
'{"ok":true,"id":"open"}',
|
||||
clock
|
||||
);
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
|
||||
VALUES('old-sanction','t5','123','ban','game','Original reason','','retired-user','Former staff',?)`
|
||||
)
|
||||
.run(clock);
|
||||
runMigrations(legacy, migrations);
|
||||
runMigrations(legacy, migrations);
|
||||
migrateModerationCases(legacy);
|
||||
expect(legacy.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||
expect(
|
||||
legacy
|
||||
.prepare(
|
||||
'SELECT status,revision,priority,assignee_id FROM moderation_cases ORDER BY id'
|
||||
)
|
||||
.all()
|
||||
).toEqual(
|
||||
['dismissed', 'open', 'resolved'].map((status) => ({
|
||||
status,
|
||||
revision: 7,
|
||||
priority: 'normal',
|
||||
assignee_id: null
|
||||
}))
|
||||
);
|
||||
expect(
|
||||
legacy
|
||||
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE snapshot=?')
|
||||
.get('{"original":true}')
|
||||
).toEqual({ count: 3 });
|
||||
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
||||
count: 3
|
||||
});
|
||||
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
||||
result: '{"ok":true,"id":"open"}'
|
||||
});
|
||||
expect(legacy.prepare('SELECT id,case_id FROM moderation_sanctions').get()).toEqual({
|
||||
id: 'old-sanction',
|
||||
case_id: null
|
||||
});
|
||||
legacy.prepare("UPDATE moderation_cases SET status='in_review' WHERE id='open'").run();
|
||||
expect(() =>
|
||||
legacy.prepare("UPDATE moderation_sanctions SET case_id='missing'").run()
|
||||
).toThrow('FOREIGN KEY');
|
||||
expect(() => legacy.prepare("DELETE FROM moderation_cases WHERE id='open'").run()).toThrow(
|
||||
'FOREIGN KEY'
|
||||
);
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('requires private read access and returns a complete case outside list pagination', async () => {
|
||||
const { id } = await create({ note: 'Initial context' });
|
||||
for (const actor of ['viewer', 'operator'])
|
||||
await expect(detail(id, actor)).rejects.toThrow('Moderation access');
|
||||
await expect(detail('missing')).rejects.toMatchObject({ status: 404 });
|
||||
for (let i = 0; i < 30; i++) await mutate({ op: 'case-note', id, text: `Follow-up ${i}` });
|
||||
const data = await detail(id, 'super_viewer');
|
||||
expect(data.case).toMatchObject({
|
||||
id,
|
||||
priority: 'normal',
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
revision: 31
|
||||
});
|
||||
expect(data.case.evidence).toHaveLength(31);
|
||||
expect(data.events).toHaveLength(31);
|
||||
expect(data.player).toMatchObject({ game: 't5', guid: '123' });
|
||||
expect(data.assignees).toEqual([
|
||||
{ id: 'admin', username: 'admin' },
|
||||
{ id: 'moderator', username: 'moderator' }
|
||||
]);
|
||||
await expect(
|
||||
mutate({ op: 'case-note', id, text: 'Forbidden write' }, 'super_viewer')
|
||||
).rejects.toThrow('Moderation access');
|
||||
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
|
||||
await expect(detail(id)).rejects.toThrow('Moderation access');
|
||||
});
|
||||
|
||||
it('audits title, ownership, priority and active state updates with optimistic revisions', async () => {
|
||||
const { id } = await create();
|
||||
await expect(
|
||||
mutate({ op: 'update-case', id, revision: 1, status: 'in_review' })
|
||||
).rejects.toThrow('Status change reason');
|
||||
await mutate({
|
||||
op: 'update-case',
|
||||
id,
|
||||
revision: 1,
|
||||
title: 'Escalated chat report',
|
||||
priority: 'urgent',
|
||||
assigneeId: 'admin',
|
||||
status: 'in_review',
|
||||
reason: 'Reviewing archive'
|
||||
});
|
||||
const data = await detail(id);
|
||||
expect(data.case).toMatchObject({
|
||||
title: 'Escalated chat report',
|
||||
priority: 'urgent',
|
||||
assigneeId: 'admin',
|
||||
assignee: 'admin',
|
||||
status: 'in_review',
|
||||
revision: 2
|
||||
});
|
||||
expect(JSON.parse(data.events[0].detail)).toMatchObject({
|
||||
title: { from: 'Review chat conduct', to: 'Escalated chat report' },
|
||||
assignee: { from: null, to: 'admin' },
|
||||
status: { from: 'open', to: 'in_review' },
|
||||
reason: 'Reviewing archive'
|
||||
});
|
||||
await expect(mutate({ op: 'update-case', id, revision: 1, priority: 'low' })).rejects.toThrow(
|
||||
'changed'
|
||||
);
|
||||
for (const assigneeId of ['viewer', 'super_viewer', 'operator', 'missing'])
|
||||
await expect(mutate({ op: 'update-case', id, revision: 2, assigneeId })).rejects.toThrow(
|
||||
'active administrator or moderator'
|
||||
);
|
||||
handle.prepare("UPDATE auth_users SET must_change_password=1 WHERE id='admin'").run();
|
||||
await mutate({
|
||||
op: 'update-case',
|
||||
id,
|
||||
revision: 2,
|
||||
assigneeId: 'admin',
|
||||
title: 'Existing ownership retained'
|
||||
});
|
||||
await mutate({
|
||||
op: 'update-case',
|
||||
id,
|
||||
revision: 3,
|
||||
assigneeId: null,
|
||||
status: 'escalated',
|
||||
reason: 'Needs administrator review'
|
||||
});
|
||||
expect((await detail(id)).case).toMatchObject({
|
||||
revision: 4,
|
||||
status: 'escalated',
|
||||
assigneeId: null
|
||||
});
|
||||
await expect(
|
||||
mutate({ op: 'update-case', id, revision: 4, assigneeId: 'admin' })
|
||||
).rejects.toThrow('active administrator or moderator');
|
||||
});
|
||||
|
||||
it('supports every outcome and reopening while preserving each prior decision', async () => {
|
||||
const { id } = await create();
|
||||
let revision = 1;
|
||||
for (const status of moderationCaseOutcomes) {
|
||||
await expect(mutate({ op: 'resolve-case', id, revision, status })).rejects.toThrow(
|
||||
'Decision reason'
|
||||
);
|
||||
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
||||
revision++;
|
||||
expect((await detail(id)).case.status).toBe(status);
|
||||
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
||||
'Use reopen'
|
||||
);
|
||||
await expect(
|
||||
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
||||
).rejects.toThrow('changed');
|
||||
await expect(mutate({ op: 'reopen-case', id, revision })).rejects.toThrow(
|
||||
'Reopening reason'
|
||||
);
|
||||
await mutate({ op: 'reopen-case', id, revision, reason: `New evidence after ${status}` });
|
||||
revision++;
|
||||
}
|
||||
const data = await detail(id);
|
||||
expect(data.case).toMatchObject({ revision, status: 'open' });
|
||||
for (const status of moderationCaseOutcomes) {
|
||||
expect(data.case.evidence.some((item) => item.text === `Decision ${status}`)).toBe(true);
|
||||
expect(data.events.some((event) => event.action === `case.${status}`)).toBe(true);
|
||||
}
|
||||
await expect(
|
||||
mutate({ op: 'reopen-case', id, revision, reason: 'Already open' })
|
||||
).rejects.toThrow('already active');
|
||||
await expect(
|
||||
mutate({ op: 'resolve-case', id, revision, status: 'escalated', reason: 'Wrong operation' })
|
||||
).rejects.toThrow('case outcome');
|
||||
});
|
||||
|
||||
it('links restrictions atomically with case revisions and idempotent retries', async () => {
|
||||
const { id } = await create();
|
||||
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
|
||||
for (const override of [
|
||||
{ game: 't6' },
|
||||
{ guid: '456' },
|
||||
{ durationMinutes: null },
|
||||
{ caseRevision: 0 }
|
||||
])
|
||||
await expect(mutate({ ...input, ...override })).rejects.toThrow();
|
||||
expect((await detail(id)).case.revision).toBe(1);
|
||||
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
const applied = await mutate(input);
|
||||
expect(await mutate(input)).toEqual(applied);
|
||||
let data = await detail(id);
|
||||
expect(data.case.revision).toBe(2);
|
||||
expect(data.sanctions).toMatchObject([{ id: applied.id, caseId: id, active: true }]);
|
||||
expect(data.events.filter((event) => event.action === 'case.restriction')).toHaveLength(1);
|
||||
await expect(mutate(restriction(id, 1))).rejects.toThrow('changed');
|
||||
await mutate({ op: 'revoke-sanction', id: applied.id, revision: 1, reason: 'Review revised' });
|
||||
data = await detail(id);
|
||||
expect(data.case.revision).toBe(3);
|
||||
expect(data.sanctions[0].active).toBe(false);
|
||||
expect(data.events[0]).toMatchObject({
|
||||
action: 'restriction.revoked',
|
||||
subjectId: applied.id,
|
||||
detail: 'Review revised'
|
||||
});
|
||||
await mutate({
|
||||
op: 'resolve-case',
|
||||
id,
|
||||
revision: 3,
|
||||
status: 'restriction_applied',
|
||||
reason: 'Finished review'
|
||||
});
|
||||
await expect(mutate(restriction(id, 4))).rejects.toThrow('Reopen this case');
|
||||
});
|
||||
|
||||
it('rolls back restriction, policy and case writes when a linked audit write fails', async () => {
|
||||
const { id } = await create();
|
||||
const policy = handle.prepare('SELECT revision FROM moderation_policy').get();
|
||||
handle.exec(`CREATE TRIGGER fail_case_audit BEFORE INSERT ON moderation_events
|
||||
WHEN NEW.action='case.restriction' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
|
||||
await expect(mutate(input)).rejects.toThrow('audit unavailable');
|
||||
expect((await detail(id)).case.revision).toBe(1);
|
||||
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual(policy);
|
||||
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
expect(
|
||||
handle
|
||||
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
|
||||
.get(input.idempotencyKey)
|
||||
).toEqual({ count: 0 });
|
||||
handle.exec('DROP TRIGGER fail_case_audit');
|
||||
await mutate(input);
|
||||
expect((await detail(id)).sanctions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('attaches only archived evidence for the exact player and retains snapshots after cleanup', async () => {
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
saveTelemetry({
|
||||
server,
|
||||
map: 'zombie_theater',
|
||||
event: 'game_start',
|
||||
players: [{ guid: '123', name: 'Player' }],
|
||||
receivedAt: clock
|
||||
});
|
||||
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
|
||||
id: number;
|
||||
};
|
||||
for (const [guid, message] of [
|
||||
['123', 'Verified message'],
|
||||
['123', ' Evidence with padding '],
|
||||
['456', 'Another player']
|
||||
])
|
||||
handle
|
||||
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
|
||||
.run(
|
||||
matchId,
|
||||
message,
|
||||
0,
|
||||
JSON.stringify({ type: 'message', message: { guid, message, player: 'Player' } })
|
||||
);
|
||||
const { id } = await create();
|
||||
for (const message of ['Forged text', 'Another player', 'Evidence with padding'])
|
||||
await expect(
|
||||
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, message } })
|
||||
).rejects.toThrow('could not be verified');
|
||||
const { id: otherGame } = await create({ game: 't6' });
|
||||
await expect(
|
||||
mutate({
|
||||
op: 'attach-evidence',
|
||||
id: otherGame,
|
||||
revision: 1,
|
||||
evidence: { matchId, message: 'Verified message' }
|
||||
})
|
||||
).rejects.toThrow('source match is unavailable');
|
||||
const input = {
|
||||
op: 'attach-evidence',
|
||||
id,
|
||||
revision: 1,
|
||||
evidence: { matchId, message: 'Verified message' },
|
||||
idempotencyKey: randomUUID()
|
||||
};
|
||||
await mutate(input);
|
||||
await mutate(input);
|
||||
await expect(mutate({ ...input, idempotencyKey: randomUUID() })).rejects.toThrow('changed');
|
||||
await mutate({
|
||||
op: 'attach-evidence',
|
||||
id,
|
||||
revision: 2,
|
||||
evidence: { matchId, message: ' Evidence with padding ' }
|
||||
});
|
||||
handle.prepare('DELETE FROM leaderboard_matches').run();
|
||||
const data = await detail(id);
|
||||
expect(data.case.revision).toBe(3);
|
||||
expect(data.case.evidence).toMatchObject([
|
||||
{ text: 'Verified message', snapshot: expect.stringContaining('Verified message') },
|
||||
{
|
||||
text: ' Evidence with padding ',
|
||||
snapshot: expect.stringContaining(' Evidence with padding ')
|
||||
}
|
||||
]);
|
||||
expect(data.events[0].action).toBe('case.evidence_attached');
|
||||
});
|
||||
|
||||
it('keeps decision history writable when a case has reached its evidence limit', async () => {
|
||||
const { id } = await create();
|
||||
for (let index = 0; index < 100; index++)
|
||||
handle
|
||||
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||
.run(
|
||||
randomUUID(),
|
||||
id,
|
||||
`Evidence ${index}`,
|
||||
'Staff note',
|
||||
null,
|
||||
'moderator',
|
||||
'moderator',
|
||||
clock
|
||||
);
|
||||
await expect(
|
||||
mutate({ op: 'case-note', id, revision: 1, text: 'Over the limit' })
|
||||
).rejects.toThrow('100 evidence entries');
|
||||
await mutate({
|
||||
op: 'resolve-case',
|
||||
id,
|
||||
revision: 1,
|
||||
status: 'no_action',
|
||||
reason: 'Review complete'
|
||||
});
|
||||
await mutate({ op: 'reopen-case', id, revision: 2, reason: 'Reconsider the decision' });
|
||||
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
|
||||
expect((await detail(id)).case.evidence).toHaveLength(102);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
|
||||
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
|
||||
import { moderationHref } from '../../src/lib/ui/moderation.js';
|
||||
import { moderationEventDetail, moderationHref } from '../../src/lib/ui/moderation.js';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
const currentPage = writable({
|
||||
@@ -72,3 +72,41 @@ it('does not invent a game namespace for an unscoped profile', () => {
|
||||
expect(target.searchParams.get('guid')).toBe('42');
|
||||
expect(target.searchParams.has('game')).toBe(false);
|
||||
});
|
||||
|
||||
it('presents case decisions and ownership changes as readable history while preserving staff text', () => {
|
||||
expect(
|
||||
moderationEventDetail(
|
||||
JSON.stringify({
|
||||
title: { from: 'open', to: 'constructor' },
|
||||
assignee: { from: 'open', to: 'constructor' }
|
||||
}),
|
||||
'case.updated'
|
||||
)
|
||||
).toBe('Title: open → constructor\nAssigned to: open → constructor');
|
||||
expect(
|
||||
moderationEventDetail(
|
||||
JSON.stringify({
|
||||
title: { from: 'Report', to: 'Appeal review' },
|
||||
assignee: { from: null, to: 'Moderator' },
|
||||
assigneeId: { from: null, to: 'private-internal-user-id' },
|
||||
status: { from: 'open', to: 'in_review' },
|
||||
reason: 'Reviewing additional evidence'
|
||||
}),
|
||||
'case.updated'
|
||||
)
|
||||
).toBe(
|
||||
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
|
||||
);
|
||||
expect(
|
||||
moderationEventDetail(
|
||||
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
||||
'case.reopened'
|
||||
)
|
||||
).toBe('Previous outcome: Appeal denied\nNew evidence');
|
||||
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
|
||||
'Decision with {unusual text}'
|
||||
);
|
||||
expect(
|
||||
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
|
||||
).toBe('2 archived chat entries attached from match #42.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user