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

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