Files
2026-10-15 21:33:50 -04:00

591 lines
23 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ModerationRuntimeIdentity } from '../../src/lib/server/moderation/credentials.js';
const t5 = 't5_zombie_theater_1',
t6 = 't6_zm_transit_1';
let fixture: Fixture;
let instanceId: string;
let credential: string;
let identity: ModerationRuntimeIdentity;
const clock = '2026-10-15T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([t5, t6]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
const 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);
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const host = createHost({ name: 'Moderation host' });
instanceId = randomUUID();
handle
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'library','image',28960,?)`
)
.run(instanceId, t5, host.id, clock);
handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t5', t5);
handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t6', t6);
const { moderationCredential, authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
credential = moderationCredential(instanceId, 1);
identity = authenticateModerationRuntime(
new Request('https://test/api/moderation/runtime', {
headers: { Authorization: `Bearer ${credential}` }
})
)!;
expect(identity).toMatchObject({ instanceId, game: 't5', generation: 1 });
});
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 });
}
const restriction = (overrides: Record<string, unknown> = {}) => ({
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'ban',
scope: 'game',
reason: 'Repeated abuse',
durationMinutes: 60,
...overrides
});
async function sync(overrides: Record<string, unknown> = {}) {
const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js');
return syncModerationRuntime(identity, {
protocol: 1,
server: t5,
generation: 1,
session: 'boot-1',
sessionSequence: 1,
players: [{ guid: '123', name: 'Player', connection: 'boot-1:1' }],
capabilities: ['warn', 'message', 'kick', 'announce', 'ban', 'mute'],
appliedRevision: 0,
receipts: [],
...overrides
});
}
it('enforces private read/write access, duration limits, and explicit fleet identity', async () => {
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const query = moderationQuery(new URLSearchParams());
for (const role of ['viewer', 'operator'])
expect(() => moderationOverview(role, query)).toThrow('Moderation access');
expect(moderationOverview('super_viewer', query).total).toBe(0);
for (const role of ['viewer', 'operator', 'super_viewer'])
await expect(mutate(restriction(), role)).rejects.toThrow('Moderation access');
for (const input of [{ durationMinutes: null }, { durationMinutes: 10081 }, { scope: 'fleet' }])
await expect(mutate(restriction(input))).rejects.toThrow('up to 7 days');
await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games');
expect(await mutate(restriction({ durationMinutes: 10080 }))).toHaveProperty('id');
});
it('applies current moderator limits when revoking restrictions created before an administrator downgrade', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
for (const game of ['t5', 't6'] as const)
observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock);
db().prepare("UPDATE auth_users SET role='admin' WHERE id='moderator'").run();
const restricted = await Promise.all(
[
{ scope: 'fleet', durationMinutes: 10080 },
{ durationMinutes: null },
{ durationMinutes: 11520 }
].map((input) => mutate(restriction(input)))
);
const allowed = await Promise.all(
[{ scope: 'server', server: t5 }, { scope: 'game' }].map((input) =>
mutate(restriction({ ...input, durationMinutes: 10080 }))
)
);
db().prepare("UPDATE auth_users SET role='moderator' WHERE id='moderator'").run();
// The eight-day restriction now has only six days left, but its original duration governs.
vi.setSystemTime(new Date(Date.parse(clock) + 2 * 24 * 60 * 60 * 1000));
const revocation = (id: string) => ({
op: 'revoke-sanction',
id,
revision: 1,
reason: 'Appeal accepted'
});
for (const { id } of restricted)
await expect(mutate(revocation(id))).rejects.toThrow('originally lasting up to 7 days');
expect(
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NOT NULL'
)
.get()
).toEqual({ count: 0 });
for (const { id } of allowed) expect(await mutate(revocation(id))).toMatchObject({ id });
for (const { id } of restricted)
expect(await mutate(revocation(id), 'admin')).toMatchObject({ id });
});
it('applies game and exact server registration scopes and revisioned revocation', async () => {
const game = await mutate(restriction());
const server = await mutate(restriction({ scope: 'server', server: t5, kind: 'mute' }));
const initial = await sync();
expect(initial.sanctions).toHaveLength(2);
await expect(
mutate({ op: 'revoke-sanction', id: game.id, revision: 99, reason: 'Correction' })
).rejects.toThrow('changed');
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare(
"INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES('other','other','Other','unused','moderator',0,0)"
)
.run();
await expect(
mutate({ op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Correction' }, 'other')
).rejects.toThrow('another moderator');
await mutate(
{ op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Appeal accepted' },
'admin'
);
const next = await sync({ appliedRevision: initial.revision });
expect(next.revision).toBeGreaterThan(initial.revision);
expect(next.sanctions).toMatchObject([{ id: server.id }]);
const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js');
const differentRegistration = { ...identity, serverCreatedAt: '2099-01-01T00:00:00.000Z' };
db()
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(differentRegistration.serverCreatedAt, t5);
expect(
syncModerationRuntime(differentRegistration, {
protocol: 1,
server: t5,
generation: 1,
session: 'boot-1',
sessionSequence: 1,
players: [],
capabilities: [],
appliedRevision: 0,
receipts: []
}).sanctions
).toEqual([]);
});
it('excludes expired restrictions without deleting their history', async () => {
await mutate(restriction({ durationMinutes: 1 }));
expect((await sync()).sanctions).toHaveLength(1);
vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
expect((await sync()).sanctions).toEqual([]);
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams('view=restrictions'))
);
expect(data.sanctions).toMatchObject([{ active: false, reason: 'Repeated abuse' }]);
});
it('queues a single live action and preserves exact delivery across duplicate polling', async () => {
await sync();
const request = {
op: 'action',
kind: 'kick',
game: 't5',
guid: '123',
server: t5,
reason: 'Repeated abuse',
idempotencyKey: 'same-action'
};
const created = await mutate(request);
expect(await mutate(request)).toEqual(created);
const first = await sync();
expect(first.actions).toMatchObject([
{ id: created.id, guid: '123', connection: 'boot-1:1', kind: 'kick' }
]);
expect((await sync()).actions).toEqual(first.actions);
expect(
(
await sync({
receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }]
})
).actions
).toEqual([]);
await sync({
receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }]
});
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db()
.prepare(
"SELECT COUNT(*) AS count FROM moderation_events WHERE action='action.confirmed'"
)
.get()
).toEqual({ count: 1 });
});
it('does not deliver to a reused connection or after permission revocation', async () => {
await sync();
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
});
expect(
(await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions
).toEqual([]);
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning again'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run();
expect(
(await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions
).toEqual([]);
expect(db().prepare('SELECT state FROM moderation_actions').all()).toEqual([
{ state: 'failed' },
{ state: 'failed' }
]);
});
it('keeps unacknowledged delivery uncertain and accepts durable late receipts without replay', async () => {
await sync();
const { id } = await mutate({
op: 'action',
kind: 'kick',
game: 't5',
guid: '123',
server: t5,
reason: 'Kick'
});
await sync();
vi.setSystemTime(new Date(Date.parse(clock) + 31_000));
expect((await sync()).actions).toEqual([]);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({
state: 'unknown'
});
await sync({
session: 'boot-2',
sessionSequence: 2,
players: [],
receipts: [{ id, state: 'confirmed', detail: 'Observed disconnect' }]
});
expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({
state: 'confirmed'
});
});
it('expires undelivered actions and refuses stale or unsupported live targeting', async () => {
await sync({ capabilities: ['warn'] });
await expect(
mutate({ op: 'action', kind: 'kick', game: 't5', guid: '123', server: t5, reason: 'Kick' })
).rejects.toThrow('does not support');
await expect(
mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '999',
server: t5,
reason: 'Warning'
})
).rejects.toThrow('current connection');
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
});
vi.setSystemTime(new Date(Date.parse(clock) + 31_000));
await expect(
mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
})
).rejects.toThrow('not connected');
await sync();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM moderation_actions').get()).toEqual({
state: 'expired'
});
});
it('binds machine credentials to the active generation and authenticates before body parsing', async () => {
const { POST } = await import('../../src/routes/api/moderation/runtime/+server.js');
const invoke = (token: string) =>
POST({
request: new Request('https://test/api/moderation/runtime', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: 'bad json'
})
} as Parameters<typeof POST>[0]);
expect((await invoke('ec_bank_' + 'a'.repeat(43))).status).toBe(401);
const { db } = await import('../../src/lib/server/db/index.js');
const { moderationCredential, authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
const future = moderationCredential(instanceId, 2);
expect(
authenticateModerationRuntime(
new Request('https://test', { headers: { Authorization: `Bearer ${future}` } })
)
).toBeNull();
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
expect((await invoke(credential)).status).toBe(401);
expect(
authenticateModerationRuntime(
new Request('https://test', { headers: { Authorization: `Bearer ${future}` } })
)
).toMatchObject({ generation: 2 });
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(clock, instanceId);
expect((await invoke(future)).status).toBe(401);
});
it('preserves verified chat evidence after source match cleanup and rejects forged evidence', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server: t5,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { db } = await import('../../src/lib/server/db/index.js');
const { id: matchId } = db().prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const entry = {
type: 'message',
message: {
guid: '123',
slot: 0,
player: 'Player',
message: 'Evidence message',
time: '1:20'
},
context: { realTime: null, round: null, matchElapsedMs: null }
};
db()
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
.run(matchId, 'key', 0, JSON.stringify(entry));
await expect(
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review',
evidence: { matchId, message: 'Forged message' }
})
).rejects.toThrow('could not be verified');
const { id } = await mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review',
note: 'Staff context',
evidence: { matchId, message: 'Evidence message' }
});
db().prepare('DELETE FROM leaderboard_matches').run();
expect(
db()
.prepare(
'SELECT text,source,snapshot FROM moderation_evidence WHERE case_id=? ORDER BY source'
)
.all(id)
).toEqual([
expect.objectContaining({
text: 'Evidence message',
snapshot: expect.stringContaining('Evidence message')
}),
expect.objectContaining({ text: 'Staff context', source: 'Staff note', snapshot: null })
]);
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'resolved',
reason: 'Appeal accepted'
});
await expect(
mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'dismissed',
reason: 'Stale decision'
})
).rejects.toThrow('changed');
});
it('retains paginated player search and reads pending policy coverage honestly', async () => {
await sync();
await mutate(restriction());
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams('q=Player&offset=999'))
);
expect(data.players).toMatchObject([
{ guid: '123', name: 'Player', servers: [t5], restrictions: 1 }
]);
expect(data.query.offset).toBe(0);
expect(data.coverage.find((c) => c.server === t5)?.state).toBe('pending');
const snapshot = await sync();
await sync({ appliedRevision: snapshot.revision });
expect(
moderationOverview('moderator', data.query).coverage.find((c) => c.server === t5)?.state
).toBe('current');
});
it('does not treat manually entered identities as cross-game observations', async () => {
for (const game of ['t5', 't6'])
await mutate({ op: 'note', game, guid: '123', text: 'Manual lookup' });
await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games');
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
expect(moderationOverview('admin', moderationQuery(new URLSearchParams())).players).toEqual([
expect.objectContaining({ firstSeen: null, lastSeen: null }),
expect.objectContaining({ firstSeen: null, lastSeen: null })
]);
await sync();
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
observeModerationPlayers(db(), 't6', [{ guid: '123', name: 'Verified player' }], clock);
const created = await mutate(restriction({ scope: 'fleet' }), 'admin');
expect((await sync()).sanctions).toMatchObject([{ id: created.id }]);
});
it('shows applicable fleet restrictions in either game without including other game restrictions', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
for (const game of ['t5', 't6'] as const)
observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock);
const fleet = await mutate(restriction({ scope: 'fleet' }), 'admin');
const local = {
t5: await mutate(restriction()),
t6: await mutate(restriction({ game: 't6' }))
};
await mutate(restriction({ game: 't6', guid: '456' }));
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
for (const game of ['t5', 't6'] as const) {
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams({ view: 'restrictions', game, guid: '123' }))
);
expect(data.total).toBe(2);
expect(data.sanctions.map((row) => row.id).sort()).toEqual(
[fleet.id, local[game].id].sort()
);
}
});
it('rejects oversized UTF-8 game messages before policy or queue changes', async () => {
await expect(mutate(restriction({ reason: '界'.repeat(100) }))).rejects.toThrow('UTF-8 bytes');
await sync();
await expect(
mutate({
op: 'action',
kind: 'message',
game: 't5',
guid: '123',
server: t5,
reason: '界'.repeat(100)
})
).rejects.toThrow('UTF-8 bytes');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_actions').get()).toEqual({
count: 0
});
});
it('rechecks runtime authorization after a request body could have yielded', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
await expect(sync()).rejects.toThrow('no longer active');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_runtimes').get()).toEqual({
count: 0
});
db().prepare('UPDATE native_instances SET generation=1 WHERE id=?').run(instanceId);
db().prepare('DELETE FROM moderation_credentials WHERE instance_id=?').run(instanceId);
await expect(sync()).rejects.toThrow('no longer active');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_players').get()).toEqual({
count: 0
});
});
it('rejects coerced enum values before moderator scope checks or SQL binding', async () => {
await expect(mutate(restriction({ scope: ['fleet'] }))).rejects.toThrow('restriction scope');
await sync();
await expect(
mutate({ op: 'action', kind: ['announce'], server: t5, reason: 'Message' })
).rejects.toThrow('supported live action');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({
count: 0
});
});
it('rejects delayed polls from superseded game sessions without replacing current connections', async () => {
await sync({
session: 'new-boot',
sessionSequence: 2,
players: [{ guid: '123', name: 'Current player', connection: 'new-boot:1' }]
});
await expect(sync()).rejects.toThrow('superseded');
await expect(sync({ session: 'different-boot', sessionSequence: 2 })).rejects.toThrow(
'superseded'
);
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db().prepare('SELECT session,session_sequence,players FROM moderation_runtimes').get()
).toMatchObject({
session: 'new-boot',
session_sequence: 2,
players: expect.stringContaining('Current player')
});
});
it('does not advertise current restriction coverage for a runtime lacking ban or mute support', async () => {
const first = await sync();
await sync({ capabilities: ['warn'], appliedRevision: first.revision });
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
expect(
moderationOverview('admin', moderationQuery(new URLSearchParams())).coverage.find(
(row) => row.server === t5
)
).toMatchObject({ state: 'pending', capabilities: ['warn'] });
});