Show provisioning requesters and reserve Maxis for system actions

This commit is contained in:
2026-10-08 16:19:49 -04:00
parent 2b155ded8f
commit d8fb7e1d66
10 changed files with 77 additions and 15 deletions
+24
View File
@@ -54,6 +54,16 @@ function gate<T>() {
}
describe('initial administrator setup', () => {
it('reserves Maxis for system actions regardless of username case', async () => {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1'))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
expect(auth.setupRequired()).toBe(true);
expect(auth.listUsers()).toEqual([]);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
});
it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
const input = { username: 'Admin', password, token: setupToken };
vi.stubEnv('EC_SETUP_TOKEN', undefined);
@@ -136,6 +146,20 @@ 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 username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.createUser(admin.id, { username, role, password }))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
}
expect(auth.listUsers()).toHaveLength(1);
expect(auth.listAuthAudit()).toEqual([]);
await expect(auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password }))
.resolves.toMatchObject({ username: 'maxis-operator' });
});
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) {
+14 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js';
import { provisioningActions } from '../../src/lib/provisioning.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningRequester, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
const at = '2026-09-28T12:00:00Z';
function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress {
@@ -19,6 +19,19 @@ function fixture(): ProvisioningDetail {
}
describe('provisioning outcome and evidence presentation', () => {
it('shows recorded usernames and preserves unknown manual requesters', () => {
expect(provisioningRequester({ triggered_by: 'ui', requested_by: 'alice' })).toBe('alice');
expect(provisioningRequester({ triggered_by: 'rebalance:run', requested_by: 'bob' })).toBe('bob');
expect(provisioningRequester({ triggered_by: 'ui', requested_by: null })).toBe('Not recorded');
expect(provisioningRequester({ triggered_by: 'ui' })).toBe('Not recorded');
});
it.each(['watchdog:incident', 'maintenance:job', 'autoscaling:policy', 'campaign:run', 'beta:75', 'rebalance:run', 'host-drain'])(
'shows Maxis for system work from %s', triggered_by => {
expect(provisioningRequester({ triggered_by, requested_by: null })).toBe('Maxis');
expect(provisioningRequester({ triggered_by })).toBe('Maxis');
});
it('distinguishes a failed creation retry from readiness verification and its linked recovery', () => {
const detail = fixture();
detail.deployment.state = 'failed';
+16 -3
View File
@@ -9,17 +9,30 @@ afterEach(async () => { await fixture.cleanup(); });
it('persists the manual requester while preserving source filtering and historical records', async () => {
const { withRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
const { beginDeployment, getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const legacy = beginDeployment('legacy', 'restart');
const [manual, automatic] = await withRequestUsername('alice', async () => [
beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident')
const [manual, automatic, rebalance] = await withRequestUsername('alice', async () => [
beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident'),
beginDeployment('rebalance', 'restart', 'rebalance:run')
]);
closeDb();
expect(getDeployment(manual)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: 'alice' });
expect(getDeployment(automatic)?.deployment).toMatchObject({ triggered_by: 'watchdog:incident', requested_by: null });
expect(getDeployment(rebalance)?.deployment).toMatchObject({ triggered_by: 'rebalance:run', requested_by: 'alice' });
expect(getDeployment(legacy)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: null });
expect(listDeployments({ source: 'manual' }).map(row => row.id)).toEqual(expect.arrayContaining([manual, legacy]));
expect(listDeployments({ source: 'manual' }).map(row => row.id)).not.toContain(automatic);
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
for (const view of ['active', 'history']) {
if (view === 'history') db().prepare("UPDATE server_deployments SET state='done'").run();
const result = await GET({ url: new URL(`http://localhost/api/provisioning?view=${view}`) }).json();
expect(result.deployments).toEqual(expect.arrayContaining([
expect.objectContaining({ id: manual, requested_by: 'alice' }),
expect.objectContaining({ id: automatic, requested_by: null }),
expect.objectContaining({ id: rebalance, requested_by: 'alice' }),
expect.objectContaining({ id: legacy, requested_by: null })
]));
}
});
it('isolates overlapping users and stops attributing deferred work after the request returns', async () => {