Files
electric-cherry/tests/unit/auth-access.test.ts
T

348 lines
15 KiB
TypeScript

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]',
'/api/moderation',
'/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',
'/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('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);
}
});
});