From 41799611dd264f43a34a39b16eeffd9742c63e15 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 05:24:07 +0200 Subject: [PATCH] Guard signed-out shell bootstrap services --- ..._signed_out_shell_bootstrap_auth_guards.md | 86 +++++++++++++ .../src/app/app.component.spec.ts | 115 ++++++++++++++++++ .../StellaOps.Web/src/app/app.component.ts | 36 +++++- .../context/platform-context.store.spec.ts | 21 ++++ .../core/context/platform-context.store.ts | 11 +- .../doctor/doctor-notification.service.ts | 25 +++- .../app/core/doctor/doctor-trend.service.ts | 19 ++- 7 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_014_FE_signed_out_shell_bootstrap_auth_guards.md diff --git a/docs/implplan/SPRINT_20260307_014_FE_signed_out_shell_bootstrap_auth_guards.md b/docs/implplan/SPRINT_20260307_014_FE_signed_out_shell_bootstrap_auth_guards.md new file mode 100644 index 000000000..af9d54cfa --- /dev/null +++ b/docs/implplan/SPRINT_20260307_014_FE_signed_out_shell_bootstrap_auth_guards.md @@ -0,0 +1,86 @@ +# Sprint 20260307-014 - FE Signed-Out Shell Bootstrap Auth Guards + +## Topic & Scope +- Eliminate signed-out `/welcome` shell requests that currently hit authenticated context and Doctor endpoints before a session exists. +- Start background shell services only when the authenticated shell is active, and stop them when the user leaves that state. +- Add focused Angular tests for the bootstrap gating, then verify `/welcome` with live Playwright to prove the browser stops emitting `401` bootstrap noise. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular tests, live Playwright request tracing on `https://stella-ops.local/welcome`, and sprint execution log updates. + +## Dependencies & Concurrency +- Depends on the just-landed release-health iteration (`SPRINT_20260307_012` and `SPRINT_20260307_013`) because the new defect was discovered during the topology Playwright sweep that followed those fixes. +- Safe parallelism: stay inside `src/Web/StellaOps.Web` plus sprint updates; do not touch unrelated navigation/settings/sidebar work already in progress from other agents. +- Scope is limited to signed-out shell bootstrap behavior, not broader auth UX or welcome-page visual work. + +## Documentation Prerequisites +- `src/Web/StellaOps.Web/AGENTS.md` +- `src/Web/StellaOps.Web/src/app/app.component.ts` +- `src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts` +- `src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts` +- `src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts` + +## Delivery Tracker + +### FE-AUTH-001 - Reproduce signed-out shell bootstrap traffic +Status: DONE +Dependency: none +Owners: QA +Task description: +- Replay `/welcome` and a signed-out transition into the app shell with real Playwright. +- Capture the exact unauthorized requests instead of treating console errors as generic flake. + +Completion criteria: +- [x] Live Playwright captures the concrete `401` request set. +- [x] Root cause is reduced to specific shell bootstrap call sites. + +### FE-AUTH-002 - Gate shell background services behind authenticated shell state +Status: DONE +Dependency: FE-AUTH-001 +Owners: Developer +Task description: +- Update app-shell bootstrap behavior so Platform context sync and Doctor background services only start when the authenticated shell is active. +- Ensure background polling stops cleanly when the user leaves authenticated shell state. + +Completion criteria: +- [x] Signed-out `/welcome` no longer starts protected Platform context bootstrap. +- [x] Signed-out shell no longer starts Doctor trend/report background polling. +- [x] Authenticated shell still starts the required background services. + +### FE-AUTH-003 - Add focused Angular coverage for bootstrap gating +Status: DONE +Dependency: FE-AUTH-002 +Owners: Test Automation +Task description: +- Add focused tests around `AppComponent` bootstrap behavior and any touched shell services. +- Verify the tests assert start/stop behavior, not just component creation. + +Completion criteria: +- [x] Focused Angular tests fail before the fix and pass after it. +- [x] Tests cover both signed-out suppression and authenticated-shell startup. + +### FE-AUTH-004 - Replay `/welcome` with live Playwright +Status: DONE +Dependency: FE-AUTH-003 +Owners: QA +Task description: +- Re-run a live Playwright trace on `/welcome` and capture console plus network evidence after the fix. +- Confirm the page remains interactive and the previous unauthorized bootstrap requests are gone. + +Completion criteria: +- [x] Live Playwright on `/welcome` shows no `401` bootstrap requests from Platform context or Doctor endpoints. +- [x] The sign-in action remains available and the page stays responsive. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-07 | Sprint created after live Playwright on topology/release-health showed repeated signed-out `401` requests on `/welcome` for `/api/v2/context/*` and `/api/v1/doctor/scheduler/trends/categories/*`, traced to unconditional shell bootstrap startup. | QA | +| 2026-03-07 | Gated shell bootstrap startup behind authenticated shell state in `AppComponent`, added stop semantics to the Doctor background services, and guarded `PlatformContextStore` against unauthenticated protected requests. Focused Angular coverage passed via `npx ng test --watch=false --include src/app/app.component.spec.ts --include src/app/core/context/platform-context.store.spec.ts`. | Developer | +| 2026-03-07 | Replayed signed-out `/welcome` with live Playwright on the rebuilt bundle. The page rendered with no console errors, no `401` responses, and the primary sign-in action still navigated to the Authority `/connect/authorize` flow. | QA | + +## Decisions & Risks +- Decision: fix the startup sequencing at the shell bootstrap layer instead of swallowing `401` responses, because the browser should not emit protected requests before auth exists. +- Decision: add defense-in-depth guards in both `AppComponent` and the background/context services so signed-out routes remain quiet even if a future caller starts those services too early. +- Risk: the app currently starts long-lived background services from `AppComponent`, so the fix must preserve authenticated behavior while preventing duplicate timers or stale polling after logout. + +## Next Checkpoints +- 2026-03-07: continue the Playwright page/action sweep from the signed-out/authenticated shell surfaces outward. diff --git a/src/Web/StellaOps.Web/src/app/app.component.spec.ts b/src/Web/StellaOps.Web/src/app/app.component.spec.ts index 733fbc8dc..93ab0c897 100644 --- a/src/Web/StellaOps.Web/src/app/app.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/app.component.spec.ts @@ -15,6 +15,10 @@ import { PolicyPackStore } from './features/policy-studio/services/policy-pack.s import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { signal } from '@angular/core'; import { DOCTOR_API, MockDoctorClient } from './features/doctor/services/doctor.client'; +import { DoctorTrendService } from './core/doctor/doctor-trend.service'; +import { DoctorNotificationService } from './core/doctor/doctor-notification.service'; +import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service'; +import { AUTHORITY_CONSOLE_API } from './core/api/authority-console.client'; class AuthorityAuthServiceStub { beginLogin = jasmine.createSpy('beginLogin'); @@ -33,7 +37,25 @@ class OfflineModeServiceStub { readonly offlineBannerMessage = signal(null); } +class DoctorTrendServiceStub { + readonly start = jasmine.createSpy('start'); + readonly stop = jasmine.createSpy('stop'); +} + +class DoctorNotificationServiceStub { + readonly start = jasmine.createSpy('start'); + readonly stop = jasmine.createSpy('stop'); +} + +class PlatformContextUrlSyncServiceStub { + readonly initialize = jasmine.createSpy('initialize'); +} + describe('AppComponent', () => { + let doctorTrend: DoctorTrendServiceStub; + let doctorNotification: DoctorNotificationServiceStub; + let contextUrlSync: PlatformContextUrlSyncServiceStub; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppComponent], @@ -54,10 +76,62 @@ describe('AppComponent', () => { }, { provide: OfflineModeService, useClass: OfflineModeServiceStub }, { provide: DOCTOR_API, useClass: MockDoctorClient }, + { + provide: AUTHORITY_CONSOLE_API, + useValue: { + listTenants: () => of({ + tenants: [ + { + id: 'tenant-1', + displayName: 'Tenant One', + status: 'active', + isolationMode: 'shared', + defaultRoles: [], + }, + ], + selectedTenant: 'tenant-1', + }), + getProfile: () => of({ + subjectId: 'user-1', + username: 'user.one', + displayName: 'User One', + tenant: 'tenant-1', + sessionId: 'session-1', + roles: ['operator'], + scopes: ['ui.read'], + audiences: ['stellaops-web'], + authenticationMethods: ['pwd'], + issuedAt: null, + authenticationTime: null, + expiresAt: null, + freshAuth: false, + }), + introspectToken: () => of({ + active: true, + tenant: 'tenant-1', + subject: 'user-1', + clientId: 'stellaops-web', + tokenId: 'token-1', + scopes: ['ui.read'], + audiences: ['stellaops-web'], + issuedAt: null, + authenticationTime: null, + expiresAt: null, + freshAuth: false, + }), + }, + }, + { provide: DoctorTrendService, useClass: DoctorTrendServiceStub }, + { provide: DoctorNotificationService, useClass: DoctorNotificationServiceStub }, + { provide: PlatformContextUrlSyncService, useClass: PlatformContextUrlSyncServiceStub }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ] }).compileComponents(); + + doctorTrend = TestBed.inject(DoctorTrendService) as unknown as DoctorTrendServiceStub; + doctorNotification = TestBed.inject(DoctorNotificationService) as unknown as DoctorNotificationServiceStub; + contextUrlSync = TestBed.inject(PlatformContextUrlSyncService) as unknown as PlatformContextUrlSyncServiceStub; }); it('creates the root component', () => { @@ -66,6 +140,17 @@ describe('AppComponent', () => { expect(app).toBeTruthy(); }); + it('does not start shell background services while signed out', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + expect(contextUrlSync.initialize).not.toHaveBeenCalled(); + expect(doctorTrend.start).not.toHaveBeenCalled(); + expect(doctorNotification.start).not.toHaveBeenCalled(); + expect(doctorTrend.stop).not.toHaveBeenCalled(); + expect(doctorNotification.stop).not.toHaveBeenCalled(); + }); + it('renders a router outlet for child routes', () => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); @@ -95,6 +180,36 @@ describe('AppComponent', () => { expect(compiled.querySelector('app-sidebar')).not.toBeNull(); expect(compiled.querySelector('app-context-chips')).not.toBeNull(); }); + + it('starts shell background services once the shell is authenticated', () => { + const sessionStore = TestBed.inject(AuthSessionStore); + sessionStore.setSession(buildSession()); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + expect(contextUrlSync.initialize).toHaveBeenCalledTimes(1); + expect(doctorTrend.start).toHaveBeenCalledTimes(1); + expect(doctorNotification.start).toHaveBeenCalledTimes(1); + expect(doctorTrend.stop).not.toHaveBeenCalled(); + expect(doctorNotification.stop).not.toHaveBeenCalled(); + }); + + it('stops doctor background services when auth is cleared after startup', () => { + const sessionStore = TestBed.inject(AuthSessionStore); + sessionStore.setSession(buildSession()); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + sessionStore.clear(); + fixture.detectChanges(); + + expect(doctorTrend.start).toHaveBeenCalledTimes(1); + expect(doctorNotification.start).toHaveBeenCalledTimes(1); + expect(doctorTrend.stop).toHaveBeenCalledTimes(1); + expect(doctorNotification.stop).toHaveBeenCalledTimes(1); + }); }); function buildSession(): AuthSession { diff --git a/src/Web/StellaOps.Web/src/app/app.component.ts b/src/Web/StellaOps.Web/src/app/app.component.ts index a238f3b34..bd3dffc99 100644 --- a/src/Web/StellaOps.Web/src/app/app.component.ts +++ b/src/Web/StellaOps.Web/src/app/app.component.ts @@ -4,6 +4,7 @@ import { Component, computed, DestroyRef, + effect, inject, ViewChild, } from '@angular/core'; @@ -68,6 +69,7 @@ export class AppComponent { private readonly contextUrlSync = inject(PlatformContextUrlSyncService); private readonly doctorTrend = inject(DoctorTrendService); private readonly doctorNotification = inject(DoctorNotificationService); + private shellBackgroundServicesActive = false; @ViewChild(CommandPaletteComponent) private commandPalette!: CommandPaletteComponent; @@ -109,13 +111,35 @@ export class AppComponent { // Initialize legacy route telemetry tracking (ROUTE-002) this.legacyRouteTelemetry.initialize(); - // Keep global scope in sync with route query parameters. - this.contextUrlSync.initialize(); + effect(() => { + const shouldRunShellBackgroundServices = + this.isAuthenticated() && !this.isShellExcludedRoute(this.currentUrl()); - // Start Doctor background services (deferred from APP_INITIALIZER - // to avoid NG0200 circular DI with Router during bootstrap). - this.doctorTrend.start(); - this.doctorNotification.start(); + if (shouldRunShellBackgroundServices) { + if (this.shellBackgroundServicesActive) { + return; + } + + // Keep global scope in sync with route query parameters only once the + // authenticated shell is active. + this.contextUrlSync.initialize(); + + // Start Doctor background services (deferred from APP_INITIALIZER + // to avoid NG0200 circular DI with Router during bootstrap). + this.doctorTrend.start(); + this.doctorNotification.start(); + this.shellBackgroundServicesActive = true; + return; + } + + if (!this.shellBackgroundServicesActive) { + return; + } + + this.doctorTrend.stop(); + this.doctorNotification.stop(); + this.shellBackgroundServicesActive = false; + }); } readonly isAuthenticated = this.sessionStore.isAuthenticated; diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts index e240bc7e0..d2bbb94c3 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts @@ -2,16 +2,26 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { AuthSessionStore } from '../auth/auth-session.store'; import { PlatformContextStore } from './platform-context.store'; describe('PlatformContextStore', () => { let store: PlatformContextStore; let httpMock: HttpTestingController; + let authenticated = true; beforeEach(() => { + authenticated = true; + TestBed.configureTestingModule({ providers: [ PlatformContextStore, + { + provide: AuthSessionStore, + useValue: { + isAuthenticated: () => authenticated, + }, + }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], @@ -97,4 +107,15 @@ describe('PlatformContextStore', () => { expect(store.selectedEnvironments()).toEqual(['dev']); expect(store.error()).toBeNull(); }); + + it('does not request protected context endpoints before auth is available', () => { + authenticated = false; + + store.initialize(); + + httpMock.expectNone('/api/v2/context/regions'); + expect(store.initialized()).toBe(false); + expect(store.loading()).toBe(false); + expect(store.error()).toBeNull(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts index a011321a2..291715117 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts @@ -2,6 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; import { take } from 'rxjs'; +import { AuthSessionStore } from '../auth/auth-session.store'; + export interface PlatformContextRegion { regionId: string; displayName: string; @@ -54,6 +56,7 @@ interface PlatformContextPreferencesRequestPayload { @Injectable({ providedIn: 'root' }) export class PlatformContextStore { private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); private persistPaused = false; private readonly apiDisabled = this.shouldDisableApiCalls(); private readonly initialQueryOverride = this.readScopeQueryFromLocation(); @@ -106,6 +109,12 @@ export class PlatformContextStore { return; } + if (!this.authSession.isAuthenticated()) { + this.loading.set(false); + this.error.set(null); + return; + } + if (this.apiDisabled) { this.tenantId.set(this.initialQueryOverride?.tenantId ?? null); this.loading.set(false); @@ -380,7 +389,7 @@ export class PlatformContextStore { } private persistPreferences(): void { - if (this.persistPaused || this.apiDisabled) { + if (this.persistPaused || this.apiDisabled || !this.authSession.isAuthenticated()) { return; } diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts index 0b2cbd83e..012cde6d2 100644 --- a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject, signal, DestroyRef } from '@angular/core'; import { Router } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AuthSessionStore } from '../auth/auth-session.store'; import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client'; import { ToastService } from '../services/toast.service'; @@ -15,23 +16,43 @@ const MUTED_KEY = 'stellaops_doctor_notifications_muted'; @Injectable({ providedIn: 'root' }) export class DoctorNotificationService { private readonly api = inject(DOCTOR_API); + private readonly authSession = inject(AuthSessionStore); private readonly toast = inject(ToastService); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private intervalId: ReturnType | null = null; + private startTimeoutId: ReturnType | null = null; /** Whether notifications are muted. Persisted in localStorage. */ readonly muted = signal(this.loadMutedState()); /** Start polling with 10s initial delay, then every 60s. */ start(): void { - setTimeout(() => { + if (this.intervalId !== null || this.startTimeoutId !== null) { + return; + } + + this.startTimeoutId = setTimeout(() => { + this.startTimeoutId = null; this.checkForNewReports(); this.intervalId = setInterval(() => this.checkForNewReports(), 60000); }, 10000); } + /** Stop polling when the authenticated shell is no longer active. */ + stop(): void { + if (this.startTimeoutId !== null) { + clearTimeout(this.startTimeoutId); + this.startTimeoutId = null; + } + + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + /** Toggle mute state. */ toggleMute(): void { const newState = !this.muted(); @@ -44,7 +65,7 @@ export class DoctorNotificationService { } private checkForNewReports(): void { - if (this.muted()) return; + if (this.muted() || !this.authSession.isAuthenticated()) return; this.api.listReports(1, 0) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts index cdbc8164a..9cc396afe 100644 --- a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AuthSessionStore } from '../auth/auth-session.store'; import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client'; import { DoctorTrendResponse } from './doctor-trend.models'; @@ -11,6 +12,7 @@ import { DoctorTrendResponse } from './doctor-trend.models'; @Injectable({ providedIn: 'root' }) export class DoctorTrendService { private readonly api = inject(DOCTOR_API, { optional: true }); + private readonly authSession = inject(AuthSessionStore); private readonly destroyRef = inject(DestroyRef); private intervalId: ReturnType | null = null; @@ -23,17 +25,32 @@ export class DoctorTrendService { /** Start periodic trend fetching (60s interval). */ start(): void { + if (this.intervalId !== null) { + return; + } + this.fetchTrends(); this.intervalId = setInterval(() => this.fetchTrends(), 60000); } + /** Stop periodic trend fetching and clear shell sparkline state. */ + stop(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + this.securityTrend.set([]); + this.platformTrend.set([]); + } + /** Force immediate re-fetch. */ refresh(): void { this.fetchTrends(); } private fetchTrends(): void { - if (!this.api) { + if (!this.api || !this.authSession.isAuthenticated()) { this.securityTrend.set([]); this.platformTrend.set([]); return;