Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s

This commit is contained in:
2026-10-15 21:33:50 -04:00
parent c91d49c298
commit 8b372fa2c1
50 changed files with 4744 additions and 354 deletions
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import ModerationPage from '../../src/routes/moderation/+page.svelte';
import type { ModerationOverview } from '../../src/lib/moderation.js';
import type { UserRole } from '../../src/lib/auth.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({
url: new URL('http://localhost/moderation'),
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', () => ({
goto: navigation.goto,
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
}));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.goto.mockReset().mockResolvedValue(undefined);
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const guid = '18446744073709551615';
function overview(view: ModerationOverview['query']['view'] = 'players'): ModerationOverview {
return {
query: { view, q: '', game: 't5', guid, offset: 0 },
players: [
{
game: 't5',
guid,
name: 'Dempsey',
aliases: ['Tank'],
firstSeen: '2026-10-01T12:00:00Z',
lastSeen: '2026-10-15T12:00:00Z',
servers: ['t5_zombie_theater_1'],
restrictions: 1
}
],
sanctions: [
{
id: 'restriction-1',
game: 't5',
guid,
kind: 'ban',
scope: 'game',
server: null,
reason: 'Repeated abuse',
note: 'Reviewed chat',
createdAt: '2026-10-15T12:00:00Z',
expiresAt: '2026-10-16T12:00:00Z',
revokedAt: null,
actor: 'Other moderator',
actorId: 'staff-2',
revision: 1,
active: true
}
],
cases: [],
events: [],
actions: [],
coverage: [
{
server: 't5_zombie_theater_1',
game: 't5',
connectedAt: '2026-10-15T12:00:00Z',
revision: 1,
desiredRevision: 2,
state: 'pending',
capabilities: ['ban', 'mute']
}
],
total: 1,
limit: 25,
checkedAt: '2026-10-15T12:00:00Z'
};
}
function show(initial = overview(), role: UserRole = 'moderator', extra = '') {
const data = writable({ moderation: initial });
const state = fromStore(data);
const updatePage = (next: ModerationOverview) =>
currentPage.set({
url: new URL(
`http://localhost/moderation?view=${next.query.view}&game=t5&guid=${guid}${extra}`
),
data: { user: { id: 'staff-1', role } }
});
updatePage(initial);
component = mount(ModerationPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: ModerationOverview) => {
updatePage(next);
data.set({ moderation: next });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const enter = (node: HTMLInputElement | HTMLTextAreaElement, value: string) => {
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
};
const submitDialog = () =>
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('limits moderator restrictions and preserves the decimal identity from player context', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'sanction' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show();
button('Restrict player').click();
await tick();
const dialog = document.querySelector('dialog')!;
expect(dialog.open).toBe(true);
expect(dialog.querySelector<HTMLInputElement>('input[name="target-guid"]')!.value).toBe(guid);
expect(dialog.textContent).not.toContain('Permanent');
expect(dialog.textContent).not.toContain('Entire fleet');
enter(dialog.querySelector('textarea')!, 'Repeated abuse');
submitDialog();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'create-sanction',
guid,
game: 't5',
reason: 'Repeated abuse',
scope: 'game',
durationMinutes: 60
});
expect(JSON.parse(fetch.mock.calls[0][1].body).idempotencyKey).toEqual(expect.any(String));
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalled());
const destination = new URL(navigation.goto.mock.calls[0][0], 'http://localhost');
expect(destination.searchParams.get('view')).toBe('restrictions');
expect(destination.searchParams.get('guid')).toBe(guid);
expect(navigation.goto.mock.calls[0][1].invalidateAll).toBe(true);
});
it('keeps unknown submissions immutable and retries the same idempotent request', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'note-1' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show();
button('Add private note').click();
await tick();
enter(document.querySelector('dialog textarea')!, 'Investigate a report');
submitDialog();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('The outcome is not yet known')
);
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);
});
it('retains records on refresh failure and ignores a stale response after navigation', 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('Moderation updates paused.')
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Dempsey');
button('Retry').click();
navigation.before.forEach((fn) => fn());
update(overview('restrictions'));
resolve(new Response(JSON.stringify(overview())));
await tick();
await tick();
expect(
document.querySelector('#moderation-tab-restrictions')?.getAttribute('aria-selected')
).toBe('true');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Repeated abuse');
});
it('shows enforcement pending honestly and keeps super viewer access read-only', () => {
show(overview('restrictions'), 'super_viewer');
expect(document.body.textContent).toContain('Policy pending');
expect(document.body.textContent).toContain('0 of 1 servers current');
expect(document.body.textContent).toContain('Read-only moderation access');
expect(button('Restrict player')).toBeUndefined();
expect(button('Revoke restriction')).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull();
});
it('does not offer revoking another moderator restriction', () => {
show(overview('restrictions'));
expect(button('Revoke restriction')).toBeUndefined();
});
it('limits revoke controls to the current role even for restrictions created by that staff member', () => {
const initial = overview('restrictions');
const own = { ...initial.sanctions[0], actorId: 'staff-1' };
initial.sanctions = [
{ ...own, id: 'permitted', expiresAt: '2026-10-22T12:00:00Z' },
{ ...own, id: 'fleet', scope: 'fleet' },
{ ...own, id: 'permanent', expiresAt: null },
{ ...own, id: 'longer', expiresAt: '2026-10-23T12:00:00Z' }
];
show(initial);
const controls = () =>
[...document.querySelectorAll('button')].filter(
(node) => node.textContent?.trim() === 'Revoke restriction'
);
expect(controls()).toHaveLength(1);
currentPage.update((value) => ({
...value,
data: { user: { id: 'staff-1', role: 'admin' } }
}));
flushSync();
expect(controls()).toHaveLength(4);
});
it('disables unsupported live actions and requires an explicit game for unscoped identities', async () => {
const data = overview();
data.query.game = '';
show(data);
button('Live action').click();
await tick();
const game = document.querySelector<HTMLSelectElement>('dialog select')!;
expect(game.value).toBe('');
expect(
document.querySelector<HTMLButtonElement>('dialog button[type="submit"]')!.disabled
).toBe(true);
expect(document.querySelector('dialog')!.textContent).toMatch(
/Unavailable actions\s+cannot be sent/
);
});
it('preserves player and game filters when paging and resets pagination when applying search', () => {
const data = overview();
data.total = 60;
show(data);
button('Next').click();
let destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('offset')).toBe('25');
expect(destination.searchParams.get('guid')).toBe(guid);
expect(destination.searchParams.get('game')).toBe('t5');
enter(document.querySelector<HTMLInputElement>('input[name="q"]')!, 'Tank');
document
.querySelector('form.filters')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.has('offset')).toBe(false);
expect(destination.searchParams.get('q')).toBe('Tank');
});
it('copies selected archived chat evidence separately from staff notes', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'case' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show(overview(), 'moderator', '&matchId=17&message=abusive%20message');
button('Open case').click();
await tick();
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
enter(inputs[inputs.length - 1], 'Chat report');
enter(document.querySelector('dialog textarea')!, 'Investigate context');
submitDialog();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'create-case',
title: 'Chat report',
note: 'Investigate context',
evidence: { matchId: 17, message: 'abusive message' }
});
});
+8
View File
@@ -227,6 +227,14 @@ describe('management route permissions', () => {
});
describe('authentication and machine boundaries', () => {
it('reserves only the exact runtime POST for instance credentials', () => {
expect(routePermission('/api/moderation/runtime', 'POST')).toBe('machine');
for (const role of roles)
expect(allows(role, '/api/moderation/runtime', 'POST')).toBe(false);
expect(routePermission('/api/moderation/runtime', 'GET')).toBe('moderation-read');
expect(routePermission('/api/moderation/runtime/other', 'POST')).toBe('moderation');
expect(routePermission('/api/moderation/runtime-extra', 'POST')).toBe('moderation');
});
it('makes only leaderboard API reads public', () => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission('/api/leaderboard', method)).toBe('public');
+145
View File
@@ -0,0 +1,145 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let actorId: string;
let hostId: string;
let instanceId: string;
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.stubEnv('EC_PUBLIC_URL', 'https://public-controller.example');
fixture = await newFixture([]);
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
hostId = hosts.createHost({ name: 'Moderation host' }).id;
hosts.enrollHost(hosts.createEnrollment(hostId).token, {
capabilities: ['moderation.configure']
});
actorId = randomUUID();
instanceId = randomUUID();
db()
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES(?,'admin','Admin','unused','admin',1,1)`
)
.run(actorId);
db()
.prepare(
`INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES(?,?,?,1,'library','image',28960,?)`
)
.run(instanceId, server, hostId, new Date().toISOString());
db().prepare(`INSERT INTO servers(name,game) VALUES(?,'t5')`).run(server);
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
describe('moderation runtime configuration', () => {
it('protects the fixed private file even with customized shared config destinations', async () => {
const { config } = await import('../../src/lib/server/config.js');
(config as { relDestConfig: string }).relDestConfig = 'custom-configs';
const { isPrivateConfigPath } =
await import('../../src/lib/server/fleet/private-config.js');
expect(isPrivateConfigPath('configs/electric_cherry_moderation.cfg')).toBe(true);
expect(isPrivateConfigPath('custom-configs/electric_cherry_moderation.cfg')).toBe(true);
expect(isPrivateConfigPath('custom-configs/nested/electric_cherry_moderation.cfg')).toBe(
false
);
});
it('queues private configuration and returns only an operation identifier', async () => {
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { moderationCredential } =
await import('../../src/lib/server/moderation/credentials.js');
const result = configureModerationServer(actorId, server);
const credential = moderationCredential(instanceId, 1);
expect(Object.keys(result)).toEqual(['id']);
const operation = hosts.getHostOperation(result.id)!;
expect(operation).toMatchObject({
kind: 'instance.configure-moderation',
instanceId,
generation: 1,
payload: {
moderation: {
token: credential,
configPath: 'configs/electric_cherry_moderation.cfg',
url: 'https://public-controller.example/api/moderation/runtime',
generation: 1
}
}
});
expect(JSON.stringify(result)).not.toContain(credential);
expect(JSON.stringify(hosts.listHostOperationHistory())).not.toContain(credential);
const { db } = await import('../../src/lib/server/db/index.js');
expect(JSON.stringify(db().prepare('SELECT * FROM moderation_events').all())).not.toContain(
credential
);
expect(
db()
.prepare('SELECT count(*) AS n FROM file_blobs WHERE instr(content,?)>0')
.get(Buffer.from(credential))
).toEqual({ n: 0 });
hosts.reportHostOperationStatus(hostId, result.id, {
state: 'completed',
result: {
configured: true,
generation: 1,
liveLoad: 'sent',
text: credential,
contentBase64: Buffer.from(credential).toString('base64')
},
error: `Unexpected echo ${credential}`
});
const receipt = hosts.getHostOperationStatus(result.id)!;
expect(JSON.stringify(receipt)).not.toContain(credential);
expect(receipt.result).toEqual({ configured: true, generation: 1, liveLoad: 'sent' });
});
it.each(['viewer', 'operator', 'moderator', 'super_viewer'])(
'rejects %s configuration requests',
async (role) => {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE auth_users SET role=? WHERE id=?').run(role, actorId);
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
expect(() => configureModerationServer(actorId, server)).toThrow('access');
expect(db().prepare('SELECT COUNT(*) AS n FROM ec_host_operations').get()).toEqual({
n: 0
});
}
);
it('revalidates account state, host capability and instance identity before queueing', async () => {
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE auth_users SET disabled=1 WHERE id=?').run(actorId);
expect(() => configureModerationServer(actorId, server)).toThrow('access');
db().prepare('UPDATE auth_users SET disabled=0 WHERE id=?').run(actorId);
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.heartbeat(hostId, { capabilities: [] });
expect(() => configureModerationServer(actorId, server)).toThrow('Update the host agent');
hosts.heartbeat(hostId, { capabilities: ['moderation.configure'] });
db()
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
.run(new Date().toISOString(), instanceId);
expect(() => configureModerationServer(actorId, server)).toThrow('active native server');
expect(db().prepare('SELECT COUNT(*) AS n FROM ec_host_operations').get()).toEqual({
n: 0
});
});
it.each([
'',
'http://public-controller.example',
'https://user:password@public-controller.example',
'https://public-controller.example/prefix',
'https://public-controller.example/?token=secret'
])('rejects unsafe controller origin %s', async (url) => {
const { config } = await import('../../src/lib/server/config.js');
(config as { publicUrl: string }).publicUrl = url;
const { moderationRuntimeUrl } =
await import('../../src/lib/server/moderation/configuration.js');
expect(() => moderationRuntimeUrl()).toThrow('EC_PUBLIC_URL');
});
});
+590
View File
@@ -0,0 +1,590 @@
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'] });
});
@@ -26,6 +26,7 @@ beforeEach(async () => {
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries'));
vi.stubEnv('PLUTAINER_IMAGE', image);
vi.stubEnv('RCON_PASSWORD', 'test-password');
vi.stubEnv('EC_PUBLIC_URL', 'https://public-controller.example');
const { createHost, createEnrollment, enrollHost, heartbeat } =
await import('../../src/lib/server/hosts/index.js');
@@ -460,3 +461,51 @@ it('injects unique persistent bank credentials only into private runtime files',
)
).toBe(false);
});
it('keeps legacy agents compatible without claiming moderation configuration', async () => {
const job = await queue();
await runPass();
const op = await operationFor(job.deploymentId);
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
const payload = getHostOperation(op!.id)!.payload as Record<string, unknown>;
expect(payload.moderation).toBeUndefined();
});
it('injects generation-bound moderation credentials privately on compatible hosts', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.heartbeat(hostId, {
...hosts.getHost(hostId)!.inventory,
capabilities: ['moderation.configure']
});
const job = await queue();
await runPass();
const op = await operationFor(job.deploymentId);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const { moderationCredential } = await import('../../src/lib/server/moderation/credentials.js');
const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const instance = nativeInstance(job.name)!;
const credential = moderationCredential(instance.id, instance.generation);
const payload = hosts.getHostOperation(op!.id)!.payload as {
moderation: { token: string; configPath: string; url: string; generation: number };
files: { path: string }[];
env: Record<string, string>;
};
expect(payload.moderation).toEqual({
token: credential,
configPath: 'configs/electric_cherry_moderation.cfg',
url: 'https://public-controller.example/api/moderation/runtime',
generation: instance.generation
});
expect(payload.files.some((file) => file.path.endsWith('electric_cherry_moderation.cfg'))).toBe(
false
);
expect(payload.env.PLUTAINER_EXTRA_ARGS).toContain('+exec electric_cherry_moderation.cfg');
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db()
.prepare('SELECT count(*) AS n FROM file_blobs WHERE instr(content,?) > 0')
.get(Buffer.from(credential))
).toEqual({ n: 0 });
expect(JSON.stringify(recipeForDeployment(job.deploymentId))).not.toContain(credential);
expect(JSON.stringify(hosts.listHostOperationHistory())).not.toContain(credential);
});
+38
View File
@@ -1298,6 +1298,44 @@ describe('native instance provisioning', () => {
await advance();
expect(nativeInstance(job.name)?.generation).toBe(2);
});
it('prepares separate moderation credentials for the replacement generation', async () => {
const job = await queue();
const { config } = await import('../../src/lib/server/config.js');
(config as { publicUrl: string }).publicUrl = 'https://public-controller.example';
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
heartbeat(hostId, {
...inventory(),
capabilities: [...inventory().capabilities, 'moderation.configure']
});
await advance();
const created = await acknowledge(job.deploymentId);
const oldToken = (created.payload.moderation as { token: string }).token;
const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js');
const rebuild = queueNativeRebuild(job.name, 'test');
if (!rebuild.ok) throw new Error(rebuild.message);
await advance();
const operation = (await operationFor(rebuild.deploymentId))!;
expect(operation).toMatchObject({
generation: 1,
payload: {
nextGeneration: 2,
moderation: { generation: 2, configPath: 'configs/electric_cherry_moderation.cfg' }
}
});
const newToken = (operation.payload.moderation as { token: string }).token;
expect(newToken).not.toBe(oldToken);
const { authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
const request = (token: string) =>
new Request('https://public-controller.example/api/moderation/runtime', {
headers: { Authorization: `Bearer ${token}` }
});
expect(authenticateModerationRuntime(request(oldToken))).toMatchObject({ generation: 1 });
expect(authenticateModerationRuntime(request(newToken))).toBeNull();
await acknowledge(rebuild.deploymentId);
expect(authenticateModerationRuntime(request(oldToken))).toBeNull();
expect(authenticateModerationRuntime(request(newToken))).toMatchObject({ generation: 2 });
});
it('carries confirmed admin override through serialized rebuild preparation', async () => {
const job = await queue();
await advance();
+8
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'],
['/moderation', 'players', '/moderation'],
['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => {
@@ -61,6 +62,13 @@ describe('application navigation', () => {
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
});
it('reserves private moderation navigation for moderators, administrators, and Super Viewers', () => {
for (const role of ['admin', 'moderator', 'super_viewer'] as const)
expect(navigationLocation('/moderation', role)?.group.id).toBe('players');
for (const role of ['operator', 'viewer'] as const)
expect(navigationLocation('/moderation', role)).toBeNull();
});
it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map((group) => group.label)).toEqual([
'Fleet',
+292 -258
View File
@@ -3,267 +3,301 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
const filename = 'electric_cherry_bank.cfg';
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(servers);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { destOfKind } = await import('../../src/lib/server/config.js');
for (const server of servers) {
await mkdir(destOfKind(server, 'config'), { recursive: true });
await writeFile(join(destOfKind(server, 'config'), filename), secret);
}
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
describe.each(['electric_cherry_bank.cfg', 'electric_cherry_moderation.cfg'])(
'private config %s',
(filename) => {
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(servers);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { destOfKind } = await import('../../src/lib/server/config.js');
for (const server of servers) {
await mkdir(destOfKind(server, 'config'), { recursive: true });
await writeFile(join(destOfKind(server, 'config'), filename), secret);
}
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
async function evidenceIsPrivate() {
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual([]);
expect(db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)).toEqual(
[]
);
}
async function evidenceIsPrivate() {
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual(
[]
);
expect(
db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)
).toEqual([]);
}
describe.each(servers)('private bank config on %s', (server) => {
it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation } =
await import('../../src/lib/server/fleet/staged-files.js');
const backend = await import('../../src/lib/server/backends/files.js');
const reads = vi.spyOn(backend, 'readFile');
expect(await drift.scanServerDrift(server)).toEqual([]);
expect((await drift.previewDrift([server])).files).toEqual([]);
expect((await drift.prepareDriftCampaign(server)).files).toEqual([]);
expect((await drift.runDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
const dest = destOfKind(server, 'config');
await writeFile(join(dest, 'stray.cfg'), 'unwanted');
// The exemption is for the generated path, not every matching basename.
await mkdir(join(dest, 'nested'));
await writeFile(join(dest, 'nested', filename), 'unwanted nested file');
for (const name of ['modified.gsc', 'missing.gsc']) {
const artifact = createScript({
name,
releaseVersion: '1.0.0',
targetRelPath: name,
kind: 'script',
scopeType: 'game',
scopeValue: server.slice(0, 2),
content: Buffer.from('promoted')
describe.each(servers)('private bank config on %s', (server) => {
it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { requestRestart } =
await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation } =
await import('../../src/lib/server/fleet/staged-files.js');
const backend = await import('../../src/lib/server/backends/files.js');
const reads = vi.spyOn(backend, 'readFile');
expect(await drift.scanServerDrift(server)).toEqual([]);
expect((await drift.previewDrift([server])).files).toEqual([]);
expect((await drift.prepareDriftCampaign(server)).files).toEqual([]);
expect(
(await drift.runDriftDeployment({ servers: [server] })).deploymentId
).toBeNull();
const dest = destOfKind(server, 'config');
await writeFile(join(dest, 'stray.cfg'), 'unwanted');
// The exemption is for the generated path, not every matching basename.
await mkdir(join(dest, 'nested'));
await writeFile(join(dest, 'nested', filename), 'unwanted nested file');
for (const name of ['modified.gsc', 'missing.gsc']) {
const artifact = createScript({
name,
releaseVersion: '1.0.0',
targetRelPath: name,
kind: 'script',
scopeType: 'game',
scopeValue: server.slice(0, 2),
content: Buffer.from('promoted')
});
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(artifact.version.id, artifact.script.id);
}
await writeFile(join(destOfKind(server, 'script'), 'modified.gsc'), 'modified');
const plan = await drift.previewDrift([server]);
expect(plan.files.map((file) => file.relPath).sort()).toEqual([
'missing.gsc',
'modified.gsc',
`nested/${filename}`,
'stray.cfg'
]);
const result = await drift.runDriftDeployment({
servers: [server],
expectedFingerprint: plan.fingerprint
});
const operation = requestRestart(server, {
kind: 'campaign',
id: result.deploymentId!
});
applyStagedFilesForOperation(operation.id, server);
expect(await drift.scanServerDrift(server)).toEqual([]);
expect(await readFile(join(dest, filename))).toEqual(secret);
expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(
false
);
await evidenceIsPrivate();
});
it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => {
const { prepareDriftCampaign, createDriftRebuildCampaign } =
await import('../../src/lib/server/fleet/drift.js');
const { beginDeployment } =
await import('../../src/lib/server/provisioning/history.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted');
const prepared = await prepareDriftCampaign(server);
const privateFile = {
...prepared.files[0],
before: secret,
file: { ...prepared.files[0].file, relPath: filename }
};
prepared.files.push(privateFile);
prepared.plan.files.push(privateFile.file);
const operation = beginDeployment(server, 'rebuild', 'watchdog');
const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared);
expect(
db()
.prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?')
.all(id)
).toEqual([{ rel_path: 'stray.cfg' }]);
expect(
createDriftRebuildCampaign(server, operation, 'bank-only', {
...prepared,
files: [privateFile]
})
).toBeNull();
await evidenceIsPrivate();
});
it('rejects new and persisted private deletions before activation or evidence capture', async () => {
const staged = await import('../../src/lib/server/fleet/staged-files.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { requestRestart } =
await import('../../src/lib/server/provisioning/requests.js');
const { applyActivationFiles } =
await import('../../src/lib/server/provisioning/activation-files.js');
const { captureRecipePlan } =
await import('../../src/lib/server/fleet/launch-recipes.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('old','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('old',?,1,'pending')"
)
.run(server);
const change = {
sourceKind: 'campaign' as const,
sourceId: 'old',
server,
kind: 'config' as const,
relPath: filename,
content: null,
inventorySource: 'drift:old'
};
expect(() => staged.queueFileChange(change)).toThrow('Private bank configuration');
db()
.prepare(
"INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,inventory_source) VALUES('campaign','old',?,'config',?,'drift:old')"
)
.run(server, filename);
const operation = requestRestart(server, { kind: 'campaign', id: 'old' });
expect(() => staged.applyStagedFilesForOperation(operation.id, server)).toThrow(
'Private bank configuration'
);
const recipe = captureRecipePlan(
server.startsWith('t6') ? 't6' : 't5',
server.startsWith('t6') ? 'town' : 'theater'
);
expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow(
'Private bank configuration'
);
expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(
secret
);
await evidenceIsPrivate();
});
});
it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js');
expect(() =>
createScript({
name: 'private',
kind: 'config',
targetRelPath: filename,
scopeType: 'game',
scopeValue: 't5',
content: secret
})
).toThrow('reserved');
const packed = packFileJson({
before: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
});
expect(packed.hashes.size).toBe(0);
expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64'));
await evidenceIsPrivate();
});
it('blocks legacy private campaign diffs and blob downloads', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { putFileBlob, getFileBlob } =
await import('../../src/lib/server/db/file-blobs.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
const hash = putFileBlob(secret)!;
const file = db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
for (const route of [
await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js'),
await import('../../src/routes/api/deployments/[id]/file/[fileId]/+server.js')
]) {
await expect(
route.GET({ params: { id: 'legacy', fileId: String(file.lastInsertRowid) } })
).rejects.toMatchObject({ status: 404 });
}
expect(() => getFileBlob(hash)).toThrow('Private file content');
});
it('blocks old host commands and redacts late private read receipts from older agents', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const host = hosts.createHost({ name: 'Legacy private command host' });
const credential = hosts.enrollHost(
hosts.createEnrollment(host.id).token,
{}
).credential;
const payload = {
files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
};
expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow(
'Private bank'
);
const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] });
db()
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
.run(JSON.stringify(payload), stage.id);
expect(() => hosts.getHostOperationPayload(host.id, stage.id)).toThrow('Private bank');
const healthyRead = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg'
});
expect(
hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
).toContain(healthyRead.id);
expect(
hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
).not.toContain(stage.id);
const read = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg'
});
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(artifact.version.id, artifact.script.id);
}
await writeFile(join(destOfKind(server, 'script'), 'modified.gsc'), 'modified');
const plan = await drift.previewDrift([server]);
expect(plan.files.map((file) => file.relPath).sort()).toEqual([
'missing.gsc',
'modified.gsc',
`nested/${filename}`,
'stray.cfg'
]);
const result = await drift.runDriftDeployment({
servers: [server],
expectedFingerprint: plan.fingerprint
.prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?')
.run(
JSON.stringify({ path: `configs/${filename}` }),
`configs/${filename}`,
read.id
);
hosts.reportHostOperationStatus(host.id, read.id, {
state: 'completed',
result: { contentBase64: secret.toString('base64') }
});
expect(hosts.getHostOperation(read.id)?.result).toEqual({ redacted: true });
await evidenceIsPrivate();
// Even an old operation reference cannot authorize downloading captured credentials.
const { putFileBlob, referenceBlobs } =
await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(secret)!;
referenceBlobs('operation', stage.id, [hash]);
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
expect(() =>
GET({
request: new Request('https://test', {
headers: { Authorization: `Bearer ${credential}` }
}),
params: { sha256: hash }
})
).toThrow();
});
const operation = requestRestart(server, { kind: 'campaign', id: result.deploymentId! });
applyStagedFilesForOperation(operation.id, server);
expect(await drift.scanServerDrift(server)).toEqual([]);
expect(await readFile(join(dest, filename))).toEqual(secret);
expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(false);
await evidenceIsPrivate();
});
it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => {
const { prepareDriftCampaign, createDriftRebuildCampaign } =
await import('../../src/lib/server/fleet/drift.js');
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted');
const prepared = await prepareDriftCampaign(server);
const privateFile = {
...prepared.files[0],
before: secret,
file: { ...prepared.files[0].file, relPath: filename }
};
prepared.files.push(privateFile);
prepared.plan.files.push(privateFile.file);
const operation = beginDeployment(server, 'rebuild', 'watchdog');
const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared);
expect(
db().prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?').all(id)
).toEqual([{ rel_path: 'stray.cfg' }]);
expect(
createDriftRebuildCampaign(server, operation, 'bank-only', {
...prepared,
files: [privateFile]
})
).toBeNull();
await evidenceIsPrivate();
});
it('rejects new and persisted private deletions before activation or evidence capture', async () => {
const staged = await import('../../src/lib/server/fleet/staged-files.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyActivationFiles } =
await import('../../src/lib/server/provisioning/activation-files.js');
const { captureRecipePlan } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('old','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('old',?,1,'pending')"
)
.run(server);
const change = {
sourceKind: 'campaign' as const,
sourceId: 'old',
server,
kind: 'config' as const,
relPath: filename,
content: null,
inventorySource: 'drift:old'
};
expect(() => staged.queueFileChange(change)).toThrow('Private bank configuration');
db()
.prepare(
"INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,inventory_source) VALUES('campaign','old',?,'config',?,'drift:old')"
)
.run(server, filename);
const operation = requestRestart(server, { kind: 'campaign', id: 'old' });
expect(() => staged.applyStagedFilesForOperation(operation.id, server)).toThrow(
'Private bank configuration'
);
const recipe = captureRecipePlan(
server.startsWith('t6') ? 't6' : 't5',
server.startsWith('t6') ? 'town' : 'theater'
);
expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow(
'Private bank configuration'
);
expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(secret);
await evidenceIsPrivate();
});
});
it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js');
expect(() =>
createScript({
name: 'private',
kind: 'config',
targetRelPath: filename,
scopeType: 'game',
scopeValue: 't5',
content: secret
})
).toThrow('reserved');
const packed = packFileJson({
before: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
});
expect(packed.hashes.size).toBe(0);
expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64'));
await evidenceIsPrivate();
});
it('blocks legacy private campaign diffs and blob downloads', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { putFileBlob, getFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
const hash = putFileBlob(secret)!;
const file = db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
for (const route of [
await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js'),
await import('../../src/routes/api/deployments/[id]/file/[fileId]/+server.js')
]) {
await expect(
route.GET({ params: { id: 'legacy', fileId: String(file.lastInsertRowid) } })
).rejects.toMatchObject({ status: 404 });
}
expect(() => getFileBlob(hash)).toThrow('Private file content');
});
it('blocks old host commands and redacts late private read receipts from older agents', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const host = hosts.createHost({ name: 'Legacy private command host' });
const credential = hosts.enrollHost(hosts.createEnrollment(host.id).token, {}).credential;
const payload = {
files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
};
expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow('Private bank');
const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] });
db()
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
.run(JSON.stringify(payload), stage.id);
expect(() => hosts.getHostOperationPayload(host.id, stage.id)).toThrow('Private bank');
const healthyRead = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg'
});
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).toContain(
healthyRead.id
);
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).not.toContain(
stage.id
);
const read = hosts.queueHostOperation(host.id, 'files.read', { path: 'configs/stray.cfg' });
db()
.prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?')
.run(JSON.stringify({ path: `configs/${filename}` }), `configs/${filename}`, read.id);
hosts.reportHostOperationStatus(host.id, read.id, {
state: 'completed',
result: { contentBase64: secret.toString('base64') }
});
expect(hosts.getHostOperation(read.id)?.result).toEqual({ redacted: true });
await evidenceIsPrivate();
// Even an old operation reference cannot authorize downloading captured credentials.
const { putFileBlob, referenceBlobs } = await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(secret)!;
referenceBlobs('operation', stage.id, [hash]);
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
expect(() =>
GET({
request: new Request('https://test', {
headers: { Authorization: `Bearer ${credential}` }
}),
params: { sha256: hash }
})
).toThrow();
});
);
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, expect, it, vi } from 'vitest';
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 type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/servers/t6_zm_transit_1'),
data: { user: { role: 'moderator' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
afterEach(() =>
currentPage.update((value) => ({ ...value, data: { user: { role: 'moderator' } } }))
);
it('keeps ambiguous engine chat IDs in search and preserves selected evidence for verified lookup', () => {
const body = render(ChatPanel, {
props: {
game: 't6',
history: [
{
type: 'message',
matchId: 12,
message: {
guid: 'abcdef',
player: 'Tank',
message: 'Review & investigate',
slot: 2,
time: '0:30'
},
context: { realTime: null, round: null, matchElapsedMs: null }
}
]
}
}).body;
const target = new URL(
body.match(/href="([^"]*\/moderation[^"]*)"/)![1].replaceAll('&amp;', '&'),
'http://localhost'
);
expect(target.searchParams.get('q')).toBe('abcdef');
expect(target.searchParams.has('guid')).toBe(false);
expect(target.searchParams.get('game')).toBe('t6');
expect(target.searchParams.get('matchId')).toBe('12');
expect(target.searchParams.get('message')).toBe('Review & investigate');
});
it('links scoreboard actions to the exact telemetry GUID and hides moderation from ordinary viewers', () => {
const props = {
report: {
server: 't6_zm_transit_1',
map: 'zm_transit',
event: 'game_start' as const,
receivedAt: '2026-10-15T12:00:00Z',
players: [{ guid: '18446744073709551615', name: 'Tank' }]
}
};
const body = render(PlayerScoreboard, { props }).body;
expect(body).toContain('guid=18446744073709551615');
expect(body).toContain('server=t6_zm_transit_1');
currentPage.update((value) => ({ ...value, data: { user: { role: 'viewer' } } }));
expect(render(PlayerScoreboard, { props }).body).not.toContain('/moderation?');
});
it('does not invent a game namespace for an unscoped profile', () => {
const target = new URL(moderationHref({ guid: '42', game: 'unknown' }), 'http://localhost');
expect(target.searchParams.get('guid')).toBe('42');
expect(target.searchParams.has('game')).toBe(false);
});