sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -287,6 +287,31 @@ const namespaceCompletions: ReadonlyArray<Omit<Monaco.languages.CompletionItem,
{ label: 'secret.match.count', kind: 1, insertText: 'secret.match.count(${1:ruleId})', insertTextRules: 4, documentation: 'Count secret findings.' },
{ label: 'secret.bundle.version', kind: 1, insertText: 'secret.bundle.version("${1:version}")', insertTextRules: 4, documentation: 'Check secret rule bundle version.' },
{ label: 'secret.mask.applied', kind: 5, insertText: 'secret.mask.applied', documentation: 'Whether masking succeeded.' },
// Evidence-Weighted Score fields (EWS)
{ label: 'score', kind: 5, insertText: 'score', documentation: 'Evidence-weighted score object. Access via score.value, score.bucket, etc.' },
{ label: 'score.value', kind: 5, insertText: 'score.value', documentation: 'Numeric score value (0-100). Use in comparisons like: score.value >= 80' },
{ label: 'score.bucket', kind: 5, insertText: 'score.bucket', documentation: 'Score bucket: ActNow, ScheduleNext, Investigate, or Watchlist.' },
{ label: 'score.is_act_now', kind: 5, insertText: 'score.is_act_now', documentation: 'True if bucket is ActNow (highest priority).' },
{ label: 'score.is_schedule_next', kind: 5, insertText: 'score.is_schedule_next', documentation: 'True if bucket is ScheduleNext.' },
{ label: 'score.is_investigate', kind: 5, insertText: 'score.is_investigate', documentation: 'True if bucket is Investigate.' },
{ label: 'score.is_watchlist', kind: 5, insertText: 'score.is_watchlist', documentation: 'True if bucket is Watchlist (lowest priority).' },
{ label: 'score.flags', kind: 5, insertText: 'score.flags', documentation: 'Array of score flags (e.g., "kev", "live-signal", "vendor-na").' },
{ label: 'score.rch', kind: 5, insertText: 'score.rch', documentation: 'Reachability dimension score (0-1 normalized). Alias: score.reachability' },
{ label: 'score.reachability', kind: 5, insertText: 'score.reachability', documentation: 'Reachability dimension score (0-1 normalized). Alias: score.rch' },
{ label: 'score.rts', kind: 5, insertText: 'score.rts', documentation: 'Runtime signal dimension score (0-1 normalized). Alias: score.runtime' },
{ label: 'score.runtime', kind: 5, insertText: 'score.runtime', documentation: 'Runtime signal dimension score (0-1 normalized). Alias: score.rts' },
{ label: 'score.bkp', kind: 5, insertText: 'score.bkp', documentation: 'Backport dimension score (0-1 normalized). Alias: score.backport' },
{ label: 'score.backport', kind: 5, insertText: 'score.backport', documentation: 'Backport dimension score (0-1 normalized). Alias: score.bkp' },
{ label: 'score.xpl', kind: 5, insertText: 'score.xpl', documentation: 'Exploit evidence dimension score (0-1 normalized). Alias: score.exploit' },
{ label: 'score.exploit', kind: 5, insertText: 'score.exploit', documentation: 'Exploit evidence dimension score (0-1 normalized). Alias: score.xpl' },
{ label: 'score.src', kind: 5, insertText: 'score.src', documentation: 'Source trust dimension score (0-1 normalized). Alias: score.source_trust' },
{ label: 'score.source_trust', kind: 5, insertText: 'score.source_trust', documentation: 'Source trust dimension score (0-1 normalized). Alias: score.src' },
{ label: 'score.mit', kind: 5, insertText: 'score.mit', documentation: 'Mitigation dimension score (0-1 normalized). Alias: score.mitigation' },
{ label: 'score.mitigation', kind: 5, insertText: 'score.mitigation', documentation: 'Mitigation dimension score (0-1 normalized). Alias: score.mit' },
{ label: 'score.policy_digest', kind: 5, insertText: 'score.policy_digest', documentation: 'SHA-256 digest of the policy used for scoring.' },
{ label: 'score.calculated_at', kind: 5, insertText: 'score.calculated_at', documentation: 'ISO 8601 timestamp when score was calculated.' },
{ label: 'score.explanations', kind: 5, insertText: 'score.explanations', documentation: 'Array of human-readable explanations for the score.' },
];
/**
@@ -382,6 +407,29 @@ const vexJustificationCompletions: ReadonlyArray<Omit<Monaco.languages.Completio
{ label: 'inline_mitigations_already_exist', kind: 21, insertText: '"inline_mitigations_already_exist"', documentation: 'Inline mitigations already exist.' },
];
/**
* Completion items for score buckets (Evidence-Weighted Score).
*/
const scoreBucketCompletions: ReadonlyArray<Omit<Monaco.languages.CompletionItem, 'range'>> = [
{ label: 'ActNow', kind: 21, insertText: '"ActNow"', documentation: 'Highest priority: immediate action required.' },
{ label: 'ScheduleNext', kind: 21, insertText: '"ScheduleNext"', documentation: 'High priority: schedule remediation soon.' },
{ label: 'Investigate', kind: 21, insertText: '"Investigate"', documentation: 'Medium priority: requires investigation.' },
{ label: 'Watchlist', kind: 21, insertText: '"Watchlist"', documentation: 'Low priority: monitor for changes.' },
];
/**
* Completion items for score flags (Evidence-Weighted Score).
*/
const scoreFlagCompletions: ReadonlyArray<Omit<Monaco.languages.CompletionItem, 'range'>> = [
{ label: 'kev', kind: 21, insertText: '"kev"', documentation: 'Known Exploited Vulnerability (CISA KEV list).' },
{ label: 'live-signal', kind: 21, insertText: '"live-signal"', documentation: 'Runtime evidence detected active exploitation.' },
{ label: 'vendor-na', kind: 21, insertText: '"vendor-na"', documentation: 'Vendor confirms not affected.' },
{ label: 'epss-high', kind: 21, insertText: '"epss-high"', documentation: 'High EPSS probability score.' },
{ label: 'reachable', kind: 21, insertText: '"reachable"', documentation: 'Code is statically or dynamically reachable.' },
{ label: 'unreachable', kind: 21, insertText: '"unreachable"', documentation: 'Code is confirmed unreachable.' },
{ label: 'backported', kind: 21, insertText: '"backported"', documentation: 'Fix has been backported by vendor.' },
];
/**
* Registers the completion provider for stella-dsl.
*
@@ -415,7 +463,8 @@ export function registerStellaDslCompletions(monaco: typeof Monaco): Monaco.IDis
if (textUntilPosition.endsWith('sbom.') || textUntilPosition.endsWith('advisory.') ||
textUntilPosition.endsWith('vex.') || textUntilPosition.endsWith('signals.') ||
textUntilPosition.endsWith('telemetry.') || textUntilPosition.endsWith('run.') ||
textUntilPosition.endsWith('secret.') || textUntilPosition.endsWith('env.')) {
textUntilPosition.endsWith('secret.') || textUntilPosition.endsWith('env.') ||
textUntilPosition.endsWith('score.')) {
suggestions.push(...namespaceCompletions.map(c => ({ ...c, range })));
}
@@ -429,6 +478,16 @@ export function registerStellaDslCompletions(monaco: typeof Monaco): Monaco.IDis
suggestions.push(...vexJustificationCompletions.map(c => ({ ...c, range })));
}
// Check for score bucket context
if (textUntilPosition.match(/score\.bucket\s*(==|!=|in)\s*["[]?$/)) {
suggestions.push(...scoreBucketCompletions.map(c => ({ ...c, range })));
}
// Check for score flags context
if (textUntilPosition.match(/score\.flags\s*(contains|in)\s*["[]?$/)) {
suggestions.push(...scoreFlagCompletions.map(c => ({ ...c, range })));
}
// Check for action context (after 'then' or 'else')
if (textUntilPosition.match(/\b(then|else)\s*$/)) {
suggestions.push(...actionCompletions.map(c => ({ ...c, range })));

View File

@@ -0,0 +1,417 @@
/**
* Gated Buckets Component Tests.
* Sprint: SPRINT_9200_0001_0004 (Frontend Quiet Triage UI)
* Task: QTU-9200-029 - Unit tests for gated chips component
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GatedBucketsComponent, BucketExpandEvent } from './gated-buckets.component';
import { GatedBucketsSummary, GatingReason } from '../../models/gating.model';
describe('GatedBucketsComponent', () => {
let component: GatedBucketsComponent;
let fixture: ComponentFixture<GatedBucketsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GatedBucketsComponent],
}).compileComponents();
fixture = TestBed.createComponent(GatedBucketsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should display zero actionable count by default', () => {
expect(component.actionableCount()).toBe(0);
});
it('should display zero hidden count by default', () => {
expect(component.totalHidden()).toBe(0);
});
it('should have no expanded bucket by default', () => {
expect(component.expandedBucket()).toBeNull();
});
it('should not show all by default', () => {
expect(component.showAll()).toBe(false);
});
});
describe('summary input', () => {
const mockSummary: GatedBucketsSummary = {
actionableCount: 15,
totalHiddenCount: 48,
unreachableCount: 23,
policyDismissedCount: 5,
backportedCount: 12,
vexNotAffectedCount: 8,
supersededCount: 0,
userMutedCount: 0
};
beforeEach(() => {
component.summary = mockSummary;
fixture.detectChanges();
});
it('should display actionable count from summary', () => {
expect(component.actionableCount()).toBe(15);
});
it('should display total hidden count from summary', () => {
expect(component.totalHidden()).toBe(48);
});
it('should display unreachable count from summary', () => {
expect(component.unreachableCount()).toBe(23);
});
it('should display policy dismissed count from summary', () => {
expect(component.policyDismissedCount()).toBe(5);
});
it('should display backported count from summary', () => {
expect(component.backportedCount()).toBe(12);
});
it('should display VEX not-affected count from summary', () => {
expect(component.vexNotAffectedCount()).toBe(8);
});
it('should render actionable summary in DOM', () => {
const compiled = fixture.nativeElement;
const countEl = compiled.querySelector('.actionable-count');
expect(countEl.textContent).toBe('15');
});
it('should render hidden hint when hidden count > 0', () => {
const compiled = fixture.nativeElement;
const hintEl = compiled.querySelector('.hidden-hint');
expect(hintEl).toBeTruthy();
expect(hintEl.textContent).toContain('48 hidden');
});
it('should render unreachable chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.unreachable');
expect(chip).toBeTruthy();
expect(chip.textContent).toContain('+23');
});
it('should render policy-dismissed chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.policy-dismissed');
expect(chip).toBeTruthy();
expect(chip.textContent).toContain('+5');
});
it('should render backported chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.backported');
expect(chip).toBeTruthy();
expect(chip.textContent).toContain('+12');
});
it('should render vex-not-affected chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.vex-not-affected');
expect(chip).toBeTruthy();
expect(chip.textContent).toContain('+8');
});
it('should not render superseded chip when count is 0', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.superseded');
expect(chip).toBeNull();
});
it('should not render user-muted chip when count is 0', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.user-muted');
expect(chip).toBeNull();
});
});
describe('chip expansion', () => {
const mockSummary: GatedBucketsSummary = {
actionableCount: 10,
totalHiddenCount: 30,
unreachableCount: 20,
policyDismissedCount: 10,
backportedCount: 0,
vexNotAffectedCount: 0,
supersededCount: 0,
userMutedCount: 0
};
beforeEach(() => {
component.summary = mockSummary;
fixture.detectChanges();
});
it('should expand bucket on click', () => {
component.toggleBucket('unreachable');
expect(component.expandedBucket()).toBe('unreachable');
});
it('should collapse bucket when clicking same bucket again', () => {
component.toggleBucket('unreachable');
expect(component.expandedBucket()).toBe('unreachable');
component.toggleBucket('unreachable');
expect(component.expandedBucket()).toBeNull();
});
it('should switch expanded bucket when clicking different bucket', () => {
component.toggleBucket('unreachable');
expect(component.expandedBucket()).toBe('unreachable');
component.toggleBucket('policy_dismissed');
expect(component.expandedBucket()).toBe('policy_dismissed');
});
it('should emit bucketExpand event on expansion', () => {
const emitSpy = spyOn(component.bucketExpand, 'emit');
component.toggleBucket('unreachable');
expect(emitSpy).toHaveBeenCalledWith({
reason: 'unreachable',
count: 20
} as BucketExpandEvent);
});
it('should not emit bucketExpand event on collapse', () => {
component.toggleBucket('unreachable');
const emitSpy = spyOn(component.bucketExpand, 'emit');
component.toggleBucket('unreachable'); // collapse
expect(emitSpy).not.toHaveBeenCalled();
});
it('should add expanded class to expanded chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.unreachable');
expect(chip.classList.contains('expanded')).toBe(false);
component.toggleBucket('unreachable');
fixture.detectChanges();
expect(chip.classList.contains('expanded')).toBe(true);
});
it('should set aria-expanded attribute correctly', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.unreachable');
expect(chip.getAttribute('aria-expanded')).toBe('false');
component.toggleBucket('unreachable');
fixture.detectChanges();
expect(chip.getAttribute('aria-expanded')).toBe('true');
});
});
describe('show all toggle', () => {
const mockSummary: GatedBucketsSummary = {
actionableCount: 5,
totalHiddenCount: 25,
unreachableCount: 25,
policyDismissedCount: 0,
backportedCount: 0,
vexNotAffectedCount: 0,
supersededCount: 0,
userMutedCount: 0
};
beforeEach(() => {
component.summary = mockSummary;
fixture.detectChanges();
});
it('should render show all toggle when hidden count > 0', () => {
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle).toBeTruthy();
});
it('should display "Show all" text initially', () => {
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle.textContent.trim()).toBe('Show all');
});
it('should toggle showAll on click', () => {
expect(component.showAll()).toBe(false);
component.toggleShowAll();
expect(component.showAll()).toBe(true);
component.toggleShowAll();
expect(component.showAll()).toBe(false);
});
it('should emit showAllChange event', () => {
const emitSpy = spyOn(component.showAllChange, 'emit');
component.toggleShowAll();
expect(emitSpy).toHaveBeenCalledWith(true);
});
it('should display "Hide gated" text when showAll is true', () => {
component.toggleShowAll();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle.textContent.trim()).toBe('Hide gated');
});
it('should add active class when showAll is true', () => {
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle.classList.contains('active')).toBe(false);
component.toggleShowAll();
fixture.detectChanges();
expect(toggle.classList.contains('active')).toBe(true);
});
it('should set aria-pressed attribute correctly', () => {
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
component.toggleShowAll();
fixture.detectChanges();
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
});
describe('no hidden findings', () => {
const mockSummary: GatedBucketsSummary = {
actionableCount: 50,
totalHiddenCount: 0,
unreachableCount: 0,
policyDismissedCount: 0,
backportedCount: 0,
vexNotAffectedCount: 0,
supersededCount: 0,
userMutedCount: 0
};
beforeEach(() => {
component.summary = mockSummary;
fixture.detectChanges();
});
it('should not render hidden hint when no hidden findings', () => {
const compiled = fixture.nativeElement;
const hintEl = compiled.querySelector('.hidden-hint');
expect(hintEl).toBeNull();
});
it('should not render show all toggle when no hidden findings', () => {
const compiled = fixture.nativeElement;
const toggle = compiled.querySelector('.show-all-toggle');
expect(toggle).toBeNull();
});
it('should not render any bucket chips', () => {
const compiled = fixture.nativeElement;
const chips = compiled.querySelectorAll('.bucket-chip');
expect(chips.length).toBe(0);
});
});
describe('icon retrieval', () => {
it('should return correct icon for unreachable', () => {
expect(component.getIcon('unreachable')).toBe('🛡️');
});
it('should return correct icon for policy_dismissed', () => {
expect(component.getIcon('policy_dismissed')).toBe('📋');
});
it('should return correct icon for backported', () => {
expect(component.getIcon('backported')).toBe('🔧');
});
it('should return correct icon for vex_not_affected', () => {
expect(component.getIcon('vex_not_affected')).toBe('✅');
});
it('should return correct icon for superseded', () => {
expect(component.getIcon('superseded')).toBe('🔄');
});
it('should return correct icon for user_muted', () => {
expect(component.getIcon('user_muted')).toBe('🔇');
});
});
describe('label retrieval', () => {
it('should return correct label for unreachable', () => {
expect(component.getLabel('unreachable')).toBe('Unreachable');
});
it('should return correct label for policy_dismissed', () => {
expect(component.getLabel('policy_dismissed')).toBe('Policy Dismissed');
});
it('should return correct label for backported', () => {
expect(component.getLabel('backported')).toBe('Backported');
});
});
describe('accessibility', () => {
const mockSummary: GatedBucketsSummary = {
actionableCount: 10,
totalHiddenCount: 15,
unreachableCount: 15,
policyDismissedCount: 0,
backportedCount: 0,
vexNotAffectedCount: 0,
supersededCount: 0,
userMutedCount: 0
};
beforeEach(() => {
component.summary = mockSummary;
fixture.detectChanges();
});
it('should have role="group" on container', () => {
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.gated-buckets');
expect(container.getAttribute('role')).toBe('group');
});
it('should have aria-label on container', () => {
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.gated-buckets');
expect(container.getAttribute('aria-label')).toBe('Gated findings summary');
});
it('should have descriptive aria-label on bucket chips', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.unreachable');
expect(chip.getAttribute('aria-label')).toContain('15 unreachable findings');
});
});
});

View File

@@ -0,0 +1,489 @@
/**
* Gating Explainer Component Tests.
* Sprint: SPRINT_9200_0001_0004 (Frontend Quiet Triage UI)
* Task: QTU-9200-030 - Unit tests for why hidden modal
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GatingExplainerComponent } from './gating-explainer.component';
import { FindingGatingStatus, GatingReason } from '../../models/gating.model';
describe('GatingExplainerComponent', () => {
let component: GatingExplainerComponent;
let fixture: ComponentFixture<GatingExplainerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GatingExplainerComponent],
}).compileComponents();
fixture = TestBed.createComponent(GatingExplainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should be visible by default', () => {
expect(component.isVisible()).toBe(true);
});
it('should have "none" as default gating reason', () => {
expect(component.gatingReason()).toBe('none');
});
it('should not have VEX trust by default', () => {
expect(component.hasVexTrust()).toBe(false);
});
it('should not be able to ungate by default', () => {
expect(component.canUngating()).toBe(false);
});
});
describe('unreachable status', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-001',
isGated: true,
gatingReason: 'unreachable',
gatingExplanation: 'The vulnerable method is never called from any entrypoint.',
subgraphId: 'subgraph-123'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display unreachable reason', () => {
expect(component.gatingReason()).toBe('unreachable');
});
it('should display correct label', () => {
expect(component.reasonLabel()).toBe('Unreachable');
});
it('should display correct icon', () => {
expect(component.reasonIcon()).toBe('🛡️');
});
it('should display custom explanation', () => {
expect(component.explanation()).toBe('The vulnerable method is never called from any entrypoint.');
});
it('should have subgraph ID available', () => {
expect(component.subgraphId()).toBe('subgraph-123');
});
it('should render view reachability link', () => {
const compiled = fixture.nativeElement;
const link = compiled.querySelector('.evidence-link');
expect(link).toBeTruthy();
expect(link.textContent).toContain('View reachability graph');
});
it('should render learn-more link for unreachable', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/triage/reachability-analysis');
});
it('should not allow ungating for unreachable', () => {
expect(component.canUngating()).toBe(false);
});
it('should emit viewReachabilityGraph on link click', () => {
const emitSpy = spyOn(component.viewReachabilityGraph, 'emit');
component.viewReachability();
expect(emitSpy).toHaveBeenCalledWith('subgraph-123');
});
});
describe('policy_dismissed status', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-002',
isGated: true,
gatingReason: 'policy_dismissed',
gatingExplanation: 'Policy rule CVE-age-threshold dismissed this finding.'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display policy_dismissed reason', () => {
expect(component.gatingReason()).toBe('policy_dismissed');
});
it('should display correct label', () => {
expect(component.reasonLabel()).toBe('Policy Dismissed');
});
it('should allow ungating for policy_dismissed', () => {
expect(component.canUngating()).toBe(true);
});
it('should render ungating button', () => {
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.ungating-btn');
expect(btn).toBeTruthy();
expect(btn.textContent.trim()).toBe('Show in actionable list');
});
it('should emit ungateRequest when clicking ungating button', () => {
const emitSpy = spyOn(component.ungateRequest, 'emit');
component.requestUngating();
expect(emitSpy).toHaveBeenCalledWith('finding-002');
});
it('should render learn-more link for policy rules', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/policy/rules');
});
});
describe('backported status', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-003',
isGated: true,
gatingReason: 'backported',
gatingExplanation: 'Fixed in RHEL backport 1.2.3-4.el8'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display backported reason', () => {
expect(component.gatingReason()).toBe('backported');
});
it('should display correct icon', () => {
expect(component.reasonIcon()).toBe('🔧');
});
it('should not allow ungating for backported', () => {
expect(component.canUngating()).toBe(false);
});
it('should render learn-more link for backport detection', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/triage/backport-detection');
});
});
describe('vex_not_affected status with VEX trust', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-004',
isGated: true,
gatingReason: 'vex_not_affected',
gatingExplanation: 'VEX from vendor declares not affected.',
vexTrustStatus: {
trustScore: 0.85,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: true,
trustFactors: {
issuerTrust: 0.9,
issuerHistory: 0.8,
justificationQuality: 0.85,
documentAge: 0.85
}
}
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display vex_not_affected reason', () => {
expect(component.gatingReason()).toBe('vex_not_affected');
});
it('should have VEX trust available', () => {
expect(component.hasVexTrust()).toBe(true);
});
it('should display trust score', () => {
expect(component.vexTrustScore()).toBe(0.85);
});
it('should display trust threshold', () => {
expect(component.vexTrustThreshold()).toBe(0.8);
});
it('should indicate threshold is met', () => {
expect(component.meetsThreshold()).toBe(true);
});
it('should render VEX trust summary', () => {
const compiled = fixture.nativeElement;
const summary = compiled.querySelector('.vex-trust-summary');
expect(summary).toBeTruthy();
});
it('should format score correctly', () => {
expect(component.formatScore(0.85)).toBe('85%');
expect(component.formatScore(0.8)).toBe('80%');
expect(component.formatScore(undefined)).toBe('—');
});
it('should render view VEX details link', () => {
const compiled = fixture.nativeElement;
const links = compiled.querySelectorAll('.evidence-link');
const vexLink = Array.from(links).find((el: any) =>
el.textContent.includes('View VEX details')
);
expect(vexLink).toBeTruthy();
});
it('should emit viewVexStatus on link click', () => {
const emitSpy = spyOn(component.viewVexStatus, 'emit');
component.viewVexDetails();
expect(emitSpy).toHaveBeenCalled();
});
it('should render learn-more link for VEX trust scoring', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/vex/trust-scoring');
});
});
describe('user_muted status', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-005',
isGated: true,
gatingReason: 'user_muted',
gatingExplanation: 'Muted by user@example.com on 2024-01-15'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display user_muted reason', () => {
expect(component.gatingReason()).toBe('user_muted');
});
it('should display correct icon', () => {
expect(component.reasonIcon()).toBe('🔇');
});
it('should allow ungating for user_muted', () => {
expect(component.canUngating()).toBe(true);
});
it('should render learn-more link for muting', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/triage/muting');
});
});
describe('superseded status', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-006',
isGated: true,
gatingReason: 'superseded',
gatingExplanation: 'Superseded by CVE-2024-5678'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should display superseded reason', () => {
expect(component.gatingReason()).toBe('superseded');
});
it('should display correct icon', () => {
expect(component.reasonIcon()).toBe('🔄');
});
it('should not allow ungating for superseded', () => {
expect(component.canUngating()).toBe(false);
});
it('should render learn-more link for superseded', () => {
const compiled = fixture.nativeElement;
const learnMore = compiled.querySelector('.learn-more');
expect(learnMore).toBeTruthy();
expect(learnMore.getAttribute('href')).toBe('/docs/vulnerability/superseded');
});
});
describe('delta comparison link', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-007',
isGated: true,
gatingReason: 'unreachable',
deltasId: 'delta-456'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have deltasId available', () => {
expect(component.deltasId()).toBe('delta-456');
});
it('should render view delta comparison link', () => {
const compiled = fixture.nativeElement;
const links = compiled.querySelectorAll('.evidence-link');
const deltaLink = Array.from(links).find((el: any) =>
el.textContent.includes('View delta comparison')
);
expect(deltaLink).toBeTruthy();
});
it('should emit viewDeltaComparison on link click', () => {
const emitSpy = spyOn(component.viewDeltaComparison, 'emit');
component.viewDeltas();
expect(emitSpy).toHaveBeenCalledWith('delta-456');
});
});
describe('close functionality', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-008',
isGated: true,
gatingReason: 'unreachable'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should be visible initially', () => {
expect(component.isVisible()).toBe(true);
});
it('should hide on close', () => {
component.close();
expect(component.isVisible()).toBe(false);
});
it('should emit closeExplainer on close', () => {
const emitSpy = spyOn(component.closeExplainer, 'emit');
component.close();
expect(emitSpy).toHaveBeenCalled();
});
it('should render close button', () => {
const compiled = fixture.nativeElement;
const closeBtn = compiled.querySelector('.close-btn');
expect(closeBtn).toBeTruthy();
expect(closeBtn.getAttribute('aria-label')).toBe('Close');
});
it('should show again when new status is set', () => {
component.close();
expect(component.isVisible()).toBe(false);
component.status = { ...mockStatus, findingId: 'finding-009' };
fixture.detectChanges();
expect(component.isVisible()).toBe(true);
});
it('should add hidden class when not visible', () => {
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.gating-explainer');
expect(container.classList.contains('hidden')).toBe(false);
component.close();
fixture.detectChanges();
expect(container.classList.contains('hidden')).toBe(true);
});
});
describe('default explanations', () => {
it('should provide default explanation for unreachable when none provided', () => {
component.status = {
findingId: 'finding-010',
isGated: true,
gatingReason: 'unreachable'
};
fixture.detectChanges();
expect(component.explanation()).toContain('not reachable from any application entrypoint');
});
it('should provide default explanation for policy_dismissed when none provided', () => {
component.status = {
findingId: 'finding-011',
isGated: true,
gatingReason: 'policy_dismissed'
};
fixture.detectChanges();
expect(component.explanation()).toContain('dismissed by a policy rule');
});
it('should provide default explanation for backported when none provided', () => {
component.status = {
findingId: 'finding-012',
isGated: true,
gatingReason: 'backported'
};
fixture.detectChanges();
expect(component.explanation()).toContain('distribution backport');
});
});
describe('accessibility', () => {
const mockStatus: FindingGatingStatus = {
findingId: 'finding-013',
isGated: true,
gatingReason: 'unreachable'
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have aria-label on close button', () => {
const compiled = fixture.nativeElement;
const closeBtn = compiled.querySelector('.close-btn');
expect(closeBtn.getAttribute('aria-label')).toBe('Close');
});
it('should render as semantic structure', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.explainer-header')).toBeTruthy();
expect(compiled.querySelector('.explainer-body')).toBeTruthy();
});
});
});

View File

@@ -69,7 +69,7 @@ import {
</div>
}
<!-- Action hints -->
<!-- Action hints with learn-more links (Sprint 9200.0001.0004 - Task 14) -->
<div class="action-hints">
@switch (gatingReason()) {
@case ('unreachable') {
@@ -78,36 +78,54 @@ import {
path is not reachable from any entrypoint. Review the reachability graph
to verify.
</p>
<a class="learn-more" href="/docs/triage/reachability-analysis" target="_blank">
📖 Learn more about reachability analysis
</a>
}
@case ('policy_dismissed') {
<p class="hint">
This finding was dismissed by a policy rule. Check your policy configuration
to understand which rule applied.
</p>
<a class="learn-more" href="/docs/policy/rules" target="_blank">
📖 Learn more about policy rules
</a>
}
@case ('backported') {
<p class="hint">
The vulnerability was patched via a distribution backport. The installed
version includes the security fix even though the version number is lower.
</p>
<a class="learn-more" href="/docs/triage/backport-detection" target="_blank">
📖 Learn more about backport detection
</a>
}
@case ('vex_not_affected') {
<p class="hint">
A trusted VEX statement declares this component is not affected.
Review the VEX document to understand the justification.
</p>
<a class="learn-more" href="/docs/vex/trust-scoring" target="_blank">
📖 Learn more about VEX trust scoring
</a>
}
@case ('superseded') {
<p class="hint">
This CVE has been superseded by a newer advisory. Check for the
updated vulnerability information.
</p>
<a class="learn-more" href="/docs/vulnerability/superseded" target="_blank">
📖 Learn more about superseded CVEs
</a>
}
@case ('user_muted') {
<p class="hint">
You or another user explicitly muted this finding. You can unmute it
to restore visibility.
</p>
<a class="learn-more" href="/docs/triage/muting" target="_blank">
📖 Learn more about muting findings
</a>
}
}
</div>
@@ -254,6 +272,22 @@ import {
color: #5d4037;
}
.learn-more {
display: inline-block;
margin-top: 8px;
padding: 4px 8px;
font-size: 11px;
color: var(--primary-color, #1976d2);
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
}
.learn-more:hover {
background: var(--primary-light, #e3f2fd);
text-decoration: underline;
}
.ungating-actions {
display: flex;
justify-content: flex-end;

View File

@@ -0,0 +1,497 @@
/**
* Replay Command Component Tests.
* Sprint: SPRINT_9200_0001_0004 (Frontend Quiet Triage UI)
* Task: QTU-9200-032 - Unit tests for replay command copy
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReplayCommandComponent } from './replay-command.component';
import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model';
describe('ReplayCommandComponent', () => {
let component: ReplayCommandComponent;
let fixture: ComponentFixture<ReplayCommandComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReplayCommandComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReplayCommandComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should have full as active tab by default', () => {
expect(component.activeTab()).toBe('full');
});
it('should not be copied by default', () => {
expect(component.copied()).toBe(false);
});
it('should not have short command by default', () => {
expect(component.hasShortCommand()).toBe(false);
});
it('should not have offline command by default', () => {
expect(component.hasOfflineCommand()).toBe(false);
});
it('should display "No command available" when no response', () => {
const compiled = fixture.nativeElement;
const commandText = compiled.querySelector('.command-text');
expect(commandText.textContent).toContain('No command available');
});
});
describe('simple command input', () => {
const simpleCommand = 'stellaops scan --digest sha256:abc123 --replay';
beforeEach(() => {
component.command = simpleCommand;
fixture.detectChanges();
});
it('should display the command', () => {
expect(component.activeCommand()?.command).toBe(simpleCommand);
});
it('should render command in DOM', () => {
const compiled = fixture.nativeElement;
const commandText = compiled.querySelector('.command-text');
expect(commandText.textContent).toContain('stellaops scan');
});
});
describe('full response with multiple commands', () => {
const mockResponse: ReplayCommandResponse = {
findingId: 'finding-001',
scanId: 'scan-001',
fullCommand: {
type: 'full',
command: 'stellaops scan --digest sha256:abc --sbom sbom.json --feed feed.json',
shell: 'bash',
requiresNetwork: true,
prerequisites: ['stellaops CLI v0.9+', 'Docker running']
},
shortCommand: {
type: 'short',
command: 'stellaops replay --id scan-001',
shell: 'bash',
requiresNetwork: true
},
offlineCommand: {
type: 'offline',
command: 'stellaops replay --bundle evidence-bundle.tar.gz',
shell: 'bash',
requiresNetwork: false
},
bundle: {
downloadUri: '/api/evidence/scan-001/bundle.tar.gz',
sizeBytes: 15728640,
format: 'tar.gz'
},
generatedAt: '2024-01-15T10:30:00Z',
expectedVerdictHash: 'sha256:verdict123abc'
};
beforeEach(() => {
component.response = mockResponse;
fixture.detectChanges();
});
it('should have short command available', () => {
expect(component.hasShortCommand()).toBe(true);
});
it('should have offline command available', () => {
expect(component.hasOfflineCommand()).toBe(true);
});
it('should display full command by default', () => {
expect(component.activeCommand()?.command).toContain('stellaops scan');
});
it('should switch to short command on tab click', () => {
component.setActiveTab('short');
expect(component.activeTab()).toBe('short');
expect(component.activeCommand()?.command).toBe('stellaops replay --id scan-001');
});
it('should switch to offline command on tab click', () => {
component.setActiveTab('offline');
expect(component.activeTab()).toBe('offline');
expect(component.activeCommand()?.command).toContain('evidence-bundle.tar.gz');
});
it('should render all three tabs', () => {
const compiled = fixture.nativeElement;
const tabs = compiled.querySelectorAll('.tab');
expect(tabs.length).toBe(3);
});
it('should highlight active tab', () => {
const compiled = fixture.nativeElement;
const fullTab = compiled.querySelector('.tab.active');
expect(fullTab.textContent.trim()).toBe('Full');
});
it('should have prerequisites', () => {
expect(component.hasPrerequisites()).toBe(true);
});
it('should render prerequisites list', () => {
const compiled = fixture.nativeElement;
const prereqList = compiled.querySelector('.prereq-list');
expect(prereqList).toBeTruthy();
expect(prereqList.querySelectorAll('li').length).toBe(2);
});
it('should render network warning for network-requiring command', () => {
const compiled = fixture.nativeElement;
const warning = compiled.querySelector('.network-warning');
expect(warning).toBeTruthy();
expect(warning.textContent).toContain('requires network access');
});
it('should not render network warning for offline command', () => {
component.setActiveTab('offline');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const warning = compiled.querySelector('.network-warning');
expect(warning).toBeNull();
});
it('should have bundle URL', () => {
expect(component.hasBundleUrl()).toBe(true);
expect(component.bundleUrl()).toBe('/api/evidence/scan-001/bundle.tar.gz');
});
it('should render bundle download section', () => {
const compiled = fixture.nativeElement;
const bundleSection = compiled.querySelector('.bundle-download');
expect(bundleSection).toBeTruthy();
});
it('should render bundle link with download attribute', () => {
const compiled = fixture.nativeElement;
const link = compiled.querySelector('.bundle-link');
expect(link).toBeTruthy();
expect(link.hasAttribute('download')).toBe(true);
});
it('should display bundle info', () => {
const compiled = fixture.nativeElement;
const bundleInfo = compiled.querySelector('.bundle-info');
expect(bundleInfo.textContent).toContain('15.0 MB');
expect(bundleInfo.textContent).toContain('tar.gz');
});
it('should have expected hash', () => {
expect(component.expectedHash()).toBe('sha256:verdict123abc');
});
it('should render hash verification section', () => {
const compiled = fixture.nativeElement;
const hashSection = compiled.querySelector('.hash-verification');
expect(hashSection).toBeTruthy();
});
it('should display hash value', () => {
const compiled = fixture.nativeElement;
const hashValue = compiled.querySelector('.hash-value');
expect(hashValue.textContent).toBe('sha256:verdict123abc');
});
});
describe('copy functionality', () => {
const mockResponse: ReplayCommandResponse = {
findingId: 'finding-001',
scanId: 'scan-001',
fullCommand: {
type: 'full',
command: 'stellaops scan --test',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '2024-01-15T10:30:00Z',
expectedVerdictHash: ''
};
beforeEach(() => {
component.response = mockResponse;
fixture.detectChanges();
});
it('should copy command to clipboard', fakeAsync(async () => {
const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
await component.copyCommand();
expect(writeTextSpy).toHaveBeenCalledWith('stellaops scan --test');
}));
it('should set copied state after copy', fakeAsync(async () => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
await component.copyCommand();
expect(component.copied()).toBe(true);
}));
it('should emit copySuccess event', fakeAsync(async () => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
const emitSpy = spyOn(component.copySuccess, 'emit');
await component.copyCommand();
expect(emitSpy).toHaveBeenCalledWith('stellaops scan --test');
}));
it('should reset copied state after timeout', fakeAsync(async () => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
await component.copyCommand();
expect(component.copied()).toBe(true);
tick(2000);
expect(component.copied()).toBe(false);
}));
it('should display copied state in button', fakeAsync(async () => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
await component.copyCommand();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const copyBtn = compiled.querySelector('.copy-btn');
expect(copyBtn.textContent).toContain('Copied!');
expect(copyBtn.classList.contains('copied')).toBe(true);
}));
it('should disable copy button when no command', () => {
component.response = undefined;
fixture.detectChanges();
const compiled = fixture.nativeElement;
const copyBtn = compiled.querySelector('.copy-btn');
expect(copyBtn.disabled).toBe(true);
});
});
describe('formatBundleSize', () => {
it('should format bytes', () => {
expect(component.formatBundleSize(512)).toBe('512 B');
});
it('should format kilobytes', () => {
expect(component.formatBundleSize(2048)).toBe('2.0 KB');
});
it('should format megabytes', () => {
expect(component.formatBundleSize(5242880)).toBe('5.0 MB');
});
it('should handle undefined', () => {
expect(component.formatBundleSize(undefined)).toBe('');
});
it('should format with one decimal place', () => {
expect(component.formatBundleSize(1536)).toBe('1.5 KB');
});
});
describe('shell styling', () => {
it('should apply bash shell attribute', () => {
component.response = {
findingId: '',
scanId: '',
fullCommand: {
type: 'full',
command: 'stellaops scan',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
fixture.detectChanges();
const compiled = fixture.nativeElement;
const commandText = compiled.querySelector('.command-text');
expect(commandText.getAttribute('data-shell')).toBe('bash');
});
it('should apply powershell shell attribute', () => {
component.response = {
findingId: '',
scanId: '',
fullCommand: {
type: 'full',
command: 'stellaops.exe scan',
shell: 'powershell',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
fixture.detectChanges();
const compiled = fixture.nativeElement;
const commandText = compiled.querySelector('.command-text');
expect(commandText.getAttribute('data-shell')).toBe('powershell');
});
});
describe('tab accessibility', () => {
const mockResponse: ReplayCommandResponse = {
findingId: 'finding-001',
scanId: 'scan-001',
fullCommand: {
type: 'full',
command: 'stellaops scan',
shell: 'bash',
requiresNetwork: false
},
shortCommand: {
type: 'short',
command: 'stellaops replay',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
beforeEach(() => {
component.response = mockResponse;
fixture.detectChanges();
});
it('should have role="tablist" on tabs container', () => {
const compiled = fixture.nativeElement;
const tablist = compiled.querySelector('.command-tabs');
expect(tablist.getAttribute('role')).toBe('tablist');
});
it('should have role="tab" on each tab', () => {
const compiled = fixture.nativeElement;
const tabs = compiled.querySelectorAll('.tab');
tabs.forEach((tab: HTMLElement) => {
expect(tab.getAttribute('role')).toBe('tab');
});
});
it('should have aria-selected on active tab', () => {
const compiled = fixture.nativeElement;
const activeTab = compiled.querySelector('.tab.active');
expect(activeTab.getAttribute('aria-selected')).toBe('true');
});
it('should have aria-selected false on inactive tabs', () => {
const compiled = fixture.nativeElement;
const inactiveTab = compiled.querySelectorAll('.tab')[1];
expect(inactiveTab.getAttribute('aria-selected')).toBe('false');
});
});
describe('fallback behavior', () => {
it('should fallback to full command when short selected but not available', () => {
component.response = {
findingId: '',
scanId: '',
fullCommand: {
type: 'full',
command: 'stellaops scan --full',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
fixture.detectChanges();
component.setActiveTab('short');
expect(component.activeCommand()?.command).toBe('stellaops scan --full');
});
it('should fallback to full command when offline selected but not available', () => {
component.response = {
findingId: '',
scanId: '',
fullCommand: {
type: 'full',
command: 'stellaops scan --full',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
fixture.detectChanges();
component.setActiveTab('offline');
expect(component.activeCommand()?.command).toBe('stellaops scan --full');
});
});
describe('DOM structure', () => {
const mockResponse: ReplayCommandResponse = {
findingId: 'finding-001',
scanId: 'scan-001',
fullCommand: {
type: 'full',
command: 'stellaops scan',
shell: 'bash',
requiresNetwork: false
},
generatedAt: '',
expectedVerdictHash: ''
};
beforeEach(() => {
component.response = mockResponse;
fixture.detectChanges();
});
it('should have main container', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.replay-command')).toBeTruthy();
});
it('should have header section', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.replay-header')).toBeTruthy();
});
it('should display title', () => {
const compiled = fixture.nativeElement;
const title = compiled.querySelector('.replay-title');
expect(title.textContent).toBe('Replay Command');
});
it('should display subtitle', () => {
const compiled = fixture.nativeElement;
const subtitle = compiled.querySelector('.replay-subtitle');
expect(subtitle.textContent).toBe('Reproduce this verdict deterministically');
});
it('should have command container', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.command-container')).toBeTruthy();
});
it('should have command actions', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.command-actions')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,436 @@
/**
* VEX Trust Display Component Tests.
* Sprint: SPRINT_9200_0001_0004 (Frontend Quiet Triage UI)
* Task: QTU-9200-031 - Unit tests for VEX trust display
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VexTrustDisplayComponent } from './vex-trust-display.component';
import { VexTrustStatus, TrustScoreBreakdown } from '../../models/gating.model';
describe('VexTrustDisplayComponent', () => {
let component: VexTrustDisplayComponent;
let fixture: ComponentFixture<VexTrustDisplayComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VexTrustDisplayComponent],
}).compileComponents();
fixture = TestBed.createComponent(VexTrustDisplayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should not have score by default', () => {
expect(component.hasScore()).toBe(false);
});
it('should not have threshold by default', () => {
expect(component.hasThreshold()).toBe(false);
});
it('should not have breakdown by default', () => {
expect(component.hasBreakdown()).toBe(false);
});
it('should not show breakdown by default', () => {
expect(component.showBreakdown()).toBe(false);
});
it('should display unknown status', () => {
expect(component.statusText()).toBe('Unknown');
});
it('should have unknown trust class', () => {
expect(component.trustClass()).toBe('trust-unknown');
});
});
describe('score without threshold', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.75,
meetsPolicyThreshold: true
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have score', () => {
expect(component.hasScore()).toBe(true);
});
it('should not have threshold', () => {
expect(component.hasThreshold()).toBe(false);
});
it('should display formatted score', () => {
expect(component.displayScore()).toBe('75%');
});
it('should calculate score percent', () => {
expect(component.scorePercent()).toBe(75);
});
it('should render score value in DOM', () => {
const compiled = fixture.nativeElement;
const scoreEl = compiled.querySelector('.score-value');
expect(scoreEl.textContent).toBe('75%');
});
it('should not render threshold comparison', () => {
const compiled = fixture.nativeElement;
const thresholdEl = compiled.querySelector('.threshold-comparison');
expect(thresholdEl).toBeNull();
});
});
describe('score with threshold - passing', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.85,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: true
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have both score and threshold', () => {
expect(component.hasScore()).toBe(true);
expect(component.hasThreshold()).toBe(true);
});
it('should display formatted threshold', () => {
expect(component.displayThreshold()).toBe('80%');
});
it('should calculate threshold percent', () => {
expect(component.thresholdPercent()).toBe(80);
});
it('should indicate meets threshold', () => {
expect(component.meetsThreshold()).toBe(true);
});
it('should have pass trust class', () => {
expect(component.trustClass()).toBe('trust-pass');
});
it('should have pass status badge class', () => {
expect(component.statusBadgeClass()).toBe('pass');
});
it('should display passing status text', () => {
expect(component.statusText()).toBe('✓ Meets threshold');
});
it('should render threshold comparison in DOM', () => {
const compiled = fixture.nativeElement;
const thresholdEl = compiled.querySelector('.threshold-comparison');
expect(thresholdEl).toBeTruthy();
expect(thresholdEl.textContent).toContain('80%');
});
it('should render status badge with pass class', () => {
const compiled = fixture.nativeElement;
const badge = compiled.querySelector('.status-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('pass')).toBe(true);
});
it('should render trust bar with threshold marker', () => {
const compiled = fixture.nativeElement;
const marker = compiled.querySelector('.threshold-marker');
expect(marker).toBeTruthy();
});
});
describe('score with threshold - failing', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.62,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: false
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should indicate does not meet threshold', () => {
expect(component.meetsThreshold()).toBe(false);
});
it('should have fail trust class', () => {
expect(component.trustClass()).toBe('trust-fail');
});
it('should have fail status badge class', () => {
expect(component.statusBadgeClass()).toBe('fail');
});
it('should display failing status text', () => {
expect(component.statusText()).toBe('✗ Below threshold');
});
it('should render status badge with fail class', () => {
const compiled = fixture.nativeElement;
const badge = compiled.querySelector('.status-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('fail')).toBe(true);
});
});
describe('trust breakdown', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.78,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: false,
trustBreakdown: {
authority: 0.9,
accuracy: 0.7,
timeliness: 0.8,
verification: 0.65
}
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have breakdown available', () => {
expect(component.hasBreakdown()).toBe(true);
});
it('should return breakdown data', () => {
const breakdown = component.breakdown();
expect(breakdown?.authority).toBe(0.9);
expect(breakdown?.accuracy).toBe(0.7);
});
it('should calculate authority percent', () => {
expect(component.authorityPercent()).toBe(90);
});
it('should calculate accuracy percent', () => {
expect(component.accuracyPercent()).toBe(70);
});
it('should calculate timeliness percent', () => {
expect(component.timelinessPercent()).toBe(80);
});
it('should calculate verification percent', () => {
expect(component.verificationPercent()).toBe(65);
});
it('should not show breakdown by default', () => {
expect(component.showBreakdown()).toBe(false);
});
it('should render show breakdown button when collapsed', () => {
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.show-breakdown-btn');
expect(btn).toBeTruthy();
expect(btn.textContent).toContain('Show trust breakdown');
});
it('should toggle breakdown visibility', () => {
expect(component.showBreakdown()).toBe(false);
component.toggleBreakdown();
expect(component.showBreakdown()).toBe(true);
component.toggleBreakdown();
expect(component.showBreakdown()).toBe(false);
});
it('should render breakdown factors when shown', () => {
component.toggleBreakdown();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const factors = compiled.querySelectorAll('.factor');
expect(factors.length).toBe(4);
});
it('should display factor labels', () => {
component.toggleBreakdown();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const labels = compiled.querySelectorAll('.factor-label');
expect(labels[0].textContent).toContain('Authority');
expect(labels[1].textContent).toContain('Accuracy');
expect(labels[2].textContent).toContain('Timeliness');
expect(labels[3].textContent).toContain('Verification');
});
it('should display factor values', () => {
component.toggleBreakdown();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const values = compiled.querySelectorAll('.factor-value');
expect(values[0].textContent).toBe('90%');
expect(values[1].textContent).toBe('70%');
});
});
describe('formatFactor', () => {
it('should format valid factor value', () => {
expect(component.formatFactor(0.85)).toBe('85%');
});
it('should format zero', () => {
expect(component.formatFactor(0)).toBe('0%');
});
it('should format 1.0', () => {
expect(component.formatFactor(1.0)).toBe('100%');
});
it('should handle undefined', () => {
expect(component.formatFactor(undefined)).toBe('—');
});
it('should round decimal values', () => {
expect(component.formatFactor(0.756)).toBe('76%');
});
});
describe('trust bar visualization', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.65,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: false
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should render trust bar', () => {
const compiled = fixture.nativeElement;
const bar = compiled.querySelector('.trust-bar');
expect(bar).toBeTruthy();
});
it('should render trust fill with correct width', () => {
const compiled = fixture.nativeElement;
const fill = compiled.querySelector('.trust-fill');
expect(fill).toBeTruthy();
expect(fill.style.width).toBe('65%');
});
it('should render threshold marker at correct position', () => {
const compiled = fixture.nativeElement;
const marker = compiled.querySelector('.threshold-marker');
expect(marker).toBeTruthy();
expect(marker.style.left).toBe('80%');
});
it('should display threshold value in marker label', () => {
const compiled = fixture.nativeElement;
const markerLabel = compiled.querySelector('.marker-label');
expect(markerLabel.textContent).toBe('80%');
});
});
describe('edge cases', () => {
it('should handle score of 0', () => {
component.status = {
trustScore: 0,
meetsPolicyThreshold: false
};
fixture.detectChanges();
expect(component.hasScore()).toBe(true);
expect(component.displayScore()).toBe('0%');
expect(component.scorePercent()).toBe(0);
});
it('should handle score of 1.0', () => {
component.status = {
trustScore: 1.0,
meetsPolicyThreshold: true
};
fixture.detectChanges();
expect(component.displayScore()).toBe('100%');
expect(component.scorePercent()).toBe(100);
});
it('should handle partial breakdown data', () => {
component.status = {
trustScore: 0.7,
meetsPolicyThreshold: true,
trustBreakdown: {
authority: 0.8,
accuracy: undefined as any,
timeliness: 0.6,
verification: undefined as any
}
};
fixture.detectChanges();
expect(component.authorityPercent()).toBe(80);
expect(component.timelinessPercent()).toBe(60);
});
});
describe('DOM structure', () => {
const mockStatus: VexTrustStatus = {
trustScore: 0.75,
policyTrustThreshold: 0.8,
meetsPolicyThreshold: false
};
beforeEach(() => {
component.status = mockStatus;
fixture.detectChanges();
});
it('should have main container', () => {
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.vex-trust-display');
expect(container).toBeTruthy();
});
it('should have trust header', () => {
const compiled = fixture.nativeElement;
const header = compiled.querySelector('.trust-header');
expect(header).toBeTruthy();
});
it('should have trust score main section', () => {
const compiled = fixture.nativeElement;
const main = compiled.querySelector('.trust-score-main');
expect(main).toBeTruthy();
});
it('should have score label', () => {
const compiled = fixture.nativeElement;
const label = compiled.querySelector('.score-label');
expect(label).toBeTruthy();
expect(label.textContent).toBe('trust score');
});
it('should have trust bar container', () => {
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.trust-bar-container');
expect(container).toBeTruthy();
});
});
});

View File

@@ -19,6 +19,39 @@
<div class="error" role="alert">{{ error() }}</div>
}
<!-- Gated Buckets Summary (Sprint 9200.0001.0004) -->
@if (gatingLoading()) {
<div class="gating-loading" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
Loading gating summary...
</div>
} @else if (gatingError()) {
<div class="gating-error" role="alert">
<span class="error-icon" aria-hidden="true">⚠️</span>
{{ gatingError() }}
<button class="retry-btn" (click)="loadGatedBuckets()" aria-label="Retry loading gating summary">
Retry
</button>
</div>
} @else if (gatedBuckets(); as buckets) {
<app-gated-buckets
[summary]="buckets"
(bucketExpand)="onBucketExpand($event)"
(showAllChange)="onShowAllGated()"
/>
}
<!-- Gating Explainer Modal -->
@if (gatingExplainerFinding(); as gatingStatus) {
<app-gating-explainer
[status]="gatingStatus"
(closeExplainer)="closeGatingExplainer()"
(viewReachabilityGraph)="setTab('reachability')"
(viewDeltaComparison)="setTab('evidence')"
(viewVexStatus)="setTab('evidence')"
/>
}
<div class="layout">
<aside class="left">
<div class="left__header">
@@ -161,6 +194,22 @@
>
Policy
</button>
<button
id="triage-tab-delta"
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === 'delta'"
[attr.aria-selected]="activeTab() === 'delta'"
[attr.tabindex]="activeTab() === 'delta' ? 0 : -1"
aria-controls="triage-panel-delta"
(click)="setTab('delta')"
>
Delta
@if (unifiedEvidence()?.deltas) {
<span class="tab-badge">!</span>
}
</button>
<button
id="triage-tab-attestations"
type="button"
@@ -186,6 +235,20 @@
role="tabpanel"
aria-labelledby="triage-tab-evidence"
>
<!-- Evidence Loading/Error State (Sprint 9200.0001.0004 - Task 36/37) -->
@if (evidenceLoading()) {
<div class="evidence-loading" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
Loading unified evidence...
</div>
}
@if (evidenceError()) {
<div class="evidence-error" role="alert">
<span class="error-icon" aria-hidden="true">⚠️</span>
{{ evidenceError() }}
</div>
}
<header class="evidence-header">
<div>
<h3>Evidence Summary</h3>
@@ -194,6 +257,36 @@
{{ selectedVuln()!.component?.name }} {{ selectedVuln()!.component?.version }}
</p>
</div>
<!-- Verification Status Indicator (Sprint 9200.0001.0004 - Task 28) -->
@if (unifiedEvidence()?.verification; as verification) {
<div class="verification-status" [class]="'status-' + verification.status">
@switch (verification.status) {
@case ('verified') {
<span class="status-icon"></span>
<span class="status-text">Verified</span>
}
@case ('partial') {
<span class="status-icon"></span>
<span class="status-text">Partial</span>
}
@case ('failed') {
<span class="status-icon"></span>
<span class="status-text">Failed</span>
}
@default {
<span class="status-icon">?</span>
<span class="status-text">Unknown</span>
}
}
@if (verification.issues?.length) {
<span class="status-issues" title="{{ verification.issues.join(', ') }}">
{{ verification.issues.length }} issue(s)
</span>
}
</div>
}
<button
type="button"
class="btn btn--primary"
@@ -276,7 +369,35 @@
<button type="button" class="btn btn--primary" (click)="openDecisionDrawer()">Record decision</button>
}
</div>
<!-- VEX Trust Display (Sprint 9200.0001.0004 - Task 18) -->
@if (unifiedEvidence()?.vexClaims?.length && unifiedEvidence()!.vexClaims![0].trustScore !== undefined) {
<div class="vex-trust-section">
<app-vex-trust-display
[status]="{ trustScore: unifiedEvidence()!.vexClaims![0].trustScore, meetsPolicyThreshold: unifiedEvidence()!.vexClaims![0].meetsPolicyThreshold }"
/>
</div>
}
</section>
<!-- Replay Command (Sprint 9200.0001.0004 - Task 24) -->
@if (unifiedEvidence()?.replayCommand) {
<section class="evidence-section">
<header class="evidence-section__header">
<h4>Replay Command</h4>
</header>
<app-replay-command
[command]="unifiedEvidence()!.replayCommand!"
/>
@if (unifiedEvidence()?.evidenceBundleUrl) {
<div class="bundle-download-action">
<button type="button" class="btn btn--secondary" (click)="downloadEvidenceBundle()">
📦 Download Evidence Bundle
</button>
</div>
}
</section>
}
</section>
} @else if (activeTab() === 'overview') {
<section
@@ -470,6 +591,65 @@
</div>
}
</section>
} @else if (activeTab() === 'delta') {
<!-- Delta Tab (Sprint 9200.0001.0004 - Tasks 25-26) -->
<section
id="triage-panel-delta"
class="section"
role="tabpanel"
aria-labelledby="triage-tab-delta"
>
<h3>Delta Comparison</h3>
<p class="hint">Changes from previous scan</p>
@if (unifiedEvidence()?.deltas; as delta) {
<div class="delta-summary">
<div class="delta-stat" [class.positive]="delta.summary?.addedCount === 0" [class.negative]="(delta.summary?.addedCount ?? 0) > 0">
<span class="stat-value">+{{ delta.summary?.addedCount ?? 0 }}</span>
<span class="stat-label">new</span>
</div>
<div class="delta-stat" [class.positive]="(delta.summary?.removedCount ?? 0) > 0" [class.neutral]="delta.summary?.removedCount === 0">
<span class="stat-value">-{{ delta.summary?.removedCount ?? 0 }}</span>
<span class="stat-label">resolved</span>
</div>
<div class="delta-stat" [class.neutral]="delta.summary?.changedCount === 0">
<span class="stat-value">~{{ delta.summary?.changedCount ?? 0 }}</span>
<span class="stat-label">changed</span>
</div>
</div>
@if (delta.summary?.isNew) {
<div class="delta-badge new">
🆕 This is a <strong>new finding</strong> not present in the previous scan.
</div>
}
@if (delta.summary?.statusChanged) {
<div class="delta-badge changed">
📝 Status changed from <code>{{ delta.summary?.previousStatus ?? 'unknown' }}</code>
</div>
}
<div class="delta-details">
<p class="hint">
Compared scan <code>{{ delta.previousScanId }}</code> to <code>{{ delta.currentScanId }}</code>
@if (delta.comparedAt) {
at {{ delta.comparedAt }}
}
</p>
@if (delta.deltaReportUri) {
<a class="btn btn--secondary" [href]="delta.deltaReportUri" target="_blank">
📄 View full delta report
</a>
}
</div>
} @else {
<div class="empty">
No delta information available for this finding.
<p class="hint">Delta comparison requires at least two scan runs.</p>
</div>
}
</section>
} @else if (activeTab() === 'attestations') {
<section
id="triage-panel-attestations"

View File

@@ -36,6 +36,61 @@
margin-bottom: 1rem;
}
// === Loading & Error States (Sprint 9200.0001.0004 - Tasks 36/37) ===
.gating-loading,
.evidence-loading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
color: #0369a1;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.gating-error,
.evidence-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
color: #92400e;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.retry-btn {
margin-left: auto;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
background: #fef3c7;
border: 1px solid #fbbf24;
border-radius: 4px;
cursor: pointer;
color: #92400e;
transition: all 0.15s ease;
&:hover {
background: #fde68a;
}
}
.layout {
display: grid;
grid-template-columns: minmax(320px, 420px) 1fr;
@@ -529,3 +584,143 @@
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
}
// === Sprint 9200.0001.0004 - Quiet Triage UI Styles ===
// Verification Status Indicator
.verification-status {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
&.status-verified {
background: #dcfce7;
color: #166534;
}
&.status-partial {
background: #fef3c7;
color: #92400e;
}
&.status-failed {
background: #fee2e2;
color: #991b1b;
}
&.status-unknown {
background: #f3f4f6;
color: #6b7280;
}
.status-icon {
font-size: 0.9rem;
}
.status-issues {
margin-left: 0.5rem;
font-size: 0.7rem;
opacity: 0.8;
cursor: help;
}
}
// Tab badge for Delta
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 4px;
border-radius: 50%;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// Delta Panel Styles
.delta-summary {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.delta-stat {
padding: 0.75rem 1rem;
border-radius: 8px;
background: #f3f4f6;
text-align: center;
min-width: 80px;
&.positive {
background: #dcfce7;
}
&.negative {
background: #fee2e2;
}
&.neutral {
background: #f3f4f6;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.delta-badge {
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
&.new {
background: #dbeafe;
border-left: 4px solid #2563eb;
color: #1e40af;
}
&.changed {
background: #fef3c7;
border-left: 4px solid #f59e0b;
color: #92400e;
}
}
.delta-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
// VEX Trust section in evidence panel
.vex-trust-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed #e5e7eb;
}
// Bundle download action
.bundle-download-action {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed #e5e7eb;
}

View File

@@ -22,18 +22,29 @@ import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why
import { KeyboardHelpComponent } from './components/keyboard-help/keyboard-help.component';
import { EvidencePillsComponent } from './components/evidence-pills/evidence-pills.component';
import { DecisionDrawerComponent, type DecisionFormData } from './components/decision-drawer/decision-drawer.component';
import { GatedBucketsComponent, type BucketExpandEvent } from './components/gated-buckets/gated-buckets.component';
import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component';
import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.component';
import { ReplayCommandComponent } from './components/replay-command/replay-command.component';
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
import { TtfsTelemetryService } from './services/ttfs-telemetry.service';
import { GatingService } from './services/gating.service';
import { VexDecisionModalComponent } from './vex-decision-modal.component';
import {
TriageAttestationDetailModalComponent,
type TriageAttestationDetail,
} from './triage-attestation-detail-modal.component';
import { type EvidenceBundle, EvidenceBitset } from './models/evidence.model';
import type {
GatedBucketsSummary,
FindingGatingStatus,
GatingReason,
UnifiedEvidenceResponse
} from './models/gating.model';
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations';
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations' | 'delta';
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'policy', 'attestations'];
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'delta', 'policy', 'attestations'];
const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [
'path-list',
'compact-graph',
@@ -78,6 +89,10 @@ interface PolicyGateCell {
TriageAttestationDetailModalComponent,
EvidencePillsComponent,
DecisionDrawerComponent,
GatedBucketsComponent,
GatingExplainerComponent,
VexTrustDisplayComponent,
ReplayCommandComponent,
],
providers: [TriageShortcutsService],
templateUrl: './triage-workspace.component.html',
@@ -91,6 +106,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly vexApi = inject<VexDecisionsApi>(VEX_DECISIONS_API);
private readonly gatingService = inject(GatingService);
private readonly shortcuts = inject(TriageShortcutsService);
private readonly ttfsTelemetry = inject(TtfsTelemetryService);
@@ -129,6 +145,19 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
readonly findingsSort = signal<'default' | 'deterministic'>('default');
readonly keyboardStatus = signal<string | null>(null);
// === Gated Buckets State (Sprint 9200.0001.0004) ===
readonly gatedBuckets = signal<GatedBucketsSummary | null>(null);
readonly expandedGatingBucket = signal<GatingReason | null>(null);
readonly showAllGated = signal(false);
readonly gatingExplainerFinding = signal<FindingGatingStatus | null>(null);
readonly unifiedEvidence = signal<UnifiedEvidenceResponse | null>(null);
// === Loading & Error States (Sprint 9200.0001.0004 - Task 36/37) ===
readonly gatingLoading = signal(false);
readonly evidenceLoading = signal(false);
readonly gatingError = signal<string | null>(null);
readonly evidenceError = signal<string | null>(null);
private keyboardStatusTimeout: number | null = null;
readonly selectedVuln = computed(() => {
@@ -320,6 +349,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
this.artifactId.set(artifactId);
await this.load();
await this.loadVexDecisions();
await this.loadGatedBuckets();
const first = this.findings()[0]?.vuln.vulnId ?? null;
this.selectedVulnId.set(first);
@@ -343,6 +373,95 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
}
}
/** Load gated buckets summary for the current artifact/scan. */
async loadGatedBuckets(): Promise<void> {
const artifactId = this.artifactId();
if (!artifactId) return;
this.gatingLoading.set(true);
this.gatingError.set(null);
try {
// Use artifactId as scanId for now; adjust when scan context is available
const resp = await firstValueFrom(this.gatingService.getGatedBucketsSummary(artifactId));
this.gatedBuckets.set(resp);
} catch (err) {
// Non-fatal: workspace should still render without gated buckets
this.gatedBuckets.set(null);
this.gatingError.set(err instanceof Error ? err.message : 'Failed to load gating summary');
} finally {
this.gatingLoading.set(false);
}
}
/** Handle bucket chip click - expand/collapse filtered view. */
onBucketExpand(event: BucketExpandEvent): void {
if (this.expandedGatingBucket() === event.reason) {
this.expandedGatingBucket.set(null);
} else {
this.expandedGatingBucket.set(event.reason);
}
}
/** Toggle showing all gated findings. */
onShowAllGated(): void {
this.showAllGated.set(!this.showAllGated());
this.expandedGatingBucket.set(null);
}
/** Show gating explainer modal for a finding. */
async showGatingExplainer(findingId: string): Promise<void> {
this.gatingLoading.set(true);
this.gatingError.set(null);
try {
const status = await firstValueFrom(this.gatingService.getGatingStatus(findingId));
this.gatingExplainerFinding.set(status);
} catch (err) {
this.gatingExplainerFinding.set(null);
this.gatingError.set(err instanceof Error ? err.message : 'Failed to load gating details');
} finally {
this.gatingLoading.set(false);
}
}
/** Close the gating explainer. */
closeGatingExplainer(): void {
this.gatingExplainerFinding.set(null);
this.gatingError.set(null);
}
/** Load unified evidence for selected finding. */
async loadUnifiedEvidence(findingId: string): Promise<void> {
this.evidenceLoading.set(true);
this.evidenceError.set(null);
try {
const evidence = await firstValueFrom(this.gatingService.getUnifiedEvidence(findingId, {
includeReplayCommand: true,
includeDeltas: true,
includeReachability: true,
includeVex: true,
includeAttestations: true,
}));
this.unifiedEvidence.set(evidence);
} catch (err) {
this.unifiedEvidence.set(null);
this.evidenceError.set(err instanceof Error ? err.message : 'Failed to load evidence');
} finally {
this.evidenceLoading.set(false);
}
}
/** Download evidence bundle for the selected finding. */
downloadEvidenceBundle(): void {
const evidence = this.unifiedEvidence();
if (!evidence?.evidenceBundleUrl) return;
// Open download in new tab
const win = this.document.defaultView;
if (win) {
win.open(evidence.evidenceBundleUrl, '_blank');
}
}
async loadVexDecisions(): Promise<void> {
const subjectName = this.artifactId();
if (!subjectName) return;
@@ -372,6 +491,9 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
const alertCreatedAt = finding.vuln.publishedAt ? new Date(finding.vuln.publishedAt) : new Date();
this.ttfsTelemetry.startTracking(vulnId, alertCreatedAt);
}
// Load unified evidence for the selected finding (Sprint 9200.0001.0004)
void this.loadUnifiedEvidence(vulnId);
}
this.selectedVulnId.set(vulnId);