This commit is contained in:
@@ -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' }
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
);
|
||||
|
||||
@@ -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('&', '&'),
|
||||
'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);
|
||||
});
|
||||
Reference in New Issue
Block a user