Guard signed-out shell bootstrap services
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user