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[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[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('carries the session username through an authenticated action, ignoring requester fields from the client', async () => { const { currentRequestUsername } = await import('../../src/lib/server/auth/request-context.js'); signIn('operator'); const call = requestFor('/api/provisioning', { method: 'POST', body: { requested_by: 'someone-else' }, resolve: async event => { await event.request.json(); expect(currentRequestUsername()).toBe('tester'); return new Response('queued'); } }); expect((await call.invoke()).status).toBe(200); expect(currentRequestUsername()).toBeNull(); }); 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], ['viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 403], ['operator', '/api/servers/[name]/rcon-diagnostics', 'GET', 200], ['admin', '/api/servers/[name]/rcon-diagnostics', '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.each(['GET', 'HEAD'])('restricts global settings %s requests to administrators', async method => { for (const role of ['viewer', 'operator', 'admin'] as const) { signIn(role); const call = requestFor('/api/settings', { method }); expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403); if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce(); else expect(call.resolve).not.toHaveBeenCalled(); } }); it.each(['GET', 'HEAD'])('restricts raw beta file %s requests to operators and administrators', async method => { for (const role of ['viewer', 'operator', 'admin'] as const) { signIn(role); const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', { method, path: '/api/betas/1/revisions/1/files/1' }); expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200); if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled(); else expect(call.resolve).toHaveBeenCalledOnce(); } }); 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; const cancel = vi.fn(); const response = new Response(new ReadableStream({ 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(); }); });