This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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,
|
||||
@@ -6,11 +8,42 @@ import {
|
||||
type RoutePermission
|
||||
} from '../../src/lib/server/auth/access.js';
|
||||
|
||||
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
|
||||
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',
|
||||
@@ -77,6 +110,7 @@ describe('management route permissions', () => {
|
||||
])('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);
|
||||
});
|
||||
@@ -89,14 +123,14 @@ describe('management route permissions', () => {
|
||||
'/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/server-keys'
|
||||
])('allows Super Viewers to read admin route %s without changing it', (route) => {
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(routePermission(route, method)).toBe('admin');
|
||||
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);
|
||||
@@ -104,6 +138,7 @@ describe('management route permissions', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/provisioning/[id]/override', 'POST'],
|
||||
['/api/hosts', 'POST'],
|
||||
['/api/hosts/[id]', 'PATCH'],
|
||||
['/api/hosts/[id]', 'DELETE'],
|
||||
@@ -120,6 +155,7 @@ describe('management route permissions', () => {
|
||||
['/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);
|
||||
});
|
||||
@@ -136,7 +172,8 @@ describe('management route permissions', () => {
|
||||
'/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(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);
|
||||
}
|
||||
@@ -243,6 +280,8 @@ describe('authentication and machine boundaries', () => {
|
||||
'authenticated',
|
||||
'viewer',
|
||||
'operator',
|
||||
'operator-read',
|
||||
'admin-read',
|
||||
'admin',
|
||||
'machine'
|
||||
] as RoutePermission[]) {
|
||||
|
||||
@@ -234,10 +234,32 @@ describe('browser authentication guard', () => {
|
||||
['operator', '/api/hosts/[id]', 'PATCH', 403],
|
||||
['operator', '/api/users', 'GET', 403],
|
||||
['admin', '/api/hosts/[id]', 'PATCH', 200],
|
||||
['admin', '/api/users', 'GET', 200]
|
||||
['admin', '/api/users', 'GET', 200],
|
||||
['super_viewer', '/api/users', 'GET', 200],
|
||||
['super_viewer', '/api/users/audit', 'GET', 200],
|
||||
['super_viewer', '/api/server-keys', 'HEAD', 200],
|
||||
['super_viewer', '/api/configs/[id]', 'GET', 200],
|
||||
['super_viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
|
||||
['super_viewer', '/api/servers/[name]/logs/[file]', 'GET', 200],
|
||||
['super_viewer', '/api/servers/[name]/telemetry-requests', 'GET', 200],
|
||||
['super_viewer', '/api/users', 'POST', 403],
|
||||
['super_viewer', '/api/users/[id]', 'PATCH', 403],
|
||||
['super_viewer', '/api/users/[id]/revoke-sessions', 'POST', 403],
|
||||
['super_viewer', '/api/server-keys', 'POST', 403],
|
||||
['super_viewer', '/api/servers', 'POST', 403],
|
||||
['super_viewer', '/api/provisioning/[id]/override', 'POST', 403],
|
||||
['super_viewer', '/api/hosts/[id]', 'PATCH', 403],
|
||||
['super_viewer', '/api/servers/[name]/probe-rcon', 'POST', 403],
|
||||
['super_viewer', '/api/configs/inspect', 'POST', 403],
|
||||
['super_viewer', '/api/settings/retention/preview', 'POST', 200],
|
||||
['super_viewer', '/api/auth/password', 'POST', 200],
|
||||
['super_viewer', '/api/auth/logout', 'POST', 200]
|
||||
] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
|
||||
const session = signIn(role);
|
||||
const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
|
||||
const call = requestFor(routeId, {
|
||||
method,
|
||||
body: ['GET', 'HEAD'].includes(method) ? undefined : {}
|
||||
});
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
if (expectedStatus === 200) {
|
||||
@@ -245,14 +267,28 @@ describe('browser authentication guard', () => {
|
||||
expect(call.event.locals.user).toEqual(session.user);
|
||||
} else expect(call.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'restricts global settings %s requests to administrators',
|
||||
it.each(['/settings', '/settings/users', '/server-keys', '/configs/[id]'])(
|
||||
'allows Super Viewer pages and page-data requests at %s',
|
||||
async (route) => {
|
||||
signIn('super_viewer');
|
||||
for (const path of [route, `${route}/__data.json`]) {
|
||||
const call = requestFor(route, { path });
|
||||
expect((await call.invoke()).status).toBe(200);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
|
||||
'permits only administrators to write settings and Super Viewers to read them',
|
||||
async (method) => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/settings', { method });
|
||||
expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403);
|
||||
if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce();
|
||||
const allowed =
|
||||
role === 'admin' ||
|
||||
(role === 'super_viewer' && ['GET', 'HEAD'].includes(method));
|
||||
expect((await call.invoke()).status).toBe(allowed ? 200 : 403);
|
||||
if (allowed) expect(call.resolve).toHaveBeenCalledOnce();
|
||||
else expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
@@ -260,7 +296,7 @@ describe('browser authentication guard', () => {
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'restricts raw beta file %s requests to operators and administrators',
|
||||
async (method) => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', {
|
||||
method,
|
||||
@@ -282,11 +318,12 @@ describe('browser authentication guard', () => {
|
||||
expect((await anonymous.invoke()).status).toBe(401);
|
||||
expect(anonymous.resolve).not.toHaveBeenCalled();
|
||||
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor(route, { method });
|
||||
expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200);
|
||||
if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled();
|
||||
const allowed = role === 'operator' || role === 'admin';
|
||||
expect((await call.invoke()).status).toBe(allowed ? 200 : 403);
|
||||
if (!allowed) expect(call.resolve).not.toHaveBeenCalled();
|
||||
else expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js';
|
||||
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
|
||||
import { migrations } from '../../src/lib/server/db/migrations.js';
|
||||
|
||||
let handle: Database.Database;
|
||||
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
|
||||
|
||||
beforeEach(() => {
|
||||
handle = new Database(':memory:');
|
||||
handle.pragma('foreign_keys = ON');
|
||||
runMigrations(handle, migrations.slice(0, migration.version - 1));
|
||||
handle.exec(`
|
||||
INSERT INTO auth_users VALUES
|
||||
('admin','Administrator','Admin','admin-password-hash','admin',0,0,10,20,30),
|
||||
('operator','Operator','Op','operator-password-hash','operator',0,1,40,50,NULL),
|
||||
('viewer','Viewer','View','viewer-password-hash','viewer',1,0,60,70,80);
|
||||
INSERT INTO auth_sessions VALUES
|
||||
('admin-token-hash','admin',10,1000,100),
|
||||
('operator-token-hash','operator',20,2000,200);
|
||||
INSERT INTO auth_rate_limits VALUES ('limit-key',100,3);
|
||||
INSERT INTO auth_audit(actor_id,action,target_id,details,created_at)
|
||||
VALUES ('admin','user.created','viewer','{"role":"viewer"}',100);
|
||||
INSERT INTO servers(name,last_reply_snippet) VALUES ('saved','keep diagnostic');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => handle.close());
|
||||
|
||||
function rows(table: string) {
|
||||
return handle.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all();
|
||||
}
|
||||
|
||||
describe('super viewer role migration', () => {
|
||||
it('preserves existing accounts, sessions, audit, rate limits and fleet data while extending the role constraint', () => {
|
||||
const tables = ['auth_users', 'auth_sessions', 'auth_audit', 'auth_rate_limits', 'servers'];
|
||||
const original = tables.map(rows);
|
||||
expect(() =>
|
||||
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'")
|
||||
).toThrow('CHECK constraint failed');
|
||||
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
|
||||
expect(tables.map(rows)).toEqual(original);
|
||||
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([
|
||||
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
|
||||
]);
|
||||
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'");
|
||||
expect(handle.prepare("SELECT role FROM auth_users WHERE id='viewer'").get()).toEqual({
|
||||
role: 'super_viewer'
|
||||
});
|
||||
expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow(
|
||||
'CHECK constraint failed'
|
||||
);
|
||||
expect(() =>
|
||||
handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'")
|
||||
).toThrow('UNIQUE constraint failed');
|
||||
expect(() => handle.exec("UPDATE auth_sessions SET user_id='missing'")).toThrow(
|
||||
'FOREIGN KEY constraint failed'
|
||||
);
|
||||
handle.exec("DELETE FROM auth_users WHERE id='operator'");
|
||||
expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([
|
||||
{ user_id: 'admin' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not rebuild an already upgraded table or remove super viewers when replayed', () => {
|
||||
runMigrations(handle, migrations);
|
||||
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'");
|
||||
const users = rows('auth_users');
|
||||
const sessions = rows('auth_sessions');
|
||||
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
||||
|
||||
migrateSuperViewerRole(handle);
|
||||
handle.pragma(`user_version = ${migration.version - 1}`);
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(rows('auth_users')).toEqual(users);
|
||||
expect(rows('auth_sessions')).toEqual(sessions);
|
||||
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an unsafe direct rebuild and rolls back a failed reference check before retrying', () => {
|
||||
const users = rows('auth_users');
|
||||
const sessions = rows('auth_sessions');
|
||||
expect(() => migrateSuperViewerRole(handle)).toThrow('foreign keys to be disabled');
|
||||
expect(rows('auth_users')).toEqual(users);
|
||||
expect(rows('auth_sessions')).toEqual(sessions);
|
||||
|
||||
handle.pragma('foreign_keys = OFF');
|
||||
handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100)");
|
||||
handle.pragma('foreign_keys = ON');
|
||||
expect(() => runMigrations(handle, migrations)).toThrow('left broken references');
|
||||
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(migration.version - 1);
|
||||
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
expect(rows('auth_users')).toEqual(users);
|
||||
expect(rows('auth_sessions')).toHaveLength(sessions.length + 1);
|
||||
expect(() =>
|
||||
handle.exec("UPDATE auth_users SET role='super_viewer' WHERE id='viewer'")
|
||||
).toThrow('CHECK constraint failed');
|
||||
expect(
|
||||
handle
|
||||
.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_super_viewer'")
|
||||
.get()
|
||||
).toBeUndefined();
|
||||
|
||||
handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'");
|
||||
runMigrations(handle, migrations);
|
||||
expect(rows('auth_users')).toEqual(users);
|
||||
expect(rows('auth_sessions')).toEqual(sessions);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -256,7 +256,7 @@ describe('password storage and verification', () => {
|
||||
describe('administrator user management', () => {
|
||||
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
|
||||
const admin = seedUser();
|
||||
for (const role of ['admin', 'operator', 'viewer'] as const) {
|
||||
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
|
||||
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
||||
await expect(
|
||||
auth.createUser(admin.id, { username, role, password })
|
||||
@@ -275,7 +275,7 @@ describe('administrator user management', () => {
|
||||
|
||||
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
|
||||
const admin = seedUser();
|
||||
for (const role of ['admin', 'operator', 'viewer'] as const) {
|
||||
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
|
||||
const user = await auth.createUser(admin.id, {
|
||||
username: role + 'User',
|
||||
displayName: ' ' + role + ' user ',
|
||||
@@ -295,13 +295,14 @@ describe('administrator user management', () => {
|
||||
await expect(
|
||||
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(auth.listUsers()).toHaveLength(4);
|
||||
expect(auth.listUsers()).toHaveLength(5);
|
||||
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
||||
});
|
||||
|
||||
it('rejects administrator mutations by operators, viewers, disabled administrators and forced-change administrators', async () => {
|
||||
it('rejects administrator mutations by read-only roles, operators, disabled administrators and forced-change administrators', async () => {
|
||||
const target = seedUser('target', 'viewer');
|
||||
const actors = [
|
||||
seedUser('superviewer', 'super_viewer'),
|
||||
seedUser('operator', 'operator'),
|
||||
seedUser('viewer', 'viewer'),
|
||||
seedUser('disabledadmin', 'admin', { disabled: true }),
|
||||
@@ -324,7 +325,11 @@ describe('administrator user management', () => {
|
||||
|
||||
it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
|
||||
const first = seedUser();
|
||||
seedUser('superviewer', 'super_viewer');
|
||||
const session = auth.createSession(first.id);
|
||||
await expect(
|
||||
auth.updateUser(first.id, first.id, { role: 'super_viewer' })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await expect(
|
||||
auth.updateUser(first.id, first.id, { role: 'operator' })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
@@ -333,7 +338,8 @@ describe('administrator user management', () => {
|
||||
);
|
||||
expect(auth.readSession(session.token)?.user.role).toBe('admin');
|
||||
const second = seedUser('secondadmin');
|
||||
await auth.updateUser(first.id, first.id, { role: 'viewer' });
|
||||
await auth.updateUser(first.id, first.id, { role: 'super_viewer' });
|
||||
expect(auth.getUser(first.id)?.role).toBe('super_viewer');
|
||||
expect(auth.readSession(session.token)).toBeNull();
|
||||
await expect(
|
||||
auth.updateUser(second.id, second.id, { disabled: true })
|
||||
@@ -347,7 +353,8 @@ describe('administrator user management', () => {
|
||||
const administratorSession = auth.createSession(admin.id);
|
||||
const first = auth.createSession(target.id),
|
||||
second = auth.createSession(target.id);
|
||||
await auth.updateUser(admin.id, target.id, { role: 'operator' });
|
||||
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
||||
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
||||
expect(auth.readSession(first.token)).toBeNull();
|
||||
expect(auth.readSession(second.token)).toBeNull();
|
||||
const third = auth.createSession(target.id);
|
||||
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
} from '../../src/lib/ui/command-palette.js';
|
||||
|
||||
describe('command palette destinations', () => {
|
||||
it('lets Super Viewers search administration pages without offering operations', () => {
|
||||
const results = palettePageResults('super_viewer', '');
|
||||
expect(results.filter((item) => item.group === 'Actions')).toEqual([]);
|
||||
expect(results.map((item) => item.href)).toEqual(
|
||||
palettePageResults('admin', '')
|
||||
.filter((item) => item.group === 'Pages')
|
||||
.map((item) => item.href)
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps administrator destinations and operations out of inappropriate roles', () => {
|
||||
const viewer = palettePageResults('viewer', '');
|
||||
expect(
|
||||
|
||||
@@ -54,6 +54,12 @@ describe('application navigation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('gives Super Viewers all administrator destinations and shortcuts', () => {
|
||||
expect(visibleNavigation('super_viewer')).toEqual(visibleNavigation('admin'));
|
||||
expect(navigationLocation('/settings/users', 'super_viewer')?.group.id).toBe('admin');
|
||||
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
|
||||
});
|
||||
|
||||
it('groups file releases together and retains Campaigns as its own destination', () => {
|
||||
expect(navigationGroups.map((group) => group.label)).toEqual([
|
||||
'Fleet',
|
||||
|
||||
@@ -377,7 +377,7 @@ it('never removes the calibration window when choosing a shorter resource retent
|
||||
n: 0
|
||||
});
|
||||
});
|
||||
it('previews draft retention without saving it and restricts retention tools to administrators', async () => {
|
||||
it('previews draft retention without saving it and distinguishes admin reads from writes', async () => {
|
||||
const a = await api();
|
||||
a.db
|
||||
.prepare(
|
||||
@@ -401,8 +401,9 @@ it('previews draft retention without saving it and restricts retention tools to
|
||||
expect(a.fleetSettings().deployKeepDays).toBe(0);
|
||||
expect(a.db.prepare('SELECT id FROM deployments').get()).toEqual({ id: 'preview' });
|
||||
const { routePermission } = await import('../../src/lib/server/auth/access.js');
|
||||
expect(routePermission('/api/settings/retention', 'GET')).toBe('admin');
|
||||
expect(routePermission('/api/settings/retention/preview', 'POST')).toBe('admin');
|
||||
expect(routePermission('/api/settings/retention', 'GET')).toBe('admin-read');
|
||||
expect(routePermission('/api/settings/retention', 'POST')).toBe('admin');
|
||||
expect(routePermission('/api/settings/retention/preview', 'POST')).toBe('admin-read');
|
||||
});
|
||||
it('protects cancelled workflows and linked file history through ambiguous and unreconciled receipts', async () => {
|
||||
const a = await api();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import UsersPage from '../../src/routes/settings/users/+page.svelte';
|
||||
import ServerKeysPage from '../../src/routes/server-keys/+page.svelte';
|
||||
import MaintenancePage from '../../src/routes/maintenance/+page.svelte';
|
||||
import AccountPage from '../../src/routes/account/+page.svelte';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
const currentPage = writable({
|
||||
url: new URL('http://localhost/settings/users'),
|
||||
data: { user: { role: 'admin' as UserRole } }
|
||||
});
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||
}
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||
|
||||
function asRole(role: UserRole) {
|
||||
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
|
||||
}
|
||||
|
||||
describe('administrator pages', () => {
|
||||
it('shows the Super Viewer label while preserving personal password and sign-out controls', () => {
|
||||
asRole('super_viewer');
|
||||
const body = render(AccountPage).body;
|
||||
expect(body).toContain('Super Viewer');
|
||||
expect(body).not.toContain('super_viewer');
|
||||
expect(body).toContain('Change password');
|
||||
expect(body).toContain('Sign out');
|
||||
});
|
||||
|
||||
it('lets Super Viewers inspect account roles and activity without account mutation controls', () => {
|
||||
asRole('super_viewer');
|
||||
const body = render(UsersPage).body;
|
||||
expect(body).toContain('User');
|
||||
expect(body).toContain('Super Viewer');
|
||||
expect(body).toContain('Account activity');
|
||||
expect(body).toContain('Refresh activity');
|
||||
expect(body).not.toContain('Add user');
|
||||
expect(body).not.toContain('Edit access');
|
||||
expect(body).not.toContain('Sign out sessions');
|
||||
expect(body).not.toContain('You do not have access');
|
||||
});
|
||||
|
||||
it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => {
|
||||
asRole(role);
|
||||
const body = render(UsersPage).body;
|
||||
expect(body).toContain('You do not have access');
|
||||
expect(body).not.toContain('Account activity');
|
||||
});
|
||||
|
||||
it('retains administrator user creation and describes the assignable Super Viewer role', () => {
|
||||
asRole('admin');
|
||||
const body = render(UsersPage).body;
|
||||
expect(body).toContain('Add user');
|
||||
expect(body).toContain('Super Viewer');
|
||||
expect(body).toContain('Read all fleet and administrator pages without making changes.');
|
||||
});
|
||||
|
||||
it('keeps key searches available while hiding key creation from Super Viewers', () => {
|
||||
asRole('super_viewer');
|
||||
const body = render(ServerKeysPage).body;
|
||||
expect(body).toContain('Saved keys');
|
||||
expect(body).toContain('Search keys');
|
||||
expect(body).toContain('Filter keys by game');
|
||||
expect(body).not.toContain('Add key');
|
||||
expect(body).not.toContain('Save key');
|
||||
|
||||
asRole('admin');
|
||||
expect(render(ServerKeysPage).body).toContain('Add key');
|
||||
});
|
||||
|
||||
it('keeps maintenance views available without offering a fleet scan to Super Viewers', () => {
|
||||
asRole('super_viewer');
|
||||
const body = render(MaintenancePage).body;
|
||||
expect(body).toContain('Maintenance views');
|
||||
expect(body).toContain('Policies & schedules');
|
||||
expect(body).toContain('Activity');
|
||||
expect(body).not.toContain('Check now');
|
||||
|
||||
asRole('admin');
|
||||
expect(render(MaintenancePage).body).toContain('Check now');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import { defaultPlan, requirement, type BetaTestView } from '../../src/lib/beta-plan.js';
|
||||
import { betaStatus, type BetaDeploymentServer } from '../../src/lib/ui/betas.js';
|
||||
import BetaTestProgress from '../../src/lib/ui/components/BetaTestProgress.svelte';
|
||||
import BetaList from '../../src/routes/betas/+page.svelte';
|
||||
import NewBeta from '../../src/routes/betas/new/+page.svelte';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/betas/75') }) }));
|
||||
const currentPage = writable({
|
||||
url: new URL('http://localhost/betas/75'),
|
||||
data: { user: { role: 'admin' as UserRole } }
|
||||
});
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||
}
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||
function asRole(role: UserRole) {
|
||||
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
|
||||
}
|
||||
beforeEach(() => asRole('admin'));
|
||||
|
||||
it('prioritizes lifecycle, failures and pauses over readiness without treating stage text as failure', () => {
|
||||
expect(betaStatus({ state: 'promoted', testOutcome: 'paused', servers: [] })).toMatchObject({
|
||||
@@ -153,3 +169,48 @@ it('shows missing historical evidence as not recorded, without claiming zero ser
|
||||
expect(body).not.toContain('Awaiting test run');
|
||||
expect(body).not.toContain('0 / 0');
|
||||
});
|
||||
|
||||
it.each(['super_viewer', 'viewer'] as const)(
|
||||
'keeps beta evidence available to %s without pause, retry or sign-off controls',
|
||||
(role) => {
|
||||
asRole(role);
|
||||
const test: BetaTestView = {
|
||||
plan: { ...defaultPlan(), requirements: [requirement('manual', 'health')] },
|
||||
outcome: 'active',
|
||||
reason: null,
|
||||
servers: [testRun('manual', false)],
|
||||
evidence: []
|
||||
};
|
||||
for (const outcome of ['active', 'paused', 'failed'] as const) {
|
||||
const body = render(BetaTestProgress, {
|
||||
props: {
|
||||
test: { ...test, outcome },
|
||||
betaId: 75,
|
||||
closed: false,
|
||||
onUpdate: async () => {}
|
||||
}
|
||||
}).body;
|
||||
expect(body).toContain('Test results');
|
||||
expect(body).toContain('Test policy');
|
||||
expect(body).toContain('View relevant evidence');
|
||||
expect(body).not.toContain('Tester name and observation');
|
||||
expect(body).not.toContain('Sign off</button>');
|
||||
expect(body).not.toContain('Pause testing');
|
||||
expect(body).not.toContain('Resume testing');
|
||||
expect(body).not.toContain('Start fresh attempt');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('hides new-beta entry points from Super Viewers and explains directly opened creation pages', () => {
|
||||
asRole('super_viewer');
|
||||
expect(render(BetaList).body).not.toContain('href="/betas/new"');
|
||||
const creation = render(NewBeta).body;
|
||||
expect(creation).toContain('Beta creation requires operator access');
|
||||
expect(creation).toContain('View betas');
|
||||
expect(creation).not.toContain('<form');
|
||||
|
||||
asRole('operator');
|
||||
expect(render(BetaList).body).toContain('href="/betas/new"');
|
||||
expect(render(NewBeta).body).not.toContain('Beta creation requires operator access');
|
||||
});
|
||||
|
||||
@@ -26,3 +26,13 @@ it('keeps the navigation palette useful for viewers without offering write actio
|
||||
expect(body).not.toContain('Create server');
|
||||
expect(body).not.toContain('Add host');
|
||||
});
|
||||
|
||||
it('includes administration pages for Super Viewers without write actions', () => {
|
||||
const body = render(CommandPalette, { props: { open: true, role: 'super_viewer' } }).body;
|
||||
expect(body).toContain('Settings');
|
||||
expect(body).toContain('Users');
|
||||
expect(body).toContain('Keys');
|
||||
expect(body).not.toContain('Create server');
|
||||
expect(body).not.toContain('Manage servers');
|
||||
expect(body).not.toContain('Add host');
|
||||
});
|
||||
|
||||
@@ -99,6 +99,16 @@ const shell = (overrides: Partial<AuthUser> = {}) =>
|
||||
}).body;
|
||||
|
||||
describe('application shell', () => {
|
||||
it('explains Super Viewer access while exposing administrator navigation and shortcuts', () => {
|
||||
const body = shell({ role: 'super_viewer' });
|
||||
expect(body).toContain('Super Viewer access');
|
||||
expect(body).toContain('href="/settings/users"');
|
||||
expect(body).toContain('href="/server-keys"');
|
||||
expect(body).toContain('href="/settings"');
|
||||
expect(body).toContain('g h');
|
||||
expect(body).toContain('g k');
|
||||
});
|
||||
|
||||
it('offers collapsible desktop navigation, a labelled mobile drawer, search and help', () => {
|
||||
const body = shell();
|
||||
expect(body).toContain('id="desktop-navigation"');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { AuthUser } from '../../src/lib/auth.js';
|
||||
import type { ProvisioningDetail, RecoveryObservation } from '../../src/lib/provisioning-detail.js';
|
||||
import { provisioningNeedsRefresh } from '../../src/lib/ui/provisioning-presentation.js';
|
||||
import ProvisioningPage from '../../src/routes/provisioning/[id]/+page.svelte';
|
||||
@@ -79,6 +80,39 @@ function pageBody(detail: ProvisioningDetail) {
|
||||
props: { data: { detail, user: null, setupRequired: false } }
|
||||
}).body;
|
||||
}
|
||||
|
||||
it('lets Super Viewers inspect actionable operations without mutation controls', () => {
|
||||
const detail = fixture('failed');
|
||||
Object.assign(detail, {
|
||||
canResumeVerification: true,
|
||||
canResumeCleanup: true,
|
||||
canResumeRebuild: true,
|
||||
canRetryWithRebuild: true,
|
||||
canCancel: true,
|
||||
canCancelCreation: true,
|
||||
canOverrideSafety: true
|
||||
});
|
||||
const bodyFor = (role: AuthUser['role']) =>
|
||||
render(ProvisioningPage, {
|
||||
props: { data: { detail, user: { role } as AuthUser, setupRequired: false } }
|
||||
}).body;
|
||||
const readOnly = bodyFor('super_viewer');
|
||||
const admin = bodyFor('admin');
|
||||
expect(readOnly).toContain('View server');
|
||||
expect(readOnly).toContain('operation-a');
|
||||
for (const action of [
|
||||
'Resume health verification',
|
||||
'Resume rebuild cleanup',
|
||||
'Resume cleanup',
|
||||
'Retry with rebuild',
|
||||
'Cancel operation',
|
||||
'Cancel creation and remove server',
|
||||
'Admin override…'
|
||||
]) {
|
||||
expect(readOnly).not.toContain(action);
|
||||
expect(admin).toContain(action);
|
||||
}
|
||||
});
|
||||
function recovery(state: RecoveryObservation['state']): RecoveryObservation {
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
import Scripts from '../../src/routes/scripts/+page.svelte';
|
||||
import Plugins from '../../src/routes/plugins/+page.svelte';
|
||||
import ConfigLibrary from '../../src/lib/ui/components/ConfigLibrary.svelte';
|
||||
import ConfigEditor from '../../src/lib/ui/components/ConfigEditor.svelte';
|
||||
import ConfigTextEditor from '../../src/lib/ui/components/ConfigTextEditor.svelte';
|
||||
|
||||
const currentPage = writable({
|
||||
url: new URL('http://localhost/scripts'),
|
||||
data: { user: { role: 'super_viewer' as UserRole } }
|
||||
});
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||
}
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn(),
|
||||
beforeNavigate: vi.fn(),
|
||||
afterNavigate: vi.fn()
|
||||
}));
|
||||
afterEach(() =>
|
||||
currentPage.update((value) => ({
|
||||
...value,
|
||||
data: { user: { role: 'super_viewer' } }
|
||||
}))
|
||||
);
|
||||
|
||||
it.each(['super_viewer', 'viewer'] as const)(
|
||||
'keeps release libraries browsable without upload or creation controls for %s',
|
||||
(role) => {
|
||||
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
|
||||
const scripts = render(Scripts).body;
|
||||
expect(scripts).toContain('Search scripts');
|
||||
expect(scripts).not.toContain('Upload scripts');
|
||||
const plugins = render(Plugins).body;
|
||||
expect(plugins).toContain('Filter plugins by game');
|
||||
expect(plugins).not.toContain('Add plugin');
|
||||
const configs = render(ConfigLibrary).body;
|
||||
expect(configs).toContain('Inspect a server');
|
||||
expect(configs).toContain('Refresh');
|
||||
expect(configs).not.toContain('New config');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['operator', 'admin'] as const)('preserves release creation controls for %s', (role) => {
|
||||
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
|
||||
expect(render(Scripts).body).toContain('Upload scripts');
|
||||
expect(render(Plugins).body).toContain('Add plugin');
|
||||
expect(render(ConfigLibrary).body).toContain('New config');
|
||||
});
|
||||
|
||||
it('shows a configuration viewer and version selection without importing or restoring edits', () => {
|
||||
const body = render(ConfigEditor, {
|
||||
props: {
|
||||
id: 1,
|
||||
kind: 'config',
|
||||
versions: [{ id: 1, version: 1, notes: null }],
|
||||
currentVersionId: 1,
|
||||
runningBetaId: null,
|
||||
scopeType: 'game',
|
||||
scopeValue: 't5',
|
||||
onsaved: vi.fn()
|
||||
}
|
||||
}).body;
|
||||
expect(body).toContain('Configuration viewer');
|
||||
expect(body).toContain('Starting version');
|
||||
expect(body).not.toContain('Import .cfg');
|
||||
expect(body).not.toContain('Save new version');
|
||||
});
|
||||
|
||||
it('keeps readonly configuration text selectable and its view switch enabled', () => {
|
||||
const body = render(ConfigTextEditor, {
|
||||
props: { text: 'set g_inactivity "0"', readonly: true }
|
||||
}).body;
|
||||
expect(body).toMatch(/<textarea[^>]*readonly/);
|
||||
expect(body).not.toMatch(/<textarea[^>]*disabled/);
|
||||
expect(body).not.toMatch(/<button[^>]*disabled/);
|
||||
expect(body).toContain('Raw .cfg');
|
||||
expect(body).toContain('Settings');
|
||||
});
|
||||
@@ -152,6 +152,16 @@ it('keeps connection summaries visible to viewers without raw diagnostics or pro
|
||||
expect(body).not.toContain('legacy private reply');
|
||||
});
|
||||
|
||||
it('keeps Super Viewer diagnostics readable without offering probes or lifecycle actions', () => {
|
||||
const body = pageBody('diagnostics', 'super_viewer');
|
||||
expect(body).toContain('Connection details');
|
||||
expect(body).toContain('Inspect requests');
|
||||
expect(body).not.toContain('Test connection');
|
||||
expect(body).not.toContain('More actions');
|
||||
expect(body).not.toContain('legacy private reply');
|
||||
expect(pageBody('configuration', 'super_viewer')).toContain('Promoted configuration');
|
||||
});
|
||||
|
||||
it('uses the shared Activity view for server history', () => {
|
||||
const body = pageBody('history');
|
||||
expect(body).toContain('Open in Activity');
|
||||
|
||||
@@ -44,4 +44,23 @@ describe('grouped Settings editor', () => {
|
||||
expect(body).toContain('Expired data cannot be restored by increasing retention.');
|
||||
expect(body).toContain('Player safety checks still apply.');
|
||||
});
|
||||
|
||||
it('keeps settings navigation and cleanup previews available without offering mutations in read-only mode', () => {
|
||||
const body = render(SettingsEditor, {
|
||||
props: {
|
||||
initial: { ...defaultFleetSettings, preparationConcurrency: 7 },
|
||||
readOnly: true
|
||||
}
|
||||
}).body.replaceAll(/<!--.*?-->/g, '');
|
||||
expect(body).toContain('aria-label="Settings sections"');
|
||||
expect(body.match(/<input[^>]*id="settings-search"[^>]*>/)?.[0]).not.toContain('disabled');
|
||||
expect(body).toMatch(/<fieldset[^>]*disabled[^>]*><legend[^>]*>Fleet settings/);
|
||||
expect(body).toMatch(/<input[^>]*id="setting-preparationConcurrency"[^>]*value="7"/);
|
||||
expect(body).toContain('Refresh cleanup preview');
|
||||
expect(body).not.toContain('Save changes');
|
||||
expect(body).not.toContain('Discard');
|
||||
expect(body).not.toContain('Protect record');
|
||||
expect(body).not.toContain('Mark failure resolved');
|
||||
expect(body).not.toContain('All changes saved');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user