Allow public leaderboard API reads

This commit is contained in:
2026-10-05 16:46:44 -04:00
parent bd10c1c5d2
commit 083d29323f
5 changed files with 83 additions and 37 deletions
+14 -1
View File
@@ -8,7 +8,7 @@ const allows = (role: UserRole, route: string | null, method = 'GET') => roleAll
describe('management route permissions', () => {
it.each([
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard',
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]',
'/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]',
@@ -72,6 +72,19 @@ describe('management route permissions', () => {
});
describe('authentication and machine boundaries', () => {
it('makes only leaderboard API reads public', () => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission('/api/leaderboard', method)).toBe('public');
for (const route of ['/leaderboard', '/leaderboard/players/[guid]',
'/leaderboard/players/[game]/[guid]', '/api/leaderboard/players/[guid]',
'/api/leaderboard-extra', '/api/matches', '/api/servers/[name]/matches', '/matches/[id]']) {
expect(routePermission(route, method)).toBe('viewer');
}
}
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
expect(routePermission('/api/leaderboard', method)).toBe('operator');
}
});
it('makes only the intended login/bootstrap methods public', () => {
for (const route of ['/login', '/setup']) {
expect(routePermission(route, 'GET')).toBe('public');
+45 -2
View File
@@ -24,7 +24,7 @@ function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session
}
type Resolve = Parameters<Handle>[0]['resolve'];
function requestFor(routeId: string | null, options: {
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit;
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');
@@ -32,7 +32,7 @@ function requestFor(routeId: string | null, options: {
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 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]) };
@@ -47,6 +47,49 @@ beforeEach(() => {
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' });