Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+154
-48
@@ -1,37 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
import { roleAllows, routePermission, type RoutePermission } from '../../src/lib/server/auth/access.js';
|
||||
import {
|
||||
roleAllows,
|
||||
routePermission,
|
||||
type RoutePermission
|
||||
} from '../../src/lib/server/auth/access.js';
|
||||
|
||||
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
|
||||
const allows = (role: UserRole, route: string | null, method = 'GET') => roleAllows(role, routePermission(route, method));
|
||||
const allows = (role: UserRole, route: string | null, method = 'GET') =>
|
||||
roleAllows(role, routePermission(route, method));
|
||||
|
||||
describe('management route permissions', () => {
|
||||
it.each([
|
||||
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/rebalancing', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
||||
'/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]',
|
||||
'/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 => {
|
||||
'/',
|
||||
'/api/fleet',
|
||||
'/capacity',
|
||||
'/api/capacity',
|
||||
'/api/rebalancing',
|
||||
'/api/servers',
|
||||
'/servers/[name]',
|
||||
'/api/servers/[name]/scripts',
|
||||
'/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]',
|
||||
'/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/servers', 'POST'],
|
||||
['/api/servers/[name]', 'DELETE'],
|
||||
['/api/provisioning', 'POST'],
|
||||
['/api/rebalancing', 'POST'],
|
||||
['/api/provisioning/[id]/cancel', 'POST'], ['/api/provisioning/[id]/resume', 'POST'],
|
||||
['/api/deployment-collections', 'POST'], ['/api/deployment-collections/preview', 'POST'],
|
||||
['/api/provisioning/[id]/cancel', 'POST'],
|
||||
['/api/provisioning/[id]/resume', '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']
|
||||
['/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);
|
||||
@@ -40,10 +82,19 @@ describe('management route permissions', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/api/users', '/api/users/[id]', '/api/users/[id]/password', '/settings', '/settings/users',
|
||||
'/api/settings', '/api/settings/retention', '/api/settings/retention/preview',
|
||||
'/server-keys', '/keys', '/api/server-keys', '/api/provisioning/[id]/override'
|
||||
])('reserves all methods on %s for administrators', route => {
|
||||
'/api/users',
|
||||
'/api/users/[id]',
|
||||
'/api/users/[id]/password',
|
||||
'/settings',
|
||||
'/settings/users',
|
||||
'/api/settings',
|
||||
'/api/settings/retention',
|
||||
'/api/settings/retention/preview',
|
||||
'/server-keys',
|
||||
'/keys',
|
||||
'/api/server-keys',
|
||||
'/api/provisioning/[id]/override'
|
||||
])('reserves all methods on %s for administrators', (route) => {
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(routePermission(route, method)).toBe('admin');
|
||||
expect(allows('viewer', route, method)).toBe(false);
|
||||
@@ -53,11 +104,20 @@ describe('management route permissions', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/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']
|
||||
['/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('operator', route, method)).toBe(false);
|
||||
@@ -65,11 +125,16 @@ describe('management route permissions', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/configs/[id]', '/api/configs/[id]', '/api/configs/inspect',
|
||||
'/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]',
|
||||
'/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 => {
|
||||
'/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');
|
||||
expect(allows('viewer', route, method)).toBe(false);
|
||||
@@ -82,9 +147,16 @@ 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]']) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -105,41 +177,75 @@ describe('authentication and machine boundaries', () => {
|
||||
}
|
||||
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(['/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/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']
|
||||
['/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']) {
|
||||
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']) {
|
||||
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 => {
|
||||
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', 'admin', 'machine'] as RoutePermission[]) {
|
||||
for (const permission of [
|
||||
'public',
|
||||
'authenticated',
|
||||
'viewer',
|
||||
'operator',
|
||||
'admin',
|
||||
'machine'
|
||||
] as RoutePermission[]) {
|
||||
expect(roleAllows('unexpected' as UserRole, permission)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user