wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -70,7 +70,7 @@ export class AdvisoryApiHttpClient implements AdvisoryApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {

View File

@@ -14,6 +14,7 @@ export interface AuthorityTenantViewDto {
export interface TenantCatalogResponseDto {
readonly tenants: readonly AuthorityTenantViewDto[];
readonly selectedTenant?: string | null;
}
export interface ConsoleProfileDto {
@@ -100,14 +101,15 @@ export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
this.authSession.getActiveTenantId();
if (!tenantId) {
throw new Error(
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
);
return new HttpHeaders();
}
return new HttpHeaders({
'X-StellaOps-Tenant': tenantId,
'X-Stella-Tenant': tenantId,
'X-Tenant-Id': tenantId,
});
}
}

View File

@@ -102,6 +102,6 @@ export class ConsoleExportClient {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
}

View File

@@ -0,0 +1,91 @@
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 { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL } from './console-status.client';
import { ConsoleSearchHttpClient } from './console-search.client';
class FakeAuthSessionStore {
activeTenantId: string | null = 'tenant-default';
getActiveTenantId(): string | null {
return this.activeTenantId;
}
}
describe('ConsoleSearchHttpClient', () => {
let client: ConsoleSearchHttpClient;
let authSession: FakeAuthSessionStore;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
providers: [
ConsoleSearchHttpClient,
{ provide: CONSOLE_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
client = TestBed.inject(ConsoleSearchHttpClient);
authSession = TestBed.inject(AuthSessionStore) as unknown as FakeAuthSessionStore;
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('uses explicit tenant header when tenantId is provided', () => {
client.search({ tenantId: 'tenant-x', traceId: 'trace-1', query: 'jwt' }).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/search' && r.params.get('query') === 'jwt');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
req.flush({
items: [],
ranking: { sortKeys: [], payloadHash: 'sha256:test' },
nextPageToken: null,
total: 0,
traceId: 'trace-1',
});
});
it('omits tenant header when no tenant is available in options or session', () => {
authSession.activeTenantId = null;
client.search({ traceId: 'trace-2' }).subscribe();
const req = httpMock.expectOne('/api/search');
expect(req.request.headers.has('X-StellaOps-Tenant')).toBeFalse();
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-2');
req.flush({
items: [],
ranking: { sortKeys: [], payloadHash: 'sha256:test2' },
nextPageToken: null,
total: 0,
traceId: 'trace-2',
});
});
it('rejects search when authorization fails', () => new Promise<void>((resolve, reject) => {
tenantService.authorize.and.returnValue(false);
client.search({ traceId: 'trace-3' }).subscribe({
next: () => reject(new Error('expected error')),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/search');
resolve();
},
});
}));
});

View File

@@ -200,12 +200,15 @@ export class ConsoleSearchHttpClient implements ConsoleSearchApi {
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (tenant) {
headers = headers.set('X-StellaOps-Tenant', tenant);
}
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
@@ -244,9 +247,9 @@ export class ConsoleSearchHttpClient implements ConsoleSearchApi {
return params;
}
private resolveTenant(tenantId?: string): string {
private resolveTenant(tenantId?: string): string | null {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant?.trim() || null;
}
private mapError(err: unknown, traceId: string): Error {

View File

@@ -86,6 +86,6 @@ export class ConsoleStatusClient {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
}

View File

@@ -211,7 +211,7 @@ export class ConsoleVexHttpClient implements ConsoleVexApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private mapError(err: unknown, traceId: string): Error {

View File

@@ -182,7 +182,7 @@ export class ConsoleVulnHttpClient implements ConsoleVulnApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private mapError(err: unknown, traceId: string): Error {

View File

@@ -108,6 +108,6 @@ export class CvssClient {
}
private resolveTenant(): string {
return this.authSession.getActiveTenantId() ?? 'default';
return this.authSession.getActiveTenantId() ?? '';
}
}

View File

@@ -6,13 +6,16 @@ import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.
import { ExceptionEventsHttpClient, EXCEPTION_EVENTS_API_BASE_URL } from './exception-events.client';
class FakeAuthSessionStore {
activeTenantId: string | null = 'tenant-default';
getActiveTenantId(): string | null {
return 'tenant-default';
return this.activeTenantId;
}
}
describe('ExceptionEventsHttpClient', () => {
let client: ExceptionEventsHttpClient;
let authSession: FakeAuthSessionStore;
let tenantService: { authorize: jasmine.Spy };
let eventSourceFactory: jasmine.Spy;
@@ -31,6 +34,7 @@ describe('ExceptionEventsHttpClient', () => {
});
client = TestBed.inject(ExceptionEventsHttpClient);
authSession = TestBed.inject(AuthSessionStore) as unknown as FakeAuthSessionStore;
});
it('creates an EventSource for the tenant and parses JSON events', () => new Promise<void>((resolve, reject) => {
@@ -49,7 +53,7 @@ describe('ExceptionEventsHttpClient', () => {
error: (err) => reject(new Error(err)),
});
expect(eventSourceFactory).toHaveBeenCalledWith('/api/exceptions/events?tenant=tenant-x&traceId=trace-1');
expect(eventSourceFactory).toHaveBeenCalledWith('/api/exceptions/events?traceId=trace-1&tenant=tenant-x');
(fakeSource as any).onmessage?.({
data: JSON.stringify({
@@ -73,4 +77,15 @@ describe('ExceptionEventsHttpClient', () => {
},
});
}));
it('omits tenant query parameter when no tenant is available', () => {
authSession.activeTenantId = null;
const fakeSource: Partial<EventSource> = { close: jasmine.createSpy('close') };
eventSourceFactory.and.returnValue(fakeSource);
const subscription = client.streamEvents({ traceId: 'trace-3' }).subscribe();
expect(eventSourceFactory).toHaveBeenCalledWith('/api/exceptions/events?traceId=trace-3');
subscription.unsubscribe();
});
});

View File

@@ -37,7 +37,10 @@ export class ExceptionEventsHttpClient implements ExceptionEventsApi {
return throwError(() => new Error('Unauthorized: missing exception:read scope'));
}
const params = new URLSearchParams({ tenant, traceId });
const params = new URLSearchParams({ traceId });
if (tenant) {
params.set('tenant', tenant);
}
if (options.projectId) {
params.set('projectId', options.projectId);
}
@@ -64,9 +67,9 @@ export class ExceptionEventsHttpClient implements ExceptionEventsApi {
});
}
private resolveTenant(tenantId?: string): string {
private resolveTenant(tenantId?: string): string | null {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant?.trim() || null;
}
}
@@ -85,4 +88,3 @@ export class MockExceptionEventsApiService implements ExceptionEventsApi {
});
}
}

View File

@@ -168,7 +168,7 @@ export class ExceptionApiHttpClient implements ExceptionApi {
}
private resolveTenant(tenantId?: string): string {
return (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId() || 'default';
return (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId() || '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {

View File

@@ -205,7 +205,7 @@ export class ExportCenterHttpClient implements ExportCenterApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private mapError(err: unknown, traceId: string): Error {

View File

@@ -336,7 +336,7 @@ export class FindingsLedgerHttpClient implements FindingsLedgerApi {
const tenant = tenantId?.trim() ||
this.tenantService.activeTenantId() ||
this.authStore.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private generateCorrelationId(): string {

View File

@@ -104,7 +104,7 @@ export class FirstSignalHttpClient implements FirstSignalApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {

View File

@@ -260,7 +260,7 @@ export class GraphPlatformHttpClient implements GraphPlatformApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private mapError(err: unknown, traceId: string): Error {

View File

@@ -8,13 +8,16 @@ import { MockOrchestratorControlClient, OrchestratorControlHttpClient } from './
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
class FakeAuthSessionStore {
activeTenantId: string | null = 'tenant-default';
getActiveTenantId(): string | null {
return 'tenant-default';
return this.activeTenantId;
}
}
describe('OrchestratorControlHttpClient', () => {
let client: OrchestratorControlHttpClient;
let authSession: FakeAuthSessionStore;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
@@ -34,6 +37,7 @@ describe('OrchestratorControlHttpClient', () => {
});
client = TestBed.inject(OrchestratorControlHttpClient);
authSession = TestBed.inject(AuthSessionStore) as unknown as FakeAuthSessionStore;
httpMock = TestBed.inject(HttpTestingController);
});
@@ -133,6 +137,17 @@ describe('OrchestratorControlHttpClient', () => {
},
});
}));
it('omits tenant header when no tenant is available in options or session', () => {
authSession.activeTenantId = null;
client.listQuotas({ traceId: 'trace-6' }).subscribe();
const req = httpMock.expectOne('/api/orchestrator/quotas');
expect(req.request.headers.has('X-StellaOps-Tenant')).toBeFalse();
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-6');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-3"', traceId: 'trace-6' });
});
});
describe('MockOrchestratorControlClient', () => {
@@ -161,4 +176,3 @@ describe('MockOrchestratorControlClient', () => {
});
}));
});

View File

@@ -350,13 +350,13 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
);
}
private resolveTenant(tenantId?: string): string {
private resolveTenant(tenantId?: string): string | null {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant?.trim() || null;
}
private buildHeaders(
tenantId: string,
tenantId: string | null,
traceId: string,
projectId?: string,
ifNoneMatch?: string,
@@ -364,11 +364,14 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (tenantId) {
headers = headers.set('X-StellaOps-Tenant', tenantId);
}
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}

View File

@@ -68,7 +68,7 @@ export class OrchestratorHttpClient implements OrchestratorApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {

View File

@@ -74,7 +74,7 @@ export class PolicyExceptionsHttpClient implements PolicyExceptionsApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {

View File

@@ -611,7 +611,7 @@ export class PolicyGatesHttpClient implements PolicyGatesApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string): HttpHeaders {

View File

@@ -427,7 +427,10 @@ export class PolicySimulationHttpClient implements PolicySimulationApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
if (!tenant) {
throw new Error('PolicySimulationHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string): HttpHeaders {

View File

@@ -158,6 +158,6 @@ export class RiskHttpClient implements RiskApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
}

View File

@@ -64,6 +64,9 @@ interface AdvisoryKnowledgeSearchResultDto {
severity: string;
canRun: boolean;
runCommand: string;
control?: string;
requiresConfirmation?: boolean;
isDestructive?: boolean;
};
};
debug?: Record<string, string>;
@@ -257,6 +260,9 @@ export class SearchClient {
severity: open.doctor.severity,
canRun: open.doctor.canRun,
runCommand: open.doctor.runCommand,
control: open.doctor.control,
requiresConfirmation: open.doctor.requiresConfirmation,
isDestructive: open.doctor.isDestructive,
};
}

View File

@@ -22,6 +22,9 @@ export interface SearchDoctorOpenAction {
severity: string;
canRun: boolean;
runCommand: string;
control?: string;
requiresConfirmation?: boolean;
isDestructive?: boolean;
}
export interface SearchOpenAction {

View File

@@ -384,7 +384,7 @@ export class VexConsensusHttpClient implements VexConsensusApi {
const tenant = tenantId?.trim() ||
this.tenantService.activeTenantId() ||
this.authStore.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private cacheStatement(statement: VexConsensusStatement): void {

View File

@@ -122,7 +122,7 @@ export class VexDecisionsHttpClient implements VexDecisionsApi {
private resolveTenant(tenantId?: string): string {
const tenant = tenantId ?? this.tenantService.activeTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
}

View File

@@ -128,7 +128,7 @@ export class VexEvidenceHttpClient implements VexEvidenceApi {
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {

View File

@@ -490,7 +490,7 @@ export class VulnExportOrchestratorService implements VulnExportOrchestratorApi
const tenant = tenantId?.trim() ||
this.tenantService.activeTenantId() ||
this.authStore.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private createError(code: string, message: string, traceId: string): Error {

View File

@@ -376,7 +376,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
const tenant = (tenantId && tenantId.trim()) ||
this.tenantService.activeTenantId() ||
this.authSession.getActiveTenantId();
return tenant ?? 'default';
return tenant ?? '';
}
private generateRequestId(): string {

View File

@@ -78,6 +78,36 @@ export class AuthSessionStore {
this.clearPersistedSession();
}
setTenantId(tenantId: string | null): void {
const normalizedTenantId = tenantId?.trim() || null;
const session = this.sessionSignal();
if (session) {
const nextSession: AuthSession = {
...session,
tenantId: normalizedTenantId,
};
this.sessionSignal.set(nextSession);
this.persistSession(nextSession);
const metadata = this.toMetadata(nextSession);
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
return;
}
const metadata = this.persistedSignal();
if (!metadata) {
return;
}
const nextMetadata: PersistedSessionMetadata = {
...metadata,
tenantId: normalizedTenantId,
};
this.persistedSignal.set(nextMetadata);
this.persistMetadata(nextMetadata);
}
private readPersistedMetadata(
restoredSession: AuthSession | null
): PersistedSessionMetadata | null {

View File

@@ -467,7 +467,7 @@ export class AuthorityAuthService {
freshAuthExpiresAtEpochMs: accessMetadata.freshAuthExpiresAtEpochMs,
};
this.sessionStore.setSession(session);
void this.getConsoleSession().loadConsoleContext();
void this.getConsoleSession().loadConsoleContext().catch(() => undefined);
this.scheduleRefresh(tokens, this.config.authority);
}

View File

@@ -49,6 +49,10 @@ export {
TENANT_HEADERS,
} from './tenant-http.interceptor';
export {
TenantHeaderTelemetryService,
} from './tenant-header-telemetry.service';
export {
TenantPersistenceService,
PersistenceAuditMetadata,

View File

@@ -3,6 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject } from 'rxjs';
import { AuthSessionStore } from './auth-session.store';
import { ConsoleSessionStore } from '../console/console-session.store';
/**
* Scope required for an operation.
@@ -121,6 +122,7 @@ export interface JwtClaims {
@Injectable({ providedIn: 'root' })
export class TenantActivationService {
private readonly authStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly destroyRef = inject(DestroyRef);
// Internal state
@@ -137,7 +139,13 @@ export class TenantActivationService {
// Computed properties
readonly activeTenant = computed(() => this._activeTenant());
readonly activeTenantId = computed(() => this._activeTenant()?.tenantId ?? null);
readonly activeTenantId = computed(
() =>
this._activeTenant()?.tenantId ??
this.consoleStore.selectedTenantId() ??
this.authStore.tenantId() ??
null,
);
readonly activeProjectId = computed(() => this._activeTenant()?.projectId ?? null);
readonly lastDecision = computed(() => this._lastDecision());
readonly isActivated = computed(() => this._activeTenant() !== null);
@@ -231,6 +239,28 @@ export class TenantActivationService {
this._activeTenant.set(null);
}
setActiveTenantId(tenantId: string | null): void {
const normalizedTenantId = tenantId?.trim() ?? '';
if (!normalizedTenantId) {
this._activeTenant.set(null);
return;
}
const current = this._activeTenant();
if (current?.tenantId === normalizedTenantId) {
return;
}
const session = this.authStore.session();
this._activeTenant.set({
tenantId: normalizedTenantId,
projectId: current?.projectId,
activatedAt: new Date().toISOString(),
activatedBy: session?.identity.subject ?? 'console',
scopes: [...(session?.scopes ?? [])],
});
}
/**
* Check if the current session has all required scopes.
* @param requiredScopes Scopes needed for the operation

View File

@@ -0,0 +1,30 @@
import { Injectable, computed, signal } from '@angular/core';
interface LegacyHeaderUsage {
readonly headerName: string;
readonly count: number;
}
@Injectable({ providedIn: 'root' })
export class TenantHeaderTelemetryService {
private readonly legacyUsageSignal = signal<Record<string, number>>({});
readonly legacyUsage = computed<readonly LegacyHeaderUsage[]>(() => {
const entries = Object.entries(this.legacyUsageSignal());
return entries
.map(([headerName, count]) => ({ headerName, count }))
.sort((left, right) => left.headerName.localeCompare(right.headerName));
});
recordLegacyUsage(headerName: string): void {
const normalizedHeaderName = headerName.trim();
if (!normalizedHeaderName) {
return;
}
this.legacyUsageSignal.update((current) => ({
...current,
[normalizedHeaderName]: (current[normalizedHeaderName] ?? 0) + 1,
}));
}
}

View File

@@ -0,0 +1,125 @@
import { HTTP_INTERCEPTORS, HttpClient, HttpHeaders } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ConsoleSessionStore } from '../console/console-session.store';
import { TenantActivationService } from './tenant-activation.service';
import { AuthSessionStore } from './auth-session.store';
import { TENANT_HEADERS, TenantHttpInterceptor } from './tenant-http.interceptor';
import { TenantHeaderTelemetryService } from './tenant-header-telemetry.service';
class MockTenantActivationService {
activeTenantId = () => null;
activeProjectId = () => null;
}
class MockAuthSessionStore {
private tenantIdValue: string | null = 'tenant-default';
tenantId = () => this.tenantIdValue;
session = () => null;
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
}
}
class MockConsoleSessionStore {
private tenantIdValue: string | null = 'tenant-console';
selectedTenantId = () => this.tenantIdValue;
setSelectedTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
}
}
describe('TenantHttpInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let consoleStore: MockConsoleSessionStore;
let authStore: MockAuthSessionStore;
let telemetry: TenantHeaderTelemetryService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{ provide: TenantActivationService, useClass: MockTenantActivationService },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
{ provide: ConsoleSessionStore, useClass: MockConsoleSessionStore },
{
provide: HTTP_INTERCEPTORS,
useClass: TenantHttpInterceptor,
multi: true,
},
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
consoleStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore;
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
telemetry = TestBed.inject(TenantHeaderTelemetryService);
});
afterEach(() => {
httpMock.verify();
});
it('adds canonical and compatibility tenant headers from selected tenant', () => {
consoleStore.setSelectedTenantId('tenant-bravo');
http.get('/api/v2/platform/overview').subscribe();
const request = httpMock.expectOne('/api/v2/platform/overview');
expect(request.request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT)).toBe('tenant-bravo');
expect(request.request.headers.get(TENANT_HEADERS.STELLA_TENANT)).toBe('tenant-bravo');
expect(request.request.headers.get(TENANT_HEADERS.TENANT_ID)).toBe('tenant-bravo');
request.flush({});
});
it('normalizes legacy header input and tracks legacy usage telemetry', () => {
http.get('/api/v2/security/findings', {
headers: new HttpHeaders({
[TENANT_HEADERS.STELLA_TENANT]: 'tenant-legacy',
}),
}).subscribe();
const request = httpMock.expectOne('/api/v2/security/findings');
expect(request.request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT)).toBe('tenant-legacy');
expect(request.request.headers.get(TENANT_HEADERS.STELLA_TENANT)).toBe('tenant-legacy');
expect(request.request.headers.get(TENANT_HEADERS.TENANT_ID)).toBe('tenant-legacy');
request.flush({});
expect(telemetry.legacyUsage()).toEqual([
{
headerName: TENANT_HEADERS.STELLA_TENANT,
count: 1,
},
]);
});
it('skips tenant headers for public config endpoint requests', () => {
http.get('/config.json').subscribe();
const request = httpMock.expectOne('/config.json');
expect(request.request.headers.has(TENANT_HEADERS.STELLAOPS_TENANT)).toBeFalse();
expect(request.request.headers.has(TENANT_HEADERS.STELLA_TENANT)).toBeFalse();
expect(request.request.headers.has(TENANT_HEADERS.TENANT_ID)).toBeFalse();
request.flush({});
});
it('does not inject tenant headers when no tenant context is available', () => {
consoleStore.setSelectedTenantId(null);
authStore.setTenantId(null);
http.get('/api/v2/platform/overview').subscribe();
const request = httpMock.expectOne('/api/v2/platform/overview');
expect(request.request.headers.has(TENANT_HEADERS.STELLAOPS_TENANT)).toBeFalse();
expect(request.request.headers.has(TENANT_HEADERS.STELLA_TENANT)).toBeFalse();
expect(request.request.headers.has(TENANT_HEADERS.TENANT_ID)).toBeFalse();
request.flush({});
});
});

View File

@@ -5,11 +5,14 @@ import { catchError } from 'rxjs/operators';
import { TenantActivationService } from './tenant-activation.service';
import { AuthSessionStore } from './auth-session.store';
import { ConsoleSessionStore } from '../console/console-session.store';
import { TenantHeaderTelemetryService } from './tenant-header-telemetry.service';
/**
* HTTP headers for tenant scoping.
*/
export const TENANT_HEADERS = {
STELLA_TENANT: 'X-Stella-Tenant',
TENANT_ID: 'X-Tenant-Id',
STELLAOPS_TENANT: 'X-StellaOps-Tenant',
PROJECT_ID: 'X-Project-Id',
@@ -26,6 +29,8 @@ export const TENANT_HEADERS = {
export class TenantHttpInterceptor implements HttpInterceptor {
private readonly tenantService = inject(TenantActivationService);
private readonly authStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly telemetry = inject(TenantHeaderTelemetryService);
intercept(
request: HttpRequest<unknown>,
@@ -45,11 +50,6 @@ export class TenantHttpInterceptor implements HttpInterceptor {
}
private shouldSkip(request: HttpRequest<unknown>): boolean {
// Skip if tenant header already present
if (request.headers.has(TENANT_HEADERS.TENANT_ID)) {
return true;
}
// Skip public endpoints that don't require tenant context
const url = request.url.toLowerCase();
const publicPaths = [
@@ -67,11 +67,15 @@ export class TenantHttpInterceptor implements HttpInterceptor {
private addTenantHeaders(request: HttpRequest<unknown>): HttpRequest<unknown> {
const headers: Record<string, string> = {};
this.recordLegacyHeaderUsage(request);
// Add tenant ID (use "default" if no active tenant)
const tenantId = this.getTenantId() ?? 'default';
headers[TENANT_HEADERS.TENANT_ID] = tenantId;
headers[TENANT_HEADERS.STELLAOPS_TENANT] = tenantId;
// Canonical tenant value can come from explicit request headers or active session state.
const tenantId = this.resolveRequestedTenantId(request) ?? this.getTenantId();
if (tenantId) {
headers[TENANT_HEADERS.STELLAOPS_TENANT] = tenantId;
headers[TENANT_HEADERS.STELLA_TENANT] = tenantId;
headers[TENANT_HEADERS.TENANT_ID] = tenantId;
}
// Add project ID if active
const projectId = this.tenantService.activeProjectId();
@@ -104,10 +108,42 @@ export class TenantHttpInterceptor implements HttpInterceptor {
return activeTenantId;
}
const selectedTenant = this.consoleStore.selectedTenantId();
if (selectedTenant) {
return selectedTenant;
}
// Fall back to session tenant
return this.authStore.tenantId();
}
private resolveRequestedTenantId(request: HttpRequest<unknown>): string | null {
const candidates = [
request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT),
request.headers.get(TENANT_HEADERS.STELLA_TENANT),
request.headers.get(TENANT_HEADERS.TENANT_ID),
];
for (const candidate of candidates) {
const normalized = candidate?.trim();
if (normalized) {
return normalized;
}
}
return null;
}
private recordLegacyHeaderUsage(request: HttpRequest<unknown>): void {
if (request.headers.has(TENANT_HEADERS.STELLA_TENANT)) {
this.telemetry.recordLegacyUsage(TENANT_HEADERS.STELLA_TENANT);
}
if (request.headers.has(TENANT_HEADERS.TENANT_ID)) {
this.telemetry.recordLegacyUsage(TENANT_HEADERS.TENANT_ID);
}
}
private handleTenantError(
error: HttpErrorResponse,
request: HttpRequest<unknown>

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
@@ -7,12 +7,21 @@ import {
TenantCatalogResponseDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { PlatformContextStore } from '../context/platform-context.store';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleSessionStore } from './console-session.store';
class MockConsoleApi implements AuthorityConsoleApi {
failTenant: string | null = null;
selectedTenant: string | null = 'tenant-bravo';
listCalls: Array<string | undefined> = [];
profileCalls: Array<string | undefined> = [];
introspectionCalls: Array<string | undefined> = [];
private createTenantResponse(): TenantCatalogResponseDto {
return {
selectedTenant: this.selectedTenant,
tenants: [
{
id: 'tenant-default',
@@ -21,20 +30,33 @@ class MockConsoleApi implements AuthorityConsoleApi {
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
{
id: 'tenant-bravo',
displayName: 'Tenant Bravo',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.viewer'],
},
],
};
}
listTenants() {
listTenants(tenantId?: string) {
this.listCalls.push(tenantId);
if (this.failTenant && tenantId === this.failTenant) {
return throwError(() => new Error(`tenant ${tenantId} is not available`));
}
return of(this.createTenantResponse());
}
getProfile() {
getProfile(tenantId?: string) {
this.profileCalls.push(tenantId);
return of({
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
tenant: tenantId ?? 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
@@ -47,10 +69,11 @@ class MockConsoleApi implements AuthorityConsoleApi {
});
}
introspectToken() {
introspectToken(tenantId?: string) {
this.introspectionCalls.push(tenantId);
return of({
active: true,
tenant: 'tenant-default',
tenant: tenantId ?? 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
@@ -81,16 +104,35 @@ class MockAuthSessionStore {
return this.tenantIdValue;
}
tenantId = () => this.tenantIdValue;
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
}
}
class MockTenantActivationService {
readonly setActiveTenantId = jasmine.createSpy('setActiveTenantId');
}
class MockPlatformContextStore {
private tenantIdValue: string | null = null;
tenantId = () => this.tenantIdValue;
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
}
}
describe('ConsoleSessionService', () => {
let service: ConsoleSessionService;
let store: ConsoleSessionStore;
let api: MockConsoleApi;
let authStore: MockAuthSessionStore;
let tenantActivation: MockTenantActivationService;
let platformContext: MockPlatformContextStore;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -99,21 +141,31 @@ describe('ConsoleSessionService', () => {
ConsoleSessionService,
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
{ provide: TenantActivationService, useClass: MockTenantActivationService },
{ provide: PlatformContextStore, useClass: MockPlatformContextStore },
],
});
service = TestBed.inject(ConsoleSessionService);
store = TestBed.inject(ConsoleSessionStore);
api = TestBed.inject(AUTHORITY_CONSOLE_API) as unknown as MockConsoleApi;
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
tenantActivation = TestBed.inject(TenantActivationService) as unknown as MockTenantActivationService;
platformContext = TestBed.inject(PlatformContextStore) as unknown as MockPlatformContextStore;
});
it('loads console context for active tenant', async () => {
it('loads console context and honors selected tenant returned by Authority', async () => {
await service.loadConsoleContext();
expect(store.tenants().length).toBe(1);
expect(store.selectedTenantId()).toBe('tenant-default');
expect(store.tenants().length).toBe(2);
expect(store.selectedTenantId()).toBe('tenant-bravo');
expect(authStore.getActiveTenantId()).toBe('tenant-bravo');
expect(platformContext.tenantId()).toBe('tenant-bravo');
expect(tenantActivation.setActiveTenantId).toHaveBeenCalledWith('tenant-bravo');
expect(store.profile()?.displayName).toBe('Console User');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
expect(api.profileCalls).toContain('tenant-bravo');
expect(api.introspectionCalls).toContain('tenant-bravo');
});
it('clears store when no tenant available', async () => {
@@ -136,4 +188,31 @@ describe('ConsoleSessionService', () => {
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
});
it('switches tenant and updates active context', async () => {
await service.loadConsoleContext();
api.selectedTenant = 'tenant-default';
await service.switchTenant('tenant-default');
expect(store.selectedTenantId()).toBe('tenant-default');
expect(authStore.getActiveTenantId()).toBe('tenant-default');
expect(platformContext.tenantId()).toBe('tenant-default');
expect(tenantActivation.setActiveTenantId).toHaveBeenCalledWith('tenant-default');
});
it('rolls back tenant switch when tenant context load fails', async () => {
await service.loadConsoleContext();
api.failTenant = 'tenant-default';
let threw = false;
try {
await service.switchTenant('tenant-default');
} catch {
threw = true;
}
expect(threw).toBeTrue();
expect(store.selectedTenantId()).toBe('tenant-bravo');
expect(authStore.getActiveTenantId()).toBe('tenant-bravo');
expect(platformContext.tenantId()).toBe('tenant-bravo');
});
});

View File

@@ -9,6 +9,8 @@ import {
ConsoleTokenIntrospectionDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { PlatformContextStore } from '../context/platform-context.store';
import {
ConsoleProfile,
ConsoleSessionStore,
@@ -23,17 +25,25 @@ export class ConsoleSessionService {
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
private readonly store = inject(ConsoleSessionStore);
private readonly authSession = inject(AuthSessionStore);
private readonly tenantActivation = inject(TenantActivationService);
private readonly platformContext = inject(PlatformContextStore);
async loadConsoleContext(tenantId?: string | null): Promise<void> {
const activeTenant =
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
const activeTenant = this.normalizeTenantId(tenantId)
?? this.platformContext.tenantId()
?? this.authSession.getActiveTenantId();
if (!activeTenant) {
this.store.clear();
this.platformContext.setTenantId(null);
this.tenantActivation.setActiveTenantId(null);
return;
}
this.store.setSelectedTenant(activeTenant);
this.authSession.setTenantId(activeTenant);
this.platformContext.setTenantId(activeTenant);
this.tenantActivation.setActiveTenantId(activeTenant);
this.store.setLoading(true);
this.store.setError(null);
@@ -44,10 +54,15 @@ export class ConsoleSessionService {
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
this.mapTenant(tenant)
);
const selectedTenantId = this.resolveSelectedTenantId(
activeTenant,
tenantResponse.selectedTenant,
tenants,
);
const [profileDto, tokenDto] = await Promise.all([
firstValueFrom(this.api.getProfile(activeTenant)),
firstValueFrom(this.api.introspectToken(activeTenant)),
firstValueFrom(this.api.getProfile(selectedTenantId)),
firstValueFrom(this.api.introspectToken(selectedTenantId)),
]);
const profile = this.mapProfile(profileDto);
@@ -57,23 +72,56 @@ export class ConsoleSessionService {
tenants,
profile,
token: tokenInfo,
selectedTenantId: activeTenant,
selectedTenantId,
});
this.authSession.setTenantId(selectedTenantId);
this.platformContext.setTenantId(selectedTenantId);
this.tenantActivation.setActiveTenantId(selectedTenantId);
} catch (error) {
console.error('Failed to load console context', error);
this.store.setError('Unable to load console context.');
throw error;
} finally {
this.store.setLoading(false);
}
}
async switchTenant(tenantId: string): Promise<void> {
if (!tenantId || tenantId === this.store.selectedTenantId()) {
return this.loadConsoleContext(tenantId);
const requestedTenant = this.normalizeTenantId(tenantId);
if (!requestedTenant) {
return this.loadConsoleContext();
}
this.store.setSelectedTenant(tenantId);
await this.loadConsoleContext(tenantId);
const previousTenant =
this.store.selectedTenantId() ??
this.authSession.getActiveTenantId();
if (requestedTenant === previousTenant) {
return this.loadConsoleContext(requestedTenant);
}
this.store.setSelectedTenant(requestedTenant);
this.authSession.setTenantId(requestedTenant);
this.platformContext.setTenantId(requestedTenant);
this.tenantActivation.setActiveTenantId(requestedTenant);
try {
await this.loadConsoleContext(requestedTenant);
} catch (error) {
this.store.setSelectedTenant(previousTenant);
this.authSession.setTenantId(previousTenant);
this.platformContext.setTenantId(previousTenant);
this.tenantActivation.setActiveTenantId(previousTenant);
if (previousTenant) {
try {
await this.loadConsoleContext(previousTenant);
} catch {
// Keep store error from the original tenant switch failure.
}
}
throw error;
}
}
async refresh(): Promise<void> {
@@ -82,6 +130,30 @@ export class ConsoleSessionService {
clear(): void {
this.store.clear();
this.platformContext.setTenantId(null);
this.tenantActivation.setActiveTenantId(null);
}
private normalizeTenantId(value?: string | null): string | null {
const normalized = (value ?? '').trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
private resolveSelectedTenantId(
requestedTenantId: string,
selectedTenantIdFromApi: string | null | undefined,
tenants: readonly ConsoleTenant[],
): string {
const preferredTenantId = this.normalizeTenantId(selectedTenantIdFromApi);
if (preferredTenantId && tenants.some((tenant) => tenant.id === preferredTenantId)) {
return preferredTenantId;
}
if (tenants.some((tenant) => tenant.id === requestedTenantId)) {
return requestedTenantId;
}
return tenants[0]?.id ?? requestedTenantId;
}
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {

View File

@@ -120,4 +120,10 @@ describe('ConsoleSessionStore', () => {
expect(store.loading()).toBeFalse();
expect(store.error()).toBeNull();
});
it('increments tenant context version on tenant switch', () => {
const initialVersion = store.tenantContextVersion();
store.setSelectedTenant('tenant-a');
expect(store.tenantContextVersion()).toBeGreaterThan(initialVersion);
});
});

View File

@@ -47,6 +47,7 @@ export class ConsoleSessionStore {
private readonly selectedTenantIdSignal = signal<string | null>(null);
private readonly profileSignal = signal<ConsoleProfile | null>(null);
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
private readonly tenantContextVersionSignal = signal(0);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
@@ -54,6 +55,7 @@ export class ConsoleSessionStore {
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
readonly profile = computed(() => this.profileSignal());
readonly tokenInfo = computed(() => this.tokenSignal());
readonly tenantContextVersion = computed(() => this.tenantContextVersionSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly currentTenant = computed(() => {
@@ -86,6 +88,7 @@ export class ConsoleSessionStore {
this.profileSignal.set(context.profile);
this.tokenSignal.set(context.token);
this.selectedTenantIdSignal.set(selected);
this.bumpTenantContextVersion();
}
setProfile(profile: ConsoleProfile | null): void {
@@ -115,11 +118,18 @@ export class ConsoleSessionStore {
fallbackSelection;
this.selectedTenantIdSignal.set(nextSelection);
this.bumpTenantContextVersion();
return nextSelection;
}
setSelectedTenant(tenantId: string | null): void {
this.selectedTenantIdSignal.set(tenantId);
const normalizedTenantId = tenantId?.trim() || null;
if (this.selectedTenantIdSignal() === normalizedTenantId) {
return;
}
this.selectedTenantIdSignal.set(normalizedTenantId);
this.bumpTenantContextVersion();
}
currentTenantSnapshot(): ConsoleTenant | null {
@@ -133,5 +143,10 @@ export class ConsoleSessionStore {
this.tokenSignal.set(null);
this.loadingSignal.set(false);
this.errorSignal.set(null);
this.bumpTenantContextVersion();
}
private bumpTenantContextVersion(): void {
this.tenantContextVersionSignal.update((value) => value + 1);
}
}

View File

@@ -14,10 +14,16 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
}
let params = request.params;
const tenantId = this.context.tenantId();
const regions = this.context.selectedRegions();
const environments = this.context.selectedEnvironments();
const timeWindow = this.context.timeWindow();
if (tenantId && !params.has('tenant') && !params.has('tenantId')) {
params = params.set('tenant', tenantId);
params = params.set('tenantId', tenantId);
}
if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
params = params.set('regions', regions.join(','));
params = params.set('region', regions[0]);

View File

@@ -19,7 +19,7 @@ export interface PlatformContextEnvironment {
}
export interface PlatformContextPreferences {
tenantId: string;
tenantId: string | null;
actorId: string;
regions: string[];
environments: string[];
@@ -35,8 +35,10 @@ const REGION_QUERY_KEYS = ['regions', 'region'];
const ENVIRONMENT_QUERY_KEYS = ['environments', 'environment', 'env'];
const TIME_WINDOW_QUERY_KEYS = ['timeWindow', 'time'];
const STAGE_QUERY_KEYS = ['stage'];
const TENANT_QUERY_KEYS = ['tenant', 'tenantId'];
interface PlatformContextQueryState {
tenantId: string | null;
regions: string[];
environments: string[];
timeWindow: string;
@@ -52,6 +54,7 @@ export class PlatformContextStore {
readonly regions = signal<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]);
readonly tenantId = signal<string | null>(null);
readonly selectedRegions = signal<string[]>([]);
readonly selectedEnvironments = signal<string[]>([]);
readonly timeWindow = signal(DEFAULT_TIME_WINDOW);
@@ -98,6 +101,7 @@ export class PlatformContextStore {
}
if (this.apiDisabled) {
this.tenantId.set(this.initialQueryOverride?.tenantId ?? null);
this.loading.set(false);
this.error.set(null);
this.initialized.set(true);
@@ -178,12 +182,27 @@ export class PlatformContextStore {
this.bumpContextVersion();
}
setTenantId(tenantId: string | null): void {
const normalizedTenantId = this.normalizeTenantId(tenantId);
if (normalizedTenantId === this.tenantId()) {
return;
}
this.tenantId.set(normalizedTenantId);
if (this.initialized()) {
this.persistPreferences();
}
this.bumpContextVersion();
}
scopeQueryPatch(): Record<string, string | null> {
const regions = this.selectedRegions();
const environments = this.selectedEnvironments();
const timeWindow = this.timeWindow();
const tenantId = this.tenantId();
return {
tenant: tenantId,
regions: regions.length > 0 ? regions.join(',') : null,
environments: environments.length > 0 ? environments.join(',') : null,
timeWindow: timeWindow !== DEFAULT_TIME_WINDOW ? timeWindow : null,
@@ -201,10 +220,12 @@ export class PlatformContextStore {
return;
}
const nextTenantId = this.normalizeTenantId(queryState.tenantId);
const allowedRegions = this.regions().map((item) => item.regionId);
const nextRegions = this.normalizeIds(queryState.regions, allowedRegions);
const nextTimeWindow = queryState.timeWindow || DEFAULT_TIME_WINDOW;
const nextStage = queryState.stage || DEFAULT_STAGE;
const tenantChanged = nextTenantId !== this.tenantId();
const regionsChanged = !this.arraysEqual(nextRegions, this.selectedRegions());
const timeChanged = nextTimeWindow !== this.timeWindow();
const stageChanged = nextStage !== this.stage();
@@ -214,6 +235,7 @@ export class PlatformContextStore {
: this.selectedEnvironments();
if (regionsChanged) {
this.tenantId.set(nextTenantId);
this.selectedRegions.set(nextRegions);
this.timeWindow.set(nextTimeWindow);
this.stage.set(nextStage);
@@ -230,7 +252,8 @@ export class PlatformContextStore {
if (environmentsChanged) {
this.selectedEnvironments.set(nextEnvironments);
}
if (timeChanged || environmentsChanged || stageChanged) {
if (tenantChanged || timeChanged || environmentsChanged || stageChanged) {
this.tenantId.set(nextTenantId);
this.timeWindow.set(nextTimeWindow);
this.stage.set(nextStage);
this.persistPreferences();
@@ -239,7 +262,8 @@ export class PlatformContextStore {
return;
}
if (timeChanged || stageChanged) {
if (tenantChanged || timeChanged || stageChanged) {
this.tenantId.set(nextTenantId);
this.timeWindow.set(nextTimeWindow);
this.stage.set(nextStage);
this.persistPreferences();
@@ -254,6 +278,7 @@ export class PlatformContextStore {
.subscribe({
next: (prefs) => {
const preferenceState: PlatformContextQueryState = {
tenantId: this.normalizeTenantId(prefs?.tenantId ?? null),
regions: prefs?.regions ?? [],
environments: prefs?.environments ?? [],
timeWindow: (prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
@@ -264,6 +289,7 @@ export class PlatformContextStore {
hydrated.regions,
this.regions().map((item) => item.regionId),
);
this.tenantId.set(hydrated.tenantId);
this.selectedRegions.set(preferredRegions);
this.timeWindow.set(hydrated.timeWindow);
this.stage.set(hydrated.stage);
@@ -272,6 +298,7 @@ export class PlatformContextStore {
error: () => {
// Preferences are optional; continue with default empty context.
const fallbackState = this.mergeWithInitialQueryOverride({
tenantId: null,
regions: [],
environments: [],
timeWindow: DEFAULT_TIME_WINDOW,
@@ -281,6 +308,7 @@ export class PlatformContextStore {
fallbackState.regions,
this.regions().map((item) => item.regionId),
);
this.tenantId.set(fallbackState.tenantId);
this.selectedRegions.set(preferredRegions);
this.selectedEnvironments.set([]);
this.timeWindow.set(fallbackState.timeWindow);
@@ -350,6 +378,7 @@ export class PlatformContextStore {
}
const payload = {
tenantId: this.tenantId(),
regions: this.selectedRegions(),
environments: this.selectedEnvironments(),
timeWindow: this.timeWindow(),
@@ -379,6 +408,7 @@ export class PlatformContextStore {
}
return {
tenantId: override.tenantId ?? baseState.tenantId,
regions: override.regions.length > 0 ? override.regions : baseState.regions,
environments: override.environments.length > 0 ? override.environments : baseState.environments,
timeWindow: override.timeWindow || baseState.timeWindow,
@@ -408,16 +438,18 @@ export class PlatformContextStore {
}
private parseScopeQueryState(queryParams: Record<string, unknown>): PlatformContextQueryState | null {
const tenantId = this.normalizeTenantId(this.readQueryValue(queryParams, TENANT_QUERY_KEYS));
const regions = this.readQueryList(queryParams, REGION_QUERY_KEYS);
const environments = this.readQueryList(queryParams, ENVIRONMENT_QUERY_KEYS);
const timeWindow = this.readQueryValue(queryParams, TIME_WINDOW_QUERY_KEYS);
const stage = this.readQueryValue(queryParams, STAGE_QUERY_KEYS);
if (regions.length === 0 && environments.length === 0 && !timeWindow && !stage) {
if (!tenantId && regions.length === 0 && environments.length === 0 && !timeWindow && !stage) {
return null;
}
return {
tenantId,
regions,
environments,
timeWindow: (timeWindow || DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
@@ -511,6 +543,11 @@ export class PlatformContextStore {
return [...deduped.values()];
}
private normalizeTenantId(value: string | null | undefined): string | null {
const normalized = (value ?? '').trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
private arraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;

View File

@@ -0,0 +1,130 @@
import { Component, computed, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterLink, provideRouter } from '@angular/router';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { AppTopbarComponent } from './app-topbar.component';
@Component({ selector: 'app-global-search', standalone: true, template: '' })
class StubGlobalSearchComponent {}
@Component({ selector: 'app-context-chips', standalone: true, template: '' })
class StubContextChipsComponent {}
@Component({ selector: 'app-user-menu', standalone: true, template: '' })
class StubUserMenuComponent {}
class MockAuthSessionStore {
readonly isAuthenticated = signal(true);
}
class MockConsoleSessionStore {
private readonly tenantsSignal = signal([
{ id: 'tenant-alpha', displayName: 'Tenant Alpha', status: 'active', isolationMode: 'shared', defaultRoles: [] as readonly string[] },
{ id: 'tenant-bravo', displayName: 'Tenant Bravo', status: 'active', isolationMode: 'shared', defaultRoles: [] as readonly string[] },
]);
private readonly selectedTenantIdSignal = signal<string | null>('tenant-alpha');
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly tenants = computed(() => this.tenantsSignal());
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly hasContext = computed(() => this.tenantsSignal().length > 0);
readonly currentTenant = computed(() => this.tenantsSignal().find((tenant) => tenant.id === this.selectedTenantIdSignal()) ?? null);
setError(message: string | null): void {
this.errorSignal.set(message);
}
}
class MockConsoleSessionService {
readonly loadConsoleContext = jasmine
.createSpy('loadConsoleContext')
.and.callFake(async () => undefined);
readonly switchTenant = jasmine
.createSpy('switchTenant')
.and.callFake(async () => undefined);
}
describe('AppTopbarComponent', () => {
let fixture: ComponentFixture<AppTopbarComponent>;
let component: AppTopbarComponent;
let sessionService: MockConsoleSessionService;
let sessionStore: MockConsoleSessionStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppTopbarComponent],
providers: [
provideRouter([]),
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
{ provide: ConsoleSessionStore, useClass: MockConsoleSessionStore },
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
],
})
.overrideComponent(AppTopbarComponent, {
set: {
imports: [
StubGlobalSearchComponent,
StubContextChipsComponent,
StubUserMenuComponent,
RouterLink,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(AppTopbarComponent);
component = fixture.componentInstance;
sessionService = TestBed.inject(ConsoleSessionService) as unknown as MockConsoleSessionService;
sessionStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore;
fixture.detectChanges();
});
it('opens tenant selector and renders tenant options', async () => {
const trigger = fixture.nativeElement.querySelector('.topbar__tenant-btn') as HTMLButtonElement;
expect(trigger).toBeTruthy();
trigger.click();
fixture.detectChanges();
await fixture.whenStable();
const options = fixture.nativeElement.querySelectorAll('.topbar__tenant-option');
expect(options.length).toBe(2);
});
it('switches tenant when a new tenant option is selected', async () => {
const trigger = fixture.nativeElement.querySelector('.topbar__tenant-btn') as HTMLButtonElement;
trigger.click();
fixture.detectChanges();
await fixture.whenStable();
const options = fixture.nativeElement.querySelectorAll('.topbar__tenant-option');
const tenantBravoButton = options[1] as HTMLButtonElement;
tenantBravoButton.click();
fixture.detectChanges();
await fixture.whenStable();
expect(sessionService.switchTenant).toHaveBeenCalledWith('tenant-bravo');
expect(component.tenantPanelOpen()).toBeFalse();
});
it('shows retry action when tenant catalog load fails', async () => {
sessionStore.setError('Unable to load console context.');
component.tenantPanelOpen.set(true);
fixture.detectChanges();
const retryButton = fixture.nativeElement.querySelector('.topbar__tenant-retry') as HTMLButtonElement;
expect(retryButton).toBeTruthy();
retryButton.click();
fixture.detectChanges();
await fixture.whenStable();
expect(sessionService.loadConsoleContext).toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ import {
EventEmitter,
computed,
DestroyRef,
effect,
inject,
ElementRef,
HostListener,
@@ -15,6 +16,7 @@ import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { filter } from 'rxjs/operators';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { GlobalSearchComponent } from '../global-search/global-search.component';
@@ -89,14 +91,66 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
</div>
<!-- Tenant selector -->
@if (activeTenant()) {
@if (isAuthenticated()) {
<div class="topbar__tenant">
<button type="button" class="topbar__tenant-btn" aria-haspopup="listbox">
<span class="topbar__tenant-label">{{ activeTenant() }}</span>
<button
type="button"
class="topbar__tenant-btn"
[class.topbar__tenant-btn--busy]="tenantSwitchInFlight()"
[attr.aria-expanded]="tenantPanelOpen()"
aria-haspopup="listbox"
aria-controls="topbar-tenant-listbox"
[attr.aria-label]="'Tenant selector. Current tenant: ' + activeTenantDisplayName()"
(click)="toggleTenantPanel()"
(keydown)="onTenantTriggerKeydown($event)"
>
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
@if (tenantPanelOpen()) {
<div class="topbar__tenant-panel" role="dialog" aria-label="Tenant selection">
@if (tenantLoading()) {
<p class="topbar__tenant-state">Loading tenant catalog...</p>
} @else if (tenantError(); as errorMessage) {
<div class="topbar__tenant-state topbar__tenant-state--error">
<p>{{ errorMessage }}</p>
<button type="button" class="topbar__tenant-retry" (click)="refreshTenantCatalog()">Retry</button>
</div>
} @else if (tenants().length === 0) {
<p class="topbar__tenant-state">No tenant assignments available.</p>
} @else {
<ul
id="topbar-tenant-listbox"
class="topbar__tenant-list"
role="listbox"
[attr.aria-activedescendant]="'topbar-tenant-option-' + (activeTenant() ?? '')"
(keydown)="onTenantListKeydown($event)"
>
@for (tenant of tenants(); track tenant.id; let tenantIndex = $index) {
<li>
<button
type="button"
class="topbar__tenant-option"
[id]="'topbar-tenant-option-' + tenant.id"
[class.topbar__tenant-option--active]="tenant.id === activeTenant()"
[disabled]="tenantSwitchInFlight()"
role="option"
[attr.data-tenant-option-index]="tenantIndex"
[attr.aria-selected]="tenant.id === activeTenant()"
(click)="onTenantSelected(tenant.id)"
>
<span class="topbar__tenant-option-name">{{ tenant.displayName }}</span>
<span class="topbar__tenant-option-id">{{ tenant.id }}</span>
</button>
</li>
}
</ul>
}
</div>
}
</div>
}
@@ -263,13 +317,7 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
}
.topbar__tenant {
display: none;
}
@media (min-width: 768px) {
.topbar__tenant {
display: block;
}
position: relative;
}
@media (max-width: 767px) {
@@ -305,17 +353,124 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
}
}
.topbar__tenant-btn--busy {
cursor: wait;
opacity: 0.75;
}
.topbar__tenant-panel {
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 120;
min-width: 260px;
max-width: min(92vw, 360px);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
box-shadow: var(--shadow-dropdown);
padding: 0.45rem;
}
.topbar__tenant-state {
margin: 0;
padding: 0.5rem 0.45rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.topbar__tenant-state--error {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.topbar__tenant-retry {
align-self: flex-start;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.72rem;
font-family: var(--font-family-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.3rem 0.45rem;
cursor: pointer;
}
.topbar__tenant-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.topbar__tenant-option {
width: 100%;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.45rem 0.5rem;
&:hover {
border-color: var(--color-border-primary);
background: var(--color-surface-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
}
.topbar__tenant-option--active {
border-color: color-mix(in srgb, var(--color-brand-primary) 45%, var(--color-border-primary));
background: color-mix(in srgb, var(--color-brand-primary) 11%, var(--color-surface-primary));
}
.topbar__tenant-option-name {
font-size: 0.77rem;
line-height: 1.2;
}
.topbar__tenant-option-id {
font-size: 0.67rem;
color: var(--color-text-tertiary);
font-family: var(--font-family-mono);
letter-spacing: 0.03em;
}
.topbar__tenant-label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 575px) {
.topbar__tenant-btn {
padding: 0.32rem 0.45rem;
}
.topbar__tenant-label {
max-width: 72px;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppTopbarComponent {
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleSession = inject(ConsoleSessionService);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
@@ -324,8 +479,17 @@ export class AppTopbarComponent {
@Output() menuToggle = new EventEmitter<void>();
readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly tenants = this.consoleStore.tenants;
readonly tenantLoading = this.consoleStore.loading;
readonly tenantError = this.consoleStore.error;
readonly activeTenant = this.consoleStore.selectedTenantId;
readonly activeTenantDisplayName = computed(() =>
this.consoleStore.currentTenant()?.displayName ?? this.activeTenant() ?? 'Tenant',
);
readonly scopePanelOpen = signal(false);
readonly tenantPanelOpen = signal(false);
readonly tenantSwitchInFlight = signal(false);
readonly tenantBootstrapAttempted = signal(false);
readonly currentPath = signal(this.router.url);
readonly primaryAction = computed(() => this.resolvePrimaryAction(this.currentPath()));
@@ -336,6 +500,28 @@ export class AppTopbarComponent {
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => this.currentPath.set(event.urlAfterRedirects));
effect(() => {
const authenticated = this.isAuthenticated();
if (!authenticated) {
this.closeScopePanel();
this.closeTenantPanel();
this.tenantBootstrapAttempted.set(false);
return;
}
if (this.consoleStore.hasContext()) {
this.tenantBootstrapAttempted.set(false);
return;
}
if (this.consoleStore.loading() || this.tenantBootstrapAttempted()) {
return;
}
this.tenantBootstrapAttempted.set(true);
void this.consoleSession.loadConsoleContext().catch(() => undefined);
});
}
toggleScopePanel(): void {
@@ -346,9 +532,105 @@ export class AppTopbarComponent {
this.scopePanelOpen.set(false);
}
async toggleTenantPanel(): Promise<void> {
if (this.tenantPanelOpen()) {
this.closeTenantPanel();
return;
}
this.tenantPanelOpen.set(true);
await this.loadTenantContextIfNeeded();
}
closeTenantPanel(): void {
this.tenantPanelOpen.set(false);
}
async refreshTenantCatalog(): Promise<void> {
this.tenantBootstrapAttempted.set(true);
try {
await this.consoleSession.loadConsoleContext();
} catch {
// Store-level error state drives panel messaging.
}
}
async onTenantSelected(tenantId: string): Promise<void> {
if (this.tenantSwitchInFlight()) {
return;
}
const activeTenantId = this.activeTenant();
if (activeTenantId === tenantId) {
this.closeTenantPanel();
return;
}
this.tenantSwitchInFlight.set(true);
try {
await this.consoleSession.switchTenant(tenantId);
this.closeTenantPanel();
} catch {
// Store-level error state drives panel messaging.
} finally {
this.tenantSwitchInFlight.set(false);
}
}
onTenantTriggerKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
void this.toggleTenantPanel().then(() => {
this.focusTenantOptionByIndex(event.key === 'ArrowUp' ? this.tenants().length - 1 : 0);
});
}
}
onTenantListKeydown(event: KeyboardEvent): void {
const options = this.tenants();
if (options.length === 0) {
return;
}
const focusedElement = document.activeElement as HTMLElement | null;
const focusedIndexRaw = focusedElement?.getAttribute('data-tenant-option-index');
const focusedIndex = focusedIndexRaw ? Number.parseInt(focusedIndexRaw, 10) : -1;
if (event.key === 'Escape') {
event.preventDefault();
this.closeTenantPanel();
this.focusTenantTrigger();
return;
}
if (event.key === 'Home') {
event.preventDefault();
this.focusTenantOptionByIndex(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
this.focusTenantOptionByIndex(options.length - 1);
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.focusTenantOptionByIndex(focusedIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.focusTenantOptionByIndex(focusedIndex - 1);
}
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.closeScopePanel();
this.closeTenantPanel();
}
@HostListener('document:click', ['$event'])
@@ -360,9 +642,41 @@ export class AppTopbarComponent {
const host = this.elementRef.nativeElement;
const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false;
const insideTenant = host.querySelector('.topbar__tenant')?.contains(target) ?? false;
if (!insideScope) {
this.closeScopePanel();
}
if (!insideTenant) {
this.closeTenantPanel();
}
}
private async loadTenantContextIfNeeded(): Promise<void> {
if (this.consoleStore.hasContext() || this.consoleStore.loading()) {
return;
}
this.tenantBootstrapAttempted.set(true);
try {
await this.consoleSession.loadConsoleContext();
} catch {
// Store-level error state drives panel messaging.
}
}
private focusTenantOptionByIndex(index: number): void {
const optionElements = this.elementRef.nativeElement.querySelectorAll('.topbar__tenant-option') as NodeListOf<HTMLButtonElement>;
if (optionElements.length === 0) {
return;
}
const normalizedIndex = ((index % optionElements.length) + optionElements.length) % optionElements.length;
optionElements.item(normalizedIndex)?.focus();
}
private focusTenantTrigger(): void {
const trigger = this.elementRef.nativeElement.querySelector('.topbar__tenant-btn') as HTMLButtonElement | null;
trigger?.focus();
}
private resolvePrimaryAction(path: string): { label: string; route: string } | null {

View File

@@ -42,6 +42,7 @@ describe('PlatformContextUrlSyncService', () => {
initialized: signal(true),
contextVersion: signal(0),
scopeQueryPatch: jasmine.createSpy('scopeQueryPatch').and.returnValue({
tenant: 'tenant-alpha',
regions: 'us-east',
environments: 'prod',
timeWindow: '7d',
@@ -99,6 +100,7 @@ describe('PlatformContextUrlSyncService', () => {
await waitForCondition(() => router.url.includes('regions=us-east'));
expect(router.url).toContain('/mission-control');
expect(router.url).toContain('tenant=tenant-alpha');
expect(router.url).toContain('regions=us-east');
expect(router.url).toContain('environments=prod');
expect(router.url).toContain('timeWindow=7d');

View File

@@ -0,0 +1,270 @@
import type { Page } from '@playwright/test';
type StubAuthSession = {
subjectId: string;
tenant: string;
scopes: string[];
};
export type CapturedApiRequest = {
url: string;
tenantId: string | null;
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read ui.admin authority:tenants.read authority:clients.read authority:clients.write findings:read orch:read orch:operate advisory:read vex:read exceptions:read exceptions:approve aoc:verify scanner:read policy:read policy:author policy:review policy:approve policy:simulate policy:audit release:read release:write release:publish sbom:read',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
const tenantCatalog = [
{
id: 'tenant-alpha',
displayName: 'Tenant Alpha',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['admin'],
},
{
id: 'tenant-bravo',
displayName: 'Tenant Bravo',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['admin'],
},
] as const;
export type MultiTenantFixture = {
capturedApiRequests: CapturedApiRequest[];
clearCapturedApiRequests: () => void;
};
export async function installMultiTenantSessionFixture(page: Page): Promise<MultiTenantFixture> {
const capturedApiRequests: CapturedApiRequest[] = [];
let selectedTenant = 'tenant-alpha';
const adminSession: StubAuthSession = {
subjectId: 'e2e-tenant-admin',
tenant: selectedTenant,
scopes: [
'ui.read',
'ui.admin',
'authority:tenants.read',
'authority:clients.read',
'authority:clients.write',
'findings:read',
'orch:read',
'orch:operate',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
'scanner:read',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'release:read',
'release:write',
'release:publish',
'sbom:read',
'admin',
],
};
await page.addInitScript((session: StubAuthSession) => {
let seededTenant = session.tenant;
try {
const raw = window.sessionStorage.getItem('stellaops.auth.session.full');
if (raw) {
const parsed = JSON.parse(raw) as { tenantId?: string | null };
if (typeof parsed.tenantId === 'string' && parsed.tenantId.trim().length > 0) {
seededTenant = parsed.tenantId.trim();
}
}
} catch {
// ignore malformed persisted session values
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = {
...session,
tenant: seededTenant,
};
}, adminSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
}),
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
}),
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-e2e-fixture' }),
}),
);
await page.route('**/console/tenants**', (route) => {
const requestedTenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
if (tenantCatalog.some((tenant) => tenant.id === requestedTenant)) {
selectedTenant = requestedTenant;
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tenants: tenantCatalog,
selectedTenant,
}),
});
});
await page.route('**/console/profile**', (route) => {
const tenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
selectedTenant = tenant;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
subjectId: adminSession.subjectId,
username: 'tenant-admin',
displayName: 'Tenant Admin',
tenant,
roles: ['admin'],
scopes: adminSession.scopes,
audiences: ['stellaops'],
authenticationMethods: ['pwd'],
}),
});
});
await page.route('**/console/token/introspect**', (route) => {
const tenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
selectedTenant = tenant;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
active: true,
tenant,
subject: adminSession.subjectId,
clientId: 'stellaops-console',
scopes: adminSession.scopes,
audiences: ['stellaops'],
}),
});
});
await page.route('**/api/**', (route) => {
const tenantId = resolveTenantFromRequestHeaders(route.request().headers());
capturedApiRequests.push({
url: route.request().url(),
tenantId,
});
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tenantId: tenantId ?? selectedTenant,
items: [],
}),
});
});
return {
capturedApiRequests,
clearCapturedApiRequests: () => {
capturedApiRequests.length = 0;
},
};
}
function resolveTenantFromRequestHeaders(headers: Record<string, string>): string | null {
const headerCandidates = [
headers['x-stellaops-tenant'],
headers['x-stella-tenant'],
headers['x-tenant-id'],
];
for (const candidate of headerCandidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
}

View File

@@ -0,0 +1,48 @@
export type TenantPageMatrixEntry = {
section: string;
route: string;
expectedBreadcrumb: string;
};
export const tenantSwitchPageMatrix: readonly TenantPageMatrixEntry[] = [
{
section: 'Mission Control',
route: '/mission-control/board',
expectedBreadcrumb: 'Mission Board',
},
{
section: 'Releases',
route: '/releases/overview',
expectedBreadcrumb: 'Release Overview',
},
{
section: 'Security',
route: '/security/posture',
expectedBreadcrumb: 'Security',
},
{
section: 'Security',
route: '/security/unknowns',
expectedBreadcrumb: 'Unknowns',
},
{
section: 'Evidence',
route: '/evidence/overview',
expectedBreadcrumb: 'Evidence',
},
{
section: 'Ops',
route: '/ops/operations',
expectedBreadcrumb: 'Operations',
},
{
section: 'Setup',
route: '/setup/topology/overview',
expectedBreadcrumb: 'Topology',
},
{
section: 'Admin',
route: '/setup/identity-access',
expectedBreadcrumb: 'Identity & Access',
},
];

View File

@@ -0,0 +1,173 @@
import { expect, test, type Page } from '@playwright/test';
import { type CapturedApiRequest, installMultiTenantSessionFixture } from './support/multi-tenant-session.fixture';
import { tenantSwitchPageMatrix } from './support/tenant-switch-page-matrix';
test.describe.configure({ mode: 'serial' });
test.describe('Multi-tenant switch matrix', () => {
test('switches tenant from header and persists across primary sections (desktop)', async ({ page }) => {
const fixture = await installMultiTenantSessionFixture(page);
await page.setViewportSize({ width: 1440, height: 900 });
await go(page, '/mission-control/board');
await expectTenantLabelContains(page, 'alpha');
await switchTenant(page, 'Tenant Bravo');
await expectTenantLabelContains(page, 'bravo');
fixture.clearCapturedApiRequests();
for (const entry of tenantSwitchPageMatrix) {
await navigateInApp(page, entry.route);
await expect(page.locator('main')).toBeVisible();
await expectTenantLabelContains(page, 'bravo');
}
const tenantScopedRequests = fixture.capturedApiRequests.filter((request) => {
const url = request.url.toLowerCase();
return !url.includes('/api/auth/') &&
!url.includes('/health') &&
!url.includes('/ready') &&
!url.includes('/metrics');
});
expect(tenantScopedRequests.length).toBeGreaterThan(0);
for (const request of tenantScopedRequests) {
expect(request.tenantId).toBe('tenant-bravo');
}
});
test('keeps tenant selector usable and persistent on mobile viewport', async ({ page }) => {
await installMultiTenantSessionFixture(page);
await page.setViewportSize({ width: 390, height: 844 });
await go(page, '/mission-control/board');
await expect(page.locator('.topbar__tenant-btn')).toBeVisible();
await switchTenant(page, 'Tenant Bravo');
await expectTenantLabelContains(page, 'bravo');
await navigateInApp(page, '/setup/identity-access');
await expectTenantLabelContains(page, 'bravo');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
await expectTenantLabelContains(page, 'bravo');
});
for (const entry of tenantSwitchPageMatrix) {
test(`applies selected tenant on ${entry.section} route`, async ({ page }) => {
const fixture = await installMultiTenantSessionFixture(page);
await page.setViewportSize({ width: 1440, height: 900 });
await go(page, '/mission-control/board');
await switchTenant(page, 'Tenant Bravo');
await expectTenantLabelContains(page, 'bravo');
fixture.clearCapturedApiRequests();
await navigateInApp(page, entry.route);
await expect.poll(() => new URL(page.url()).pathname).toBe(entry.route);
const routeHints = buildRouteHints(entry.section, entry.expectedBreadcrumb);
await expect
.poll(async () => {
const mainText = (await page.locator('main').innerText()).toLowerCase();
return routeHints.some((hint) => mainText.includes(hint.toLowerCase()));
})
.toBe(true);
await expectTenantLabelContains(page, 'bravo');
const tenantScopedRequests = toTenantScopedRequests(fixture.capturedApiRequests);
const crossTenantRequests = tenantScopedRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-bravo');
expect(crossTenantRequests.length).toBe(0);
for (const request of tenantScopedRequests) {
expect(request.tenantId).toBe('tenant-bravo');
}
});
}
test('switches tenant in both directions without stale request headers', async ({ page }) => {
const fixture = await installMultiTenantSessionFixture(page);
await page.setViewportSize({ width: 1440, height: 900 });
await go(page, '/mission-control/board');
await switchTenant(page, 'Tenant Bravo');
await expectTenantLabelContains(page, 'bravo');
fixture.clearCapturedApiRequests();
await navigateInApp(page, '/setup/topology/overview');
const bravoRequests = toTenantScopedRequests(fixture.capturedApiRequests);
const crossTenantBravoRequests = bravoRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-bravo');
expect(crossTenantBravoRequests.length).toBe(0);
for (const request of bravoRequests) {
expect(request.tenantId).toBe('tenant-bravo');
}
await switchTenant(page, 'Tenant Alpha');
await expectTenantLabelContains(page, 'alpha');
fixture.clearCapturedApiRequests();
await navigateInApp(page, '/security/posture');
const alphaRequests = toTenantScopedRequests(fixture.capturedApiRequests);
const crossTenantAlphaRequests = alphaRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-alpha');
expect(crossTenantAlphaRequests.length).toBe(0);
for (const request of alphaRequests) {
expect(request.tenantId).toBe('tenant-alpha');
}
});
});
function toTenantScopedRequests(capturedApiRequests: readonly CapturedApiRequest[]): CapturedApiRequest[] {
return capturedApiRequests.filter((request) => {
const url = request.url.toLowerCase();
return !url.includes('/api/auth/') &&
!url.includes('/health') &&
!url.includes('/ready') &&
!url.includes('/metrics');
});
}
function buildRouteHints(section: string, expectedBreadcrumb: string): readonly string[] {
const hints = new Set<string>();
const sectionHint = section.trim();
const singularSectionHint = sectionHint.endsWith('s') ? sectionHint.slice(0, -1) : sectionHint;
hints.add(expectedBreadcrumb.trim());
hints.add(sectionHint);
hints.add(singularSectionHint);
return [...hints].filter((hint) => hint.length > 0);
}
async function switchTenant(page: Page, displayName: string): Promise<void> {
const trigger = page.locator('.topbar__tenant-btn');
await trigger.click();
const option = page.locator('.topbar__tenant-option-name', {
hasText: displayName,
});
await expect(option).toBeVisible();
await option.click();
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function navigateInApp(page: Page, path: string): Promise<void> {
await page.evaluate((nextPath) => {
window.history.pushState({}, '', nextPath);
window.dispatchEvent(new PopStateEvent('popstate'));
}, path);
await expect.poll(() => new URL(page.url()).pathname).toBe(path);
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function expectTenantLabelContains(page: Page, tenantHint: string): Promise<void> {
await expect
.poll(async () => (await page.locator('.topbar__tenant-btn').innerText()).toLowerCase())
.toContain(tenantHint.toLowerCase());
}