Repair release investigation workspace contracts

This commit is contained in:
master
2026-03-09 23:19:42 +02:00
parent 3ecafc49a3
commit 359fafa9da
20 changed files with 1806 additions and 284 deletions

View File

@@ -96,14 +96,39 @@ const surfaceConfigs = [
{
key: 'release-investigation-deploy-diff',
path: '/releases/investigation/deploy-diff',
heading: /deploy diff|deployment diff|missing parameters/i,
heading: /deploy diff|deployment diff|no comparison selected/i,
searchQuery: 'deployment diff',
actions: [
{
key: 'open-deployments',
selector: 'main a:has-text("Open Deployments")',
expectedUrlPattern: '/releases/deployments',
expectedTextPattern: /deployments/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
{
key: 'open-releases-overview',
selector: 'main a:has-text("Open Releases Overview")',
expectedUrlPattern: '/releases/overview',
expectedTextPattern: /overview|releases/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
],
},
{
key: 'release-investigation-change-trace',
path: '/releases/investigation/change-trace',
heading: /change trace/i,
searchQuery: 'change trace',
actions: [
{
key: 'open-deployments',
selector: 'main a:has-text("Open Deployments")',
expectedUrlPattern: '/releases/deployments',
expectedTextPattern: /deployments/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
],
},
{
key: 'registry-admin',

View File

@@ -0,0 +1,106 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { ChangeTraceViewerComponent } from './change-trace-viewer.component';
import { ChangeTraceService } from './services/change-trace.service';
describe('ChangeTraceViewerComponent', () => {
let fixture: ComponentFixture<ChangeTraceViewerComponent>;
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let changeTraceService: jasmine.SpyObj<ChangeTraceService>;
const mockTrace = {
schema: 'stella.change-trace/v1',
subject: {
type: 'artifact.compare',
imageRef: 'acme/demo',
digest: 'sha256:def456',
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
},
basis: {
analyzedAt: '2026-03-09T00:00:00Z',
policies: ['lineage.compare'],
diffMethods: ['pkg'],
engineVersion: 'sbomservice-lineage-compare',
},
deltas: [],
summary: {
changedPackages: 0,
packagesAdded: 0,
packagesRemoved: 0,
changedSymbols: 0,
changedBytes: 0,
riskDelta: 0,
verdict: 'neutral',
},
} as any;
beforeEach(async () => {
routeParamMap$ = new BehaviorSubject(convertToParamMap({}));
queryParamMap$ = new BehaviorSubject(convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
}));
changeTraceService = jasmine.createSpyObj('ChangeTraceService', [
'getTrace',
'buildTrace',
'loadFromFile',
'exportToFile',
]) as jasmine.SpyObj<ChangeTraceService>;
changeTraceService.getTrace.and.returnValue(of(mockTrace));
changeTraceService.buildTrace.and.returnValue(of(mockTrace));
await TestBed.configureTestingModule({
imports: [ChangeTraceViewerComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
paramMap: routeParamMap$.asObservable(),
queryParamMap: queryParamMap$.asObservable(),
},
},
{
provide: ChangeTraceService,
useValue: changeTraceService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChangeTraceViewerComponent);
});
it('renders the scoped no-comparison workspace when no digests or trace id are present', () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('No Comparison Selected');
expect(text).toContain('Open Deployments');
expect(changeTraceService.buildTrace).not.toHaveBeenCalled();
});
it('auto-builds a trace when comparison digests are present in the route query', () => {
queryParamMap$.next(convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
from: 'sha256:abc123',
to: 'sha256:def456',
}));
fixture.detectChanges();
expect(changeTraceService.buildTrace).toHaveBeenCalledWith(
'sha256:abc123',
'sha256:def456',
'demo-prod',
false,
);
});
});

View File

@@ -5,6 +5,7 @@
// -----------------------------------------------------------------------------
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
@@ -13,8 +14,9 @@ import {
signal,
computed,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { combineLatest } from 'rxjs';
import { SummaryHeaderComponent } from './components/summary-header/summary-header.component';
import { DeltaListComponent } from './components/delta-list/delta-list.component';
@@ -22,6 +24,7 @@ import { ProofPanelComponent } from './components/proof-panel/proof-panel.compon
import { ByteDiffViewerComponent } from './components/byte-diff-viewer/byte-diff-viewer.component';
import { ChangeTrace, PackageDelta } from './models/change-trace.models';
import { ChangeTraceService } from './services/change-trace.service';
import { readReleaseInvestigationQueryState } from '../release-investigation/release-investigation-context';
@Component({
selector: 'stella-change-trace-viewer',
@@ -29,7 +32,8 @@ import { ChangeTraceService } from './services/change-trace.service';
SummaryHeaderComponent,
DeltaListComponent,
ProofPanelComponent,
ByteDiffViewerComponent
ByteDiffViewerComponent,
RouterLink,
],
template: `
<div class="change-trace-viewer">
@@ -97,11 +101,30 @@ import { ChangeTraceService } from './services/change-trace.service';
@if (!trace() && !loading() && !error()) {
<div class="empty-state">
<div class="empty-content">
<h2>No Change Trace Loaded</h2>
<p>Load a change trace file or navigate to a specific trace ID.</p>
<button class="btn btn-primary" (click)="fileInput.click()">
Load Change Trace File
</button>
<h2>{{ emptyStateTitle() }}</h2>
<p>{{ emptyStateMessage() }}</p>
<div class="empty-actions">
@if (hasComparisonContext()) {
<a
class="btn btn-secondary"
[routerLink]="['/releases/investigation/deploy-diff']"
[queryParams]="comparisonQueryParams()"
>
Open Deploy Diff
</a>
} @else {
<a
class="btn btn-secondary"
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
>
Open Deployments
</a>
}
<button class="btn btn-primary" (click)="fileInput.click()">
Load Change Trace File
</button>
</div>
</div>
</div>
}
@@ -214,6 +237,13 @@ import { ChangeTraceService } from './services/change-trace.service';
}
}
.empty-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 1200px) {
.content-grid {
grid-template-columns: 1fr;
@@ -247,19 +277,55 @@ export class ChangeTraceViewerComponent implements OnInit {
readonly selectedDelta = signal<PackageDelta | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly emptyStateTitle = signal('No Comparison Selected');
readonly emptyStateMessage = signal('Open this workspace from Deployments or provide from and to digests in the URL.');
readonly hasComparisonContext = signal(false);
readonly scopeQueryParams = signal<Record<string, string>>({});
readonly comparisonQueryParams = signal<Record<string, string>>({});
// Computed values
readonly hasTrace = computed(() => this.trace() !== null);
readonly deltas = computed(() => this.trace()?.deltas ?? []);
constructor() {
// Subscribe to route params with automatic cleanup
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
const traceId = params['traceId'];
if (traceId) {
this.loadTrace(traceId);
}
});
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(takeUntilDestroyed())
.subscribe(([params, queryParams]) => {
const traceId = params.get('traceId');
const investigationState = readReleaseInvestigationQueryState(queryParams);
const comparisonQueryParams = {
...investigationState.scopeQueryParams,
...(investigationState.fromDigest ? { from: investigationState.fromDigest } : {}),
...(investigationState.toDigest ? { to: investigationState.toDigest } : {}),
...(investigationState.fromLabel ? { fromLabel: investigationState.fromLabel } : {}),
...(investigationState.toLabel ? { toLabel: investigationState.toLabel } : {}),
};
this.scopeQueryParams.set(investigationState.scopeQueryParams);
this.comparisonQueryParams.set(comparisonQueryParams);
if (traceId) {
this.hasComparisonContext.set(true);
this.loadTrace(traceId);
return;
}
if (investigationState.fromDigest && investigationState.toDigest) {
this.hasComparisonContext.set(true);
this.buildTrace(
investigationState.fromDigest,
investigationState.toDigest,
investigationState.tenantId,
);
return;
}
this.resetEmptyState(
'No Comparison Selected',
'Open this workspace from Deployments or provide from and to digests in the URL.',
false,
);
});
}
ngOnInit(): void {
@@ -277,8 +343,33 @@ export class ChangeTraceViewerComponent implements OnInit {
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load trace');
this.handleTraceError(
err,
'No Change Trace Available',
'The requested change trace could not be reconstructed from the current comparison data.',
);
},
});
}
buildTrace(fromDigest: string, toDigest: string, tenantId: string): void {
this.loading.set(true);
this.error.set(null);
this.changeTraceService.buildTrace(fromDigest, toDigest, tenantId, false).subscribe({
next: (trace) => {
this.trace.set(trace);
this.selectedDelta.set(trace.deltas[0] ?? null);
this.loading.set(false);
this.emptyStateTitle.set('No Change Trace Available');
this.emptyStateMessage.set('The selected comparison did not yield any change-trace entries yet.');
},
error: (err) => {
this.handleTraceError(
err,
'No Change Trace Available',
'The selected comparison does not have change-trace data in the current stack yet.',
);
},
});
}
@@ -320,4 +411,30 @@ export class ChangeTraceViewerComponent implements OnInit {
clearError(): void {
this.error.set(null);
}
private handleTraceError(err: unknown, emptyTitle: string, emptyMessage: string): void {
if (err instanceof HttpErrorResponse && err.status === 404) {
this.trace.set(null);
this.selectedDelta.set(null);
this.loading.set(false);
this.error.set(null);
this.emptyStateTitle.set(emptyTitle);
this.emptyStateMessage.set(emptyMessage);
return;
}
const errorMessage = err instanceof Error ? err.message : 'Failed to load trace';
this.error.set(errorMessage);
this.loading.set(false);
}
private resetEmptyState(title: string, message: string, hasComparisonContext: boolean): void {
this.trace.set(null);
this.selectedDelta.set(null);
this.loading.set(false);
this.error.set(null);
this.emptyStateTitle.set(title);
this.emptyStateMessage.set(message);
this.hasComparisonContext.set(hasComparisonContext);
}
}

View File

@@ -27,13 +27,15 @@ export class ChangeTraceService {
* Build a new change trace from two scan IDs.
*/
buildTrace(
fromScanId: string,
toScanId: string,
fromDigest: string,
toDigest: string,
tenantId: string,
includeByteDiff: boolean
): Observable<ChangeTrace> {
return this.http.post<ChangeTrace>(`${this.apiUrl}/build`, {
fromScanId,
toScanId,
fromDigest,
toDigest,
tenantId,
includeByteDiff,
});
}

View File

@@ -4,9 +4,10 @@
* @description Unit tests for deploy diff panel component.
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { DeployDiffPanelComponent } from './deploy-diff-panel.component';
import { DeployDiffService } from '../../services/deploy-diff.service';
import {
@@ -15,16 +16,63 @@ import {
PolicyHit,
PolicyResult,
} from '../../models/deploy-diff.models';
import { LineageDiffResponse } from '../../../lineage/models/lineage.models';
describe('DeployDiffPanelComponent', () => {
let fixture: ComponentFixture<DeployDiffPanelComponent>;
let component: DeployDiffPanelComponent;
let httpMock: HttpTestingController;
const compareUrl = '/api/sbomservice/api/v1/lineage/compare';
const expectCompareRequest = () =>
httpMock.expectOne((request) =>
request.method === 'GET'
&& request.url === compareUrl
&& request.params.get('a') === 'sha256:abc123'
&& request.params.get('b') === 'sha256:def456'
&& request.params.get('tenant') === 'demo-prod',
);
const mockCompareResponse: LineageDiffResponse = {
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
computedAt: '2026-01-25T10:00:00Z',
componentDiff: {
added: [
{
purl: 'pkg:npm/lodash@4.17.21',
name: 'lodash',
currentVersion: '4.17.21',
currentLicense: 'MIT',
changeType: 'added',
},
],
removed: [],
changed: [
{
purl: 'pkg:npm/axios@1.0.0',
name: 'axios',
previousVersion: '0.21.0',
currentVersion: '1.0.0',
changeType: 'version-changed',
},
],
sourceTotal: 52,
targetTotal: 53,
},
vexDeltas: [
{
cve: 'CVE-2026-1234',
currentStatus: 'affected',
},
],
};
const mockComponentDiff: ComponentDiff = {
id: 'comp-1',
id: 'added-0-lodash',
changeType: 'added',
name: 'lodash',
purl: 'pkg:npm/lodash@4.17.21',
fromVersion: null,
toVersion: '4.17.21',
licenseChanged: false,
@@ -32,12 +80,12 @@ describe('DeployDiffPanelComponent', () => {
};
const mockPolicyHit: PolicyHit = {
id: 'hit-1',
gate: 'version-check',
id: 'vex-0',
gate: 'vex-delta',
severity: 'high',
result: 'fail',
message: 'Major version upgrade detected',
componentIds: ['comp-1'],
message: 'CVE-2026-1234 is affected in the target artifact.',
componentIds: ['added-0-lodash'],
};
const mockPolicyResult: PolicyResult = {
@@ -45,7 +93,7 @@ describe('DeployDiffPanelComponent', () => {
overrideAvailable: true,
failCount: 1,
warnCount: 0,
passCount: 5,
passCount: 0,
};
const mockDiffResult: SbomDiffResult = {
@@ -53,15 +101,16 @@ describe('DeployDiffPanelComponent', () => {
removed: [],
changed: [
{
id: 'comp-2',
id: 'changed-0-axios',
changeType: 'changed',
name: 'axios',
purl: 'pkg:npm/axios@1.0.0',
fromVersion: '0.21.0',
toVersion: '1.0.0',
licenseChanged: false,
versionChange: {
type: 'major',
description: 'Major version upgrade',
description: 'Major version change 0.21.0 -> 1.0.0',
breaking: true,
},
},
@@ -94,80 +143,79 @@ describe('DeployDiffPanelComponent', () => {
fixture = TestBed.createComponent(DeployDiffPanelComponent);
component = fixture.componentInstance;
// Set required inputs
fixture.componentRef.setInput('fromDigest', 'sha256:abc123');
fixture.componentRef.setInput('toDigest', 'sha256:def456');
});
const loadDiff = async () => {
fixture.detectChanges();
const req = expectCompareRequest();
req.flush(mockCompareResponse);
for (let attempt = 0; attempt < 5; attempt += 1) {
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
if (!component.loading()) {
break;
}
}
};
const loadError = async (status: number, statusText: string, message: string) => {
fixture.detectChanges();
const req = expectCompareRequest();
req.flush({ message }, { status, statusText });
for (let attempt = 0; attempt < 5; attempt += 1) {
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
if (!component.loading()) {
break;
}
}
};
afterEach(() => {
httpMock.verify();
fixture.destroy();
});
describe('DD-008: Container assembly', () => {
it('renders header with version info', fakeAsync(() => {
fixture.detectChanges();
// Respond to diff request
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
it('renders header with version info', async () => {
await loadDiff();
const header = fixture.nativeElement.querySelector('.diff-panel__header');
expect(header).toBeTruthy();
const title = header.querySelector('.header-title');
expect(title.textContent).toContain('Deployment Diff');
}));
});
it('shows summary strip with counts', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
it('shows summary strip with counts', async () => {
await loadDiff();
const summary = fixture.nativeElement.querySelector('.diff-panel__summary');
expect(summary).toBeTruthy();
expect(summary.textContent).toContain('1');
expect(summary.textContent).toContain('added');
expect(summary.textContent).toContain('policy failure');
}));
});
it('integrates side-by-side viewer', fakeAsync(() => {
fixture.detectChanges();
it('integrates side-by-side viewer', async () => {
await loadDiff();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const viewer = fixture.nativeElement.querySelector('app-sbom-side-by-side');
const viewer = fixture.nativeElement.querySelector('.sbom-side-by-side');
expect(viewer).toBeTruthy();
}));
});
it('shows action bar at bottom', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
it('shows action bar at bottom', async () => {
await loadDiff();
const actionBar = fixture.nativeElement.querySelector('app-deploy-action-bar');
expect(actionBar).toBeTruthy();
}));
});
});
describe('Loading state', () => {
@@ -176,92 +224,54 @@ describe('DeployDiffPanelComponent', () => {
const skeleton = fixture.nativeElement.querySelector('.loading-skeleton');
expect(skeleton).toBeTruthy();
// Don't flush HTTP yet - check loading state
httpMock.expectOne('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456');
expectCompareRequest();
});
it('hides loading skeleton after data loads', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
it('hides loading skeleton after data loads', async () => {
await loadDiff();
const skeleton = fixture.nativeElement.querySelector('.loading-skeleton');
expect(skeleton).toBeFalsy();
}));
});
});
describe('Error state', () => {
it('shows error state on API failure', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush({ message: 'Not found' }, { status: 404, statusText: 'Not Found' });
tick();
fixture.detectChanges();
it('shows error state on API failure', async () => {
await loadError(404, 'Not Found', 'Not found');
const errorState = fixture.nativeElement.querySelector('.error-state');
expect(errorState).toBeTruthy();
expect(errorState.textContent).toContain('Failed to load diff');
}));
});
it('shows retry button on error', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
tick();
fixture.detectChanges();
it('shows retry button on error', async () => {
await loadError(500, 'Server Error', 'Error');
const retryBtn = fixture.nativeElement.querySelector('.retry-btn');
expect(retryBtn).toBeTruthy();
expect(retryBtn.textContent).toContain('Retry');
}));
});
});
describe('Action handling', () => {
it('opens override dialog on allow_override click', fakeAsync(() => {
fixture.detectChanges();
it('opens override dialog on allow_override click', async () => {
await loadDiff();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Trigger action
component.onActionClick('allow_override');
fixture.detectChanges();
expect(component.showOverrideDialog()).toBeTrue();
}));
});
it('emits actionTaken on block', fakeAsync(() => {
it('emits actionTaken on block', async () => {
let emittedAction: any = null;
component.actionTaken.subscribe(a => (emittedAction = a));
component.actionTaken.subscribe((action) => {
emittedAction = action;
});
fixture.detectChanges();
await loadDiff();
const diffReq = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
diffReq.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Trigger block action
component.onActionClick('block');
tick();
const blockReq = httpMock.expectOne('/api/v1/deploy/block');
blockReq.flush({
@@ -270,23 +280,16 @@ describe('DeployDiffPanelComponent', () => {
toDigest: 'sha256:def456',
timestamp: '2026-01-25T10:00:00Z',
});
tick();
await fixture.whenStable();
expect(emittedAction).toBeTruthy();
expect(emittedAction.type).toBe('block');
}));
});
});
describe('Expand/collapse', () => {
it('toggles expanded component', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
it('toggles expanded component', async () => {
await loadDiff();
expect(component.expandedComponentId()).toBeUndefined();
@@ -295,29 +298,18 @@ describe('DeployDiffPanelComponent', () => {
component.onExpandToggle('comp-1');
expect(component.expandedComponentId()).toBeUndefined();
}));
});
});
describe('Refresh', () => {
it('clears cache and refetches on refresh', fakeAsync(() => {
fixture.detectChanges();
it('clears cache and refetches on refresh', async () => {
await loadDiff();
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Refresh
component.refresh();
tick();
const req2 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
expect(req2).toBeTruthy();
req2.flush(mockDiffResult);
}));
const req2 = expectCompareRequest();
req2.flush(mockCompareResponse);
await fixture.whenStable();
});
});
});

View File

@@ -456,6 +456,9 @@ export class DeployDiffPanelComponent implements OnInit {
/** Optional to version label */
readonly toLabel = input<string | undefined>();
/** Tenant context for the comparison */
readonly tenantId = input<string>('demo-prod');
/** Current signer identity (for override) */
readonly currentSigner = input<SignerIdentity | undefined>();
@@ -498,7 +501,7 @@ export class DeployDiffPanelComponent implements OnInit {
const from = this.fromDigest();
const to = this.toDigest();
if (from && to) {
this.fetchDiff(from, to);
this.fetchDiff(from, to, this.tenantId());
}
});
}
@@ -508,12 +511,12 @@ export class DeployDiffPanelComponent implements OnInit {
}
/** Fetch diff data */
async fetchDiff(from: string, to: string): Promise<void> {
async fetchDiff(from: string, to: string, tenantId: string): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const result = await this.diffService.fetchDiff({ fromDigest: from, toDigest: to });
const result = await this.diffService.fetchDiff({ fromDigest: from, toDigest: to, tenantId });
this.diffResult.set(result);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to fetch diff');
@@ -526,7 +529,7 @@ export class DeployDiffPanelComponent implements OnInit {
/** Refresh diff */
refresh(): void {
this.diffService.clearCache();
this.fetchDiff(this.fromDigest(), this.toDigest());
this.fetchDiff(this.fromDigest(), this.toDigest(), this.tenantId());
}
/** Handle expand toggle */

View File

@@ -16,6 +16,8 @@ export interface SbomDiffRequest {
readonly fromDigest: string;
/** New version SBOM digest */
readonly toDigest: string;
/** Tenant identifier used for lineage compare */
readonly tenantId?: string;
}
/**

View File

@@ -0,0 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { DeployDiffPage } from './deploy-diff.page';
describe('DeployDiffPage', () => {
let fixture: ComponentFixture<DeployDiffPage>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
beforeEach(async () => {
queryParamMap$ = new BehaviorSubject(convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
}));
await TestBed.configureTestingModule({
imports: [DeployDiffPage],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
queryParamMap: queryParamMap$.asObservable(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(DeployDiffPage);
fixture.detectChanges();
});
it('renders the scoped no-comparison workspace instead of the legacy missing-parameters failure', () => {
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('No Comparison Selected');
expect(text).toContain('Open this workspace from a deployment');
expect(text).toContain('Open Deployments');
expect(text).not.toContain('Missing Parameters');
});
});

View File

@@ -9,6 +9,9 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DeployDiffPanelComponent } from '../components/deploy-diff-panel/deploy-diff-panel.component';
import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
import {
readReleaseInvestigationQueryState,
} from '../../release-investigation/release-investigation-context';
/**
* Deploy diff page component.
@@ -28,7 +31,13 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<a routerLink="/deploy" class="breadcrumb-link">Deployments</a>
<a
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
class="breadcrumb-link"
>
Deployments
</a>
</li>
<li class="breadcrumb-separator" aria-hidden="true">/</li>
<li class="breadcrumb-item breadcrumb-item--current" aria-current="page">
@@ -42,6 +51,7 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
<app-deploy-diff-panel
[fromDigest]="fromDigest()!"
[toDigest]="toDigest()!"
[tenantId]="tenantId()"
[fromLabel]="fromLabel()"
[toLabel]="toLabel()"
[currentSigner]="currentSigner()"
@@ -55,21 +65,34 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<h2>Missing Parameters</h2>
<h2>No Comparison Selected</h2>
<p>
To view a deployment diff, provide both <code>from</code> and <code>to</code> digest parameters.
Open this workspace from a deployment or provide both <code>from</code> and <code>to</code> digest parameters.
</p>
<div class="example-url">
<strong>Example:</strong>
<code>/deploy/diff?from=sha256:abc...&amp;to=sha256:def...</code>
<code>/releases/investigation/deploy-diff?from=sha256:abc...&amp;to=sha256:def...</code>
</div>
<div class="empty-actions">
<a
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
class="back-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Open Deployments
</a>
<a
[routerLink]="['/releases/overview']"
[queryParams]="scopeQueryParams()"
class="secondary-link"
>
Open Releases Overview
</a>
</div>
<a routerLink="/deploy" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back to Deployments
</a>
</div>
}
</div>
@@ -195,6 +218,25 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
background: var(--color-primary-bg);
}
}
.empty-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.secondary-link {
color: var(--color-text-secondary);
font-size: 0.875rem;
text-decoration: none;
&:hover {
color: var(--color-brand-primary);
text-decoration: underline;
}
}
`],
})
export class DeployDiffPage implements OnInit {
@@ -213,6 +255,12 @@ export class DeployDiffPage implements OnInit {
/** To label query param (optional) */
readonly toLabel = signal<string | undefined>(undefined);
/** Tenant context for lineage compare */
readonly tenantId = signal<string>('demo-prod');
/** Scope query params preserved across investigation navigation */
readonly scopeQueryParams = signal<Record<string, string>>({});
/** Current user/signer - in production would come from auth service */
readonly currentSigner = signal<SignerIdentity>({
userId: 'user-123',
@@ -228,10 +276,13 @@ export class DeployDiffPage implements OnInit {
ngOnInit(): void {
// Subscribe to query params
this.route.queryParamMap.subscribe(params => {
this.fromDigest.set(params.get('from'));
this.toDigest.set(params.get('to'));
this.fromLabel.set(params.get('fromLabel') ?? undefined);
this.toLabel.set(params.get('toLabel') ?? undefined);
const state = readReleaseInvestigationQueryState(params);
this.scopeQueryParams.set(state.scopeQueryParams);
this.tenantId.set(state.tenantId);
this.fromDigest.set(state.fromDigest);
this.toDigest.set(state.toDigest);
this.fromLabel.set(state.fromLabel);
this.toLabel.set(state.toLabel);
});
}
@@ -245,15 +296,19 @@ export class DeployDiffPage implements OnInit {
// Could navigate to blocked deployments list
break;
case 'allow_override':
// Could navigate to deployment progress
this.router.navigate(['/deploy', 'progress'], {
queryParams: { digest: this.toDigest() },
this.router.navigate(['/releases/approvals'], {
queryParams: {
...this.scopeQueryParams(),
digest: this.toDigest(),
},
});
break;
case 'schedule_canary':
// Navigate to canary monitoring
this.router.navigate(['/deploy', 'canary'], {
queryParams: { digest: this.toDigest() },
this.router.navigate(['/releases/deployments'], {
queryParams: {
...this.scopeQueryParams(),
digest: this.toDigest(),
},
});
break;
}

View File

@@ -4,38 +4,99 @@
* @description Unit tests for deploy diff service.
*/
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { DeployDiffService } from './deploy-diff.service';
import { SbomDiffResult, ComponentDiff, PolicyHit } from '../models/deploy-diff.models';
import { SbomDiffResult } from '../models/deploy-diff.models';
import { LineageDiffResponse } from '../../lineage/models/lineage.models';
describe('DeployDiffService', () => {
let service: DeployDiffService;
let httpMock: HttpTestingController;
const compareUrl = '/api/sbomservice/api/v1/lineage/compare';
const expectCompareRequest = (fromDigest: string, toDigest: string, tenantId = 'demo-prod') =>
httpMock.expectOne((request) =>
request.method === 'GET'
&& request.url === compareUrl
&& request.params.get('a') === fromDigest
&& request.params.get('b') === toDigest
&& request.params.get('tenant') === tenantId,
);
const mockCompareResponse: LineageDiffResponse = {
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
computedAt: '2026-01-25T10:00:00Z',
componentDiff: {
added: [
{
purl: 'pkg:npm/new-package@1.0.0',
name: 'new-package',
currentVersion: '1.0.0',
changeType: 'added',
},
],
removed: [],
changed: [
{
purl: 'pkg:npm/axios@1.0.0',
name: 'axios',
previousVersion: '0.21.0',
currentVersion: '1.0.0',
changeType: 'version-changed',
},
],
sourceTotal: 50,
targetTotal: 51,
},
vexDeltas: [
{
cve: 'CVE-2026-1234',
currentStatus: 'affected',
},
],
};
const mockDiffResult: SbomDiffResult = {
added: [
{
id: 'comp-1',
id: 'added-0-new-package',
changeType: 'added',
name: 'new-package',
purl: 'pkg:npm/new-package@1.0.0',
fromVersion: null,
toVersion: '1.0.0',
licenseChanged: false,
},
],
removed: [],
changed: [],
unchanged: 50,
changed: [
{
id: 'changed-0-axios',
changeType: 'changed',
name: 'axios',
purl: 'pkg:npm/axios@1.0.0',
fromVersion: '0.21.0',
toVersion: '1.0.0',
licenseChanged: false,
versionChange: {
type: 'major',
description: 'Major version change 0.21.0 -> 1.0.0',
breaking: true,
},
},
],
unchanged: 0,
policyHits: [
{
id: 'hit-1',
gate: 'version-check',
id: 'vex-0',
gate: 'vex-delta',
severity: 'high',
result: 'fail',
message: 'Version check failed',
componentIds: ['comp-1'],
message: 'CVE-2026-1234 is affected in the target artifact.',
componentIds: ['added-0-new-package'],
},
],
policyResult: {
@@ -43,7 +104,7 @@ describe('DeployDiffService', () => {
overrideAvailable: true,
failCount: 1,
warnCount: 0,
passCount: 5,
passCount: 0,
},
metadata: {
fromDigest: 'sha256:abc123',
@@ -72,108 +133,121 @@ describe('DeployDiffService', () => {
});
describe('DD-002: fetchDiff', () => {
it('calls diff API with correct params', fakeAsync(async () => {
it('calls diff API with correct params', async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
const req = expectCompareRequest('sha256:abc123', 'sha256:def456');
expect(req.request.method).toBe('GET');
req.flush(mockDiffResult);
req.flush(mockCompareResponse);
const result = await promise;
expect(result).toEqual(mockDiffResult);
}));
expect(result).toMatchObject({
added: [expect.objectContaining(mockDiffResult.added[0])],
removed: [],
changed: [expect.objectContaining(mockDiffResult.changed[0])],
policyHits: [expect.objectContaining({
id: 'vex-0',
gate: 'vex-delta',
severity: 'high',
result: 'fail',
message: 'CVE-2026-1234 is affected in the target artifact.',
})],
policyResult: mockDiffResult.policyResult,
metadata: mockDiffResult.metadata,
});
});
it('maps response to typed model', fakeAsync(async () => {
it('maps response to typed model', async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
const req = expectCompareRequest('sha256:abc123', 'sha256:def456');
req.flush(mockCompareResponse);
const result = await promise;
expect(result.added.length).toBe(1);
expect(result.added[0].name).toBe('new-package');
expect(result.policyHits[0].gate).toBe('version-check');
}));
expect(result.policyHits[0].gate).toBe('vex-delta');
});
it('caches result for repeated comparisons', fakeAsync(async () => {
// First call
it('caches result for repeated comparisons', async () => {
const promise1 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
const req1 = expectCompareRequest('sha256:abc123', 'sha256:def456');
req1.flush(mockCompareResponse);
await promise1;
// Second call should use cache
const promise2 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
// No HTTP request should be made
httpMock.expectNone('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456');
httpMock.expectNone((request) => request.url === compareUrl);
const result2 = await promise2;
expect(result2).toEqual(mockDiffResult);
}));
expect(result2).toMatchObject({
added: [expect.objectContaining(mockDiffResult.added[0])],
removed: [],
changed: [expect.objectContaining(mockDiffResult.changed[0])],
policyHits: [expect.objectContaining({
id: 'vex-0',
gate: 'vex-delta',
severity: 'high',
result: 'fail',
message: 'CVE-2026-1234 is affected in the target artifact.',
})],
policyResult: mockDiffResult.policyResult,
metadata: mockDiffResult.metadata,
});
});
it('handles invalid digests with error', fakeAsync(async () => {
it('handles invalid digests with error', async () => {
const promise = service.fetchDiff({
fromDigest: 'invalid',
toDigest: 'also-invalid',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=invalid&to=also-invalid'
);
const req = expectCompareRequest('invalid', 'also-invalid');
req.flush({ message: 'Invalid digest format' }, { status: 400, statusText: 'Bad Request' });
try {
await promise;
fail('Should have thrown');
throw new Error('Should have thrown');
} catch (err: any) {
expect(err.message).toContain('Invalid');
}
}));
});
it('handles 404 with appropriate message', fakeAsync(async () => {
it('handles 404 with appropriate message', async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:notfound',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:notfound&to=sha256:def456'
);
const req = expectCompareRequest('sha256:notfound', 'sha256:def456');
req.flush(null, { status: 404, statusText: 'Not Found' });
try {
await promise;
fail('Should have thrown');
throw new Error('Should have thrown');
} catch (err: any) {
expect(err.message).toContain('not found');
expect(err.message).toContain('No lineage comparison data is available');
}
}));
});
});
describe('submitOverride', () => {
it('calls override API', fakeAsync(async () => {
it('calls override API', async () => {
const promise = service.submitOverride(
'sha256:abc123',
'sha256:def456',
@@ -203,11 +277,11 @@ describe('DeployDiffService', () => {
const result = await promise;
expect(result.type).toBe('allow_override');
}));
});
});
describe('blockDeployment', () => {
it('calls block API', fakeAsync(async () => {
it('calls block API', async () => {
const promise = service.blockDeployment('sha256:abc123', 'sha256:def456');
const req = httpMock.expectOne('/api/v1/deploy/block');
@@ -222,11 +296,11 @@ describe('DeployDiffService', () => {
const result = await promise;
expect(result.type).toBe('block');
}));
});
});
describe('scheduleCanary', () => {
it('calls canary API', fakeAsync(async () => {
it('calls canary API', async () => {
const promise = service.scheduleCanary(
'sha256:abc123',
'sha256:def456',
@@ -254,7 +328,7 @@ describe('DeployDiffService', () => {
const result = await promise;
expect(result.type).toBe('schedule_canary');
}));
});
});
describe('filterComponents', () => {
@@ -266,9 +340,8 @@ describe('DeployDiffService', () => {
it('filters by policy result', () => {
const result = service.filterComponents(mockDiffResult, 'all', 'failing', '');
expect(result.every(c => mockDiffResult.policyHits.some(
h => h.result === 'fail' && h.componentIds?.includes(c.id)
))).toBeTrue();
expect(result.length).toBe(1);
expect(result[0].id).toBe('added-0-new-package');
});
it('filters by search query', () => {
@@ -280,9 +353,9 @@ describe('DeployDiffService', () => {
describe('getPolicyHitsForComponent', () => {
it('returns hits for specific component', () => {
const hits = service.getPolicyHitsForComponent(mockDiffResult, 'comp-1');
const hits = service.getPolicyHitsForComponent(mockDiffResult, 'added-0-new-package');
expect(hits.length).toBe(1);
expect(hits[0].id).toBe('hit-1');
expect(hits[0].id).toBe('vex-0');
});
it('returns global hits (no componentIds)', () => {
@@ -306,17 +379,14 @@ describe('DeployDiffService', () => {
});
describe('clearCache', () => {
it('clears cached results', fakeAsync(async () => {
// First call - caches result
it('clears cached results', async () => {
const promise1 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
const req1 = expectCompareRequest('sha256:abc123', 'sha256:def456');
req1.flush(mockCompareResponse);
await promise1;
// Clear cache
@@ -328,16 +398,14 @@ describe('DeployDiffService', () => {
toDigest: 'sha256:def456',
});
const req2 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req2.flush(mockDiffResult);
const req2 = expectCompareRequest('sha256:abc123', 'sha256:def456');
req2.flush(mockCompareResponse);
await promise2;
}));
});
});
describe('loading state', () => {
it('sets loading true during fetch', fakeAsync(() => {
it('sets loading true during fetch', async () => {
expect(service.loading()).toBeFalse();
const promise = service.fetchDiff({
@@ -347,13 +415,11 @@ describe('DeployDiffService', () => {
expect(service.loading()).toBeTrue();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
const req = expectCompareRequest('sha256:abc123', 'sha256:def456');
req.flush(mockCompareResponse);
await promise;
expect(service.loading()).toBeFalse();
}));
});
});
});

View File

@@ -16,6 +16,13 @@ import {
ComponentDiff,
PolicyHit,
} from '../models/deploy-diff.models';
import { LineageGraphService } from '../../lineage/services/lineage-graph.service';
import {
ComponentChange as LineageComponentChange,
LineageDiffResponse,
ReachabilityDelta,
VexDelta,
} from '../../lineage/models/lineage.models';
/**
* Cache entry for diff results.
@@ -40,9 +47,11 @@ interface DiffCacheEntry {
})
export class DeployDiffService {
private readonly http = inject(HttpClient);
private readonly lineageGraphService = inject(LineageGraphService);
/** API base URL */
private readonly apiBase = '/api/v1';
private readonly defaultTenantId = 'demo-prod';
/** Cache TTL in milliseconds (5 minutes) */
private readonly cacheTtlMs = 5 * 60 * 1000;
@@ -69,7 +78,8 @@ export class DeployDiffService {
* @returns Promise resolving to diff result
*/
async fetchDiff(request: SbomDiffRequest): Promise<SbomDiffResult> {
const cacheKey = this.getCacheKey(request.fromDigest, request.toDigest);
const tenantId = (request.tenantId ?? this.defaultTenantId).trim() || this.defaultTenantId;
const cacheKey = this.getCacheKey(request.fromDigest, request.toDigest, tenantId);
// Check cache first
const cached = this.getFromCache(cacheKey);
@@ -82,17 +92,14 @@ export class DeployDiffService {
this.error.set(null);
try {
const result = await firstValueFrom(
this.http.get<SbomDiffResult>(
`${this.apiBase}/sbom/diff`,
{
params: {
from: request.fromDigest,
to: request.toDigest,
},
}
)
const lineageDiff = await firstValueFrom(
this.lineageGraphService.compare(
request.fromDigest,
request.toDigest,
tenantId,
),
);
const result = this.mapLineageDiffToDeployDiff(lineageDiff);
// Cache the result
this.addToCache(cacheKey, result);
@@ -259,8 +266,8 @@ export class DeployDiffService {
/**
* Generates cache key from digests.
*/
private getCacheKey(from: string, to: string): string {
return `${from}:${to}`;
private getCacheKey(from: string, to: string, tenantId: string = this.defaultTenantId): string {
return `${tenantId}:${from}:${to}`;
}
/**
@@ -306,12 +313,18 @@ export class DeployDiffService {
if (err.error?.message) {
return err.error.message;
}
if (typeof err.error?.error === 'string') {
return err.error.error;
}
if (err.status === 404) {
return 'SBOM diff endpoint not found. Please verify the digests are valid.';
return 'No lineage comparison data is available for the selected release artifacts yet.';
}
if (err.status === 400) {
return 'Invalid request. Please check the digest format.';
}
if (err.status === 401) {
return 'Authentication is required to load this comparison.';
}
return `Server error: ${err.statusText || 'Unknown error'}`;
}
if (err instanceof Error) {
@@ -319,4 +332,174 @@ export class DeployDiffService {
}
return 'An unexpected error occurred';
}
private mapLineageDiffToDeployDiff(lineageDiff: LineageDiffResponse): SbomDiffResult {
const added = lineageDiff.componentDiff?.added.map((change, index) =>
this.mapLineageChange(change, 'added', index),
) ?? [];
const removed = lineageDiff.componentDiff?.removed.map((change, index) =>
this.mapLineageChange(change, 'removed', index),
) ?? [];
const changed = lineageDiff.componentDiff?.changed.map((change, index) =>
this.mapLineageChange(change, 'changed', index),
) ?? [];
const policyHits = this.mapPolicyHits(lineageDiff);
const failCount = policyHits.filter((hit) => hit.result === 'fail').length;
const warnCount = policyHits.filter((hit) => hit.result === 'warn').length;
return {
added,
removed,
changed,
unchanged: 0,
policyHits,
policyResult: {
allowed: failCount === 0,
overrideAvailable: failCount > 0,
failCount,
warnCount,
passCount: failCount === 0 && warnCount === 0 ? 1 : 0,
},
metadata: {
fromDigest: lineageDiff.fromDigest,
toDigest: lineageDiff.toDigest,
computedAt: lineageDiff.computedAt,
fromTotalComponents: lineageDiff.componentDiff?.sourceTotal ?? 0,
toTotalComponents: lineageDiff.componentDiff?.targetTotal ?? 0,
},
};
}
private mapLineageChange(
change: LineageComponentChange,
changeType: ComponentDiff['changeType'],
index: number,
): ComponentDiff {
const fromVersion = changeType === 'added' ? null : change.previousVersion ?? null;
const toVersion = changeType === 'removed' ? null : change.currentVersion ?? null;
return {
id: `${changeType}-${index}-${change.name}`,
changeType,
name: change.name,
purl: change.purl,
fromVersion,
toVersion,
fromLicense: change.previousLicense,
toLicense: change.currentLicense,
licenseChanged: (change.previousLicense ?? null) !== (change.currentLicense ?? null),
versionChange: changeType === 'changed'
? this.classifyVersionChange(change.previousVersion, change.currentVersion)
: undefined,
};
}
private classifyVersionChange(
fromVersion?: string,
toVersion?: string,
): ComponentDiff['versionChange'] {
if (!fromVersion || !toVersion || fromVersion === toVersion) {
return {
type: 'unknown',
description: 'Version lineage unavailable',
breaking: false,
};
}
const fromParts = fromVersion.split('.').map((part) => Number.parseInt(part, 10));
const toParts = toVersion.split('.').map((part) => Number.parseInt(part, 10));
if (Number.isFinite(fromParts[0]) && Number.isFinite(toParts[0]) && toParts[0] > fromParts[0]) {
return {
type: 'major',
description: `Major version change ${fromVersion} -> ${toVersion}`,
breaking: true,
};
}
if (Number.isFinite(fromParts[1]) && Number.isFinite(toParts[1]) && toParts[1] > fromParts[1]) {
return {
type: 'minor',
description: `Minor version change ${fromVersion} -> ${toVersion}`,
breaking: false,
};
}
if (Number.isFinite(fromParts[2]) && Number.isFinite(toParts[2]) && toParts[2] > fromParts[2]) {
return {
type: 'patch',
description: `Patch version change ${fromVersion} -> ${toVersion}`,
breaking: false,
};
}
return {
type: 'unknown',
description: `Version change ${fromVersion} -> ${toVersion}`,
breaking: false,
};
}
private mapPolicyHits(lineageDiff: LineageDiffResponse): PolicyHit[] {
return [
...this.mapVexPolicyHits(lineageDiff.vexDeltas),
...this.mapReachabilityPolicyHits(lineageDiff.reachabilityDeltas),
];
}
private mapVexPolicyHits(vexDeltas: VexDelta[] | undefined): PolicyHit[] {
return (vexDeltas ?? [])
.map((delta, index): PolicyHit | null => {
if (delta.currentStatus === 'affected') {
return {
id: `vex-${index}`,
gate: 'vex-delta',
severity: 'high',
result: 'fail',
message: `${delta.cve} is affected in the target artifact.`,
};
}
if (delta.currentStatus === 'under_investigation' || delta.currentStatus === 'unknown') {
return {
id: `vex-${index}`,
gate: 'vex-delta',
severity: 'medium',
result: 'warn',
message: `${delta.cve} requires investigation before rollout.`,
};
}
return null;
})
.filter((hit): hit is PolicyHit => hit !== null);
}
private mapReachabilityPolicyHits(reachabilityDeltas: ReachabilityDelta[] | undefined): PolicyHit[] {
return (reachabilityDeltas ?? [])
.map((delta, index): PolicyHit | null => {
if (delta.currentReachable) {
return {
id: `reachability-${index}`,
gate: 'reachability-delta',
severity: 'critical',
result: 'fail',
message: `${delta.cve} is reachable in the target artifact.`,
};
}
if (delta.previousReachable) {
return {
id: `reachability-${index}`,
gate: 'reachability-delta',
severity: 'low',
result: 'warn',
message: `${delta.cve} changed reachability state and should be reviewed.`,
};
}
return null;
})
.filter((hit): hit is PolicyHit => hit !== null);
}
}

View File

@@ -0,0 +1,44 @@
import { ParamMap, Params } from '@angular/router';
const DEFAULT_TENANT_ID = 'demo-prod';
const SCOPE_QUERY_KEYS = [
'tenant',
'regions',
'region',
'environments',
'environment',
'timeWindow',
] as const;
export interface ReleaseInvestigationQueryState {
tenantId: string;
scopeQueryParams: Params;
fromDigest: string | null;
toDigest: string | null;
fromLabel?: string;
toLabel?: string;
}
export function readReleaseInvestigationQueryState(queryParams: ParamMap): ReleaseInvestigationQueryState {
const scopeQueryParams: Params = {};
for (const key of SCOPE_QUERY_KEYS) {
const value = queryParams.get(key);
if (value) {
scopeQueryParams[key] = value;
}
}
return {
tenantId: (queryParams.get('tenant') ?? DEFAULT_TENANT_ID).trim(),
scopeQueryParams,
fromDigest: queryParams.get('from'),
toDigest: queryParams.get('to'),
fromLabel: queryParams.get('fromLabel') ?? undefined,
toLabel: queryParams.get('toLabel') ?? undefined,
};
}
export function hasReleaseInvestigationDigests(state: ReleaseInvestigationQueryState): boolean {
return !!state.fromDigest && !!state.toDigest;
}

View File

@@ -5,6 +5,10 @@
"src/test-setup.ts",
"src/app/core/api/first-signal.client.spec.ts",
"src/app/core/console/console-status.service.spec.ts",
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"