Make local UI setup truthful and rerunnable
This commit is contained in:
@@ -13,7 +13,9 @@ const outputDirectory = path.join(webRoot, 'output', 'playwright');
|
||||
|
||||
const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const DEFAULT_USERNAME = process.env.STELLAOPS_FRONTDOOR_USERNAME?.trim() || 'admin';
|
||||
const DEFAULT_PASSWORD = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() || 'Admin@Stella2026!';
|
||||
const DEFAULT_PASSWORD_CANDIDATES = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim()
|
||||
? [process.env.STELLAOPS_FRONTDOOR_PASSWORD.trim()]
|
||||
: ['Admin@Stella2026!', 'Admin@Stella1'];
|
||||
const DEFAULT_STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
||||
const DEFAULT_REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
||||
|
||||
@@ -66,17 +68,60 @@ async function waitForAuthTransition(page, usernameField, passwordField, timeout
|
||||
}).catch(() => {}),
|
||||
usernameField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}),
|
||||
passwordField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}),
|
||||
page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, {
|
||||
page.waitForFunction(
|
||||
() =>
|
||||
Boolean(sessionStorage.getItem('stellaops.auth.session.full'))
|
||||
|| Boolean(localStorage.getItem('stellaops.auth.session.full')),
|
||||
null,
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
}).catch(() => {}),
|
||||
},
|
||||
).catch(() => {}),
|
||||
page.waitForTimeout(timeoutMs),
|
||||
]);
|
||||
}
|
||||
|
||||
async function hasBrowserSession(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
Boolean(sessionStorage.getItem('stellaops.auth.session.full'))
|
||||
|| Boolean(localStorage.getItem('stellaops.auth.session.full')),
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
async function ensureAuthorityLoginReachable(page, baseUrl, signInTrigger, usernameField, passwordField) {
|
||||
const hasLoginForm = async () =>
|
||||
(await usernameField.count()) > 0
|
||||
&& (await passwordField.count()) > 0;
|
||||
|
||||
if (page.url().includes('/connect/authorize') || await hasLoginForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clicked = await clickIfVisible(signInTrigger, 10_000);
|
||||
if (clicked) {
|
||||
await waitForAuthTransition(page, usernameField, passwordField, 10_000);
|
||||
}
|
||||
|
||||
if (page.url().includes('/connect/authorize') || await hasLoginForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${baseUrl}/connect/authorize`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
}).catch(() => {});
|
||||
await waitForAuthTransition(page, usernameField, passwordField, 10_000);
|
||||
}
|
||||
|
||||
export async function authenticateFrontdoor(options = {}) {
|
||||
const baseUrl = options.baseUrl?.trim() || DEFAULT_BASE_URL;
|
||||
const username = options.username?.trim() || DEFAULT_USERNAME;
|
||||
const password = options.password?.trim() || DEFAULT_PASSWORD;
|
||||
const passwordCandidates = Array.isArray(options.passwordCandidates) && options.passwordCandidates.length > 0
|
||||
? options.passwordCandidates.map((value) => String(value ?? '').trim()).filter(Boolean)
|
||||
: options.password?.trim()
|
||||
? [options.password.trim()]
|
||||
: DEFAULT_PASSWORD_CANDIDATES;
|
||||
const statePath = options.statePath || DEFAULT_STATE_PATH;
|
||||
const reportPath = options.reportPath || DEFAULT_REPORT_PATH;
|
||||
const headless = options.headless ?? true;
|
||||
@@ -160,19 +205,17 @@ export async function authenticateFrontdoor(options = {}) {
|
||||
'input[type="password"]',
|
||||
]);
|
||||
|
||||
const signInClicked = await clickIfVisible(signInTrigger);
|
||||
if (signInClicked) {
|
||||
await waitForAuthTransition(page, usernameField, passwordField);
|
||||
} else {
|
||||
await page.waitForTimeout(1_500);
|
||||
}
|
||||
await ensureAuthorityLoginReachable(page, baseUrl, signInTrigger, usernameField, passwordField);
|
||||
|
||||
const hasLoginForm = (await usernameField.count()) > 0 && (await passwordField.count()) > 0;
|
||||
if (page.url().includes('/connect/authorize') || hasLoginForm) {
|
||||
const filledUser = await fillIfVisible(usernameField, username);
|
||||
const filledPassword = await fillIfVisible(passwordField, password);
|
||||
await Promise.all([
|
||||
usernameField.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}),
|
||||
passwordField.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}),
|
||||
]);
|
||||
|
||||
if (!filledUser || !filledPassword) {
|
||||
const filledUser = await fillIfVisible(usernameField, username);
|
||||
if (!filledUser) {
|
||||
throw new Error(`Authority login form was reached at ${page.url()} but the credentials fields were not interactable.`);
|
||||
}
|
||||
|
||||
@@ -184,25 +227,52 @@ export async function authenticateFrontdoor(options = {}) {
|
||||
'button:has-text("Login")',
|
||||
]);
|
||||
|
||||
await submitButton.click({ timeout: 10_000 });
|
||||
let authenticated = false;
|
||||
for (const candidate of passwordCandidates) {
|
||||
const filledPassword = await fillIfVisible(passwordField, candidate);
|
||||
if (!filledPassword) {
|
||||
throw new Error(`Authority login form was reached at ${page.url()} but the password field was not interactable.`);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
page.waitForURL(
|
||||
(url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'),
|
||||
{ timeout: 30_000 },
|
||||
).catch(() => {}),
|
||||
page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, {
|
||||
timeout: 30_000,
|
||||
}).catch(() => {}),
|
||||
]);
|
||||
await submitButton.click({ timeout: 10_000 });
|
||||
|
||||
await Promise.race([
|
||||
page.waitForURL(
|
||||
(url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'),
|
||||
{ timeout: 30_000 },
|
||||
).catch(() => {}),
|
||||
page.waitForFunction(
|
||||
() =>
|
||||
Boolean(sessionStorage.getItem('stellaops.auth.session.full'))
|
||||
|| Boolean(localStorage.getItem('stellaops.auth.session.full')),
|
||||
null,
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
).catch(() => {}),
|
||||
]);
|
||||
|
||||
authenticated = await hasBrowserSession(page);
|
||||
if (authenticated) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!page.url().includes('/connect/authorize')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await waitForShell(page);
|
||||
await page.waitForTimeout(2_500);
|
||||
|
||||
const sessionStatus = await page.evaluate(() => ({
|
||||
hasFullSession: Boolean(sessionStorage.getItem('stellaops.auth.session.full')),
|
||||
hasSessionInfo: Boolean(sessionStorage.getItem('stellaops.auth.session.info')),
|
||||
hasFullSession:
|
||||
Boolean(sessionStorage.getItem('stellaops.auth.session.full'))
|
||||
|| Boolean(localStorage.getItem('stellaops.auth.session.full')),
|
||||
hasSessionInfo:
|
||||
Boolean(sessionStorage.getItem('stellaops.auth.session.info'))
|
||||
|| Boolean(localStorage.getItem('stellaops.auth.session.info')),
|
||||
}));
|
||||
const signInStillVisible = await signInTrigger.isVisible().catch(() => false);
|
||||
if (!sessionStatus.hasFullSession || (!page.url().includes('/connect/authorize') && signInStillVisible)) {
|
||||
|
||||
@@ -347,7 +347,7 @@ async function applyStep(page, currentStepId, nextStepId) {
|
||||
&& response.url().includes(`/steps/${currentStepId}/apply`),
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
clickPrimaryAction(page, /^Apply and Continue$/),
|
||||
clickPrimaryAction(page, /Apply and Continue/i),
|
||||
]);
|
||||
|
||||
if (nextStepId) {
|
||||
@@ -436,11 +436,6 @@ async function main() {
|
||||
);
|
||||
await settle(page, 1000);
|
||||
|
||||
await ensureFieldValue(page, '#db-host', 'db.stella-ops.local');
|
||||
await ensureFieldValue(page, '#db-port', '5432');
|
||||
await ensureFieldValue(page, '#db-database', 'stellaops_platform');
|
||||
await ensureFieldValue(page, '#db-user', 'stellaops');
|
||||
await ensureFieldValue(page, '#db-password', 'stellaops');
|
||||
const databaseValidated = await validateDatabase(page);
|
||||
await applyStep(page, 'database', 'cache');
|
||||
results.push({
|
||||
@@ -456,10 +451,6 @@ async function main() {
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await settle(page, 750);
|
||||
await ensureFieldValue(page, '#cache-host', 'cache.stella-ops.local');
|
||||
await ensureFieldValue(page, '#cache-port', '6379');
|
||||
await fillIfVisible(page, '#cache-password', '');
|
||||
await ensureFieldValue(page, '#cache-database', '0');
|
||||
await applyStep(page, 'cache', 'migrations');
|
||||
results.push({
|
||||
action: 'cache-step-completed',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
@@ -6,12 +6,14 @@ import { SetupSession } from '../models/setup-wizard.models';
|
||||
import { SetupWizardApiService } from '../services/setup-wizard-api.service';
|
||||
import { SetupWizardStateService } from '../services/setup-wizard-state.service';
|
||||
import { SetupWizardComponent } from './setup-wizard.component';
|
||||
import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.service';
|
||||
|
||||
describe('SetupWizardComponent', () => {
|
||||
let component: SetupWizardComponent;
|
||||
let fixture: ComponentFixture<SetupWizardComponent>;
|
||||
let stateService: SetupWizardStateService;
|
||||
let apiService: jasmine.SpyObj<SetupWizardApiService>;
|
||||
let doctorRecheckService: jasmine.SpyObj<DoctorRecheckService>;
|
||||
let router: Router;
|
||||
|
||||
const freshSession: SetupSession = {
|
||||
@@ -66,6 +68,19 @@ describe('SetupWizardComponent', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const cryptoPendingSession: SetupSession = {
|
||||
...freshSession,
|
||||
completedSteps: ['database', 'cache', 'migrations', 'admin'],
|
||||
currentStep: 'crypto',
|
||||
steps: [
|
||||
{ stepId: 'database', status: 'completed' },
|
||||
{ stepId: 'cache', status: 'completed' },
|
||||
{ stepId: 'migrations', status: 'completed' },
|
||||
{ stepId: 'admin', status: 'completed' },
|
||||
{ stepId: 'crypto', status: 'in_progress' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const apiSpy = jasmine.createSpyObj('SetupWizardApiService', [
|
||||
'createSession',
|
||||
@@ -77,6 +92,7 @@ describe('SetupWizardComponent', () => {
|
||||
'runValidationChecks',
|
||||
'finalizeSetup',
|
||||
]);
|
||||
const doctorRecheckSpy = jasmine.createSpyObj('DoctorRecheckService', ['offerRecheck']);
|
||||
|
||||
apiSpy.createSession.and.returnValue(of(freshSession));
|
||||
apiSpy.getCurrentSession.and.returnValue(of(cacheSession));
|
||||
@@ -111,6 +127,7 @@ describe('SetupWizardComponent', () => {
|
||||
providers: [
|
||||
SetupWizardStateService,
|
||||
{ provide: SetupWizardApiService, useValue: apiSpy },
|
||||
{ provide: DoctorRecheckService, useValue: doctorRecheckSpy },
|
||||
provideRouter([]),
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -119,97 +136,110 @@ describe('SetupWizardComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
stateService = TestBed.inject(SetupWizardStateService);
|
||||
apiService = TestBed.inject(SetupWizardApiService) as jasmine.SpyObj<SetupWizardApiService>;
|
||||
doctorRecheckService = TestBed.inject(DoctorRecheckService) as jasmine.SpyObj<DoctorRecheckService>;
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
it('initializes fresh sessions on the welcome step', fakeAsync(() => {
|
||||
it('initializes fresh sessions on the welcome step', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(apiService.createSession).toHaveBeenCalled();
|
||||
expect(stateService.session()).toEqual(freshSession);
|
||||
expect(stateService.currentStepId()).toBe('welcome');
|
||||
expect(apiService.runValidationChecks).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
it('starts the truthful wizard on database after the welcome step', fakeAsync(() => {
|
||||
it('starts the truthful wizard on database after the welcome step', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onWelcomeStart();
|
||||
|
||||
expect(stateService.currentStepId()).toBe('database');
|
||||
expect(apiService.runValidationChecks).toHaveBeenCalledWith('test-session-123', 'database');
|
||||
}));
|
||||
});
|
||||
|
||||
it('moves to cache when the current database step is already completed', fakeAsync(() => {
|
||||
it('moves to cache when the current database step is already completed', () => {
|
||||
apiService.createSession.and.returnValue(of(databaseSession));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
stateService.updateStepStatus('database', 'completed');
|
||||
component.onNext();
|
||||
|
||||
expect(stateService.currentStepId()).toBe('cache');
|
||||
expect(apiService.runValidationChecks).toHaveBeenCalledWith('test-session-123', 'cache');
|
||||
}));
|
||||
});
|
||||
|
||||
it('applies the current step through save -> apply -> refresh', fakeAsync(() => {
|
||||
it('applies the current step through save -> apply -> refresh', () => {
|
||||
apiService.createSession.and.returnValue(of(databaseSession));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onExecuteStep();
|
||||
tick();
|
||||
|
||||
expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {});
|
||||
expect(apiService.applyStep).toHaveBeenCalledWith('test-session-123', 'database', {});
|
||||
expect(apiService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(stateService.currentStepId()).toBe('cache');
|
||||
}));
|
||||
expect(doctorRecheckService.offerRecheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('probes the current step through save -> probe -> refresh', fakeAsync(() => {
|
||||
it('probes the current step through save -> probe -> refresh', () => {
|
||||
apiService.createSession.and.returnValue(of(databaseSession));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onTestConnection();
|
||||
tick();
|
||||
|
||||
expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {});
|
||||
expect(apiService.probeStep).toHaveBeenCalledWith('test-session-123', 'database', {});
|
||||
expect(stateService.currentStepId()).toBe('database');
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not call skip when the current step is not skippable', fakeAsync(() => {
|
||||
it('does not call skip when the current step is not skippable', () => {
|
||||
apiService.createSession.and.returnValue(of(databaseSession));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onSkipStep();
|
||||
|
||||
expect(apiService.skipStep).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
it('finalizes the installation when all required steps are complete', fakeAsync(() => {
|
||||
it('finalizes the installation when all required steps are complete', () => {
|
||||
spyOn(router, 'navigate');
|
||||
apiService.createSession.and.returnValue(of(completedSession));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onComplete();
|
||||
tick();
|
||||
|
||||
expect(apiService.finalizeSetup).toHaveBeenCalledWith('test-session-123');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
}));
|
||||
});
|
||||
|
||||
it('surfaces finalize errors without navigating away', fakeAsync(() => {
|
||||
it('applies the last pending required step before finalizing the installation', () => {
|
||||
spyOn(router, 'navigate');
|
||||
apiService.createSession.and.returnValue(of(cryptoPendingSession));
|
||||
apiService.saveDraftConfig.and.returnValue(of(cryptoPendingSession));
|
||||
apiService.applyStep.and.returnValue(of({
|
||||
stepId: 'crypto',
|
||||
status: 'completed',
|
||||
message: 'Crypto step applied',
|
||||
canRetry: true,
|
||||
}));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onComplete();
|
||||
|
||||
expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {});
|
||||
expect(apiService.applyStep).toHaveBeenCalledWith('test-session-123', 'crypto', {});
|
||||
expect(apiService.finalizeSetup).toHaveBeenCalledWith('test-session-123');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('surfaces finalize errors without navigating away', () => {
|
||||
spyOn(router, 'navigate');
|
||||
apiService.createSession.and.returnValue(of(completedSession));
|
||||
apiService.finalizeSetup.and.returnValue(
|
||||
@@ -217,12 +247,10 @@ describe('SetupWizardComponent', () => {
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onComplete();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toBe('Finalize backend unavailable');
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { SetupWizardStateService } from '../services/setup-wizard-state.service';
|
||||
import { SetupWizardApiService } from '../services/setup-wizard-api.service';
|
||||
import { StepContentComponent } from './step-content.component';
|
||||
import { mergeSetupStepLocalDefaults, StepContentComponent } from './step-content.component';
|
||||
import {
|
||||
SetupStep,
|
||||
SetupStepId,
|
||||
@@ -1107,24 +1107,15 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
|
||||
onComplete(): void {
|
||||
const session = this.state.session();
|
||||
const step = this.state.currentStep();
|
||||
if (!session) return;
|
||||
|
||||
this.state.executing.set(true);
|
||||
if (step && step.id !== 'welcome' && step.status !== 'completed' && step.status !== 'skipped') {
|
||||
this.applyCurrentStep(step, () => this.finalizeSession(session.sessionId, step.id));
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.finalizeSetup(session.sessionId).subscribe({
|
||||
next: (result) => {
|
||||
this.state.executing.set(false);
|
||||
if (result.success) {
|
||||
this.router.navigate(['/']);
|
||||
} else {
|
||||
this.state.error.set(result.message);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.executing.set(false);
|
||||
this.state.error.set(err?.message ?? 'Failed to finalize setup');
|
||||
},
|
||||
});
|
||||
this.finalizeSession(session.sessionId);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
@@ -1170,6 +1161,8 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureCurrentStepDefaults();
|
||||
|
||||
if (!currentStepId || currentStepId === 'welcome') {
|
||||
onSaved?.(session);
|
||||
return;
|
||||
@@ -1190,6 +1183,16 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private ensureCurrentStepDefaults(): void {
|
||||
const step = this.state.currentStep();
|
||||
if (!step || step.id === 'welcome') {
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = mergeSetupStepLocalDefaults(step.id, this.state.configValues());
|
||||
this.state.setConfigValues(merged);
|
||||
}
|
||||
|
||||
private syncSessionFromBackend(preferredStepId: SetupStepId | null = null): void {
|
||||
this.api.getCurrentSession().subscribe({
|
||||
next: (session) => {
|
||||
@@ -1237,7 +1240,7 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private applyCurrentStep(step: SetupStep): void {
|
||||
private applyCurrentStep(step: SetupStep, onSuccess?: () => void): void {
|
||||
const session = this.state.session();
|
||||
if (!session) {
|
||||
return;
|
||||
@@ -1265,6 +1268,11 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
this.doctorRecheck.offerRecheck(step.id, step.name);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncSessionFromBackend();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -1276,5 +1284,31 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private finalizeSession(sessionId: string, preferredStepId: SetupStepId | null = null): void {
|
||||
this.state.executing.set(true);
|
||||
|
||||
this.api.finalizeSetup(sessionId).subscribe({
|
||||
next: (result) => {
|
||||
this.state.executing.set(false);
|
||||
if (result.success) {
|
||||
this.router.navigate(['/']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.error.set(result.message);
|
||||
if (preferredStepId) {
|
||||
this.syncSessionFromBackend(preferredStepId);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.executing.set(false);
|
||||
this.state.error.set(err?.message ?? 'Failed to finalize setup');
|
||||
if (preferredStepId) {
|
||||
this.syncSessionFromBackend(preferredStepId);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('StepContentComponent', () => {
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StepContentComponent],
|
||||
|
||||
@@ -38,6 +38,74 @@ import {
|
||||
createIntegrationInstance,
|
||||
} from '../models/setup-wizard.models';
|
||||
|
||||
const LOCAL_AUTHORITY_DEFAULTS: Record<string, string> = {
|
||||
'authority.provider': 'standard',
|
||||
'authority.standard.minLength': '12',
|
||||
'authority.standard.requireUppercase': 'true',
|
||||
'authority.standard.requireLowercase': 'true',
|
||||
'authority.standard.requireDigit': 'true',
|
||||
'authority.standard.requireSpecialChar': 'true',
|
||||
};
|
||||
|
||||
const LOCAL_ADMIN_DEFAULTS: Record<string, string> = {
|
||||
...LOCAL_AUTHORITY_DEFAULTS,
|
||||
'users.superuser.username': 'admin',
|
||||
'users.superuser.email': 'admin@stella-ops.local',
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
};
|
||||
|
||||
export const SETUP_STEP_LOCAL_DEFAULTS: Record<string, Record<string, string>> = {
|
||||
database: {
|
||||
'database.host': 'db.stella-ops.local',
|
||||
'database.port': '5432',
|
||||
'database.database': 'stellaops_platform',
|
||||
'database.user': 'stellaops',
|
||||
'database.password': 'stellaops',
|
||||
},
|
||||
cache: {
|
||||
'cache.host': 'cache.stella-ops.local',
|
||||
'cache.port': '6379',
|
||||
'cache.database': '0',
|
||||
},
|
||||
admin: LOCAL_ADMIN_DEFAULTS,
|
||||
authority: LOCAL_AUTHORITY_DEFAULTS,
|
||||
users: {
|
||||
'users.superuser.username': 'admin',
|
||||
'users.superuser.email': 'admin@stella-ops.local',
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
},
|
||||
crypto: {
|
||||
'crypto.provider': 'default',
|
||||
},
|
||||
sources: {
|
||||
'sources.mode': 'custom',
|
||||
},
|
||||
telemetry: {
|
||||
'telemetry.otlpEndpoint': 'http://localhost:4317',
|
||||
'telemetry.serviceName': 'stellaops',
|
||||
},
|
||||
};
|
||||
|
||||
export function mergeSetupStepLocalDefaults(
|
||||
stepId: SetupStepId,
|
||||
configValues: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const defaults = SETUP_STEP_LOCAL_DEFAULTS[stepId];
|
||||
if (!defaults) {
|
||||
return { ...configValues };
|
||||
}
|
||||
|
||||
const merged = { ...configValues };
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
const current = merged[key];
|
||||
if (typeof current !== 'string' || current.trim().length === 0) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step content component.
|
||||
* Dynamically renders configuration forms based on step type.
|
||||
@@ -1813,50 +1881,15 @@ export class StepContentComponent {
|
||||
'https://mirrors.stella-ops.org/feeds/',
|
||||
]);
|
||||
|
||||
/** Sensible defaults for local/development setup. */
|
||||
private static readonly LOCAL_DEFAULTS: Record<string, Record<string, string>> = {
|
||||
database: {
|
||||
'database.host': 'db.stella-ops.local',
|
||||
'database.port': '5432',
|
||||
'database.database': 'stellaops_platform',
|
||||
'database.user': 'stellaops',
|
||||
'database.password': 'stellaops',
|
||||
},
|
||||
cache: {
|
||||
'cache.host': 'cache.stella-ops.local',
|
||||
'cache.port': '6379',
|
||||
'cache.database': '0',
|
||||
},
|
||||
authority: {
|
||||
'authority.provider': 'standard',
|
||||
'authority.standard.minLength': '12',
|
||||
'authority.standard.requireUppercase': 'true',
|
||||
'authority.standard.requireLowercase': 'true',
|
||||
'authority.standard.requireDigit': 'true',
|
||||
'authority.standard.requireSpecialChar': 'true',
|
||||
},
|
||||
users: {
|
||||
'users.superuser.username': 'admin',
|
||||
'users.superuser.email': 'admin@stella-ops.local',
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
},
|
||||
crypto: {
|
||||
'crypto.provider': 'default',
|
||||
},
|
||||
sources: {
|
||||
'sources.mode': 'custom',
|
||||
},
|
||||
telemetry: {
|
||||
'telemetry.otlpEndpoint': 'http://localhost:4317',
|
||||
'telemetry.serviceName': 'stellaops',
|
||||
},
|
||||
};
|
||||
|
||||
/** Emit defaults for the current step if no values are set yet. */
|
||||
private readonly defaultsEffect = effect(() => {
|
||||
const step = this.step();
|
||||
const step = this.tryGetStep();
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.configValues();
|
||||
const defaults = StepContentComponent.LOCAL_DEFAULTS[step.id];
|
||||
const defaults = SETUP_STEP_LOCAL_DEFAULTS[step.id];
|
||||
if (defaults) {
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (!config[key]) {
|
||||
@@ -1894,6 +1927,14 @@ export class StepContentComponent {
|
||||
}
|
||||
});
|
||||
|
||||
private tryGetStep(): SetupStep | null {
|
||||
try {
|
||||
return this.step();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Source feed mode: 'mirror' (Stella Ops pre-aggregated) or 'custom' (individual feeds)
|
||||
readonly sourceFeedMode = signal<'mirror' | 'custom' | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
SETUP_STEP_LOCAL_DEFAULTS,
|
||||
mergeSetupStepLocalDefaults,
|
||||
} from './step-content.component';
|
||||
|
||||
describe('StepContentComponent local defaults', () => {
|
||||
it('includes the standard admin bootstrap defaults under the unified admin step', () => {
|
||||
expect(SETUP_STEP_LOCAL_DEFAULTS.admin).toEqual(expect.objectContaining({
|
||||
'authority.provider': 'standard',
|
||||
'users.superuser.username': 'admin',
|
||||
'users.superuser.email': 'admin@stella-ops.local',
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps the legacy authority and users defaults for compatibility', () => {
|
||||
expect(SETUP_STEP_LOCAL_DEFAULTS.authority).toEqual(expect.objectContaining({
|
||||
'authority.provider': 'standard',
|
||||
}));
|
||||
expect(SETUP_STEP_LOCAL_DEFAULTS.users).toEqual(expect.objectContaining({
|
||||
'users.superuser.username': 'admin',
|
||||
}));
|
||||
});
|
||||
|
||||
it('merges admin defaults without overwriting explicit operator input', () => {
|
||||
expect(mergeSetupStepLocalDefaults('admin', {
|
||||
'users.superuser.username': 'custom-admin',
|
||||
'users.superuser.email': '',
|
||||
})).toEqual(expect.objectContaining({
|
||||
'authority.provider': 'standard',
|
||||
'users.superuser.username': 'custom-admin',
|
||||
'users.superuser.email': 'admin@stella-ops.local',
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,42 @@ describe('SetupWizardStateService', () => {
|
||||
expect(service.steps().find((step) => step.id === 'cache')?.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('preserves locally entered sensitive values when backend session config is sanitized', () => {
|
||||
service.setConfigValues({
|
||||
'users.superuser.password': 'Admin@Stella1',
|
||||
'database.host': 'old-host',
|
||||
});
|
||||
|
||||
const session: SetupSession = {
|
||||
sessionId: 'session-sensitive',
|
||||
scopeKey: 'installation',
|
||||
status: 'in_progress',
|
||||
startedAt: '2026-04-14T00:00:00Z',
|
||||
definitionVersion: 'v1',
|
||||
configValues: {
|
||||
'database.host': 'db.stella-ops.local',
|
||||
},
|
||||
currentStep: 'admin',
|
||||
steps: [
|
||||
{
|
||||
stepId: 'database',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
stepId: 'admin',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
completedSteps: ['database'],
|
||||
skippedSteps: [],
|
||||
};
|
||||
|
||||
service.initializeSession(session);
|
||||
|
||||
expect(service.configValues()['database.host']).toBe('db.stella-ops.local');
|
||||
expect(service.configValues()['users.superuser.password']).toBe('Admin@Stella1');
|
||||
});
|
||||
|
||||
it('falls back to the first pending step when the backend session has no current step', () => {
|
||||
const session: SetupSession = {
|
||||
sessionId: 'session-2',
|
||||
@@ -80,7 +116,7 @@ describe('SetupWizardStateService', () => {
|
||||
|
||||
service.initializeSession(session);
|
||||
|
||||
expect(service.currentStepId()).toBe('cache');
|
||||
expect(service.currentStepId()).toBe('welcome');
|
||||
});
|
||||
|
||||
it('navigates backward without returning to welcome', () => {
|
||||
@@ -126,6 +162,33 @@ describe('SetupWizardStateService', () => {
|
||||
expect(service.progressPercent()).toBe(83);
|
||||
});
|
||||
|
||||
it('allows finalization from the last required step before it is applied', () => {
|
||||
const session: SetupSession = {
|
||||
sessionId: 'session-3',
|
||||
scopeKey: 'installation',
|
||||
status: 'in_progress',
|
||||
startedAt: '2026-04-14T00:00:00Z',
|
||||
definitionVersion: 'v1',
|
||||
configValues: {},
|
||||
currentStep: 'crypto',
|
||||
steps: [
|
||||
{ stepId: 'database', status: 'completed' },
|
||||
{ stepId: 'cache', status: 'completed' },
|
||||
{ stepId: 'migrations', status: 'completed' },
|
||||
{ stepId: 'admin', status: 'completed' },
|
||||
{ stepId: 'crypto', status: 'in_progress' },
|
||||
],
|
||||
completedSteps: ['database', 'cache', 'migrations', 'admin'],
|
||||
skippedSteps: [],
|
||||
};
|
||||
|
||||
service.initializeSession(session);
|
||||
|
||||
expect(service.currentStepId()).toBe('crypto');
|
||||
expect(service.allRequiredComplete()).toBeFalse();
|
||||
expect(service.navigation().canComplete).toBeTrue();
|
||||
});
|
||||
|
||||
it('never exposes a skippable current step in the truthful wizard flow', () => {
|
||||
service.currentStepId.set('database');
|
||||
expect(service.canSkipCurrentStep()).toBeFalse();
|
||||
|
||||
@@ -179,7 +179,7 @@ export class SetupWizardStateService {
|
||||
currentStepIndex: index,
|
||||
canGoBack: index > 0,
|
||||
canGoNext: index < ordered.length - 1 && this.canProceedFromCurrentStep(),
|
||||
canComplete: this.allRequiredComplete(),
|
||||
canComplete: this.canCompleteFromCurrentStep(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ export class SetupWizardStateService {
|
||||
*/
|
||||
initializeSession(session: SetupSession): void {
|
||||
this.session.set(session);
|
||||
this.configValues.set({ ...session.configValues });
|
||||
this.configValues.set(this.mergeSensitiveConfigValues(this.configValues(), session.configValues));
|
||||
const stepStates = new Map(session.steps.map(step => [step.stepId, step]));
|
||||
|
||||
this.steps.update(steps =>
|
||||
@@ -354,6 +354,38 @@ export class SetupWizardStateService {
|
||||
}));
|
||||
}
|
||||
|
||||
private mergeSensitiveConfigValues(
|
||||
current: Record<string, string>,
|
||||
next: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const merged = { ...next };
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (!this.isSensitiveKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof merged[key] === 'string' && merged[key].trim().length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
return normalized.includes('password')
|
||||
|| normalized.includes('secret')
|
||||
|| normalized.includes('token')
|
||||
|| normalized.includes('privatekey')
|
||||
|| normalized.endsWith('.pin')
|
||||
|| normalized.endsWith('.connectionstring');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update step status
|
||||
*/
|
||||
@@ -567,6 +599,26 @@ export class SetupWizardStateService {
|
||||
return step.status === 'completed' || step.status === 'skipped' || step.isSkippable;
|
||||
}
|
||||
|
||||
private canCompleteFromCurrentStep(): boolean {
|
||||
if (this.allRequiredComplete()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const step = this.currentStep();
|
||||
if (!step) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ordered = this.orderedSteps();
|
||||
const index = this.currentStepIndex();
|
||||
if (index !== ordered.length - 1 || !this.dependenciesMet()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pendingRequired = this.pendingRequiredSteps();
|
||||
return pendingRequired.length === 1 && pendingRequired[0].id === step.id;
|
||||
}
|
||||
|
||||
private mapSessionStepStatus(
|
||||
backendStatus: SetupSession['steps'][number]['status'] | undefined,
|
||||
currentStep: SetupStepId | undefined,
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
},
|
||||
"files": [
|
||||
"src/test-setup.ts",
|
||||
"src/app/features/setup-wizard/components/setup-wizard.component.spec.ts",
|
||||
"src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts",
|
||||
"src/app/features/integration-hub/integration.service.spec.ts",
|
||||
"src/app/features/integrations/integration-wizard.component.spec.ts",
|
||||
"src/tests/deployments/create-deployment.component.spec.ts",
|
||||
|
||||
Reference in New Issue
Block a user