save checkpoint
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user