import { describe, expect, it } from 'vitest'; import { readdirSync } from 'node:fs'; import { relative, resolve } from 'node:path'; import { canModerate, canReadModeration, moderatorMaximumRestrictionMs, 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', 'moderator', 'admin']; const allows = (role: UserRole, route: string | null, method = 'GET') => roleAllows(role, routePermission(route, method)); describe('management route permissions', () => { it.each([ '/moderation', '/moderation/players/[guid]', '/moderation/cases/[id]', '/api/moderation', '/api/moderation/cases/[id]', '/api/moderation/players/[guid]', '/api/moderation/sanctions/[id]/revoke' ])('limits private moderation access at %s without changing fleet privileges', (route) => { for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) { const reading = ['GET', 'HEAD'].includes(method); expect(routePermission(route, method)).toBe(reading ? 'moderation-read' : 'moderation'); for (const role of roles) { expect(allows(role, route, method)).toBe( role === 'admin' || role === 'moderator' || (reading && role === 'super_viewer') ); } } }); it('keeps moderators within safe summary reads and explicit moderation operations', () => { expect(allows('moderator', '/api/fleet')).toBe(true); expect(allows('moderator', '/api/servers/[name]/chat')).toBe(true); expect(allows('moderator', '/api/servers', 'POST')).toBe(false); expect(allows('moderator', '/api/users')).toBe(false); expect(allows('moderator', '/api/configs/[id]')).toBe(false); expect(allows('moderator', '/api/drift')).toBe(false); expect(allows('moderator', '/api/moderation-extra', 'POST')).toBe(false); expect(allows('moderator', '/api/auth/password', 'POST')).toBe(true); for (const role of roles) { expect(canModerate(role)).toBe(role === 'moderator' || role === 'admin'); expect(canReadModeration(role)).toBe( role === 'moderator' || role === 'admin' || role === 'super_viewer' ); } expect(canModerate(null)).toBe(false); expect(canReadModeration(undefined)).toBe(false); expect(moderatorMaximumRestrictionMs).toBe(604_800_000); }); 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', '/api/plugins/updates']) 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', '/afk', '/api/afk', '/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('keeps AFK reporting separate from game credentials and administrator corrections', () => { for (const route of ['/afk', '/api/afk']) { for (const method of ['GET', 'HEAD']) { expect(routePermission(route, method)).toBe('viewer'); for (const role of roles) expect(allows(role, route, method)).toBe(true); } } expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine'); for (const role of roles) { expect(allows(role, '/api/afk/[action]', 'POST')).toBe(false); expect(allows(role, '/api/afk/corrections', 'POST')).toBe(role === 'admin'); } expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin'); }); 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('reserves only the exact runtime POST for instance credentials', () => { expect(routePermission('/api/moderation/runtime', 'POST')).toBe('machine'); for (const role of roles) expect(allows(role, '/api/moderation/runtime', 'POST')).toBe(false); expect(routePermission('/api/moderation/runtime', 'GET')).toBe('moderation-read'); expect(routePermission('/api/moderation/runtime/other', 'POST')).toBe('moderation'); expect(routePermission('/api/moderation/runtime-extra', 'POST')).toBe('moderation'); }); 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', 'moderation', 'moderation-read', 'admin', 'machine' ] as RoutePermission[]) { expect(roleAllows('unexpected' as UserRole, permission)).toBe(false); } }); });