Repair release investigation workspace contracts
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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...&to=sha256:def...</code>
|
||||
<code>/releases/investigation/deploy-diff?from=sha256:abc...&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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user