feat: add bulk triage view component and related stories

- Exported BulkTriageViewComponent and its related types from findings module.
- Created a new accessibility test suite for score components using axe-core.
- Introduced design tokens for score components to standardize styling.
- Enhanced score breakdown popover for mobile responsiveness with drag handle.
- Added date range selector functionality to score history chart component.
- Implemented unit tests for date range selector in score history chart.
- Created Storybook stories for bulk triage view and score history chart with date range selector.
This commit is contained in:
StellaOps Bot
2025-12-26 01:01:35 +02:00
parent ed3079543c
commit 17613acf57
45 changed files with 9418 additions and 64 deletions

View File

@@ -0,0 +1,536 @@
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
const mockFindings = [
{
id: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
advisoryId: 'CVE-2024-1234',
packageName: 'lodash',
packageVersion: '4.17.20',
severity: 'critical',
status: 'open',
},
{
id: 'CVE-2024-5678@pkg:npm/express@4.18.0',
advisoryId: 'CVE-2024-5678',
packageName: 'express',
packageVersion: '4.18.0',
severity: 'high',
status: 'open',
},
{
id: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
advisoryId: 'GHSA-abc123',
packageName: 'requests',
packageVersion: '2.25.0',
severity: 'medium',
status: 'open',
},
];
const mockScoreResults = [
{
findingId: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
score: 92,
bucket: 'ActNow',
inputs: { rch: 0.9, rts: 0.8, bkp: 0, xpl: 0.9, src: 0.8, mit: 0.1 },
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.05 },
flags: ['live-signal', 'proven-path'],
explanations: ['High reachability via static analysis', 'Active runtime signals detected'],
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: true },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
},
{
findingId: 'CVE-2024-5678@pkg:npm/express@4.18.0',
score: 78,
bucket: 'ScheduleNext',
inputs: { rch: 0.7, rts: 0.3, bkp: 0, xpl: 0.6, src: 0.8, mit: 0 },
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.05 },
flags: ['proven-path'],
explanations: ['Verified call path to vulnerable function'],
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: false },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
},
{
findingId: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
score: 45,
bucket: 'Investigate',
inputs: { rch: 0.4, rts: 0, bkp: 0, xpl: 0.5, src: 0.6, mit: 0 },
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.05 },
flags: ['speculative'],
explanations: ['Reachability unconfirmed'],
caps: { speculativeCap: true, notAffectedCap: false, runtimeFloor: false },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
},
];
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/api/findings**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: mockFindings, total: mockFindings.length }),
})
);
await page.route('**/api/scores/batch', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: mockScoreResults }),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test.describe('Score Pill Component', () => {
test('displays score pills with correct bucket colors', async ({ page }) => {
await page.goto('/findings');
await expect(page.getByRole('heading', { name: /findings/i })).toBeVisible({ timeout: 10000 });
// Wait for scores to load
await page.waitForResponse('**/api/scores/batch');
// Check Act Now score (92) has red styling
const actNowPill = page.locator('stella-score-pill').filter({ hasText: '92' });
await expect(actNowPill).toBeVisible();
await expect(actNowPill).toHaveCSS('background-color', 'rgb(220, 38, 38)'); // #DC2626
// Check Schedule Next score (78) has amber styling
const scheduleNextPill = page.locator('stella-score-pill').filter({ hasText: '78' });
await expect(scheduleNextPill).toBeVisible();
// Check Investigate score (45) has blue styling
const investigatePill = page.locator('stella-score-pill').filter({ hasText: '45' });
await expect(investigatePill).toBeVisible();
});
test('score pill shows tooltip on hover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await scorePill.hover();
// Tooltip should appear with bucket name
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/act now|schedule next|investigate|watchlist/i);
});
test('score pill is keyboard accessible', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await scorePill.focus();
// Should have focus ring
await expect(scorePill).toBeFocused();
// Enter key should trigger click
await page.keyboard.press('Enter');
// Score breakdown popover should appear
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
});
});
test.describe('Score Breakdown Popover', () => {
test('opens on score pill click and shows all dimensions', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on the first score pill
await page.locator('stella-score-pill').first().click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show all 6 dimensions
await expect(popover.getByText('Reachability')).toBeVisible();
await expect(popover.getByText('Runtime Signals')).toBeVisible();
await expect(popover.getByText('Backport')).toBeVisible();
await expect(popover.getByText('Exploitability')).toBeVisible();
await expect(popover.getByText('Source Trust')).toBeVisible();
await expect(popover.getByText('Mitigations')).toBeVisible();
});
test('shows flags in popover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on score with live-signal and proven-path flags
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show flag badges
await expect(popover.locator('stella-score-badge[type="live-signal"]')).toBeVisible();
await expect(popover.locator('stella-score-badge[type="proven-path"]')).toBeVisible();
});
test('shows guardrails when applied', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on score with runtime floor applied
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show runtime floor guardrail
await expect(popover.getByText(/runtime floor/i)).toBeVisible();
});
test('closes on click outside', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
await page.locator('stella-score-pill').first().click();
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
// Click outside the popover
await page.locator('body').click({ position: { x: 10, y: 10 } });
await expect(page.locator('stella-score-breakdown-popover')).toBeHidden();
});
test('closes on Escape key', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
await page.locator('stella-score-pill').first().click();
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('stella-score-breakdown-popover')).toBeHidden();
});
});
test.describe('Score Badge Component', () => {
test('displays all flag types correctly', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Check for live-signal badge (green)
const liveSignalBadge = page.locator('stella-score-badge[type="live-signal"]').first();
await expect(liveSignalBadge).toBeVisible();
// Check for proven-path badge (blue)
const provenPathBadge = page.locator('stella-score-badge[type="proven-path"]').first();
await expect(provenPathBadge).toBeVisible();
// Check for speculative badge (orange)
const speculativeBadge = page.locator('stella-score-badge[type="speculative"]').first();
await expect(speculativeBadge).toBeVisible();
});
test('shows tooltip on badge hover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const badge = page.locator('stella-score-badge[type="live-signal"]').first();
await badge.hover();
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/runtime signals/i);
});
});
test.describe('Findings List Score Integration', () => {
test('loads scores automatically when findings load', async ({ page }) => {
await page.goto('/findings');
// Wait for both findings and scores to load
await page.waitForResponse('**/api/findings**');
const scoresResponse = await page.waitForResponse('**/api/scores/batch');
expect(scoresResponse.ok()).toBeTruthy();
// All score pills should be visible
const scorePills = page.locator('stella-score-pill');
await expect(scorePills).toHaveCount(3);
});
test('filters findings by bucket', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Act Now filter chip
await page.getByRole('button', { name: /act now/i }).click();
// Should only show Act Now findings
const visiblePills = page.locator('stella-score-pill:visible');
await expect(visiblePills).toHaveCount(1);
await expect(visiblePills.first()).toContainText('92');
});
test('filters findings by flag', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Live Signal filter checkbox
await page.getByLabel(/live signal/i).check();
// Should only show findings with live-signal flag
const visibleRows = page.locator('table tbody tr:visible');
await expect(visibleRows).toHaveCount(1);
});
test('sorts findings by score', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Score column header to sort
await page.getByRole('columnheader', { name: /score/i }).click();
// First row should have highest score
const firstPill = page.locator('table tbody tr').first().locator('stella-score-pill');
await expect(firstPill).toContainText('92');
// Click again to reverse sort
await page.getByRole('columnheader', { name: /score/i }).click();
// First row should now have lowest score
const firstPillReversed = page.locator('table tbody tr').first().locator('stella-score-pill');
await expect(firstPillReversed).toContainText('45');
});
});
test.describe('Bulk Triage View', () => {
test('shows bucket summary cards with correct counts', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Check bucket cards
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await expect(actNowCard).toContainText('1');
const scheduleNextCard = page.locator('.bucket-card').filter({ hasText: /schedule next/i });
await expect(scheduleNextCard).toContainText('1');
const investigateCard = page.locator('.bucket-card').filter({ hasText: /investigate/i });
await expect(investigateCard).toContainText('1');
});
test('select all in bucket selects correct findings', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Click Select All on Act Now bucket
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Action bar should appear with correct count
await expect(page.locator('.action-bar.visible')).toBeVisible();
await expect(page.locator('.selection-count')).toContainText('1');
});
test('bulk acknowledge action works', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Mock acknowledge endpoint
await page.route('**/api/findings/acknowledge', (route) =>
route.fulfill({ status: 200, body: JSON.stringify({ success: true }) })
);
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click acknowledge
await page.getByRole('button', { name: /acknowledge/i }).click();
// Progress overlay should appear
await expect(page.locator('.progress-overlay')).toBeVisible();
// Wait for completion
await expect(page.locator('.progress-overlay')).toBeHidden({ timeout: 5000 });
// Selection should be cleared
await expect(page.locator('.action-bar.visible')).toBeHidden();
});
test('bulk suppress action opens modal', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click suppress
await page.getByRole('button', { name: /suppress/i }).click();
// Modal should appear
const modal = page.locator('.modal').filter({ hasText: /suppress/i });
await expect(modal).toBeVisible();
await expect(modal.getByLabel(/reason/i)).toBeVisible();
});
test('bulk assign action opens modal', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click assign
await page.getByRole('button', { name: /assign/i }).click();
// Modal should appear
const modal = page.locator('.modal').filter({ hasText: /assign/i });
await expect(modal).toBeVisible();
await expect(modal.getByLabel(/assignee|email/i)).toBeVisible();
});
});
test.describe('Score History Chart', () => {
const mockHistory = [
{ score: 65, bucket: 'Investigate', policyDigest: 'sha256:abc', calculatedAt: '2025-01-01T10:00:00Z', trigger: 'scheduled', changedFactors: [] },
{ score: 72, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-05T10:00:00Z', trigger: 'evidence_update', changedFactors: ['xpl'] },
{ score: 85, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-10T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rts'] },
{ score: 92, bucket: 'ActNow', policyDigest: 'sha256:abc', calculatedAt: '2025-01-14T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rch'] },
];
test.beforeEach(async ({ page }) => {
await page.route('**/api/findings/*/history', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ entries: mockHistory }),
})
);
});
test('renders chart with data points', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
await expect(chart).toBeVisible();
// Should have data points
const dataPoints = chart.locator('.data-point, circle');
await expect(dataPoints).toHaveCount(4);
});
test('shows tooltip on data point hover', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
const dataPoint = chart.locator('.data-point, circle').first();
await dataPoint.hover();
await expect(page.locator('.chart-tooltip')).toBeVisible();
await expect(page.locator('.chart-tooltip')).toContainText(/score/i);
});
test('date range selector filters history', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
// Select 7 day range
await chart.getByRole('button', { name: /7 days/i }).click();
// Should filter to recent entries
const dataPoints = chart.locator('.data-point:visible, circle:visible');
const count = await dataPoints.count();
expect(count).toBeLessThanOrEqual(4);
});
});
test.describe('Accessibility', () => {
test('score pill has correct ARIA attributes', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await expect(scorePill).toHaveAttribute('role', 'status');
await expect(scorePill).toHaveAttribute('aria-label', /score.*92.*act now/i);
});
test('score badge has correct ARIA attributes', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const badge = page.locator('stella-score-badge').first();
await expect(badge).toHaveAttribute('role', 'img');
await expect(badge).toHaveAttribute('aria-label', /.+/);
});
test('bucket summary has correct ARIA label', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
const bucketSummary = page.locator('.bucket-summary');
await expect(bucketSummary).toHaveAttribute('aria-label', 'Findings by priority');
});
test('action bar has toolbar role', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding to show action bar
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
const actionBar = page.locator('.action-bar');
await expect(actionBar).toHaveAttribute('role', 'toolbar');
});
});