673 lines
28 KiB
TypeScript
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();
|
|
});
|
|
});
|