Make local UI setup truthful and rerunnable

This commit is contained in:
master
2026-04-14 21:44:35 +03:00
parent c69ebb4c48
commit 75ccdf81c1
28 changed files with 1272 additions and 173 deletions

View File

@@ -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)) {

View File

@@ -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',

View File

@@ -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();
}));
});
});

View File

@@ -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);
}
},
});
}
}

View File

@@ -38,6 +38,7 @@ describe('StepContentComponent', () => {
status: 'pending',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StepContentComponent],

View File

@@ -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);

View File

@@ -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',
}));
});
});

View File

@@ -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();

View File

@@ -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,

View File

@@ -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",