Guard signed-out shell bootstrap services

This commit is contained in:
master
2026-03-07 05:24:07 +02:00
parent b70457712b
commit 41799611dd
7 changed files with 303 additions and 10 deletions

View File

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

View File

@@ -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<string | null>(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 {

View File

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

View File

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

View File

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

View File

@@ -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<DoctorApi>(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<typeof setInterval> | null = null;
private startTimeoutId: ReturnType<typeof setTimeout> | 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))

View File

@@ -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<DoctorApi | null>(DOCTOR_API, { optional: true });
private readonly authSession = inject(AuthSessionStore);
private readonly destroyRef = inject(DestroyRef);
private intervalId: ReturnType<typeof setInterval> | 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;