sprints work
This commit is contained in:
@@ -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 })));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user