175 lines
12 KiB
TypeScript
175 lines
12 KiB
TypeScript
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';
|
|
|
|
const at = '2026-09-28T12:00:00Z';
|
|
function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress {
|
|
return { version: 1, phase: 'verify-map', phaseStartedAt: at, observedAt: at, blocker: null, checks: [], checkpoints: [],
|
|
command: null, recovery: null, recipeStatus: null, ...patch };
|
|
}
|
|
function fixture(): ProvisioningDetail {
|
|
return {
|
|
deployment: { id: 'operation-a', server: 't5_zombie_theater_1', action: 'create', state: 'awaiting-health', stage: 'Waiting for a map',
|
|
server_deleted_at: null, server_exists: 1, started_at: at, finished_at: null, triggered_by: 'ui', error: null, warnings: '[]' },
|
|
generatedAt: at, events: [], activity: { total: 0, latestId: null, failureExcerpt: null }, sources: [],
|
|
recipe: null, blueprintName: null, canResumeCleanup: false, canResumeRebuild: false, canResumeVerification: false, canCancelRemoval: false,
|
|
collection: null, progress: progress(), capacity: { evaluatedAt: at, pools: [] }, warningMessages: []
|
|
};
|
|
}
|
|
|
|
describe('provisioning outcome and evidence presentation', () => {
|
|
it('distinguishes cancellation from confirmed cleanup and validates its link data', () => {
|
|
const detail = fixture();
|
|
detail.deployment.state = 'cancelled';
|
|
detail.canCancelCreation = false;
|
|
detail.cancellation = { cleanupId: 'cleanup-a', state: 'running', stage: 'Waiting for host', error: null };
|
|
expect(isProvisioningDetail(detail, detail.deployment.id)).toBe(true);
|
|
expect(provisioningOutcome(detail).description).toContain('ports remain reserved');
|
|
detail.cancellation.state = 'done';
|
|
expect(provisioningOutcome(detail).description).toContain('have been removed');
|
|
expect(isProvisioningDetail({ ...detail, cancellation: { state: 'done' } }, detail.deployment.id)).toBe(false);
|
|
});
|
|
it.each(provisioningActions)('describes cancelled %s as terminal, with no automatic execution claim', action => {
|
|
const detail = fixture();
|
|
detail.deployment.action = action;
|
|
detail.deployment.state = 'cancelled';
|
|
const outcome = provisioningOutcome(detail);
|
|
expect(outcome.active).toBe(false);
|
|
expect(outcome.headline).toContain('cancelled');
|
|
expect(outcome.description).toContain('remaining work will not run');
|
|
expect(outcome.description).not.toMatch(/verifying|automatically/);
|
|
});
|
|
|
|
it('keeps backend-authorized cleanup actionable and active after identity deletion', () => {
|
|
const detail = fixture();
|
|
detail.deployment.action = 'destroy';
|
|
detail.deployment.state = 'failed';
|
|
detail.deployment.server_deleted_at = at;
|
|
detail.deployment.server_exists = 0;
|
|
detail.canResumeCleanup = true;
|
|
expect(provisioningOutcome(detail).description).toContain('Resume the remaining local cleanup');
|
|
detail.deployment.state = 'waiting';
|
|
detail.canResumeCleanup = false;
|
|
detail.progress = progress({ phase: 'cleanup', checks: [] });
|
|
expect(provisioningOutcome(detail).active).toBe(true);
|
|
expect(provisioningOutcome(detail).description).toContain('without repeating container removal');
|
|
expect(provisioningOutcome(detail).description).not.toContain('eligibility');
|
|
});
|
|
|
|
it('does not invent completed phases from a later phase or final outcome', () => {
|
|
const detail = fixture();
|
|
const steps = provisioningGroups(detail).flatMap(group => group.steps);
|
|
expect(steps.find(step => step.key === 'prepare')?.state).toBe('unknown');
|
|
expect(steps.find(step => step.key === 'verify-map')?.state).toBe('current');
|
|
detail.deployment.state = 'done';
|
|
expect(provisioningGroups(detail).flatMap(group => group.steps).every(step => step.state === 'unknown')).toBe(true);
|
|
detail.progress = null;
|
|
expect(provisioningGroups(detail)).toEqual([]);
|
|
expect(provisioningOutcome(detail).description).toContain('evidence was not recorded');
|
|
});
|
|
|
|
it('shows passed container and telemetry game checks while waiting on the engine map', () => {
|
|
const detail = fixture();
|
|
detail.progress = progress({ checks: [
|
|
{ key: 'container', label: 'Container readiness', result: 'pass', source: 'komodo', checkedAt: at, receivedAt: null, expected: 'healthy', actual: 'healthy', message: null },
|
|
{ key: 'game', label: 'Game status', result: 'pass', source: 'telemetry', checkedAt: at, receivedAt: at, expected: null, actual: null, message: 'Fresh telemetry accepted' },
|
|
{ key: 'map', label: 'Engine map', result: 'waiting', source: 'telemetry', checkedAt: at, receivedAt: at, expected: 'zombie_theater', actual: 'zombie_moon', message: null }
|
|
] });
|
|
const steps = provisioningGroups(detail).flatMap(group => group.steps);
|
|
expect(steps.find(step => step.key === 'verify-container')?.state).toBe('done');
|
|
expect(steps.find(step => step.key === 'verify-game')?.state).toBe('done');
|
|
expect(steps.find(step => step.key === 'verify-map')?.state).toBe('waiting');
|
|
expect(steps.find(step => step.key === 'verify-game')?.label).not.toContain('RCON');
|
|
detail.deployment.state = 'done';
|
|
expect(provisioningOutcome(detail).description).not.toContain('RCON');
|
|
});
|
|
|
|
it('newer unknown readiness evidence supersedes an old completion checkpoint', () => {
|
|
const detail = fixture();
|
|
detail.progress = progress({ checkpoints: [{ key: 'verify-game', label: 'Game passed', at, phase: 'verify-game' }],
|
|
checks: [{ key: 'game', label: 'Game status', result: 'unknown', source: null, checkedAt: '2026-09-28T12:01:00Z', receivedAt: null, expected: null, actual: null, message: 'No fresh evidence' }] });
|
|
expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-game')).toMatchObject({ state: 'unknown', at: '2026-09-28T12:01:00Z' });
|
|
});
|
|
|
|
it.each(['create', 'rebuild'] as const)('shows %s Plutonium verification from runtime evidence, separately from the release manifest', action => {
|
|
const detail = fixture();
|
|
detail.deployment.action = action;
|
|
detail.progress = progress({ phase: 'verify-plutonium',
|
|
checks: [
|
|
{ key: 'plutonium-release', label: 'Production Plutonium release', result: 'pass', source: 'manifest', checkedAt: at, receivedAt: at, expected: null, actual: 5200, message: null },
|
|
{ key: 'plutonium-version', label: 'Running Plutonium revision', result: 'unknown', source: 'rcon', checkedAt: at, receivedAt: null, expected: 5200, actual: null, message: 'Waiting for a runtime revision' }
|
|
] });
|
|
expect(isProvisioningDetail(detail, detail.deployment.id)).toBe(true);
|
|
for (const [result, state] of [['unknown', 'unknown'], ['waiting', 'waiting'], ['fail', 'failed'], ['pass', 'done']] as const) {
|
|
detail.progress.checks[1].result = result;
|
|
const steps = provisioningGroups(detail).find(group => group.key === 'verify')!.steps;
|
|
expect(steps.at(-1)).toMatchObject({ key: 'verify-plutonium', label: 'Verify Plutonium revision', state, at });
|
|
}
|
|
});
|
|
|
|
it.each(['start', 'restart', 'stop', 'destroy'] as const)('does not add Plutonium verification to %s', action => {
|
|
const detail = fixture();
|
|
detail.deployment.action = action;
|
|
expect(provisioningGroups(detail).flatMap(group => group.steps).some(step => step.key === 'verify-plutonium')).toBe(false);
|
|
});
|
|
|
|
it('does not claim historical creations verified Plutonium without evidence', () => {
|
|
const detail = fixture();
|
|
detail.deployment.state = 'done';
|
|
detail.progress = progress({ checkpoints: [{ key: 'verify-map', label: 'Selected map verified', at, phase: 'verify-map' }] });
|
|
expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-plutonium'))
|
|
.toMatchObject({ state: 'unknown', at: null });
|
|
});
|
|
|
|
it('preserves the failed request when later recovery checks pass', () => {
|
|
const detail = fixture();
|
|
detail.deployment.action = 'restart'; detail.deployment.state = 'failed';
|
|
detail.progress = progress({ phase: 'verify-game', recovery: { state: 'released', at, message: 'Recovery verified and capacity released.' },
|
|
checks: [{ key: 'game', label: 'Game status', result: 'pass', source: 'rcon', checkedAt: at, receivedAt: null, expected: null, actual: 0, message: null }] });
|
|
expect(provisioningOutcome(detail).headline).toBe('Restart failed');
|
|
expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-game')?.state).toBe('done');
|
|
});
|
|
|
|
it('keeps removal checkpoints separate from replacement creation', () => {
|
|
const detail = fixture(); detail.deployment.action = 'rebuild'; detail.deployment.state = 'waiting';
|
|
detail.progress = progress({ phase: 'cleanup', checkpoints: [{ key: 'delete-deployment', label: 'Remote deployment deleted', at, phase: 'delete-deployment' }] });
|
|
const groups = provisioningGroups(detail);
|
|
expect(groups.map(group => group.key)).toEqual(['request', 'remove', 'create', 'verify']);
|
|
expect(groups.flatMap(group => group.steps).find(step => step.key === 'delete-deployment')?.state).toBe('done');
|
|
expect(groups.flatMap(group => group.steps).find(step => step.key === 'cleanup')?.state).toBe('waiting');
|
|
expect(groups.flatMap(group => group.steps).find(step => step.key === 'prepare')?.state).toBe('pending');
|
|
});
|
|
});
|
|
|
|
describe('provisioning response and return-context boundaries', () => {
|
|
it('retains list filters without accepting an external or unrelated return destination', () => {
|
|
expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href)
|
|
.toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50');
|
|
expect(provisioningReturn('/provisioning?view=history&limit=25&offset=100').href)
|
|
.toBe('/provisioning?view=history&limit=25&offset=100');
|
|
for (const value of ['https://evil.test', '//evil.test', '/\\evil.test', '/\n/evil.test', '/settings', '/provisioning/other']) {
|
|
expect(provisioningReturn(value).href).toBe('/provisioning');
|
|
}
|
|
expect(provisioningReturn('/collections/launches/launch-a').href).toBe('/collections/launches/launch-a');
|
|
});
|
|
|
|
it('rejects incomplete nested snapshots before they replace a working detail', () => {
|
|
const detail = fixture();
|
|
expect(isProvisioningDetail(detail, detail.deployment.id)).toBe(true);
|
|
expect(isProvisioningDetail(detail, 'another-id')).toBe(false);
|
|
for (const patch of [
|
|
{ progress: undefined }, { progress: {} }, { progress: { ...detail.progress, checks: [null] } },
|
|
{ capacity: { evaluatedAt: at, pools: [{ kind: 'restart', holders: null }] } }, { activity: { total: 0 } },
|
|
{ warningMessages: {} }, { recipe: { files: [] } }, { sources: [{ kind: 'campaign', label: 'Campaign', source_id: 'a', href: 'javascript:alert(1)' }] }
|
|
]) expect(isProvisioningDetail({ ...detail, ...patch }, detail.deployment.id)).toBe(false);
|
|
expect(isProvisioningDetail({ ...detail, progress: { ...detail.progress, command: { state: 'rejected', at, message: 'Rejected before submission' } } }, detail.deployment.id)).toBe(true);
|
|
});
|
|
|
|
it('rejects an invalid history event without dropping the existing history view', () => {
|
|
const page = { events: [{ id: 1, created_at: at, message: 'Queued' }], total: 1, totalEvents: 1, latestId: 1, watermark: 1, nextCursor: null };
|
|
expect(isProvisioningEventPage(page)).toBe(true);
|
|
expect(isProvisioningEventPage({ ...page, events: [{ id: 1, created_at: 'invalid', message: 'Queued' }] })).toBe(false);
|
|
});
|
|
});
|