Files
electric-cherry/tests/unit/auth-guard.test.ts
T
2026-10-09 16:57:22 -04:00

673 lines
28 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('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.each([
['GET', '/api/drift'],
['HEAD', '/api/drift'],
['GET', '/api/plugins/github/release'],
['HEAD', '/api/plugins/github/release']
])('restricts expensive %s %s requests before handler execution', async (method, route) => {
const anonymous = requestFor(route, { method, cookie: null });
expect((await anonymous.invoke()).status).toBe(401);
expect(anonymous.resolve).not.toHaveBeenCalled();
for (const role of ['viewer', 'operator', 'admin'] as const) {
signIn(role);
const call = requestFor(route, { method });
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<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();
});
});