Add native host management and role-based operator authentication

Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
2026-10-03 11:42:36 -04:00
parent a2e2311aa9
commit e5c0fda1ed
127 changed files with 8485 additions and 657 deletions
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import type { UserRole } from '../../src/lib/auth.js';
import { roleAllows, routePermission, type RoutePermission } from '../../src/lib/server/auth/access.js';
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
const allows = (role: UserRole, route: string | null, method = 'GET') => roleAllows(role, routePermission(route, method));
describe('management route permissions', () => {
it.each([
'/', '/api/fleet', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard',
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
])('lets viewers read the safe summary route %s', route => {
expect(routePermission(route, 'GET')).toBe('viewer');
expect(allows('viewer', route)).toBe(true);
expect(allows('viewer', route, 'HEAD')).toBe(true);
});
it.each([
['/api/servers', 'POST'], ['/api/servers/[name]', 'DELETE'], ['/api/provisioning', 'POST'],
['/api/provisioning/[id]/cancel', 'POST'], ['/api/provisioning/[id]/resume', 'POST'],
['/api/deployment-collections', 'POST'], ['/api/deployment-collections/preview', 'POST'],
['/api/blueprints', 'POST'], ['/api/blueprints/[id]', 'PUT'], ['/api/blueprints/[id]', 'DELETE'],
['/api/betas', 'POST'], ['/api/betas/[id]/promote', 'POST'], ['/api/betas/[id]/rollback', 'POST'],
['/api/campaigns/[id]/rollback', 'POST'], ['/api/deployments/[id]/rollback', 'POST'],
['/api/scripts', 'POST'], ['/api/scripts/[id]/versions', 'POST'], ['/api/scripts/[id]/versions/[versionId]', 'DELETE'],
['/api/configs', 'POST'], ['/api/plugins/github/import', 'POST'],
['/api/maintenance/[id]', 'POST'], ['/api/watchdog/[id]', 'POST'], ['/api/servers/[name]/probe-rcon', 'POST']
])('requires an operator for %s %s', (route, method) => {
expect(routePermission(route, method)).toBe('operator');
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
expect(allows('admin', route, method)).toBe(true);
});
it.each(['/api/users', '/api/users/[id]', '/api/users/[id]/password', '/settings', '/settings/users', '/server-keys', '/keys', '/api/server-keys'])('reserves all methods on %s for administrators', route => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
expect(routePermission(route, method)).toBe('admin');
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
}
});
it.each([
['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]/enrollment', 'POST'],
['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'],
['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'],
['/api/game-files/[id]/download', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST']
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
expect(routePermission(route, method)).toBe('admin');
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
});
it.each([
'/configs/[id]', '/api/configs/[id]', '/api/configs/inspect',
'/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]',
'/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests'
])('prevents viewer access to secret-bearing raw content at %s', route => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('operator');
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
}
});
});
describe('authentication and machine boundaries', () => {
it('makes only the intended login/bootstrap methods public', () => {
for (const route of ['/login', '/setup']) {
expect(routePermission(route, 'GET')).toBe('public');
expect(routePermission(route, 'HEAD')).toBe('public');
expect(routePermission(route, 'POST')).not.toBe('public');
}
for (const route of ['/api/auth/login', '/api/auth/setup']) {
expect(routePermission(route, 'POST')).toBe('public');
expect(routePermission(route, 'GET')).not.toBe('public');
expect(routePermission(route, 'DELETE')).not.toBe('public');
}
expect(routePermission('/api/auth/unknown', 'POST')).toBe('admin');
});
it.each(['/account', '/api/auth/me', '/api/auth/password', '/api/auth/logout'])('permits every authenticated role to manage their own account at %s', route => {
expect(routePermission(route, 'GET')).toBe('authenticated');
expect(routePermission(route, 'POST')).toBe('authenticated');
for (const role of roles) expect(allows(role, route, 'POST')).toBe(true);
});
it.each([
['/api/agent/enroll', 'POST'], ['/api/agent/heartbeat', 'POST'], ['/api/agent/operations', 'GET'],
['/api/agent/operations/[id]', 'POST'], ['/api/agent/libraries/[id]/files/[...path]', 'GET'],
['/api/agent/imports/[id]/files/[...path]', 'PUT'], ['/api/agent/imports/[id]/complete', 'POST'],
['/api/agent/install.sh', 'GET'], ['/api/agent/release/[file]', 'GET'], ['/api/telemetry', 'POST']
])('preserves endpoint-specific machine authentication for %s', (route, method) => {
expect(routePermission(route, method)).toBe('machine');
for (const role of roles) expect(allows(role, route, method)).toBe(false);
});
it('does not grant machine exemptions to similar prefixes or unsupported telemetry methods', () => {
for (const route of ['/api/agents', '/api/agentish/operations', '/api/telemetry-extra', '/api/telemetry/anything']) {
expect(routePermission(route, 'POST')).toBe('operator');
}
expect(routePermission('/api/telemetry', 'GET')).toBe('viewer');
expect(routePermission('/api/telemetry', 'PUT')).toBe('operator');
});
it('protects unknown routes and does not infer public assets from URL suffixes', () => {
for (const route of [null, '/not-found', '/unrecognized.json', '/anything.css', '/__data.json']) {
expect(routePermission(route, 'GET')).toBe('viewer');
expect(routePermission(route, 'POST')).toBe('operator');
expect(allows('viewer', route, 'POST')).toBe(false);
}
});
it.each(['/api/agent/../server-keys', '/api/%61gent/operations', '/api/agent/%2e%2e/settings', '/api/agent\\operations', '//api/agent/operations', '/api/agent/operations?bypass=1'])('fails closed if a caller supplies noncanonical route identity %s', route => {
expect(routePermission(route, 'GET')).toBe('admin');
expect(routePermission(route, 'POST')).toBe('admin');
});
it('rejects invalid roles even when a permission would normally allow all users', () => {
for (const permission of ['public', 'authenticated', 'viewer', 'operator', 'admin', 'machine'] as RoutePermission[]) {
expect(roleAllows('unexpected' as UserRole, permission)).toBe(false);
}
});
});
+268
View File
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import type { AuthUser, UserRole } from '../../src/lib/auth.js';
type Session = { user: AuthUser; tokenHash: string; expiresAt: number };
const auth = vi.hoisted(() => ({
session: null as Session | null, current: null as Session | null, setup: false,
readSession: vi.fn(), readSessionHash: vi.fn(), recordAudit: vi.fn(), setupRequired: vi.fn()
}));
vi.mock('../../src/lib/server/auth/index.js', () => ({
SESSION_COOKIE: 'ec_session',
AuthError: class AuthError extends Error { constructor(message: string, public status = 400) { super(message); } },
readSession: auth.readSession, readSessionHash: auth.readSessionHash, recordAudit: auth.recordAudit, setupRequired: auth.setupRequired
}));
import { authenticationHandle, protectEventStream } from '../../src/lib/server/auth/guard.js';
const token = 'x'.repeat(43);
function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session {
const user: AuthUser = { id: 'user-1', username: 'tester', displayName: 'Test User', role, disabled: false,
mustChangePassword, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
auth.session = { user, tokenHash: 'hash-of-session', expiresAt: Date.now() + 3600_000 };
auth.current = auth.session;
return auth.session;
}
type Resolve = Parameters<Handle>[0]['resolve'];
function requestFor(routeId: string | null, options: {
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit;
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
} = {}) {
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
const method = options.method ?? 'GET'; const headers = new Headers(options.headers);
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
const cookies = { get: vi.fn(() => token) };
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
}
beforeEach(() => {
vi.clearAllMocks(); auth.session = null; auth.current = null; auth.setup = false;
auth.readSession.mockImplementation(() => auth.session);
auth.readSessionHash.mockImplementation(() => auth.current);
auth.setupRequired.mockImplementation(() => auth.setup);
vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'false');
});
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
describe('browser authentication guard', () => {
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
const page = requestFor('/metrics', { path: '/metrics?days=7' });
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
expect(page.resolve).not.toHaveBeenCalled();
const api = requestFor('/api/metrics'); const response = await api.invoke();
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: false });
expect(response.headers.get('cache-control')).toBe('no-store'); expect(api.resolve).not.toHaveBeenCalled();
});
it('directs first-run browsers to setup and tells APIs setup is required', async () => {
auth.setup = true;
await expect(requestFor('/').invoke()).rejects.toMatchObject({ status: 303, location: '/setup' });
const response = await requestFor('/api/fleet').invoke();
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: true });
expect((await requestFor('/setup').invoke()).status).toBe(200);
});
it('handles login/setup redirects without exposing management pages', async () => {
await expect(requestFor('/setup').invoke()).rejects.toMatchObject({ location: '/login' });
auth.setup = true;
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/setup' });
auth.setup = false; signIn('viewer');
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/' });
signIn('operator', true);
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/account' });
});
it.each([
['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403],
['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', 'GET', 200],
['operator', '/api/provisioning', 'POST', 200], ['operator', '/api/hosts/[id]', 'PATCH', 403],
['operator', '/api/users', 'GET', 403], ['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/users', 'GET', 200]
] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
const session = signIn(role); const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
const response = await call.invoke(); expect(response.status).toBe(expectedStatus);
if (expectedStatus === 200) { expect(call.resolve).toHaveBeenCalledOnce(); expect(call.event.locals.user).toEqual(session.user); }
else expect(call.resolve).not.toHaveBeenCalled();
});
it('requires temporary-password users to change it even if their role is administrator', async () => {
signIn('admin', true);
await expect(requestFor('/settings').invoke()).rejects.toMatchObject({ status: 303, location: '/account' });
const blocked = await requestFor('/api/users').invoke();
expect(blocked.status).toBe(403); expect(await blocked.json()).toMatchObject({ mustChangePassword: true });
for (const [route, method] of [['/account', 'GET'], ['/api/auth/me', 'GET'], ['/api/auth/password', 'POST'], ['/api/auth/logout', 'POST']]) {
expect((await requestFor(route, { method, body: method === 'POST' ? {} : undefined }).invoke()).status).toBe(200);
}
});
it('adds private response headers and audits successful writes without recording request secrets', async () => {
signIn('admin');
const call = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'secret-server-key' } });
const response = await call.invoke();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('vary')).toContain('Cookie');
expect(response.headers.get('x-frame-options')).toBe('DENY');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(auth.recordAudit).toHaveBeenCalledWith('user-1', 'request', '/api/servers', { method: 'POST', status: 200, path: '/api/servers' });
expect(JSON.stringify(auth.recordAudit.mock.calls)).not.toContain('secret-server-key');
});
it('does not audit denied or unsuccessful writes as successful actions', async () => {
signIn('viewer'); await requestFor('/api/servers', { method: 'POST', body: {} }).invoke();
expect(auth.recordAudit).not.toHaveBeenCalled();
signIn('admin'); await requestFor('/api/servers', { method: 'POST', body: {}, resolve: async () => new Response('bad', { status: 400 }) }).invoke();
expect(auth.recordAudit).not.toHaveBeenCalled();
});
});
describe('origin and transport enforcement', () => {
it.each(['/api/auth/login', '/api/auth/setup', '/api/servers'])('requires exact Origin for %s, including public auth requests', async route => {
signIn('admin');
for (const origin of [null, 'null', 'https://attacker.test', 'https://ec.test.attacker.test', 'http://ec.test', 'https://ec.test:8443']) {
const call = requestFor(route, { method: 'POST', origin, body: {} });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
expect((await requestFor(route, { method: 'POST', body: {} }).invoke()).status).toBe(200);
});
it('rejects explicitly cross-site requests even if their Origin header matches', async () => {
signIn('admin');
const call = requestFor('/api/auth/login', { method: 'POST', body: {}, headers: { 'Sec-Fetch-Site': 'cross-site' } });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
});
it('applies origin validation to multipart artifact uploads without consuming their body', async () => {
signIn('operator');
const form = new FormData(); form.set('name', 'script'); form.set('content', new Blob(['script bytes']), 'test.gsc');
const denied = requestFor('/api/scripts', { method: 'POST', origin: null, rawBody: form });
expect((await denied.invoke()).status).toBe(403);
const allowed = requestFor('/api/scripts', { method: 'POST', rawBody: form, resolve: async event => {
expect((await event.request.formData()).get('name')).toBe('script'); return new Response('uploaded', { status: 201 });
} });
expect((await allowed.invoke()).status).toBe(201);
});
it('requires HTTPS by default even on loopback', async () => {
signIn('admin');
for (const base of ['http://localhost', 'http://127.0.0.1', 'http://[::1]', 'http://private.lan']) {
const call = requestFor('/api/fleet', { base });
expect((await call.invoke()).status).toBe(426); expect(call.resolve).not.toHaveBeenCalled();
}
});
it('permits insecure developer sessions only with the explicit flag and a literal loopback hostname', async () => {
signIn('admin'); vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'true');
for (const base of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://[::1]:3000']) {
const call = requestFor('/api/fleet', { base }); expect((await call.invoke()).status).toBe(200);
expect(call.cookies.get).toHaveBeenCalledWith('ec_session_dev');
}
for (const base of ['http://private.lan', 'http://localhost.attacker.test', 'http://192.168.1.2']) {
expect((await requestFor('/api/fleet', { base }).invoke()).status).toBe(426);
}
const secure = requestFor('/api/fleet'); await secure.invoke(); expect(secure.cookies.get).toHaveBeenCalledWith('ec_session');
});
});
describe('machine and route identity boundaries', () => {
it.each([['/api/agent/operations', 'GET'], ['/api/agent/enroll', 'POST'], ['/api/agent/heartbeat', 'POST'], ['/api/telemetry', 'POST']] as const)('passes %s to its machine authentication without consulting browser sessions', async (routeId, method) => {
auth.readSession.mockImplementation(() => { throw new Error('Browser session must not be consulted'); });
auth.setupRequired.mockImplementation(() => { throw new Error('Browser setup must not be consulted'); });
const call = requestFor(routeId, { method, origin: null, base: 'http://host.lan', body: method === 'POST' ? {} : undefined,
resolve: async () => new Response('machine credential rejected', { status: 401 }) });
const response = await call.invoke(); expect(response.status).toBe(401); expect(call.resolve).toHaveBeenCalledOnce();
expect(auth.readSession).not.toHaveBeenCalled(); expect(auth.setupRequired).not.toHaveBeenCalled();
expect(call.event.locals.user).toBeNull();
});
it('does not allow host/enrollment bearer credentials to substitute for operator access even with a browser cookie', async () => {
signIn('admin');
for (const authorization of ['Bearer ec_host_secret', 'Bearer ec_enroll_secret']) {
const call = requestFor('/api/fleet', { headers: { Authorization: authorization } });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
const machine = requestFor('/api/agent/operations', { headers: { Authorization: 'Bearer ec_host_secret' } });
expect((await machine.invoke()).status).toBe(200); expect(machine.resolve).toHaveBeenCalledOnce();
});
it('protects encoded management paths and Svelte page data using the resolved route ID', async () => {
signIn('viewer');
for (const path of ['/api/%75sers', '/api/%61gent/../users', '/_app/pretend-public.js']) {
const call = requestFor('/api/users', { path });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
const data = requestFor('/settings/users', { path: '/settings/users/__data.json' });
await expect(data.invoke()).rejects.toMatchObject({ status: 403 }); expect(data.resolve).not.toHaveBeenCalled();
const rawConfig = requestFor('/api/configs/[id]', { path: '/api/configs/123?x=/_app/public.css' });
expect((await rawConfig.invoke()).status).toBe(403);
});
it('exempts only static assets on unresolved routes and does not use arbitrary file extensions as public access', async () => {
for (const path of ['/_app/immutable/start.js', '/favicon.svg', '/agent/ec-agent.py', '/agent/install.sh', '/agent/manifest.json']) {
const call = requestFor(null, { path }); expect((await call.invoke()).status).toBe(200); expect(call.resolve).toHaveBeenCalledOnce();
}
for (const path of ['/private.json', '/secrets.css', '/settings/users/__data.json']) {
const call = requestFor(null, { path }); await expect(call.invoke()).rejects.toMatchObject({ status: 303 }); expect(call.resolve).not.toHaveBeenCalled();
}
const write = requestFor(null, { path: '/_app/anything.js', method: 'POST', body: {} });
await expect(write.invoke()).rejects.toMatchObject({ status: 303 }); expect(write.resolve).not.toHaveBeenCalled();
});
});
describe('operator launch credential restrictions', () => {
it.each([
['/api/servers', { plutoKey: 'new-key' }],
['/api/deployment-collections', { keys: { t5_zombie_theater_1: 'new-key' } }]
])('rejects supplied administrator-managed keys at %s', async (route, body) => {
signIn('operator'); const call = requestFor(route as string, { method: 'POST', body });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
});
it.each([
['/api/servers', { previewId: 'reviewed', plutoKey: '' }],
['/api/servers', { previewId: 'reviewed' }],
['/api/deployment-collections', { previewId: 'reviewed', keys: { t5_zombie_theater_1: '' } }],
['/api/deployment-collections', { previewId: 'reviewed', keys: {} }]
])('allows saved-key launches at %s and preserves the body for the handler', async (route, body) => {
signIn('operator');
const call = requestFor(route as string, { method: 'POST', body, resolve: async event => {
expect(await event.request.json()).toEqual(body); return new Response('queued', { status: 202 });
} });
expect((await call.invoke()).status).toBe(202); expect(call.resolve).toHaveBeenCalledOnce();
});
it('allows administrators to supply keys and rejects oversized operator launch bodies before handler execution', async () => {
signIn('admin'); expect((await requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'new-key' } }).invoke()).status).toBe(200);
signIn('operator');
const tooLarge = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'x'.repeat(1024 * 1024 + 1) } });
expect((await tooLarge.invoke()).status).toBe(413); expect(tooLarge.resolve).not.toHaveBeenCalled();
});
});
describe('authenticated event streams', () => {
function streaming() {
let source!: ReadableStreamDefaultController<Uint8Array>;
const cancel = vi.fn();
const response = new Response(new ReadableStream<Uint8Array>({ start(controller) { source = controller; }, cancel }), { headers: { 'Content-Type': 'text/event-stream' } });
const abort = new AbortController();
return { response, source, cancel, abort };
}
it.each(['revoked', 'password-reset', 'role-demoted', 'storage-failure'] as const)('closes and cancels an established event stream when %s', async change => {
vi.useFakeTimers(); signIn('operator'); const source = streaming();
const protectedResponse = protectEventStream(source.response, 'hash-of-session', 'operator', source.abort.signal);
const reader = protectedResponse.body!.getReader();
source.source.enqueue(new TextEncoder().encode('event: test\n\n'));
expect((await reader.read()).done).toBe(false);
if (change === 'revoked') auth.current = null;
if (change === 'password-reset') auth.current = { ...auth.current!, user: { ...auth.current!.user, mustChangePassword: true } };
if (change === 'role-demoted') auth.current = { ...auth.current!, user: { ...auth.current!.user, role: 'viewer' } };
if (change === 'storage-failure') auth.readSessionHash.mockImplementation(() => { throw new Error('Storage unavailable'); });
const ended = reader.read(); await vi.advanceTimersByTimeAsync(15_000);
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('rechecks valid streams without refreshing idle sessions and cancels upstream when the request aborts', async () => {
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
const response = protectEventStream(source.response, 'hash-of-session', 'viewer', source.abort.signal);
const reader = response.body!.getReader(); const ended = reader.read();
await vi.advanceTimersByTimeAsync(15_000);
expect(auth.readSessionHash).toHaveBeenCalledWith('hash-of-session'); expect(source.cancel).not.toHaveBeenCalled();
source.abort.abort();
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('wraps event-stream responses at the guard while preserving content type and denying anonymous subscriptions', async () => {
const anonymous = requestFor('/api/events/servers'); expect((await anonymous.invoke()).status).toBe(401); expect(anonymous.resolve).not.toHaveBeenCalled();
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
const call = requestFor('/api/events/servers', { signal: source.abort.signal, resolve: async () => source.response });
const response = await call.invoke(); expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body!.getReader(); const ended = reader.read();
auth.current = null; await vi.advanceTimersByTimeAsync(15_000);
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
});
});
+369
View File
@@ -0,0 +1,369 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// These tests intentionally use production-strength scrypt while the complete
// suite may also be saturating the CPU. Do not weaken password parameters.
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { UserRole } from '../../src/lib/auth.js';
const password = 'correct horse battery staple';
const replacement = 'another long secret passphrase';
const setupToken = 'ec_setup_test_0123456789abcdef0123456789abcdef';
let passwordHash: string;
let replacementHash: string;
let fixture: Fixture;
let auth: typeof import('../../src/lib/server/auth/index.js');
let database: typeof import('../../src/lib/server/db/index.js');
// Real scrypt hashes are shared only as fixture data. Security transitions use
// the production API; deterministic gates below exercise its async race checks.
beforeAll(async () => {
const passwords = await import('../../src/lib/server/auth/passwords.js');
passwordHash = await passwords.hashPassword(password);
replacementHash = await passwords.hashPassword(replacement);
}, 15_000);
beforeEach(async () => {
vi.resetModules();
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
fixture = await newFixture([]);
auth = await import('../../src/lib/server/auth/index.js');
database = await import('../../src/lib/server/db/index.js');
auth.setupRequired();
});
afterEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
await fixture.cleanup();
});
function seedUser(username = 'administrator', role: UserRole = 'admin', options: { disabled?: boolean; mustChangePassword?: boolean } = {}) {
const id = randomUUID(), now = Date.now();
database.db().prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,must_change_password,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`).run(id, username, username, passwordHash, role, Number(options.disabled ?? false), Number(options.mustChangePassword ?? false), now, now);
return auth.getUser(id)!;
}
function storedUser(id: string) {
return database.db().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as { password_hash: string; role: UserRole; disabled: number; must_change_password: number };
}
function gate<T>() {
let release!: (value: T) => void;
const promise = new Promise<T>(resolve => { release = resolve; });
return { promise, release };
}
describe('initial administrator setup', () => {
it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
const input = { username: 'Admin', password, token: setupToken };
vi.stubEnv('EC_SETUP_TOKEN', undefined);
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 });
vi.stubEnv('EC_SETUP_TOKEN', 'short');
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 });
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
await expect(auth.bootstrapAdmin({ ...input, token: 'wrong' }, '127.0.0.1')).rejects.toMatchObject({ status: 401 });
expect(auth.setupRequired()).toBe(true);
expect(auth.listUsers()).toEqual([]);
});
it('allows exactly one competing bootstrap and atomically establishes its authenticated session', async () => {
const results = await Promise.allSettled([
auth.bootstrapAdmin({ username: 'FirstAdmin', displayName: ' First Admin ', password, token: setupToken, role: 'viewer' }, '192.0.2.1'),
auth.bootstrapAdmin({ username: 'SecondAdmin', password, token: setupToken }, '192.0.2.2')
]);
const successes = results.filter(result => result.status === 'fulfilled');
const failures = results.filter(result => result.status === 'rejected');
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(1);
if (failures[0].status === 'rejected') expect(failures[0].reason).toMatchObject({ status: 409 });
if (successes[0].status !== 'fulfilled') throw new Error('Bootstrap did not succeed');
const { user, session } = successes[0].value;
expect(user).toMatchObject({ role: 'admin', disabled: false, mustChangePassword: false });
expect(user.username).toBe(user.username.toLowerCase());
expect(auth.readSession(session.token)?.user.id).toBe(user.id);
expect(auth.listUsers()).toHaveLength(1);
expect(auth.setupRequired()).toBe(false);
await expect(auth.bootstrapAdmin({ username: 'ThirdAdmin', password, token: setupToken }, '192.0.2.3')).rejects.toMatchObject({ status: 409 });
const persisted = JSON.stringify(database.db().prepare('SELECT * FROM auth_users').all()) + JSON.stringify(auth.listAuthAudit());
expect(persisted).not.toContain(password);
expect(persisted).not.toContain(setupToken);
expect(JSON.stringify(user)).not.toContain('password_hash');
});
});
describe('password storage and verification', () => {
it('uses independently salted scrypt hashes and rejects wrong passwords or malformed stored hashes', async () => {
const passwords = await import('../../src/lib/server/auth/passwords.js');
const anotherHash = await passwords.hashPassword(password);
expect(anotherHash).toMatch(/^scrypt\$131072\$8\$1\$[a-f0-9]{64}\$[a-f0-9]{128}$/);
expect(anotherHash).not.toBe(passwordHash);
expect(anotherHash).not.toContain(password);
expect(await passwords.verifyPassword(password, anotherHash)).toBe(true);
expect(await passwords.verifyPassword(replacement, anotherHash)).toBe(false);
expect(await passwords.verifyPassword(password, 'scrypt$1$8$1$weak$bad')).toBe(false);
expect(await passwords.verifyPassword(undefined, anotherHash)).toBe(false);
});
it('bounds password length by Unicode characters and rejects invalid inputs before hashing', async () => {
const { validatePassword, hashPassword, verifyPassword } = await import('../../src/lib/server/auth/passwords.js');
for (const value of [undefined, null, 123, '', 'short password', 'a'.repeat(129)]) {
await expect(hashPassword(value)).rejects.toThrow('15 and 128');
}
expect(validatePassword('🦊'.repeat(15))).toBe('🦊'.repeat(15));
expect(validatePassword('a'.repeat(128))).toHaveLength(128);
expect(await verifyPassword('a'.repeat(129), passwordHash)).toBe(false);
});
it('bounds concurrent scrypt work and releases capacity when hashes finish', async () => {
const { hashPassword } = await import('../../src/lib/server/auth/passwords.js');
const first = hashPassword(password), second = hashPassword(replacement);
await expect(hashPassword('third sufficiently long passphrase')).rejects.toThrow('busy');
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
await expect(hashPassword('subsequent sufficiently long passphrase')).resolves.toMatch(/^scrypt\$/);
});
it('returns the same login error for unknown, disabled and incorrect-password accounts', async () => {
seedUser('enabled');
seedUser('disabled', 'viewer', { disabled: true });
for (const [username, supplied] of [['missing', password], ['enabled', replacement], ['disabled', password]]) {
await expect(auth.authenticatePassword({ username, password: supplied }, '192.0.2.5')).rejects.toMatchObject({ status: 401, message: 'Invalid username or password.' });
}
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
expect(auth.listAuthAudit()).toHaveLength(3);
});
});
describe('administrator user management', () => {
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
const user = await auth.createUser(admin.id, { username: role + 'User', displayName: ' ' + role + ' user ', role, password });
expect(user).toMatchObject({ username: (role + 'user'), displayName: role + ' user', role, mustChangePassword: true, disabled: false });
expect(storedUser(user.id).password_hash).toMatch(/^scrypt\$/);
expect(JSON.stringify(user)).not.toContain('password_hash');
}
await expect(auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })).rejects.toMatchObject({ status: 409 });
expect(auth.listUsers()).toHaveLength(4);
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
});
it('rejects administrator mutations by operators, viewers, disabled administrators and forced-change administrators', async () => {
const target = seedUser('target', 'viewer');
const actors = [seedUser('operator', 'operator'), seedUser('viewer', 'viewer'), seedUser('disabledadmin', 'admin', { disabled: true }), seedUser('temporaryadmin', 'admin', { mustChangePassword: true })];
const session = auth.createSession(target.id);
for (const actor of actors) {
await expect(auth.createUser(actor.id, { username: 'intruder', role: 'admin', password })).rejects.toMatchObject({ status: 403 });
await expect(auth.updateUser(actor.id, target.id, { role: 'admin' })).rejects.toMatchObject({ status: 403 });
expect(() => auth.revokeUserSessions(actor.id, target.id)).toThrow('Administrator');
}
expect(auth.getUser(target.id)?.role).toBe('viewer');
expect(auth.readSession(session.token)).not.toBeNull();
expect(auth.listUsers().some(user => user.username === 'intruder')).toBe(false);
});
it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
const first = seedUser();
const session = auth.createSession(first.id);
await expect(auth.updateUser(first.id, first.id, { role: 'operator' })).rejects.toMatchObject({ status: 409 });
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject({ status: 409 });
expect(auth.readSession(session.token)?.user.role).toBe('admin');
const second = seedUser('secondadmin');
await auth.updateUser(first.id, first.id, { role: 'viewer' });
expect(auth.readSession(session.token)).toBeNull();
await expect(auth.updateUser(second.id, second.id, { disabled: true })).rejects.toMatchObject({ status: 409 });
expect(auth.getUser(second.id)?.role).toBe('admin');
});
it('revokes every target session on role, enabled-state and password changes while keeping other users signed in', async () => {
const admin = seedUser(), target = seedUser('target', 'viewer');
const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id), second = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { role: 'operator' });
expect(auth.readSession(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { disabled: true });
expect(auth.readSession(third.token)).toBeNull();
expect(() => auth.createSession(target.id)).toThrow('unavailable');
await auth.updateUser(admin.id, target.id, { disabled: false });
expect(auth.readSession(third.token)).toBeNull();
const fourth = auth.createSession(target.id);
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
expect(reset.mustChangePassword).toBe(true);
expect(auth.readSession(fourth.token)).toBeNull();
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
await expect(auth.updateUser(admin.id, admin.id, { password: replacement })).rejects.toThrow('current password');
});
it('keeps sessions for display-only edits and supports explicit per-user and individual revocation', async () => {
const admin = seedUser(), target = seedUser('target', 'viewer');
const one = auth.createSession(target.id), two = auth.createSession(target.id), adminSession = auth.createSession(admin.id);
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
auth.revokeSession(one.token);
expect(auth.readSession(one.token)).toBeNull();
expect(auth.readSession(two.token)).not.toBeNull();
auth.revokeUserSessions(admin.id, target.id);
expect(auth.readSession(two.token)).toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull();
});
});
describe('session secrecy and expiration', () => {
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
const user = seedUser();
const first = auth.createSession(user.id), second = auth.createSession(user.id);
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(first.token).not.toBe(second.token);
const sessions = JSON.stringify(database.db().prepare('SELECT * FROM auth_sessions').all());
expect(sessions).not.toContain(first.token);
expect(sessions).not.toContain(second.token);
expect(auth.readSession(first.token)?.user).not.toHaveProperty('passwordHash');
for (const token of [undefined, '', 'x', '!', 'x'.repeat(44)]) expect(auth.readSession(token)).toBeNull();
expect(auth.readSession('x'.repeat(43))).toBeNull();
});
it('throttles activity touches and expires sessions at the idle boundary', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const user = seedUser(), session = auth.createSession(user.id);
now += 59_000;
expect(auth.readSession(session.token)).not.toBeNull();
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now - 59_000 });
now += 2_000;
expect(auth.readSession(session.token)).not.toBeNull();
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now });
const lastSeen = now;
now = lastSeen + auth.SESSION_IDLE_MS - 1;
expect(auth.readSession(session.token, false)).not.toBeNull();
now++;
expect(auth.readSession(session.token)).toBeNull();
auth.createSession(user.id);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 1 });
});
it('enforces absolute expiry even while the user remains recently active', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const session = auth.createSession(seedUser().id);
now = session.expiresAt - 1;
database.db().prepare('UPDATE auth_sessions SET last_seen_at=?').run(now - 1000);
expect(auth.readSession(session.token)).not.toBeNull();
now++;
expect(auth.readSession(session.token)).toBeNull();
});
});
describe('forced password changes and asynchronous authorization races', () => {
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
const login = await auth.authenticatePassword({ username: 'NEWUSER', password }, '192.0.2.10');
expect(login.user.mustChangePassword).toBe(true);
expect(auth.readSession(login.session.token)?.user.mustChangePassword).toBe(true);
await expect(auth.changePassword(user.id, replacement, 'a different long passphrase')).rejects.toThrow('Current password');
await expect(auth.changePassword(user.id, password, password)).rejects.toThrow('different password');
expect(auth.readSession(login.session.token)).not.toBeNull();
const changed = await auth.changePassword(user.id, password, replacement);
expect(changed.user).toMatchObject({ id: user.id, role: 'viewer', mustChangePassword: false });
expect(auth.readSession(login.session.token)).toBeNull();
expect(auth.readSession(changed.session.token)?.user.mustChangePassword).toBe(false);
await expect(auth.authenticatePassword({ username: user.username, password }, '192.0.2.11')).rejects.toMatchObject({ status: 401 });
});
it('does not mint a session when the verified password is reset during login', async () => {
const admin = seedUser(), target = seedUser('target', 'viewer');
const passwords = await import('../../src/lib/server/auth/passwords.js');
const verifying = gate<boolean>();
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
vi.spyOn(passwords, 'hashPassword').mockResolvedValueOnce(replacementHash);
const pending = auth.authenticatePassword({ username: target.username, password }, '192.0.2.20');
await auth.updateUser(admin.id, target.id, { password: replacement });
verifying.release(true);
await expect(pending).rejects.toMatchObject({ status: 401 });
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
});
it('rechecks a disabled account and current role after asynchronous login verification', async () => {
const admin = seedUser(), target = seedUser('target', 'operator');
const passwords = await import('../../src/lib/server/auth/passwords.js');
const verifying = gate<boolean>();
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
const pending = auth.authenticatePassword({ username: target.username, password }, '192.0.2.21');
await auth.updateUser(admin.id, target.id, { disabled: true });
verifying.release(true);
await expect(pending).rejects.toMatchObject({ status: 401 });
await auth.updateUser(admin.id, target.id, { disabled: false });
const second = gate<boolean>();
vi.mocked(passwords.verifyPassword).mockImplementationOnce(() => second.promise);
const nextLogin = auth.authenticatePassword({ username: target.username, password }, '192.0.2.22');
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
second.release(true);
const loggedIn = await nextLogin;
expect(loggedIn.user.role).toBe('viewer');
expect(auth.readSession(loggedIn.session.token)?.user.role).toBe('viewer');
});
it('rechecks the administrator before committing a user creation that was hashing during demotion', async () => {
const actor = seedUser(), other = seedUser('otheradmin');
const passwords = await import('../../src/lib/server/auth/passwords.js');
const hashing = gate<string>();
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
const pending = auth.createUser(actor.id, { username: 'intruder', password, role: 'admin' });
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
hashing.release(passwordHash);
await expect(pending).rejects.toMatchObject({ status: 403 });
expect(auth.listUsers()).toHaveLength(2);
});
it('does not commit a password reset if its administrator lost access while hashing', async () => {
const actor = seedUser(), other = seedUser('otheradmin'), target = seedUser('target', 'viewer');
const session = auth.createSession(target.id);
const passwords = await import('../../src/lib/server/auth/passwords.js');
const hashing = gate<string>();
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
const pending = auth.updateUser(actor.id, target.id, { password: replacement });
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
hashing.release(replacementHash);
await expect(pending).rejects.toMatchObject({ status: 403 });
expect(storedUser(target.id).password_hash).toBe(passwordHash);
expect(auth.readSession(session.token)).not.toBeNull();
});
it('rejects a self password change whose original password was concurrently reset', async () => {
const admin = seedUser(), target = seedUser('target', 'viewer');
const passwords = await import('../../src/lib/server/auth/passwords.js');
const verifying = gate<boolean>();
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
vi.spyOn(passwords, 'hashPassword').mockResolvedValue(replacementHash);
const pending = auth.changePassword(target.id, password, 'self selected new passphrase');
await auth.updateUser(admin.id, target.id, { password: replacement });
verifying.release(true);
await expect(pending).rejects.toMatchObject({ status: 409 });
expect(storedUser(target.id)).toMatchObject({ password_hash: replacementHash, must_change_password: 1 });
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
});
});
describe('persistent authentication throttling', () => {
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
database.closeDb();
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).toThrow('Too many attempts');
const persisted = JSON.stringify(database.db().prepare('SELECT * FROM auth_rate_limits').all());
expect(persisted).not.toContain('192.0.2.30');
expect(persisted).not.toContain('admin');
now += 15 * 60_000;
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
});
it('limits attempts from one address across many accounts and separates setup from login windows', () => {
for (let account = 0; account < 30; account++) auth.consumeAuthAttempt('192.0.2.40', 'account-' + account);
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'new-account')).toThrow('Too many attempts');
expect(() => auth.consumeAuthAttempt('192.0.2.41', 'new-account')).not.toThrow();
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'bootstrap', 'setup')).not.toThrow();
});
});
+104
View File
@@ -0,0 +1,104 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
import { serveGameFile } from '../../src/lib/server/game-files/http.js';
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
let directory: string; let hostId: string; let credential: string;
const data = 'real game bytes';
const file = { path: 'zone/game.ff', size: Buffer.byteLength(data), sha256: createHash('sha256').update(data).digest('hex') };
beforeEach(async () => {
state.handle = new Database(':memory:'); state.handle.pragma('foreign_keys=ON');
directory = await mkdtemp(join(tmpdir(), 'ec-libraries-')); vi.stubEnv('GAME_FILES_ROOT', directory);
const host = createHost({ name: 'Debian host' }); hostId = host.id;
const enrollment = createEnrollment(host.id);
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
});
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); }
async function published() {
const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
return publishImport(hostId, version.id);
}
describe('game-file import lifecycle', () => {
it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
const initial = begin();
expect(getHostOperation(initial.operationId!)?.payload.sourceVolume).toBe('t6_game_files');
setImportManifest(hostId, initial.id, [file]);
await expect(publishImport(hostId, initial.id)).rejects.toThrow();
expect(getLibraryVersion(initial.id)?.state).toBe('importing');
await uploadImportFile(hostId, initial.id, file.path, new Response(data).body);
const ready = await publishImport(hostId, initial.id);
expect(ready.state).toBe('ready'); expect(ready.sizeBytes).toBe(data.length);
expect(await readFile(join(ready.storageLocation, file.path), 'utf8')).toBe(data);
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect(begin('2').id).not.toBe(ready.id);
});
it('restricts an import to its source host and rejects manifest mutation', () => {
const version = begin();
expect(() => setImportManifest('another-host', version.id, [file])).toThrow(/not found/);
const first = setImportManifest(hostId, version.id, [file]);
expect(setImportManifest(hostId, version.id, [file]).manifestSha256).toBe(first.manifestSha256);
expect(() => setImportManifest(hostId, version.id, [{ ...file, path: 'changed.ff' }])).toThrow(/cannot change/);
});
it('surfaces durable agent failures while preserving historical versions', () => {
const version = begin();
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
});
it('returns a pollable publication status while large files are verified asynchronously', async () => {
const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
expect(importPublicationStatus(hostId, version.id).status).toBe('pending');
expect(requestImportPublication(hostId, version.id).status).toBe('verifying');
expect(requestImportPublication(hostId, version.id).status).toBe('verifying');
await publishImport(hostId, version.id);
expect(importPublicationStatus(hostId, version.id)).toMatchObject({ status: 'published', version: { id: version.id, manifestSha256: expect.any(String) } });
});
});
describe('verified host distribution', () => {
it('deduplicates active downloads and blocks readiness without exact manifest verification', async () => {
const version = await published();
const operation = queueLibraryDownload(hostId, version.id);
expect(queueLibraryDownload(hostId, version.id).id).toBe(operation.id);
expect(() => requireReadyLibrary(hostId, version.id)).toThrow(/finish downloading/);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', manifestSha256: 'wrong' }]);
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('failed');
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 }]);
expect(requireReadyLibrary(hostId, version.id).id).toBe(version.id);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'outdated', manifestSha256: version.manifestSha256 }]);
expect(requireReadyLibrary(hostId, version.id).id).toBe(version.id);
updateHostLibraryCaches(hostId, [{ libraryVersionId: version.id, status: 'ready', bytesDownloaded: 1, manifestSha256: version.manifestSha256 }]);
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('failed');
updateHostLibraryCaches(hostId, [], { snapshot: true });
expect(getHostLibraryCache(hostId, version.id)?.status).toBe('missing');
expect(() => requireReadyLibrary(hostId, version.id)).toThrow(/finish downloading/);
});
it('streams resumable byte ranges and guards the immutable manifest endpoint with revocable host credentials', async () => {
const version = await published();
const request = (headers: HeadersInit = {}) => new Request('https://controller/api/agent/libraries/' + version.id + '/manifest', { headers });
expect(manifestRoute({ request: request(), params: { id: version.id } }).status).toBe(401);
const authorized = request({ Authorization: 'Bearer ' + credential });
expect(manifestRoute({ request: authorized, params: { id: version.id } }).status).toBe(200);
const partial = await serveGameFile(request({ Range: 'bytes=5-' }), version.id, file.path);
expect(partial.status).toBe(206); expect(partial.headers.get('content-range')).toBe(`bytes 5-${data.length - 1}/${data.length}`);
expect(await partial.text()).toBe(data.slice(5));
const invalid = await serveGameFile(request({ Range: 'bytes=999-' }), version.id, file.path);
expect(invalid.status).toBe(416);
const resumed = await serveGameFile(request({ Range: 'bytes=5-', 'If-Range': '"different"' }), version.id, file.path);
expect(resumed.status).toBe(200); expect(await resumed.text()).toBe(data);
revokeCredentials(hostId);
expect(manifestRoute({ request: authorized, params: { id: version.id } }).status).toBe(401);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, readdir, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { assertRegularPath, manifestChecksum, parseByteRange, receiveGameFile, validateManifest, validateGamePath, verifyGameFile } from '../../src/lib/server/game-files/storage.js';
const sha = (data: string) => createHash('sha256').update(data).digest('hex');
const entry = (path = 'zone/file.ff', data = 'game data') => ({ path, size: Buffer.byteLength(data), sha256: sha(data) });
const stream = (value: string) => new Response(value).body!;
let directory: string;
afterEach(async () => { if (directory) await rm(directory, { recursive: true, force: true }); });
describe('game-file manifests', () => {
it.each(['../escape', '/absolute', 'a/../b', 'a//b', 'a\\b', 'C:/file', './file', 'a\0b', 'a/file.', 'a/file '])('rejects unsafe or ambiguous path %s', path => {
expect(() => validateGamePath(path)).toThrow();
});
it('canonicalizes file order, rejects duplicates, and hashes the canonical manifest', () => {
const files = validateManifest([entry('z.ff'), entry('a.ff')]);
expect(files.map(file => file.path)).toEqual(['a.ff', 'z.ff']);
expect(manifestChecksum(files)).toBe(sha(JSON.stringify(files)));
expect(() => validateManifest([entry('A.ff'), entry('a.ff')])).toThrow(/Duplicate/);
expect(() => validateManifest([entry('zone'), entry('zone/file.ff')])).toThrow(/file and a directory/);
expect(() => validateManifest([{ ...entry(), size: -1 }])).toThrow();
expect(() => validateManifest([{ ...entry(), sha256: 'bad' }])).toThrow();
});
});
describe('streaming storage integrity', () => {
it('atomically accepts only complete verified streams and cleans partial uploads', async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-game-storage-'));
const file = entry();
await expect(receiveGameFile(directory, file, stream('bad'))).rejects.toThrow(/mismatch/);
expect(await readdir(join(directory, 'zone'))).toEqual([]);
await expect(receiveGameFile(directory, file, stream('game data too long'))).rejects.toThrow(/exceeds/);
await receiveGameFile(directory, file, stream('game data'));
expect(await readFile(join(directory, file.path), 'utf8')).toBe('game data');
await verifyGameFile(directory, file);
await expect(verifyGameFile(directory, { ...file, sha256: sha('something') })).rejects.toThrow(/Checksum/);
});
it('refuses symlinks before writing or reading shared files', async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-game-symlink-'));
const root = join(directory, 'root'); await mkdir(root);
const outside = join(directory, 'outside'); await mkdir(outside);
await symlink(outside, join(root, 'zone'));
await expect(receiveGameFile(root, entry(), stream('game data'))).rejects.toThrow(/non-regular/);
await expect(assertRegularPath(root, 'zone/file.ff', true)).rejects.toThrow(/non-regular/);
expect(await readdir(outside)).toEqual([]);
});
});
describe('HTTP download resume ranges', () => {
it('supports open, bounded, suffix, and clipped ranges', () => {
expect(parseByteRange(null, 10)).toBeNull();
expect(parseByteRange('bytes=3-', 10)).toEqual({ start: 3, end: 9 });
expect(parseByteRange('bytes=3-5', 10)).toEqual({ start: 3, end: 5 });
expect(parseByteRange('bytes=-3', 10)).toEqual({ start: 7, end: 9 });
expect(parseByteRange('bytes=3-99', 10)).toEqual({ start: 3, end: 9 });
});
it.each(['bytes=10-', 'bytes=-0', 'bytes=5-3', 'bytes=0-1,3-4', 'bytes=-', 'not-a-range'])('rejects unsatisfiable %s', range => {
expect(() => parseByteRange(range, 10)).toThrow();
});
});
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { randomBytes } from 'node:crypto';
let fixture: Fixture;
const inventory = { hostname: 'games-1', agentVersion: '1.0.0', protocolVersion: 1, os: 'Debian 13', architecture: 'amd64', dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3, runtimeImages: ['plutainer:test'], libraries: [{ id: 'test-library', state: 'ready' }] };
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await fixture.cleanup(); });
async function enrolled() {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Game host' });
const enrollment = hosts.createEnrollment(host.id);
return { ...hosts, host, enrollment, credentials: hosts.enrollHost(enrollment.token, inventory) };
}
describe('native host enrollment and scoped authentication', () => {
it('consumes an enrollment exactly once and stores only credential hashes', async () => {
const api = await enrolled();
expect(api.authenticateCredential(api.credentials.credential)?.id).toBe(api.host.id);
expect(api.authenticateCredential(api.enrollment.token)).toBeNull();
expect(() => api.enrollHost(api.enrollment.token, inventory)).toThrow('invalid or expired');
const { db } = await import('../../src/lib/server/db/index.js');
const stored = JSON.stringify(db().prepare('SELECT * FROM ec_host_credentials').all()) + JSON.stringify(db().prepare('SELECT * FROM ec_host_enrollments').all());
expect(stored).not.toContain(api.credentials.credential); expect(stored).not.toContain(api.enrollment.token);
});
it('expires enrollments and invalidates earlier unused enrollment tokens', async () => {
const api = await enrolled();
const first = api.createEnrollment(api.host.id); const next = api.createEnrollment(api.host.id, 10);
expect(() => api.enrollHost(first.token, inventory)).toThrow('invalid or expired');
vi.useFakeTimers(); vi.setSystemTime(Date.now() + 20);
expect(() => api.enrollHost(next.token, inventory)).toThrow('invalid or expired');
});
it('rotates credentials independently and immediately revokes access', async () => {
const api = await enrolled(); const other = api.createHost({ name: 'Other' }); const second = api.enrollHost(api.createEnrollment(other.id).token, inventory);
const rotated = api.rotateCredential(api.host.id);
expect(api.authenticateCredential(api.credentials.credential)).toBeNull(); expect(api.authenticateCredential(rotated)?.id).toBe(api.host.id);
api.revokeCredentials(api.host.id);
expect(api.authenticateCredential(rotated)).toBeNull(); expect(api.authenticateCredential(second.credential)?.id).toBe(other.id);
});
it('requires HTTPS and blocks cross-origin management and agent credentials on management APIs', async () => {
const api = await enrolled(); const { authenticateAgent, requireManagementOrigin } = await import('../../src/lib/server/hosts/http.js');
const headers = { authorization: `Bearer ${api.credentials.credential}` };
expect(authenticateAgent(new Request('https://ec.test/api/agent/operations', { headers })).id).toBe(api.host.id);
expect(() => authenticateAgent(new Request('http://ec.test/api/agent/operations', { headers }))).toThrow();
expect(() => authenticateAgent(new Request('https://ec.test/api/agent/operations', { headers: { authorization: `Bearer ${api.enrollment.token}` } }))).toThrow();
expect(() => requireManagementOrigin(new Request('https://ec.test/api/hosts', { headers }))).toThrow();
expect(() => requireManagementOrigin(new Request('https://ec.test/api/hosts', { headers: { Origin: 'https://attacker.test' } }))).toThrow();
});
it('recovers credential rotation when acknowledgement is lost using the agent-persisted replacement', async () => {
const api = await enrolled(); const next = 'ec_host_' + randomBytes(32).toString('base64url');
expect(api.rotateCredential(api.host.id, next)).toBe(next);
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
expect(api.authenticateCredential(next)?.id).toBe(api.host.id);
expect(api.rotateCredential(api.host.id, next)).toBe(next);
api.revokeCredentials(api.host.id); expect(() => api.rotateCredential(api.host.id, next)).toThrow('cannot be reused');
});
it('bounds chunked request bodies before consuming the complete upload', async () => {
const { objectBody } = await import('../../src/lib/server/hosts/http.js');
let cancelled = false;
const body = new ReadableStream<Uint8Array>({ pull(controller) { controller.enqueue(new TextEncoder().encode('x'.repeat(100))); }, cancel() { cancelled = true; } });
const request = new Request('https://ec.test/api/agent/operations/x', { method: 'POST', body, duplex: 'half' } as RequestInit);
await expect(objectBody(request, 50)).rejects.toMatchObject({ status: 413 });
expect(cancelled).toBe(true);
});
});
describe('native host readiness and reservations', () => {
it('separates a connected agent from ready Docker, storage, runtime and library state', async () => {
const api = await enrolled();
expect(api.requireReadyHost(api.host.id).readiness).toBe('ready');
expect(() => api.requireReadyHost(api.host.id, { image: 'missing:image' })).toThrow('runtime');
expect(() => api.requireReadyHost(api.host.id, { libraryId: 'missing' })).toThrow('library');
expect(api.heartbeat(api.host.id, { ...inventory, libraries: [] }).readiness).toBe('preparing');
const host = api.heartbeat(api.host.id, { ...inventory, dockerRunning: false });
expect(host.connected).toBe(true); expect(host.readiness).toBe('degraded');
expect(() => api.requireReadyHost(host.id)).toThrow('Docker');
api.heartbeat(host.id, inventory);
vi.useFakeTimers(); vi.setSystemTime(Date.now() + 91_000);
expect(api.getHost(host.id)?.connectionState).toBe('offline'); expect(() => api.requireReadyHost(host.id)).toThrow('stale');
});
it('blocks new deployments on a draining or disabled host without altering existing operations', async () => {
const api = await enrolled(); const existing = api.queueHostOperation(api.host.id, 'instance.inspect', {}, { instanceId: 'immutable-id', generation: 1 });
api.updateHost(api.host.id, { mode: 'draining' });
expect(() => api.queueHostOperation(api.host.id, 'instance.create', {})).toThrow('draining');
expect(api.getHostOperation(existing.id)?.state).toBe('pending');
api.updateHost(api.host.id, { mode: 'disabled' }); expect(api.pendingHostOperations(api.host.id)).toEqual([]);
});
it('allocates per host, bind address and protocol while protecting wildcard bindings', async () => {
const api = await enrolled(); const second = api.createHost({ name: 'Other' });
expect(api.reserveHostPort(api.host.id, 'a', { port: 28960 })).toBe(28960);
expect(api.reserveHostPort(second.id, 'b', { port: 28960 })).toBe(28960);
expect(api.reserveHostPort(api.host.id, 'c', { port: 28960, protocol: 'tcp' })).toBe(28960);
expect(() => api.reserveHostPort(api.host.id, 'd', { port: 28960, bindAddress: '127.0.0.1' })).toThrow('available');
expect(api.reserveHostPort(api.host.id, 'a', { port: 28960 })).toBe(28960);
api.releaseHostPorts('a');
expect(api.reserveHostPort(api.host.id, 'e', { port: 28960, bindAddress: '127.0.0.1' })).toBe(28960);
expect(api.reserveHostPort(api.host.id, 'f', { port: 28960, bindAddress: '127.0.0.2' })).toBe(28960);
expect(() => api.reserveHostPort(api.host.id, 'g', { port: 28960, bindAddress: '0.0.0.0' })).toThrow('available');
expect(() => api.reserveHostPort(api.host.id, 'h', { port: 28000 })).toThrow('available');
});
});
describe('durable host operations', () => {
it('rotates unresolved operation delivery so an old batch cannot starve new work', async () => {
const api = await enrolled();
const operations = Array.from({ length: 30 }, () => api.queueHostOperation(api.host.id, 'instance.inspect', {}));
operations.forEach(operation => api.reportHostOperation(api.host.id, operation.id, { state: 'unknown' }));
const first = api.pendingHostOperations(api.host.id).map(operation => operation.id);
const second = api.pendingHostOperations(api.host.id).map(operation => operation.id);
expect(new Set([...first, ...second]).size).toBe(30);
expect(operations.every(operation => api.getHostOperation(operation.id)?.state === 'unknown')).toBe(true);
});
it('reconciles the same operation ID after loss of acknowledgement and prevents duplicate side effects', async () => {
const api = await enrolled(); const options = { instanceId: 'immutable-id', generation: 4, idempotencyKey: 'provisioning:123' };
const operation = api.queueHostOperation(api.host.id, 'instance.restart', {}, options);
api.reportHostOperation(api.host.id, operation.id, { state: 'accepted' }); api.reportHostOperation(api.host.id, operation.id, { state: 'running' });
expect(api.queueHostOperation(api.host.id, 'instance.restart', {}, options).id).toBe(operation.id);
expect(api.pendingHostOperations(api.host.id).map(value => value.id)).toEqual([operation.id]);
expect(() => api.queueHostOperation(api.host.id, 'instance.stop', {}, options)).toThrow('different request');
api.reportHostOperation(api.host.id, operation.id, { state: 'completed', result: { restarted: true } });
expect(api.reportHostOperation(api.host.id, operation.id, { state: 'running' }).state).toBe('completed');
expect(api.pendingHostOperations(api.host.id)).toEqual([]);
expect((await api.waitHostOperation(operation.id)).result).toEqual({ restarted: true });
});
it('does not allow another host to report a result, and timeouts retain the original operation', async () => {
const api = await enrolled(); const other = api.createHost({ name: 'Other' }); const operation = api.queueHostOperation(api.host.id, 'instance.stop', {});
expect(() => api.reportHostOperation(other.id, operation.id, { state: 'completed' })).toThrow('this host');
await expect(api.waitHostOperation(operation.id, 0)).rejects.toThrow(operation.id);
expect(api.getHostOperation(operation.id)?.state).toBe('pending');
});
});
+284
View File
@@ -0,0 +1,284 @@
import { createHash } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' }));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })),
rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
}));
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture; let hostId: string; let libraryId: string;
const image = 'example.test/plutainer:test';
const inventory = () => ({ protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100_000_000_000, runtimeImages: [image], libraries: libraryId ? [{ id: libraryId, state: 'ready' }] : [] });
beforeEach(async () => {
vi.resetModules(); vi.clearAllMocks(); remote.state = 'running'; remote.players = 0; remote.map = 'zombie_theater'; libraryId = '';
fixture = await newFixture([]);
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries'));
vi.stubEnv('PLUTAINER_IMAGE', image); vi.stubEnv('RCON_PASSWORD', 'test-password');
vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('KOMODO_API_KEY', ''); vi.stubEnv('KOMODO_API_SECRET', '');
const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js');
hostId = createHost({ name: 'Native Debian', portMin: 28960, portMax: 28970 }).id;
enrollHost(createEnrollment(hostId).token, inventory());
const backend = await import('../../src/lib/server/backends/native.js');
vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => {
if (kind !== 'instance.inspect') throw new Error(`Unexpected operation: ${kind}`);
return { healthy: remote.state === 'running', state: remote.state, exists: true } as never;
});
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
async function prepareLibrary(game: 't5' | 't6' = 't5') {
const libs = await import('../../src/lib/server/game-files/index.js');
const version = libs.createLibraryImport({ hostId, game, name: game, version: '1' });
const bytes = Buffer.from('verified base game');
const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
libs.setImportManifest(hostId, version.id, [file]);
await libs.uploadImportFile(hostId, version.id, file.path, new Response(bytes).body);
const ready = await libs.publishImport(hostId, version.id); libraryId = ready.id;
libs.updateHostLibraryCaches(hostId, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]);
const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, inventory());
return ready;
}
async function queue(game: 't5' | 't6' = 't5') {
await prepareLibrary(game); remote.map = game === 't5' ? 'zombie_theater' : 'zm_transit';
const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js');
const result = queueCreateServer({ game, gameMode: 'zombie', mapSlug: game === 't5' ? 'theater' : 'town', hostId, libraryId, image, plutoKey: 'saved-key' });
if (!result.ok) throw new Error(result.message);
return result;
}
async function operationFor(deploymentId: string) {
const { db } = await import('../../src/lib/server/db/index.js');
const row = db().prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?').get(deploymentId) as { operation_id: string | null };
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
return row?.operation_id ? getHostOperation(row.operation_id)! : null;
}
async function advance() {
const { processNativeProvisioning } = await import('../../src/lib/server/provisioning/native.js');
await processNativeProvisioning(() => null);
}
async function acknowledge(deploymentId: string) {
const operation = await operationFor(deploymentId); if (!operation) throw new Error('Missing durable native operation');
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state: 'completed', result: { state: remote.state, healthy: remote.state === 'running' } });
await advance();
return operation;
}
async function detail(id: string) {
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
return getDeployment(id)!.deployment;
}
describe('native instance provisioning', () => {
it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => {
const job = await queue(game); await advance();
const operation = await operationFor(job.deploymentId);
expect(operation).toMatchObject({ kind: 'instance.create', hostId, generation: 1, state: 'pending', payload: { game, libraryId, image, port: job.port } });
expect(operation?.payload.files).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'configs/electric_cherry_map.cfg' })]));
await acknowledge(job.deploymentId);
expect((await detail(job.deploymentId)).state).toBe('done');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT protocol FROM ec_host_ports WHERE instance_id=? ORDER BY protocol').all(operation!.instanceId)).toEqual([{ protocol: 'tcp' }, { protocol: 'udp' }]);
});
it('reconciles the same operation after controller restart and an ambiguous remote result', async () => {
const job = await queue(); await advance();
const original = (await operationFor(job.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, original.id, { state: 'unknown', error: 'Reply lost after execution' });
const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb();
const { interruptDeployments } = await import('../../src/lib/server/provisioning/history.js'); interruptDeployments();
await advance(); await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(original.id);
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.create'").get()).toEqual({ count: 1 });
expect((await detail(job.deploymentId)).state).toBe('running');
await acknowledge(job.deploymentId);
expect((await detail(job.deploymentId)).state).toBe('done');
});
it('holds disconnected instances as unknown without replacement and resumes the original operation', async () => {
const job = await queue(); await advance();
const original = (await operationFor(job.deploymentId))!;
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE ec_hosts SET last_heartbeat=? WHERE id=?').run('2000-01-01T00:00:00Z', hostId);
await advance();
expect(db().prepare('SELECT container_state FROM servers WHERE name=?').get(job.name)).toEqual({ container_state: 'unknown' });
expect((await operationFor(job.deploymentId))!.id).toBe(original.id);
const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, inventory());
await acknowledge(job.deploymentId);
expect((await detail(job.deploymentId)).state).toBe('done');
});
it('rebuilds only an empty instance and advances its generation after the acknowledged command', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js');
const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message);
remote.players = 2; await advance();
expect(await operationFor(rebuild.deploymentId)).toBeNull();
remote.players = 0; await advance();
const operation = (await operationFor(rebuild.deploymentId))!;
expect(operation).toMatchObject({ kind: 'instance.rebuild', generation: 1, payload: { nextGeneration: 2, libraryId } });
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(1);
await acknowledge(rebuild.deploymentId);
expect(nativeInstance(job.name)?.generation).toBe(2);
expect((await detail(rebuild.deploymentId)).state).toBe('done');
await advance();
expect(nativeInstance(job.name)?.generation).toBe(2);
});
it('blocks new admission on draining hosts and retires existing instances only when empty', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { updateHost } = await import('../../src/lib/server/hosts/index.js'); updateHost(hostId, { mode: 'draining' });
const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
expect(() => nativeSelection({ hostId, libraryId, image }, 't5')).toThrow(/draining/);
remote.players = 2; await advance();
const { db } = await import('../../src/lib/server/db/index.js');
const retirement = db().prepare("SELECT id FROM server_deployments WHERE action='destroy' AND server=?").get(job.name) as { id: string };
expect(retirement).toBeTruthy(); expect(await operationFor(retirement.id)).toBeNull();
remote.players = 0; await advance();
const remove = await acknowledge(retirement.id);
expect(remove.kind).toBe('instance.remove');
expect(db().prepare('SELECT * FROM servers WHERE name=?').get(job.name)).toBeUndefined();
expect(db().prepare('SELECT * FROM ec_host_ports WHERE instance_id=?').all(remove.instanceId)).toEqual([]);
const { getLibraryVersion } = await import('../../src/lib/server/game-files/index.js');
expect(getLibraryVersion(libraryId)?.state).toBe('ready');
});
it('stages campaign bytes while occupied and records agent before images only after safe activation', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { db } = await import('../../src/lib/server/db/index.js');
const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(job.name);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after) VALUES('campaign',?,'updated','hud.gsc',?)").run(job.name, after).lastInsertRowid);
const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const restart = requestRestart(job.name, { kind: 'campaign', id: 'campaign' });
remote.players = 2; await advance();
const staged = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string };
expect(staged.stage_operation_id).toBeTruthy();
const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const stage = getHostOperation(staged.stage_operation_id)!;
expect(stage.kind).toBe('files.stage'); expect(await operationFor(restart.id)).toBeNull();
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('pending');
reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } });
await advance(); expect(await operationFor(restart.id)).toBeNull();
remote.players = 0; await advance();
const activation = (await operationFor(restart.id))!;
expect(activation).toMatchObject({ kind: 'instance.restart', generation: 1, payload: { stageId: restart.id } });
expect(db().prepare('SELECT content_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null });
const files = stage.payload.files as Array<{ id: number; path: string }>;
reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } });
await advance();
expect((await detail(restart.id)).state).toBe('done');
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied');
expect(db().prepare('SELECT content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex') });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe(createHash('sha256').update(after).digest('hex'));
});
it('atomically launches mixed-game collections with per-host port reservations', async () => {
const t5 = await prepareLibrary('t5'); const t6 = await prepareLibrary('t6');
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const blueprint = saveBlueprint({ name: 'Both games', entries: [{ game: 't5', mapSlug: 'theater', count: 2 }, { game: 't6', mapSlug: 'town', count: 1 }] });
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(blueprint.id, 'fill', { hostId, libraryIds: { t5: t5.id, t6: t6.id }, image });
expect(preview.ready).toBe(true);
expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962]);
const queued = queueCollection({ previewId: preview.id, keys: Object.fromEntries(preview.servers.map(server => [server.name!, 'key-' + server.name])) });
expect(queueCollection({ previewId: preview.id }).collectionId).toBe(queued.collectionId);
const { db } = await import('../../src/lib/server/db/index.js');
const instances = db().prepare('SELECT host_id,library_id,port FROM native_instances ORDER BY port').all();
expect(instances).toEqual([{ host_id: hostId, library_id: t5.id, port: 28960 }, { host_id: hostId, library_id: t5.id, port: 28961 }, { host_id: hostId, library_id: t6.id, port: 28962 }]);
const { createHost, createEnrollment, enrollHost, hostPortAvailable } = await import('../../src/lib/server/hosts/index.js');
const secondHost = createHost({ name: 'Second Debian' }); enrollHost(createEnrollment(secondHost.id).token, inventory());
const { updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js');
updateHostLibraryCaches(secondHost.id, [{ libraryVersionId: t5.id, status: 'ready', manifestSha256: t5.manifestSha256 }]);
const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js');
const second = queueCreateServer({ game: 't5', gameMode: 'zombie', mapSlug: 'moon', hostId: secondHost.id, libraryId: t5.id, image, plutoKey: 'second-host-key' });
expect(second.ok).toBe(true); if (!second.ok) throw new Error(second.message);
expect(second.port).toBe(28960);
expect(hostPortAvailable(hostId, 28960)).toBe(false);
expect(hostPortAvailable(secondHost.id, 28960)).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 8 });
});
it('reads remote instance files through the agent including missing files and epoch timestamps', async () => {
const job = await queue();
const backend = await import('../../src/lib/server/backends/native.js');
const remoteRead = vi.mocked(backend.nativeRequest);
remoteRead.mockImplementation(async (_instance, kind, payload) => {
if (payload?.path === 'missing') return (kind === 'files.read' ? { contentBase64: null } : null) as never;
if (kind === 'files.stat') return { size: 15, mtime: 1_790_000_000, isFile: true, isDirectory: false } as never;
if (kind === 'files.read') return { contentBase64: Buffer.from('agent file bytes').toString('base64') } as never;
if (kind === 'files.list') return { entries: [{ name: 'configs', isDirectory: true, isFile: false }] } as never;
throw new Error('Unexpected file operation');
});
const files = await import('../../src/lib/server/backends/files.js');
const root = join(fixture.t5, job.name);
expect((await files.readFile(join(root, 'configs/server.cfg'))).toString()).toBe('agent file bytes');
const info = await files.stat(join(root, 'configs/server.cfg'));
expect(info.mtime.getTime()).toBe(1_790_000_000_000); expect(info.isFile()).toBe(true);
expect(await files.exists(join(root, 'missing'))).toBe(false);
await expect(files.readFile(join(root, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' });
const entries = await files.readdir(root, { withFileTypes: true });
expect(entries[0].name).toBe('configs'); expect(entries[0].isDirectory()).toBe(true);
expect(remoteRead).toHaveBeenCalledWith(expect.objectContaining({ server: job.name }), 'files.read', { path: 'configs/server.cfg' });
const { stat } = await import('node:fs/promises');
await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('retires an agent-confirmed missing container without waiting on nonexistent RCON', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const removal = destroyServer(job.name); if (!removal.ok || !removal.deploymentId) throw new Error('Removal was not queued');
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconStatusResult).mockClear();
remote.state = 'missing'; await advance();
expect((await operationFor(removal.deploymentId))?.kind).toBe('instance.remove');
expect(rconStatusResult).not.toHaveBeenCalled();
await acknowledge(removal.deploymentId);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)).toBeNull();
expect((await detail(removal.deploymentId)).state).toBe('done');
});
it('releases a failed activation slot without recording fictitious file changes', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('preflight-failed','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('preflight-failed',?,1,'pending')").run(job.name);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path) VALUES('preflight-failed',?,'updated','hud.gsc')").run(job.name).lastInsertRowid);
const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'preflight-failed', server: job.name, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('new bytes'), inventorySource: 'test', deploymentFileId: fileId });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const restart = requestRestart(job.name, { kind: 'campaign', id: 'preflight-failed' });
await advance();
const stage = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string };
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, stage.stage_operation_id, { state: 'completed', result: { state: 'staged' } });
await advance();
const activation = (await operationFor(restart.id))!;
expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('applying');
reportHostOperation(hostId, activation.id, { state: 'failed', error: 'Target changed after campaign review; no activation occurred' });
await advance();
expect((await detail(restart.id)).state).toBe('failed');
expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('pending');
expect(db().prepare('SELECT COUNT(*) AS count FROM restart_rollout WHERE healthy_at IS NULL').get()).toEqual({ count: 0 });
expect(db().prepare('SELECT content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, sha256_before: null });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')).toBeUndefined();
});
it('cancels a queued native launch locally and releases both ports without submitting a create', async () => {
const job = await queue();
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const instance = nativeInstance(job.name)!;
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const cancelled = destroyServer(job.name); expect(cancelled.ok).toBe(true);
await advance();
const { db } = await import('../../src/lib/server/db/index.js');
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect(nativeInstance(job.name)).toBeNull();
expect(db().prepare('SELECT * FROM ec_host_ports WHERE instance_id=?').all(instance.id)).toEqual([]);
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind LIKE 'instance.%'").get()).toEqual({ count: 0 });
const { getLibraryVersion } = await import('../../src/lib/server/game-files/index.js');
expect(getLibraryVersion(libraryId)?.state).toBe('ready');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
expect(getServerKey(job.name)).toBe('saved-key');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
}));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture; let hostId: string; let instanceId: string;
const server = 't5_zombie_theater_1';
const healthy = { exists: true, healthy: true, state: 'running' };
beforeEach(async () => {
vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]);
vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('KOMODO_API_KEY', ''); vi.stubEnv('KOMODO_API_SECRET', '');
const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js');
hostId = createHost({ name: 'Safety test host' }).id;
enrollHost(createEnrollment(hostId).token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true,
storageAvailable: 100_000_000_000, runtimeImages: ['plutainer:test'], libraries: [{ id: 'published-game-version', state: 'ready' }] });
const { db } = await import('../../src/lib/server/db/index.js');
instanceId = randomUUID();
db().prepare("INSERT INTO servers(name,komodo_name,game,map,game_mode,map_slug,game_port,container_state) VALUES(?,'','t5','zombie_theater','zombie','theater',28960,'running')").run(server);
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'published-game-version','plutainer:test',28960,?)`).run(instanceId, server, hostId, new Date().toISOString());
const backend = await import('../../src/lib/server/backends/native.js');
vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => {
if (kind !== 'instance.inspect') throw new Error(`Unexpected remote mutation: ${kind}`);
return healthy as never;
});
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
async function advance() {
const { processNativeProvisioning } = await import('../../src/lib/server/provisioning/native.js');
await processNativeProvisioning(() => null);
}
async function operationId(id: string) {
const { db } = await import('../../src/lib/server/db/index.js');
return (db().prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?').get(id) as { operation_id: string | null }).operation_id;
}
async function waitingRestart() {
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const result = requestLifecycle(server, 'restart'); if (!result.ok) throw new Error(result.error);
return result.deploymentId;
}
async function suspendInspection() {
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
let release!: () => void;
vi.mocked(nativeRequest).mockImplementationOnce(async () => { await new Promise<void>(resolve => { release = resolve; }); return healthy as never; });
const running = advance();
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
return { running, release };
}
async function stageCampaign() {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign-race','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign-race',?,1,'pending')").run(server);
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('reviewed campaign bytes'), inventorySource: 'campaign:test' });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const restart = requestRestart(server, { kind: 'campaign', id: 'campaign-race' });
await advance();
const staged = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string };
expect(staged.stage_operation_id).toBeTruthy();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed', result: { state: 'staged' } });
return restart.id;
}
describe('native disruption safety at dispatch', () => {
it('lets fresh occupied telemetry veto an empty RCON result and resumes after that evidence expires', async () => {
const id = await waitingRestart();
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({ event: 'game_start', server, map: 'zombie_theater', players: [{ name: 'Still playing', guid: 'player-1' }], receivedAt: new Date().toISOString() });
await advance();
expect(await operationId(id)).toBeNull();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
db().prepare('UPDATE server_telemetry SET received_at=? WHERE server=?').run('2000-01-01T00:00:00Z', server);
await advance();
expect(await operationId(id)).toBeTypeOf('string');
});
it('holds destruction if a beta becomes active while remote inspection is pending', async () => {
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const removal = destroyServer(server); if (!removal.ok || !removal.deploymentId) throw new Error('Removal was not queued.');
const pending = await suspendInspection();
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { script, version } = createScript({ name: 'Race beta', kind: 'script', targetRelPath: 'beta.gsc', content: Buffer.from('main() {}'), scopeType: 'game', scopeValue: 't5' });
const { db } = await import('../../src/lib/server/db/index.js');
const betaId = db().prepare("INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual','running')").run(script.id, version.id).lastInsertRowid;
db().prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')").run(betaId, server);
pending.release(); await pending.running;
expect(await operationId(removal.deploymentId)).toBeNull();
expect(db().prepare('SELECT name FROM servers WHERE name=?').get(server)).toEqual({ name: server });
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.remove'").get()).toEqual({ count: 0 });
});
it.each(['cancel', 'change bytes'] as const)('does not activate staged files after a %s during remote inspection', async change => {
const id = await stageCampaign();
const pending = await suspendInspection();
const { cancelStagedFiles, queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
if (change === 'cancel') cancelStagedFiles('campaign', 'campaign-race', server);
else queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('different unreviewed bytes'), inventorySource: 'campaign:test' });
pending.release(); await pending.running;
expect(await operationId(id)).toBeNull();
expect(stagedFileChanges('campaign', 'campaign-race')[0].state).toBe(change === 'cancel' ? 'cancelled' : 'pending');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
});
it('does not dispatch an old generation if the instance changes during inspection', async () => {
const id = await waitingRestart();
const pending = await suspendInspection();
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
pending.release(); await pending.running;
expect(await operationId(id)).toBeNull();
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
});
});
+4 -3
View File
@@ -13,14 +13,15 @@ beforeEach(async () => {
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
it('distinguishes missing setup from an unverifiable host volume without probing', async () => {
it('directs a fresh controller to native hosts and verified libraries without probing Komodo', async () => {
const probe = vi.spyOn(await import('../../src/lib/server/integrations/netProbe.js'), 'tcpProbe');
const { GET } = await import('../../src/routes/api/system-status/+server.js');
const response = await GET({ url: new URL('http://localhost/api/system-status') });
const body = await response.json();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(body.checks.find((check: { id: string }) => check.id === 'komodo').state).toBe('unconfigured');
expect(body.checks.find((check: { id: string }) => check.id === 'game-files').state).toBe('unverified');
expect(body.checks.find((check: { id: string }) => check.id === 'hosts').state).toBe('unconfigured');
expect(body.checks.find((check: { id: string }) => check.id === 'game-files').state).toBe('attention');
expect(body.checks.some((check: { id: string }) => check.id === 'komodo')).toBe(false);
expect(body.readyServers).toBe(0);
expect(probe).not.toHaveBeenCalled();
});
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { authenticateTelemetry } from '../../src/lib/server/auth/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent } from '../helpers/telemetry.js';
const token = 'ec_telemetry_0123456789abcdef0123456789abcdef';
const otherToken = 'ec_telemetry_fedcba9876543210fedcba9876543210';
const request = (values: Record<string, string | undefined> = {}, url = 'http://localhost/api/telemetry') => {
const headers = new Headers({ 'Content-Type': 'application/json' });
for (const [key, value] of Object.entries(values)) if (value !== undefined) headers.set(key, value);
return new Request(url, { method: 'POST', headers, body: JSON.stringify(gameEvent('heartbeat')) });
};
beforeEach(() => { vi.stubEnv('EC_TELEMETRY_TOKEN', token); });
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); });
describe('machine telemetry credentials', () => {
it.each([
{ Authorization: `Bearer ${token}` },
{ authorization: `bearer ${token}` },
{ 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ${token}`, 'X-EC-Telemetry-Token': token }
])('accepts an explicit telemetry secret without a browser session: %j', headers => {
expect(authenticateTelemetry(request(headers))).toEqual({ ok: true });
});
it.each([undefined, '', 'too-short', 'x'.repeat(513), 'x'.repeat(40) + ' not-header-safe'])('fails closed when configuration is unusable: %s', configured => {
vi.stubEnv('EC_TELEMETRY_TOKEN', configured);
expect(authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))).toMatchObject({ ok: false, status: 503 });
});
it.each([
{},
{ Cookie: `ec_session=${token}` },
{ Authorization: `Bearer ${otherToken}` },
{ 'X-EC-Telemetry-Token': otherToken },
{ Authorization: `Basic ${token}`, 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ${token}` },
{ Authorization: `Bearer ${token}, Bearer ${token}` },
{ Authorization: `Bearer ${token}`, 'X-EC-Telemetry-Token': otherToken },
{ Authorization: `Bearer ${otherToken}`, 'X-EC-Telemetry-Token': token },
{ Authorization: 'Bearer short', 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ec_host_${'1'.repeat(43)}` },
{ Authorization: `Bearer ec_enroll_${'1'.repeat(43)}` }
])('rejects missing, wrong, ambiguous or unrelated credentials: %j', headers => {
const authentication = authenticateTelemetry(request(headers));
expect(authentication).toMatchObject({ ok: false, status: 401 });
expect(JSON.stringify(authentication)).not.toContain(token);
expect(JSON.stringify(authentication)).not.toContain(otherToken);
});
it('does not accept tokens placed in query parameters', () => {
expect(authenticateTelemetry(request({}, `http://localhost/api/telemetry?token=${token}`))).toMatchObject({ ok: false, status: 401 });
});
it('retains existing transport behavior for authenticated local machine relays', () => {
expect(authenticateTelemetry(request({ 'X-EC-Telemetry-Token': token }, 'http://controller.internal/api/telemetry'))).toEqual({ ok: true });
});
it('uses the current configured token after rotation and safely rejects differing token lengths', () => {
expect(authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))).toEqual({ ok: true });
vi.stubEnv('EC_TELEMETRY_TOKEN', otherToken);
expect(authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))).toMatchObject({ ok: false, status: 401 });
expect(authenticateTelemetry(request({ Authorization: `Bearer ${otherToken}` }))).toEqual({ ok: true });
expect(authenticateTelemetry(request({ Authorization: `Bearer ${'x'.repeat(32)}` }))).toMatchObject({ ok: false, status: 401 });
});
});
describe('telemetry endpoint authentication boundary', () => {
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => { await fixture.cleanup(); });
it.each([401, 503])('returns %s before parsing or storing untrusted evidence', async status => {
if (status === 503) vi.stubEnv('EC_TELEMETRY_TOKEN', undefined);
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const incoming = request({ Cookie: 'ec_session=browser-user' });
const parse = vi.spyOn(incoming, 'json');
const listener = vi.fn();
const unsubscribe = subscribe(listener);
const response = await POST({ request: incoming });
unsubscribe();
expect(response.status).toBe(status);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(parse).not.toHaveBeenCalled();
expect(incoming.bodyUsed).toBe(false);
expect(listTelemetry()).toEqual([]);
expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({ count: 0 });
expect(listener).not.toHaveBeenCalled();
});
it('accepts authenticated automation with the unchanged event payload and excludes headers from diagnostics', async () => {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
const { listTelemetryRequests } = await import('../../src/lib/server/fleet/telemetry-requests.js');
const payload = gameEvent('heartbeat');
const response = await POST({ request: request({ 'X-EC-Telemetry-Token': token }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, telemetry: payload });
const history = listTelemetryRequests(payload.server);
expect(history).toHaveLength(1);
expect(JSON.stringify(history)).not.toContain(token);
expect(JSON.parse(history[0].payload!)).toEqual(payload);
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent, postTelemetry as post } from '../helpers/telemetry.js';
import { gameEvent, postTelemetry as post, telemetryAuthHeaders } from '../helpers/telemetry.js';
const server = 't5_zombie_theater_1';
const otherServer = 't5_zombie_theater_2';
@@ -76,7 +76,7 @@ it('records identifiable JSON sent with an unsupported or missing Content-Type',
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
// A byte body avoids Request automatically assigning text/plain to a string body.
const request = new Request('http://localhost/api/telemetry', {
method: 'POST', body: new TextEncoder().encode(JSON.stringify(payload))
method: 'POST', headers: telemetryAuthHeaders(), body: new TextEncoder().encode(JSON.stringify(payload))
});
expect(request.headers.get('content-type')).toBeNull();
expect((await POST({ request })).status).toBe(415);
@@ -124,7 +124,7 @@ it('keeps unknown and destroyed server rejections and matches the submitted serv
it('does not attribute malformed JSON or unusable server names to a server', async () => {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
const malformed = new Request('http://localhost/api/telemetry', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: `{"server":"${server}",`
method: 'POST', headers: { 'Content-Type': 'application/json', ...telemetryAuthHeaders() }, body: `{"server":"${server}",`
});
expect((await POST({ request: malformed })).status).toBe(400);
for (const value of [undefined, null, '', ' ', 123, 's'.repeat(256)]) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent, postTelemetry as post, roster } from '../helpers/telemetry.js';
import { gameEvent, postTelemetry as post, roster, telemetryAuthHeaders } from '../helpers/telemetry.js';
let fixture: Fixture;
beforeEach(async () => {
@@ -171,7 +171,7 @@ it('rejects unknown and destroyed servers and non-JSON requests', async () => {
it('rejects malformed JSON', async () => {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
expect((await POST({ request: new Request('http://localhost/api/telemetry', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{'
method: 'POST', headers: { 'Content-Type': 'application/json', ...telemetryAuthHeaders() }, body: '{'
}) })).status).toBe(400);
});