Files
electric-cherry/tests/unit/auth-guard.test.ts
T

312 lines
22 KiB
TypeScript

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; cookie?: string | null;
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(() => options.cookie === null ? undefined : options.cookie ?? 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.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
for (const setup of [false, true]) {
auth.setup = setup;
const call = requestFor('/api/leaderboard', { method, path: '/api/leaderboard?game=t6&metric=kills', origin: null, cookie: null });
const response = await call.invoke();
expect(response.status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
expect(call.event.locals.user).toBeNull();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('vary')).toContain('Cookie');
}
});
it('allows leaderboard reads when a supplied session has expired', async () => {
const call = requestFor('/api/leaderboard', { cookie: token });
expect((await call.invoke()).status).toBe(200);
expect(auth.readSession).toHaveBeenCalledWith(token);
expect(call.event.locals.user).toBeNull();
expect(call.resolve).toHaveBeenCalledOnce();
});
it('allows public leaderboard reads with a session that requires a password change', async () => {
signIn('viewer', true);
const call = requestFor('/api/leaderboard');
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
});
it('keeps anonymous leaderboard writes and related API reads protected', async () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const call = requestFor('/api/leaderboard', { method });
expect((await call.invoke()).status).toBe(401);
expect(call.resolve).not.toHaveBeenCalled();
}
for (const route of ['/api/leaderboard/players/[guid]', '/api/leaderboard-extra',
'/api/matches', '/api/servers/[name]/matches', '/api/fleet']) {
const call = requestFor(route);
expect((await call.invoke()).status).toBe(401);
expect(call.resolve).not.toHaveBeenCalled();
}
for (const route of ['/leaderboard', '/leaderboard/players/[guid]', '/matches/[id]']) {
const call = requestFor(route);
await expect(call.invoke()).rejects.toMatchObject({ status: 303 });
expect(call.resolve).not.toHaveBeenCalled();
}
});
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/ec-agent-update.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', '/agent/ec-agent-update.py.bak', '/agent/update-status.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();
});
});