295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { readdirSync } from 'node:fs';
|
|
import { relative, resolve } from 'node:path';
|
|
import type { UserRole } from '../../src/lib/auth.js';
|
|
import {
|
|
roleAllows,
|
|
routePermission,
|
|
type RoutePermission
|
|
} from '../../src/lib/server/auth/access.js';
|
|
|
|
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin'];
|
|
const allows = (role: UserRole, route: string | null, method = 'GET') =>
|
|
roleAllows(role, routePermission(route, method));
|
|
|
|
describe('management route permissions', () => {
|
|
it('denies Super Viewer writes across every registered management route', () => {
|
|
const routes = resolve('src/routes');
|
|
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
|
|
const accountRoutes = new Set([
|
|
'/api/auth/login',
|
|
'/api/auth/setup',
|
|
'/api/auth/logout',
|
|
'/api/auth/password',
|
|
'/api/auth/me',
|
|
'/account'
|
|
]);
|
|
for (const entry of entries) {
|
|
if (!/^\+(server|page)(\.server)?\.(ts|svelte)$/.test(entry.name)) continue;
|
|
const route = '/' + relative(routes, entry.parentPath).split('\\').join('/');
|
|
if (accountRoutes.has(route) || route.startsWith('/api/agent/')) continue;
|
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
|
const preview = route === '/api/settings/retention/preview' && method === 'POST';
|
|
expect(allows('super_viewer', route, method), `${method} ${route}`).toBe(preview);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('allows cleanup previews without permitting retention changes or operational reads', () => {
|
|
expect(allows('super_viewer', '/api/settings/retention/preview', 'POST')).toBe(true);
|
|
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
|
|
expect(allows('super_viewer', '/api/settings/retention', 'POST')).toBe(false);
|
|
for (const route of ['/api/drift', '/api/plugins/github/release'])
|
|
for (const method of ['GET', 'HEAD'])
|
|
expect(allows('super_viewer', route, method)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
'/',
|
|
'/api/fleet',
|
|
'/capacity',
|
|
'/api/capacity',
|
|
'/api/rebalancing',
|
|
'/api/servers',
|
|
'/servers/[name]',
|
|
'/api/servers/[name]/scripts',
|
|
'/api/metrics',
|
|
'/metrics',
|
|
'/bank',
|
|
'/api/bank',
|
|
'/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]',
|
|
'/hosts/operations',
|
|
'/api/hosts/operations',
|
|
'/operations',
|
|
'/api/game-files/[id]',
|
|
'/api/maintenance',
|
|
'/api/events/servers',
|
|
'/api/events/pipeline'
|
|
])('lets viewers read the safe summary route %s', (route) => {
|
|
expect(routePermission(route, 'GET')).toBe('viewer');
|
|
expect(allows('viewer', route)).toBe(true);
|
|
expect(allows('viewer', route, 'HEAD')).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['/api/servers', 'POST'],
|
|
['/api/servers/[name]', 'DELETE'],
|
|
['/api/provisioning', 'POST'],
|
|
['/api/rebalancing', 'POST'],
|
|
['/api/provisioning/[id]/cancel', 'POST'],
|
|
['/api/provisioning/[id]/resume', 'POST'],
|
|
['/api/provisioning/[id]/reconcile-generation', 'POST'],
|
|
['/api/deployment-collections', 'POST'],
|
|
['/api/deployment-collections/preview', 'POST'],
|
|
['/api/deployment-collections/[id]/cancel', 'POST'],
|
|
['/api/blueprints', 'POST'],
|
|
['/api/blueprints/[id]', 'PUT'],
|
|
['/api/blueprints/[id]', 'DELETE'],
|
|
['/api/betas', 'POST'],
|
|
['/api/betas/[id]/promote', 'POST'],
|
|
['/api/betas/[id]/rollback', 'POST'],
|
|
['/api/campaigns/[id]/rollback', 'POST'],
|
|
['/api/deployments/[id]/rollback', 'POST'],
|
|
['/api/scripts', 'POST'],
|
|
['/api/scripts/[id]/versions', 'POST'],
|
|
['/api/scripts/[id]/versions/[versionId]', 'DELETE'],
|
|
['/api/configs', 'POST'],
|
|
['/api/plugins/github/import', 'POST'],
|
|
['/api/maintenance/[id]', 'POST'],
|
|
['/api/watchdog/[id]', 'POST'],
|
|
['/api/servers/[name]/probe-rcon', 'POST']
|
|
])('requires an operator for %s %s', (route, method) => {
|
|
expect(routePermission(route, method)).toBe('operator');
|
|
expect(allows('viewer', route, method)).toBe(false);
|
|
expect(allows('super_viewer', route, method)).toBe(false);
|
|
expect(allows('operator', route, method)).toBe(true);
|
|
expect(allows('admin', route, method)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'/api/users',
|
|
'/api/users/[id]',
|
|
'/api/users/[id]/password',
|
|
'/settings',
|
|
'/settings/users',
|
|
'/api/settings',
|
|
'/api/settings/retention',
|
|
'/server-keys',
|
|
'/keys',
|
|
'/api/server-keys'
|
|
])('allows Super Viewers to read admin route %s without changing it', (route) => {
|
|
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
const reading = method === 'GET' || method === 'HEAD';
|
|
expect(routePermission(route, method)).toBe(reading ? 'admin-read' : 'admin');
|
|
expect(allows('super_viewer', route, method)).toBe(reading);
|
|
expect(allows('viewer', route, method)).toBe(false);
|
|
expect(allows('operator', route, method)).toBe(false);
|
|
expect(allows('admin', route, method)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
['/api/provisioning/[id]/override', 'POST'],
|
|
['/api/hosts', 'POST'],
|
|
['/api/hosts/[id]', 'PATCH'],
|
|
['/api/hosts/[id]', 'DELETE'],
|
|
['/api/hosts/[id]/enrollment', 'POST'],
|
|
['/api/hosts/[id]/credentials', 'DELETE'],
|
|
['/api/hosts/[id]/credentials', 'POST'],
|
|
['/api/hosts/[id]/operations', 'POST'],
|
|
['/api/game-files', 'POST'],
|
|
['/api/game-files/[id]/download', 'POST'],
|
|
['/api/game-files/[id]/upload/files/[...path]', 'PUT'],
|
|
['/api/game-files/[id]/upload/complete', 'POST'],
|
|
['/api/maintenance', 'POST'],
|
|
['/api/watchdog', 'POST'],
|
|
['/api/plutonium', 'POST']
|
|
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
|
|
expect(routePermission(route, method)).toBe('admin');
|
|
expect(allows('super_viewer', route, method)).toBe(false);
|
|
expect(allows('operator', route, method)).toBe(false);
|
|
expect(allows('admin', route, method)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'/configs/[id]',
|
|
'/api/configs/[id]',
|
|
'/api/configs/inspect',
|
|
'/api/campaigns/[id]/file/[fileId]',
|
|
'/api/deployments/[id]/file/[fileId]',
|
|
'/api/betas/[id]/revisions/[revision]/files/[scriptId]',
|
|
'/api/servers/[name]/logs/[file]',
|
|
'/api/servers/[name]/telemetry-requests',
|
|
'/api/servers/[name]/rcon-diagnostics'
|
|
])('prevents viewer access to secret-bearing raw content at %s', (route) => {
|
|
for (const method of ['GET', 'HEAD']) {
|
|
expect(routePermission(route, method)).toBe('operator-read');
|
|
expect(allows('super_viewer', route, method)).toBe(true);
|
|
expect(allows('viewer', route, method)).toBe(false);
|
|
expect(allows('operator', route, method)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
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');
|
|
expect(routePermission(route, 'HEAD')).toBe('public');
|
|
expect(routePermission(route, 'POST')).not.toBe('public');
|
|
}
|
|
for (const route of ['/api/auth/login', '/api/auth/setup']) {
|
|
expect(routePermission(route, 'POST')).toBe('public');
|
|
expect(routePermission(route, 'GET')).not.toBe('public');
|
|
expect(routePermission(route, 'DELETE')).not.toBe('public');
|
|
}
|
|
expect(routePermission('/api/auth/unknown', 'POST')).toBe('admin');
|
|
});
|
|
it.each(['/account', '/api/auth/me', '/api/auth/password', '/api/auth/logout'])(
|
|
'permits every authenticated role to manage their own account at %s',
|
|
(route) => {
|
|
expect(routePermission(route, 'GET')).toBe('authenticated');
|
|
expect(routePermission(route, 'POST')).toBe('authenticated');
|
|
for (const role of roles) expect(allows(role, route, 'POST')).toBe(true);
|
|
}
|
|
);
|
|
it.each([
|
|
['/api/agent/enroll', 'POST'],
|
|
['/api/agent/heartbeat', 'POST'],
|
|
['/api/agent/operations', 'GET'],
|
|
['/api/agent/operations/[id]', 'POST'],
|
|
['/api/agent/libraries/[id]/files/[...path]', 'GET'],
|
|
['/api/agent/decommission/[id]', 'POST'],
|
|
['/api/agent/imports/[id]/files/[...path]', 'PUT'],
|
|
['/api/agent/imports/[id]/complete', 'POST'],
|
|
['/api/agent/install.sh', 'GET'],
|
|
['/api/agent/release/[file]', 'GET'],
|
|
['/api/telemetry', 'POST']
|
|
])('preserves endpoint-specific machine authentication for %s', (route, method) => {
|
|
expect(routePermission(route, method)).toBe('machine');
|
|
for (const role of roles) expect(allows(role, route, method)).toBe(false);
|
|
});
|
|
it('does not grant machine exemptions to similar prefixes or unsupported telemetry methods', () => {
|
|
for (const route of [
|
|
'/api/agents',
|
|
'/api/agentish/operations',
|
|
'/api/telemetry-extra',
|
|
'/api/telemetry/anything'
|
|
]) {
|
|
expect(routePermission(route, 'POST')).toBe('operator');
|
|
}
|
|
expect(routePermission('/api/telemetry', 'GET')).toBe('viewer');
|
|
expect(routePermission('/api/telemetry', 'PUT')).toBe('operator');
|
|
});
|
|
it('protects unknown routes and does not infer public assets from URL suffixes', () => {
|
|
for (const route of [
|
|
null,
|
|
'/not-found',
|
|
'/unrecognized.json',
|
|
'/anything.css',
|
|
'/__data.json'
|
|
]) {
|
|
expect(routePermission(route, 'GET')).toBe('viewer');
|
|
expect(routePermission(route, 'POST')).toBe('operator');
|
|
expect(allows('viewer', route, 'POST')).toBe(false);
|
|
}
|
|
});
|
|
it.each([
|
|
'/api/agent/../server-keys',
|
|
'/api/%61gent/operations',
|
|
'/api/agent/%2e%2e/settings',
|
|
'/api/agent\\operations',
|
|
'//api/agent/operations',
|
|
'/api/agent/operations?bypass=1'
|
|
])('fails closed if a caller supplies noncanonical route identity %s', (route) => {
|
|
expect(routePermission(route, 'GET')).toBe('admin');
|
|
expect(routePermission(route, 'POST')).toBe('admin');
|
|
});
|
|
it('rejects invalid roles even when a permission would normally allow all users', () => {
|
|
for (const permission of [
|
|
'public',
|
|
'authenticated',
|
|
'viewer',
|
|
'operator',
|
|
'operator-read',
|
|
'admin-read',
|
|
'admin',
|
|
'machine'
|
|
] as RoutePermission[]) {
|
|
expect(roleAllows('unexpected' as UserRole, permission)).toBe(false);
|
|
}
|
|
});
|
|
});
|