save checkpoint

This commit is contained in:
master
2026-02-07 12:44:24 +02:00
parent 9339a8952c
commit 04360dff63
789 changed files with 39719 additions and 31710 deletions

View File

@@ -43,7 +43,7 @@ import type {
})
export class BinaryResolutionClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/api/v1`;
private readonly baseUrl = `${environment.apiBaseUrl}/v1`;
/**
* Resolve a single vulnerability for a binary.

View File

@@ -0,0 +1,112 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { BrandingService } from './branding.service';
describe('BrandingService', () => {
let service: BrandingService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(BrandingService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should map Authority DTO to BrandingConfiguration', () => {
const authorityResponse = {
tenantId: 'acme',
displayName: 'Acme Corp Dashboard',
logoUri: 'https://acme.test/logo.png',
faviconUri: 'https://acme.test/favicon.ico',
themeTokens: {
'--theme-brand-primary': '#ff0000',
},
};
service.fetchBranding('acme').subscribe((response) => {
expect(response.branding.tenantId).toBe('acme');
expect(response.branding.title).toBe('Acme Corp Dashboard');
expect(response.branding.logoUrl).toBe('https://acme.test/logo.png');
expect(response.branding.faviconUrl).toBe('https://acme.test/favicon.ico');
expect(response.branding.themeTokens).toEqual({
'--theme-brand-primary': '#ff0000',
});
});
const req = httpMock.expectOne('/console/branding?tenantId=acme');
expect(req.request.method).toBe('GET');
req.flush(authorityResponse);
});
it('should pass tenantId=default when no argument provided', () => {
service.fetchBranding().subscribe();
const req = httpMock.expectOne('/console/branding?tenantId=default');
expect(req.request.params.get('tenantId')).toBe('default');
req.flush({
tenantId: 'default',
displayName: 'StellaOps',
logoUri: null,
faviconUri: null,
themeTokens: {},
});
});
it('should fall back to defaults on HTTP error without console.warn', () => {
const warnSpy = spyOn(console, 'warn');
service.fetchBranding().subscribe((response) => {
expect(response.branding.tenantId).toBe('default');
expect(response.branding.title).toBe('Stella Ops Dashboard');
expect(response.branding.themeTokens).toEqual({});
});
const req = httpMock.expectOne('/console/branding?tenantId=default');
req.flush('Not Found', { status: 404, statusText: 'Not Found' });
expect(warnSpy).not.toHaveBeenCalled();
});
it('should handle null logoUri and faviconUri as undefined', () => {
service.fetchBranding().subscribe((response) => {
expect(response.branding.logoUrl).toBeUndefined();
expect(response.branding.faviconUrl).toBeUndefined();
});
const req = httpMock.expectOne('/console/branding?tenantId=default');
req.flush({
tenantId: 'default',
displayName: 'StellaOps',
logoUri: null,
faviconUri: null,
themeTokens: {},
});
});
it('should set currentBranding signal on success', () => {
expect(service.currentBranding()).toBeNull();
expect(service.isLoaded()).toBe(false);
service.fetchBranding().subscribe();
const req = httpMock.expectOne('/console/branding?tenantId=default');
req.flush({
tenantId: 'default',
displayName: 'Test Title',
logoUri: null,
faviconUri: null,
themeTokens: {},
});
expect(service.currentBranding()).toBeTruthy();
expect(service.currentBranding()!.title).toBe('Test Title');
expect(service.isLoaded()).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { catchError, map, tap } from 'rxjs/operators';
export interface BrandingConfiguration {
tenantId: string;
@@ -16,6 +16,15 @@ export interface BrandingResponse {
branding: BrandingConfiguration;
}
/** Shape returned by the Authority /console/branding endpoint. */
interface AuthorityBrandingDto {
tenantId: string;
displayName: string;
logoUri: string | null;
faviconUri: string | null;
themeTokens: Record<string, string>;
}
@Injectable({
providedIn: 'root'
})
@@ -36,13 +45,23 @@ export class BrandingService {
/**
* Fetch branding configuration from the Authority API
*/
fetchBranding(): Observable<BrandingResponse> {
return this.http.get<BrandingResponse>('/console/branding').pipe(
fetchBranding(tenantId: string = 'default'): Observable<BrandingResponse> {
return this.http.get<AuthorityBrandingDto>('/console/branding', {
params: { tenantId },
}).pipe(
map((dto) => ({
branding: {
tenantId: dto.tenantId,
title: dto.displayName || undefined,
logoUrl: dto.logoUri || undefined,
faviconUrl: dto.faviconUri || undefined,
themeTokens: dto.themeTokens,
} satisfies BrandingConfiguration,
})),
tap((response) => {
this.applyBranding(response.branding);
}),
catchError((error) => {
console.warn('Failed to fetch branding configuration, using defaults:', error);
catchError(() => {
this.applyBranding(this.defaultBranding);
return of({ branding: this.defaultBranding });
})

View File

@@ -0,0 +1,159 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { HttpBackend } from '@angular/common/http';
import { AppConfigService } from './app-config.service';
import { AppConfig } from './app-config.model';
describe('AppConfigService', () => {
let service: AppConfigService;
const minimalConfig: AppConfig = {
authority: {
issuer: 'https://auth.test',
clientId: 'test',
authorizeEndpoint: 'https://auth.test/authorize',
tokenEndpoint: 'https://auth.test/token',
redirectUri: 'https://app.test/callback',
scope: 'openid',
audience: 'api',
},
apiBaseUrls: {
scanner: 'https://scanner.test',
policy: 'https://policy.test',
concelier: 'https://concelier.test',
attestor: 'https://attestor.test',
authority: 'https://auth.test',
},
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(AppConfigService);
});
describe('normalizeApiBaseUrls (via setConfigForTesting)', () => {
it('should convert absolute http URLs to relative paths using key names', () => {
const config: AppConfig = {
...minimalConfig,
apiBaseUrls: {
gateway: 'http://gateway.stella-ops.local',
scanner: 'http://scanner.stella-ops.local',
policy: 'http://policy-gateway.stella-ops.local',
concelier: 'http://concelier.stella-ops.local',
attestor: 'http://attestor.stella-ops.local',
authority: 'http://authority.stella-ops.local',
notify: 'http://notify.stella-ops.local',
},
};
service.setConfigForTesting(config);
expect(service.config.apiBaseUrls.gateway).toBe('/gateway');
expect(service.config.apiBaseUrls.scanner).toBe('/scanner');
expect(service.config.apiBaseUrls.policy).toBe('/policy');
expect(service.config.apiBaseUrls.concelier).toBe('/concelier');
expect(service.config.apiBaseUrls.attestor).toBe('/attestor');
expect(service.config.apiBaseUrls.authority).toBe('/authority');
expect(service.config.apiBaseUrls.notify).toBe('/notify');
});
it('should convert absolute https URLs to relative paths', () => {
const config: AppConfig = {
...minimalConfig,
apiBaseUrls: {
scanner: 'https://scanner.prod.example.com',
policy: 'https://policy.prod.example.com',
concelier: 'https://concelier.prod.example.com',
attestor: 'https://attestor.prod.example.com',
authority: 'https://auth.prod.example.com',
},
};
service.setConfigForTesting(config);
expect(service.config.apiBaseUrls.scanner).toBe('/scanner');
expect(service.config.apiBaseUrls.policy).toBe('/policy');
expect(service.config.apiBaseUrls.authority).toBe('/authority');
});
it('should preserve relative paths unchanged', () => {
const config: AppConfig = {
...minimalConfig,
apiBaseUrls: {
gateway: '/platform',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
authority: '/authority',
},
};
service.setConfigForTesting(config);
expect(service.config.apiBaseUrls.gateway).toBe('/platform');
expect(service.config.apiBaseUrls.scanner).toBe('/scanner');
expect(service.config.apiBaseUrls.policy).toBe('/policy');
});
it('should handle mixed absolute and relative URLs', () => {
const config: AppConfig = {
...minimalConfig,
apiBaseUrls: {
gateway: '/platform',
scanner: 'http://scanner.stella-ops.local',
policy: '/policy',
concelier: 'http://concelier.stella-ops.local',
attestor: '/attestor',
authority: '/authority',
},
};
service.setConfigForTesting(config);
expect(service.config.apiBaseUrls.gateway).toBe('/platform');
expect(service.config.apiBaseUrls.scanner).toBe('/scanner');
expect(service.config.apiBaseUrls.policy).toBe('/policy');
expect(service.config.apiBaseUrls.concelier).toBe('/concelier');
expect(service.config.apiBaseUrls.attestor).toBe('/attestor');
});
it('should handle undefined optional fields', () => {
const config: AppConfig = {
...minimalConfig,
apiBaseUrls: {
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
authority: '/authority',
},
};
service.setConfigForTesting(config);
expect(service.config.apiBaseUrls.gateway).toBeUndefined();
expect(service.config.apiBaseUrls.notify).toBeUndefined();
});
});
describe('normalizeConfig defaults', () => {
it('should apply default dpopAlgorithms when not provided', () => {
service.setConfigForTesting(minimalConfig);
expect(service.config.authority.dpopAlgorithms).toEqual(['ES256']);
});
it('should apply default refreshLeewaySeconds when not provided', () => {
service.setConfigForTesting(minimalConfig);
expect(service.config.authority.refreshLeewaySeconds).toBe(60);
});
it('should apply default doctor config when not provided', () => {
service.setConfigForTesting(minimalConfig);
expect(service.config.doctor).toEqual({ fixEnabled: false });
});
});
});

View File

@@ -10,6 +10,7 @@ import { firstValueFrom } from 'rxjs';
import {
APP_CONFIG,
ApiBaseUrlConfig,
AppConfig,
AuthorityConfig,
ConfigStatus,
@@ -289,11 +290,31 @@ export class AppConfigService {
}
: { fixEnabled: DEFAULT_DOCTOR_FIX_ENABLED };
const apiBaseUrls = this.normalizeApiBaseUrls(config.apiBaseUrls);
return {
...config,
authority,
apiBaseUrls,
telemetry,
doctor,
};
}
/**
* Converts absolute Docker-internal URLs (e.g. http://gateway.stella-ops.local)
* to relative paths (e.g. /gateway) so requests go through the console's nginx
* reverse proxy and avoid CORS failures in containerized deployments.
*/
private normalizeApiBaseUrls(urls: ApiBaseUrlConfig): ApiBaseUrlConfig {
const entries = Object.entries(urls) as [string, string | undefined][];
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of entries) {
normalized[key] =
typeof value === 'string' && /^https?:\/\//.test(value)
? `/${key}`
: value;
}
return normalized as unknown as ApiBaseUrlConfig;
}
}

View File

@@ -7,7 +7,7 @@ import { requireConfigGuard } from './config.guard';
import { AppConfig } from './app-config.model';
describe('requireConfigGuard', () => {
let configService: jasmine.SpyObj<AppConfigService>;
let configService: any;
let router: Router;
const minimalConfig: AppConfig = {

View File

@@ -51,7 +51,7 @@ export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API');
@Injectable({ providedIn: 'root' })
export class HttpDoctorClient implements DoctorApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/api/v1/doctor`;
private readonly baseUrl = `${environment.apiBaseUrl}/v1/doctor`;
listChecks(category?: string, plugin?: string): Observable<CheckListResponse> {
const params: Record<string, string> = {};

View File

@@ -23,7 +23,7 @@ import {
})
export class IntegrationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/api/v1/integrations`;
private readonly baseUrl = `${environment.apiBaseUrl}/v1/integrations`;
/**
* List integrations with filtering and pagination.

View File

@@ -7,7 +7,7 @@
"logoutEndpoint": "/authority/connect/logout",
"redirectUri": "/auth/callback",
"postLogoutRedirectUri": "/",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit",
"scope": "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit",
"audience": "/scanner",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -570,23 +570,23 @@
box-shadow var(--theme-transition-duration) var(--theme-transition-timing);
}
// Apply to body for global effect (can be toggled via class)
.theme-transitioning,
.theme-transitioning * {
// Apply theme transitions to root element only.
// BUG-005 fix: The previous `.theme-transitioning *` universal selector applied
// transitions to every DOM element simultaneously, causing layout thrashing and
// browser hangs on complex pages. Children inherit CSS custom property changes
// instantly; explicit transitions are only needed on the root.
.theme-transitioning {
@include theme-transition;
}
// Disable transitions for reduced motion
@media (prefers-reduced-motion: reduce) {
.theme-transitioning,
.theme-transitioning * {
.theme-transitioning {
transition: none !important;
}
}
[data-reduce-motion='1'] .theme-transitioning,
[data-reduce-motion='1'] .theme-transitioning *,
[data-reduce-motion='true'] .theme-transitioning,
[data-reduce-motion='true'] .theme-transitioning * {
[data-reduce-motion='true'] .theme-transitioning {
transition: none !important;
}