feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction
- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST. - Added command-line interface with options for JSON output and help. - Included functionality to analyze project structure, detect functions, and build call graphs. - Created a package.json file for dependency management. feat: introduce stella-callgraph-python for Python call graph extraction - Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis. - Implemented command-line interface with options for JSON output and verbose logging. - Added framework detection to identify popular web frameworks and their entry points. - Created an AST analyzer to traverse Python code and extract function definitions and calls. - Included requirements.txt for project dependencies. chore: add framework detection for Python projects - Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns. - Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
@@ -85,38 +85,38 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="statusFilter()"
|
||||
(change)="setStatusFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option *ngFor="let st of allStatuses" [value]="st">
|
||||
{{ statusLabels[st] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Reachability</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="reachabilityFilter()"
|
||||
(change)="setReachabilityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option *ngFor="let reach of allReachability" [value]="reach">
|
||||
{{ reachabilityLabels[reach] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="showExceptedOnly()"
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="statusFilter()"
|
||||
(change)="setStatusFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option *ngFor="let st of allStatuses" [value]="st">
|
||||
{{ statusLabels[st] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Reachability</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="reachabilityFilter()"
|
||||
(change)="setReachabilityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option *ngFor="let reach of allReachability" [value]="reach">
|
||||
{{ reachabilityLabels[reach] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="showExceptedOnly()"
|
||||
(change)="toggleExceptedOnly()"
|
||||
/>
|
||||
<span>Show with exceptions only</span>
|
||||
@@ -147,14 +147,14 @@
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
|
||||
CVSS {{ getSortIcon('cvssScore') }}
|
||||
</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
|
||||
Status {{ getSortIcon('status') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Reachability</th>
|
||||
<th class="vuln-table__th">Components</th>
|
||||
<th class="vuln-table__th">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
|
||||
Status {{ getSortIcon('status') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Reachability</th>
|
||||
<th class="vuln-table__th">Components</th>
|
||||
<th class="vuln-table__th">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
|
||||
@@ -188,24 +188,34 @@
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span
|
||||
class="chip chip--small"
|
||||
[ngClass]="getReachabilityClass(vuln)"
|
||||
[title]="getReachabilityTooltip(vuln)"
|
||||
>
|
||||
{{ getReachabilityLabel(vuln) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td vuln-table__td--actions">
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span
|
||||
class="chip chip--small"
|
||||
[ngClass]="getReachabilityClass(vuln)"
|
||||
[title]="getReachabilityTooltip(vuln)"
|
||||
>
|
||||
{{ getReachabilityLabel(vuln) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td vuln-table__td--actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--small btn--witness"
|
||||
(click)="openWitnessModal(vuln); $event.stopPropagation()"
|
||||
*ngIf="hasWitnessData(vuln)"
|
||||
title="Show reachability witness"
|
||||
[disabled]="witnessLoading()"
|
||||
>
|
||||
<span class="btn__icon">🔍</span> Witness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--small btn--action"
|
||||
@@ -254,27 +264,48 @@
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Status</span>
|
||||
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Reachability</span>
|
||||
<span class="chip" [ngClass]="getReachabilityClass(vuln)" [title]="getReachabilityTooltip(vuln)">
|
||||
{{ getReachabilityLabel(vuln) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary btn--small"
|
||||
(click)="openWhyDrawer()"
|
||||
[disabled]="!vuln.affectedComponents.length"
|
||||
>
|
||||
Why?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Status</span>
|
||||
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Reachability</span>
|
||||
<app-confidence-tier-badge
|
||||
*ngIf="hasWitnessData(vuln)"
|
||||
[tier]="mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore)"
|
||||
[score]="vuln.reachabilityScore ?? 0"
|
||||
[showScore]="true"
|
||||
></app-confidence-tier-badge>
|
||||
<span
|
||||
*ngIf="!hasWitnessData(vuln)"
|
||||
class="chip"
|
||||
[ngClass]="getReachabilityClass(vuln)"
|
||||
[title]="getReachabilityTooltip(vuln)"
|
||||
>
|
||||
{{ getReachabilityLabel(vuln) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary btn--small"
|
||||
(click)="openWitnessModal(vuln)"
|
||||
*ngIf="hasWitnessData(vuln)"
|
||||
[disabled]="witnessLoading()"
|
||||
>
|
||||
Show Witness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary btn--small"
|
||||
(click)="openWhyDrawer()"
|
||||
[disabled]="!vuln.affectedComponents.length"
|
||||
*ngIf="!hasWitnessData(vuln)"
|
||||
>
|
||||
Why?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Badge -->
|
||||
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
|
||||
@@ -337,30 +368,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-reachability-why-drawer
|
||||
[open]="showWhyDrawer()"
|
||||
[status]="(vuln.reachabilityStatus ?? 'unknown')"
|
||||
[confidence]="vuln.reachabilityScore ?? null"
|
||||
[component]="vuln.affectedComponents[0]?.purl ?? null"
|
||||
[assetId]="vuln.affectedComponents[0]?.assetIds?.[0] ?? null"
|
||||
(close)="closeWhyDrawer()"
|
||||
></app-reachability-why-drawer>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-reachability-why-drawer
|
||||
[open]="showWhyDrawer()"
|
||||
[status]="(vuln.reachabilityStatus ?? 'unknown')"
|
||||
[confidence]="vuln.reachabilityScore ?? null"
|
||||
[component]="vuln.affectedComponents[0]?.purl ?? null"
|
||||
[assetId]="vuln.affectedComponents[0]?.assetIds?.[0] ?? null"
|
||||
(close)="closeWhyDrawer()"
|
||||
></app-reachability-why-drawer>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
(cancelled)="cancelExceptionDraft()"
|
||||
(openFullWizard)="openFullWizard()"
|
||||
></app-exception-draft-inline>
|
||||
@@ -378,4 +409,11 @@
|
||||
></app-exception-explain>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Witness Modal -->
|
||||
<app-witness-modal
|
||||
[witness]="witnessModalData()"
|
||||
[isOpen]="showWitnessModal()"
|
||||
(close)="closeWitnessModal()"
|
||||
></app-witness-modal>
|
||||
</div>
|
||||
|
||||
@@ -413,31 +413,31 @@
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status--excepted {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
// Reachability chips
|
||||
.reachability--reachable {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.reachability--unreachable {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.reachability--unknown {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.status--excepted {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
// Reachability chips
|
||||
.reachability--reachable {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.reachability--unreachable {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.reachability--unknown {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
@@ -735,6 +735,22 @@
|
||||
background: #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
&--witness {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fde68a;
|
||||
}
|
||||
|
||||
.btn__icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Explain Modal
|
||||
|
||||
@@ -119,6 +119,11 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
// Why drawer state
|
||||
readonly showWhyDrawer = signal(false);
|
||||
|
||||
// Witness modal state
|
||||
readonly showWitnessModal = signal(false);
|
||||
readonly witnessModalData = signal<ReachabilityWitness | null>(null);
|
||||
readonly witnessLoading = signal(false);
|
||||
|
||||
// Constants for template
|
||||
readonly severityLabels = SEVERITY_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
@@ -397,6 +402,75 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
this.showWhyDrawer.set(false);
|
||||
}
|
||||
|
||||
// Witness modal methods
|
||||
async openWitnessModal(vuln: Vulnerability): Promise<void> {
|
||||
this.witnessLoading.set(true);
|
||||
try {
|
||||
// Map reachability status to confidence tier
|
||||
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
|
||||
|
||||
// Get or create witness data
|
||||
const witness = await firstValueFrom(
|
||||
this.witnessClient.getWitnessForVulnerability(vuln.vulnId)
|
||||
);
|
||||
|
||||
if (witness) {
|
||||
this.witnessModalData.set(witness);
|
||||
this.showWitnessModal.set(true);
|
||||
} else {
|
||||
// Create a placeholder witness if none exists
|
||||
const placeholderWitness: ReachabilityWitness = {
|
||||
witnessId: `witness-${vuln.vulnId}`,
|
||||
scanId: 'scan-current',
|
||||
tenantId: 'tenant-default',
|
||||
vulnId: vuln.vulnId,
|
||||
cveId: vuln.cveId,
|
||||
packageName: vuln.affectedComponents[0]?.name ?? 'Unknown',
|
||||
packageVersion: vuln.affectedComponents[0]?.version,
|
||||
purl: vuln.affectedComponents[0]?.purl,
|
||||
confidenceTier: tier,
|
||||
confidenceScore: vuln.reachabilityScore ?? 0,
|
||||
isReachable: vuln.reachabilityStatus === 'reachable',
|
||||
callPath: [],
|
||||
gates: [],
|
||||
evidence: {
|
||||
callGraphHash: undefined,
|
||||
surfaceHash: undefined,
|
||||
sbomDigest: undefined,
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
this.witnessModalData.set(placeholderWitness);
|
||||
this.showWitnessModal.set(true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage(this.toErrorMessage(error), 'error');
|
||||
} finally {
|
||||
this.witnessLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
closeWitnessModal(): void {
|
||||
this.showWitnessModal.set(false);
|
||||
this.witnessModalData.set(null);
|
||||
}
|
||||
|
||||
mapReachabilityToTier(status?: string, score?: number): ConfidenceTier {
|
||||
if (!status || status === 'unknown') return 'unknown';
|
||||
if (status === 'unreachable') return 'unreachable';
|
||||
if (status === 'reachable') {
|
||||
if (score !== undefined && score >= 0.9) return 'confirmed';
|
||||
if (score !== undefined && score >= 0.7) return 'likely';
|
||||
return 'present';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
hasWitnessData(vuln: Vulnerability): boolean {
|
||||
// Show witness button if reachability data exists
|
||||
return vuln.reachabilityStatus !== undefined && vuln.reachabilityStatus !== null;
|
||||
}
|
||||
|
||||
getReachabilityClass(vuln: Vulnerability): string {
|
||||
const status = vuln.reachabilityStatus ?? 'unknown';
|
||||
return `reachability--${status}`;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Witness Modal Component Tests.
|
||||
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (TEST-001)
|
||||
*
|
||||
* Unit tests for the witness modal component.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
import { WitnessModalComponent } from './witness-modal.component';
|
||||
import {
|
||||
ReachabilityWitness,
|
||||
ConfidenceTier,
|
||||
WitnessVerificationResult,
|
||||
} from '../../core/api/witness.models';
|
||||
import { WitnessMockClient } from '../../core/api/witness.client';
|
||||
|
||||
describe('WitnessModalComponent', () => {
|
||||
let component: WitnessModalComponent;
|
||||
let fixture: ComponentFixture<WitnessModalComponent>;
|
||||
let mockWitnessClient: jest.Mocked<WitnessMockClient>;
|
||||
|
||||
const mockWitness: ReachabilityWitness = {
|
||||
witnessId: 'witness-001',
|
||||
scanId: 'scan-001',
|
||||
tenantId: 'tenant-001',
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'org.example.vulnerable-lib',
|
||||
packageVersion: '1.2.3',
|
||||
purl: 'pkg:maven/org.example/vulnerable-lib@1.2.3',
|
||||
confidenceTier: 'confirmed',
|
||||
confidenceScore: 0.95,
|
||||
isReachable: true,
|
||||
callPath: [
|
||||
{ nodeId: 'node-1', symbol: 'UserController.getUser', file: 'UserController.java', line: 42 },
|
||||
{ nodeId: 'node-2', symbol: 'UserService.findUser', file: 'UserService.java', line: 88 },
|
||||
{ nodeId: 'node-3', symbol: 'JsonParser.parse', file: 'JsonParser.java', line: 156 },
|
||||
],
|
||||
entrypoint: {
|
||||
nodeId: 'entry-1',
|
||||
symbol: 'UserController.getUser',
|
||||
file: 'UserController.java',
|
||||
line: 42,
|
||||
httpRoute: '/api/users/{id}',
|
||||
httpMethod: 'GET',
|
||||
},
|
||||
sink: {
|
||||
nodeId: 'sink-1',
|
||||
symbol: 'JsonParser.parse',
|
||||
file: 'JsonParser.java',
|
||||
line: 156,
|
||||
package: 'org.example.vulnerable-lib',
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
gateType: 'auth',
|
||||
symbol: 'AuthFilter.doFilter',
|
||||
confidence: 0.95,
|
||||
description: '@RequiresAuth annotation',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
callGraphHash: 'blake3:a1b2c3d4e5f6',
|
||||
surfaceHash: 'sha256:9f8e7d6c5b4a',
|
||||
},
|
||||
signature: {
|
||||
keyId: 'attestor-stellaops-ed25519',
|
||||
algorithm: 'ed25519',
|
||||
signatureValue: 'base64-signature-value',
|
||||
signedAt: '2025-12-18T10:30:00Z',
|
||||
},
|
||||
observedAt: '2025-12-18T10:30:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWitnessClient = {
|
||||
verifySignature: jest.fn(),
|
||||
getWitnessById: jest.fn(),
|
||||
getWitnessForVulnerability: jest.fn(),
|
||||
listWitnessesByScan: jest.fn(),
|
||||
exportWitness: jest.fn(),
|
||||
} as unknown as jest.Mocked<WitnessMockClient>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WitnessModalComponent],
|
||||
providers: [{ provide: WitnessMockClient, useValue: mockWitnessClient }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WitnessModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('when witness is provided', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display CVE ID', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain('CVE-2024-12345');
|
||||
});
|
||||
|
||||
it('should display package name', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain('org.example.vulnerable-lib');
|
||||
});
|
||||
|
||||
it('should display confidence tier', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent.toLowerCase()).toContain('confirmed');
|
||||
});
|
||||
|
||||
it('should show path visualization for reachable vulns', () => {
|
||||
expect(component.pathData()).toBeDefined();
|
||||
expect(component.pathData()?.steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when witness is unreachable', () => {
|
||||
beforeEach(() => {
|
||||
const unreachableWitness: ReachabilityWitness = {
|
||||
...mockWitness,
|
||||
isReachable: false,
|
||||
confidenceTier: 'unreachable',
|
||||
callPath: [],
|
||||
entrypoint: undefined,
|
||||
sink: undefined,
|
||||
};
|
||||
fixture.componentRef.setInput('witness', unreachableWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show not reachable message', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain('No call path found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature verification', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show signature status when available', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain('attestor-stellaops-ed25519');
|
||||
});
|
||||
|
||||
it('should verify signature on button click', async () => {
|
||||
const mockResult: WitnessVerificationResult = {
|
||||
verified: true,
|
||||
keyId: 'attestor-stellaops-ed25519',
|
||||
algorithm: 'ed25519',
|
||||
message: 'Signature valid',
|
||||
};
|
||||
mockWitnessClient.verifySignature.mockReturnValue(Promise.resolve(mockResult));
|
||||
|
||||
await component.verifySignature();
|
||||
|
||||
expect(mockWitnessClient.verifySignature).toHaveBeenCalledWith(mockWitness);
|
||||
expect(component.verificationResult()).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle verification failure', async () => {
|
||||
const failedResult: WitnessVerificationResult = {
|
||||
verified: false,
|
||||
keyId: 'attestor-stellaops-ed25519',
|
||||
algorithm: 'ed25519',
|
||||
message: 'Invalid signature',
|
||||
error: 'Signature mismatch',
|
||||
};
|
||||
mockWitnessClient.verifySignature.mockReturnValue(Promise.resolve(failedResult));
|
||||
|
||||
await component.verifySignature();
|
||||
|
||||
expect(component.verificationResult()?.verified).toBe(false);
|
||||
expect(component.verificationResult()?.error).toBe('Signature mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('download functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should generate JSON download', () => {
|
||||
// Mock URL.createObjectURL and document.createElement
|
||||
const mockUrl = 'blob:mock-url';
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue(mockUrl);
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
const mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: jest.fn(),
|
||||
};
|
||||
jest.spyOn(document, 'createElement').mockReturnValue(mockAnchor as unknown as HTMLAnchorElement);
|
||||
|
||||
component.downloadJson();
|
||||
|
||||
expect(mockAnchor.download).toContain('witness-');
|
||||
expect(mockAnchor.download).toContain('.json');
|
||||
expect(mockAnchor.click).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should copy witness ID to clipboard', async () => {
|
||||
const mockClipboard = {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
Object.assign(navigator, { clipboard: mockClipboard });
|
||||
|
||||
await component.copyWitnessId();
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith('witness-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal behavior', () => {
|
||||
it('should emit close event on backdrop click', () => {
|
||||
const closeSpy = jest.fn();
|
||||
component.close.subscribe(closeSpy);
|
||||
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const backdrop = fixture.nativeElement.querySelector('.witness-modal-backdrop');
|
||||
backdrop?.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not emit close when clicking modal content', () => {
|
||||
const closeSpy = jest.fn();
|
||||
component.close.subscribe(closeSpy);
|
||||
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const modal = fixture.nativeElement.querySelector('.witness-modal');
|
||||
modal?.click();
|
||||
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathData computed', () => {
|
||||
it('should transform witness call path to visualization data', () => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pathData = component.pathData();
|
||||
|
||||
expect(pathData).toBeDefined();
|
||||
expect(pathData?.entrypoint).toBeDefined();
|
||||
expect(pathData?.entrypoint?.symbol).toBe('UserController.getUser');
|
||||
expect(pathData?.sink?.symbol).toBe('JsonParser.parse');
|
||||
expect(pathData?.steps.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should include gates in path data', () => {
|
||||
fixture.componentRef.setInput('witness', mockWitness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pathData = component.pathData();
|
||||
|
||||
expect(pathData?.gates).toBeDefined();
|
||||
expect(pathData?.gates?.length).toBe(1);
|
||||
expect(pathData?.gates?.[0].gateType).toBe('auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidence tier badge', () => {
|
||||
const tiers: ConfidenceTier[] = ['confirmed', 'likely', 'present', 'unreachable', 'unknown'];
|
||||
|
||||
tiers.forEach((tier) => {
|
||||
it(`should display ${tier} tier correctly`, () => {
|
||||
const witness: ReachabilityWitness = {
|
||||
...mockWitness,
|
||||
confidenceTier: tier,
|
||||
};
|
||||
fixture.componentRef.setInput('witness', witness);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badgeElement = fixture.nativeElement.querySelector('app-confidence-tier-badge');
|
||||
expect(badgeElement).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user