consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -297,7 +297,7 @@ test.describe('Pack route render checks', () => {
|
||||
'/ops',
|
||||
'/ops/operations',
|
||||
'/ops/operations/data-integrity',
|
||||
'/ops/operations/orchestrator',
|
||||
'/ops/operations/jobengine',
|
||||
'/ops/integrations',
|
||||
'/ops/integrations/advisory-vex-sources',
|
||||
'/ops/policy',
|
||||
|
||||
@@ -108,7 +108,7 @@ const EXPECTATIONS: PackExpectation[] = [
|
||||
{ pack: '22', path: '/ops', text: /Ops|Overview/i, canonical: /\/ops$/ },
|
||||
{ pack: '22', path: '/ops/operations', text: /Operations|Platform Ops/i, canonical: /\/ops\/operations$/ },
|
||||
{ pack: '22', path: '/ops/operations/data-integrity', text: /Data Integrity|Trust/i, canonical: /\/ops\/operations\/data-integrity/ },
|
||||
{ pack: '22', path: '/ops/operations/orchestrator', text: /Orchestrator/i, canonical: /\/ops\/operations\/orchestrator$/ },
|
||||
{ pack: '22', path: '/ops/operations/jobengine', text: /JobEngine/i, canonical: /\/ops\/operations\/jobengine$/ },
|
||||
{ pack: '22', path: '/ops/integrations', text: /Integration Hub|Integrations/i, canonical: /\/ops\/integrations$/ },
|
||||
{ pack: '22', path: '/ops/integrations/advisory-vex-sources', text: /Advisory|VEX|Source|FeedMirror|Integrations/i, canonical: /\/ops\/integrations\/advisory-vex-sources$/ },
|
||||
{ pack: '22', path: '/ops/policy', text: /Policy|Governance/i, canonical: /\/ops\/policy/ },
|
||||
|
||||
@@ -111,7 +111,7 @@ const canonicalRoutes = [
|
||||
'/ops/operations/data-integrity',
|
||||
'/ops/operations/system-health',
|
||||
'/ops/operations/health-slo',
|
||||
'/ops/operations/orchestrator',
|
||||
'/ops/operations/jobengine',
|
||||
'/ops/operations/scheduler',
|
||||
'/ops/operations/quotas',
|
||||
'/ops/operations/offline-kit',
|
||||
@@ -493,7 +493,7 @@ async function setupHarness(page: Page): Promise<void> {
|
||||
byModule: {
|
||||
authority: 0,
|
||||
policy: 1,
|
||||
orchestrator: 0,
|
||||
jobengine: 0,
|
||||
integrations: 0,
|
||||
vex: 0,
|
||||
scanner: 0,
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const stubSession = {
|
||||
subjectId: 'risk-e2e-user',
|
||||
tenant: 'tenant-1',
|
||||
scopes: [
|
||||
'ui.read',
|
||||
'scanner:read',
|
||||
'sbom:read',
|
||||
'advisory:read',
|
||||
'vex:read',
|
||||
'exception:read',
|
||||
'exceptions:read',
|
||||
'exceptions:write',
|
||||
'exceptions:approve',
|
||||
'findings:read',
|
||||
'vuln:view',
|
||||
'risk:read',
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
@@ -9,7 +28,7 @@ const mockConfig = {
|
||||
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 risk:read risk:manage exceptions:read exceptions:manage',
|
||||
scope: 'openid profile email ui.read scanner:read sbom:read advisory:read vex:read exception:read exceptions:read exceptions:write exceptions:approve findings:read vuln:view risk:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
@@ -17,13 +36,64 @@ const mockConfig = {
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
policy: 'https://policy.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
gateway: 'https://gateway.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
const mockRiskList = {
|
||||
items: [
|
||||
{
|
||||
id: 'risk-1',
|
||||
title: 'Critical RCE path',
|
||||
description: 'Reachable from public ingress',
|
||||
severity: 'critical',
|
||||
score: 96,
|
||||
tenantId: 'tenant-1',
|
||||
lastEvaluatedAt: '2026-02-26T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'risk-2',
|
||||
title: 'Insecure dependency',
|
||||
description: 'Pending runtime confirmation',
|
||||
severity: 'high',
|
||||
score: 78,
|
||||
tenantId: 'tenant-1',
|
||||
lastEvaluatedAt: '2026-02-26T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
traceId: 'trace-risk-list',
|
||||
};
|
||||
|
||||
const mockRiskStats = {
|
||||
countsBySeverity: {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
info: 0,
|
||||
none: 0,
|
||||
},
|
||||
countsByCategory: {
|
||||
vulnerability: 2,
|
||||
misconfiguration: 0,
|
||||
compliance: 0,
|
||||
supply_chain: 0,
|
||||
secret: 0,
|
||||
other: 0,
|
||||
},
|
||||
lastComputation: '2026-02-26T10:00:00Z',
|
||||
totalScore: 174,
|
||||
averageScore: 87,
|
||||
traceId: 'trace-risk-stats',
|
||||
};
|
||||
|
||||
const mockBudgetSnapshot = {
|
||||
config: {
|
||||
id: 'budget-1',
|
||||
@@ -32,35 +102,33 @@ const mockBudgetSnapshot = {
|
||||
warningThreshold: 70,
|
||||
criticalThreshold: 90,
|
||||
period: 'monthly',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
updatedAt: '2026-02-26T00:00:00Z',
|
||||
},
|
||||
currentRiskPoints: 450,
|
||||
headroom: 550,
|
||||
utilizationPercent: 45,
|
||||
status: 'healthy',
|
||||
timeSeries: [
|
||||
{ timestamp: '2025-12-20T00:00:00Z', actual: 300, budget: 1000, headroom: 700 },
|
||||
{ timestamp: '2025-12-21T00:00:00Z', actual: 350, budget: 1000, headroom: 650 },
|
||||
{ timestamp: '2025-12-22T00:00:00Z', actual: 380, budget: 1000, headroom: 620 },
|
||||
{ timestamp: '2025-12-23T00:00:00Z', actual: 420, budget: 1000, headroom: 580 },
|
||||
{ timestamp: '2025-12-24T00:00:00Z', actual: 450, budget: 1000, headroom: 550 },
|
||||
{ timestamp: '2026-02-24T00:00:00Z', actual: 420, budget: 1000, headroom: 580 },
|
||||
{ timestamp: '2026-02-25T00:00:00Z', actual: 440, budget: 1000, headroom: 560 },
|
||||
{ timestamp: '2026-02-26T00:00:00Z', actual: 450, budget: 1000, headroom: 550 },
|
||||
],
|
||||
computedAt: '2025-12-26T00:00:00Z',
|
||||
computedAt: '2026-02-26T00:00:00Z',
|
||||
traceId: 'trace-budget-1',
|
||||
};
|
||||
|
||||
const mockBudgetKpis = {
|
||||
headroom: 550,
|
||||
headroomDelta24h: -30,
|
||||
unknownsDelta24h: 2,
|
||||
riskRetired7d: 85,
|
||||
headroomDelta24h: -10,
|
||||
unknownsDelta24h: 1,
|
||||
riskRetired7d: 25,
|
||||
exceptionsExpiring: 1,
|
||||
burnRate: 12.5,
|
||||
projectedDaysToExceeded: 44,
|
||||
burnRate: 8.5,
|
||||
projectedDaysToExceeded: 64,
|
||||
topContributors: [
|
||||
{ vulnId: 'CVE-2025-1234', riskPoints: 50, packageName: 'lodash' },
|
||||
{ vulnId: 'CVE-2025-5678', riskPoints: 35, packageName: 'express' },
|
||||
{ vulnId: 'CVE-2026-1111', riskPoints: 70, packageName: 'openssl' },
|
||||
{ vulnId: 'CVE-2026-2222', riskPoints: 42, packageName: 'nginx' },
|
||||
],
|
||||
traceId: 'trace-kpi-1',
|
||||
};
|
||||
@@ -73,71 +141,103 @@ const mockVerdict = {
|
||||
{
|
||||
category: 'high_vuln',
|
||||
summary: '2 high severity vulnerabilities detected',
|
||||
description: 'CVE-2025-1234 and CVE-2025-5678 require review',
|
||||
description: 'CVE-2026-1111 and CVE-2026-2222 require review',
|
||||
impact: 2,
|
||||
relatedIds: ['CVE-2025-1234', 'CVE-2025-5678'],
|
||||
relatedIds: ['CVE-2026-1111', 'CVE-2026-2222'],
|
||||
evidenceType: 'vex',
|
||||
},
|
||||
{
|
||||
category: 'budget_exceeded',
|
||||
summary: 'Budget utilization at 45%',
|
||||
description: 'Within healthy range but trending upward',
|
||||
impact: false,
|
||||
},
|
||||
],
|
||||
previousVerdict: {
|
||||
level: 'routine',
|
||||
timestamp: '2025-12-23T10:00:00Z',
|
||||
timestamp: '2026-02-25T10:00:00Z',
|
||||
},
|
||||
riskDelta: {
|
||||
totalDelta: 30,
|
||||
criticalDelta: 0,
|
||||
highDelta: 2,
|
||||
mediumDelta: 1,
|
||||
lowDelta: -3,
|
||||
added: 2,
|
||||
removed: 0,
|
||||
net: 2,
|
||||
},
|
||||
timestamp: '2025-12-26T10:00:00Z',
|
||||
timestamp: '2026-02-26T10:00:00Z',
|
||||
traceId: 'trace-verdict-1',
|
||||
};
|
||||
|
||||
const mockExceptions = {
|
||||
items: [
|
||||
{
|
||||
id: 'exc-1',
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-1',
|
||||
tenantId: 'tenant-1',
|
||||
title: 'Known false positive in lodash',
|
||||
type: 'vulnerability',
|
||||
name: 'known-false-positive',
|
||||
displayName: 'Known false positive in openssl',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
justification: 'Not exploitable in our configuration',
|
||||
scope: { cves: ['CVE-2025-1234'] },
|
||||
createdAt: '2025-12-20T10:00:00Z',
|
||||
type: 'vulnerability',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
tenantId: 'tenant-1',
|
||||
vulnIds: ['CVE-2026-1111'],
|
||||
cves: ['CVE-2026-1111'],
|
||||
componentPurls: ['pkg:oci/openssl@3.0.2'],
|
||||
packages: ['pkg:oci/openssl@3.0.2'],
|
||||
images: ['registry/app:latest'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Not exploitable in this deployment profile',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2026-02-20T10:00:00Z',
|
||||
endDate: '2026-03-10T10:00:00Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'approval-1',
|
||||
approvedBy: 'approver-1',
|
||||
approvedAt: '2026-02-21T10:00:00Z',
|
||||
},
|
||||
],
|
||||
auditTrail: [
|
||||
{
|
||||
auditId: 'audit-1',
|
||||
action: 'created',
|
||||
actor: 'user-1',
|
||||
timestamp: '2026-02-20T10:00:00Z',
|
||||
},
|
||||
],
|
||||
labels: { source: 'e2e' },
|
||||
createdBy: 'user-1',
|
||||
expiresAt: '2026-01-20T10:00:00Z',
|
||||
riskPointsCovered: 50,
|
||||
reviewedBy: 'approver-1',
|
||||
reviewedAt: '2025-12-21T10:00:00Z',
|
||||
createdAt: '2026-02-20T10:00:00Z',
|
||||
updatedAt: '2026-02-21T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
accessToken: 'mock-access-token',
|
||||
idToken: 'mock-id-token',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
user: {
|
||||
sub: 'user-pm-1',
|
||||
name: 'PM User',
|
||||
email: 'pm@stellaops.test',
|
||||
},
|
||||
scopes: ['risk:read', 'risk:manage', 'exceptions:read', 'exceptions:manage'],
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
async function setupMockRoutes(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.route('https://authority.local/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://authority.local',
|
||||
authorization_endpoint: 'https://authority.local/connect/authorize',
|
||||
token_endpoint: 'https://authority.local/connect/token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
function setupMockRoutes(page) {
|
||||
// Mock config
|
||||
page.route('**/config.json', (route) =>
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://authority.local',
|
||||
authorization_endpoint: 'https://authority.local/connect/authorize',
|
||||
token_endpoint: 'https://authority.local/connect/token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -145,8 +245,24 @@ function setupMockRoutes(page) {
|
||||
})
|
||||
);
|
||||
|
||||
// Mock budget snapshot API
|
||||
page.route('**/api/risk/budgets/*/snapshot', (route) =>
|
||||
await page.route('**/risk/risk**', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith('/status')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRiskStats),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRiskList),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/risk-budget/snapshot**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -154,8 +270,7 @@ function setupMockRoutes(page) {
|
||||
})
|
||||
);
|
||||
|
||||
// Mock budget KPIs API
|
||||
page.route('**/api/risk/budgets/*/kpis', (route) =>
|
||||
await page.route('**/api/risk-budget/kpis**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -163,8 +278,7 @@ function setupMockRoutes(page) {
|
||||
})
|
||||
);
|
||||
|
||||
// Mock verdict API
|
||||
page.route('**/api/risk/gate/verdict*', (route) =>
|
||||
await page.route('**/api/gate/verdict**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -172,358 +286,102 @@ function setupMockRoutes(page) {
|
||||
})
|
||||
);
|
||||
|
||||
// Mock verdict history API
|
||||
page.route('**/api/risk/gate/history*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([mockVerdict]),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock exceptions API
|
||||
page.route('**/api/v1/exceptions*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockExceptions),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock exception create API
|
||||
page.route('**/api/v1/exceptions', async (route) => {
|
||||
await page.route('**/api/policy/exceptions**', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
...mockExceptions.items[0],
|
||||
id: 'exc-new-1',
|
||||
exceptionId: 'exc-created',
|
||||
status: 'pending_review',
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockExceptions),
|
||||
});
|
||||
});
|
||||
|
||||
// Block authority
|
||||
page.route('https://authority.local/**', (route) => route.abort());
|
||||
await page.route('**/api/policy/exceptions/**/transition', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...mockExceptions.items[0], status: 'approved' }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
}
|
||||
|
||||
test.describe.skip('Risk Dashboard - Budget View' /* TODO: Budget view not yet implemented */, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// Ignore storage errors in restricted contexts.
|
||||
}
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, stubSession);
|
||||
|
||||
test('displays budget burn-up chart', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Chart should be visible
|
||||
const chart = page.locator('.burnup-chart, [data-testid="budget-chart"]');
|
||||
await expect(chart).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays budget KPI tiles', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// KPI tiles should show headroom
|
||||
await expect(page.getByText('550')).toBeVisible(); // Headroom value
|
||||
await expect(page.getByText(/headroom/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows budget status indicator', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Status should be healthy (45% utilization)
|
||||
const healthyIndicator = page.locator('.healthy, [data-status="healthy"]');
|
||||
await expect(healthyIndicator.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays exceptions expiring count', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Exceptions expiring KPI
|
||||
await expect(page.getByText(/exceptions.*expir/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows risk retired in 7 days', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Risk retired value (85)
|
||||
await expect(page.getByText('85')).toBeVisible();
|
||||
});
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test.describe.skip('Risk Dashboard - Verdict View' /* TODO: Verdict view not yet implemented */, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
test('renders budget, verdict, diff, and exception widgets', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays verdict badge', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verdict badge should show "Review"
|
||||
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
|
||||
await expect(verdictBadge).toBeVisible();
|
||||
await expect(verdictBadge).toContainText(/review/i);
|
||||
});
|
||||
|
||||
test('displays verdict drivers', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Driver summary should be visible
|
||||
await expect(page.getByText(/high severity vulnerabilities/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows risk delta from previous verdict', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Risk delta indicator
|
||||
await expect(page.getByText(/\+30|\+2/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking evidence button opens panel', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Find and click evidence button
|
||||
const evidenceButton = page.getByRole('button', { name: /show.*vex|view.*evidence/i });
|
||||
if (await evidenceButton.isVisible()) {
|
||||
await evidenceButton.click();
|
||||
|
||||
// Panel should open
|
||||
await expect(page.locator('.vex-panel, [data-testid="vex-panel"]')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('verdict tooltip shows summary', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Hover over verdict badge
|
||||
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
|
||||
await verdictBadge.hover();
|
||||
|
||||
// Tooltip should appear with summary
|
||||
// Note: Actual tooltip behavior depends on implementation
|
||||
});
|
||||
await expect(page.locator('[data-testid="budget-widget"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="verdict-widget"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="diff-widget"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="exception-widget"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe.skip('Risk Dashboard - Exception Workflow' /* TODO: Exception workflow in risk dashboard not yet implemented */, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
test('shows budget chart and KPI values', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays active exceptions', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Exception should be visible
|
||||
await expect(page.getByText(/lodash|CVE-2025-1234/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create exception modal', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Find create exception button
|
||||
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click();
|
||||
|
||||
// Modal should open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText(/create exception/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('exception form validates required fields', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Open create modal
|
||||
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Submit button should be disabled without required fields
|
||||
const submitButton = page.getByRole('button', { name: /create|submit/i }).last();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill required fields
|
||||
await page.getByLabel(/title/i).fill('Test Exception');
|
||||
await page.getByLabel(/justification/i).fill('Test justification for E2E');
|
||||
|
||||
// Submit should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows exception expiry warning', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for expiry information
|
||||
const expiryInfo = page.getByText(/expires|expiring/i);
|
||||
await expect(expiryInfo.first()).toBeVisible();
|
||||
});
|
||||
await expect(page.locator('[data-testid="budget-chart"]')).toBeVisible();
|
||||
await expect(page.getByText('Budget Limit')).toBeVisible();
|
||||
await expect(page.getByText('Actual Risk Points')).toBeVisible();
|
||||
await expect(page.getByText('Headroom')).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe.skip('Risk Dashboard - Side-by-Side Diff' /* TODO: Side-by-side diff not yet implemented */, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
test('shows verdict badge and why summary from backend response', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays before and after states', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for comparison view
|
||||
const beforePane = page.locator('.pane.before, [data-testid="before-pane"]');
|
||||
const afterPane = page.locator('.pane.after, [data-testid="after-pane"]');
|
||||
|
||||
if (await beforePane.isVisible()) {
|
||||
await expect(afterPane).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('highlights metric changes', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for delta indicators
|
||||
const deltaIndicator = page.locator('.metric-delta, .delta-badge, [data-testid="delta"]');
|
||||
if (await deltaIndicator.first().isVisible()) {
|
||||
// Delta should show change direction
|
||||
await expect(deltaIndicator.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
await expect(page.locator('[data-testid="verdict-badge"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="verdict-summary"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="verdict-summary"]').getByText(/high severity vulnerabilities/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe.skip('Risk Dashboard - Responsive Design' /* TODO: Responsive design tests depend on unimplemented features */, () => {
|
||||
test('adapts to tablet viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
test('opens create exception modal and validates required fields', async ({ page }) => {
|
||||
await page.goto('/security/risk');
|
||||
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
await page.getByRole('button', { name: /new exception/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create Exception' })).toBeVisible();
|
||||
|
||||
await setupMockRoutes(page);
|
||||
const createButton = page.locator('.modal-footer .btn-primary');
|
||||
await expect(createButton).toBeDisabled();
|
||||
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByLabel('Title *').fill('Temporary OpenSSL exception');
|
||||
await page.getByLabel('Justification *').fill('Compensating controls are active while patch rollout completes.');
|
||||
|
||||
// Dashboard should be usable on tablet
|
||||
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
|
||||
await expect(dashboard).toBeVisible();
|
||||
});
|
||||
|
||||
test('adapts to desktop viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Dashboard should use full width on desktop
|
||||
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
|
||||
await expect(dashboard).toBeVisible();
|
||||
});
|
||||
await expect(createButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('keeps dashboard usable across tablet and desktop layouts', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/security/risk');
|
||||
await expect(page.locator('.risk-dashboard')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="budget-widget"]')).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.reload();
|
||||
await expect(page.locator('.risk-dashboard')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="verdict-widget"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
const stubSession = {
|
||||
subjectId: 'score-e2e-user',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'ui.read',
|
||||
'scanner:read',
|
||||
'sbom:read',
|
||||
'advisory:read',
|
||||
'vex:read',
|
||||
'exception:read',
|
||||
'findings:read',
|
||||
'vuln:view',
|
||||
'policy:read',
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
@@ -11,8 +25,7 @@ const mockConfig = {
|
||||
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',
|
||||
scope: 'openid profile email ui.read scanner:read sbom:read advisory:read vex:read exception:read findings:read vuln:view policy:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
@@ -20,67 +33,44 @@ const mockConfig = {
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
policy: 'https://policy.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
gateway: 'https://gateway.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',
|
||||
findingId: 'CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-scan-e2e',
|
||||
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'],
|
||||
flags: ['live-signal', 'proven-path', 'anchored', 'hard-fail'],
|
||||
explanations: [
|
||||
'High reachability via static analysis',
|
||||
'Active runtime signals detected',
|
||||
],
|
||||
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: true },
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
calculatedAt: '2026-02-26T10:00:00Z',
|
||||
},
|
||||
{
|
||||
findingId: 'CVE-2024-5678@pkg:npm/express@4.18.0',
|
||||
findingId: 'CVE-2026-8002@pkg:oci/worker-api@1.8.3-anchored-scan-e2e',
|
||||
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'],
|
||||
flags: ['proven-path', 'anchored'],
|
||||
explanations: ['Verified call path to vulnerable function'],
|
||||
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: false },
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
calculatedAt: '2026-02-26T10:00:00Z',
|
||||
},
|
||||
{
|
||||
findingId: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
|
||||
findingId: 'CVE-2026-8003@pkg:oci/frontend-ui@4.0.1-scan-e2e',
|
||||
score: 45,
|
||||
bucket: 'Investigate',
|
||||
inputs: { rch: 0.4, rts: 0, bkp: 0, xpl: 0.5, src: 0.6, mit: 0 },
|
||||
@@ -89,19 +79,67 @@ const mockScoreResults = [
|
||||
explanations: ['Reachability unconfirmed'],
|
||||
caps: { speculativeCap: true, notAffectedCap: false, runtimeFloor: false },
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
calculatedAt: '2026-02-26T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
const mockHistory = {
|
||||
findingId: 'CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-scan-e2e',
|
||||
history: [
|
||||
{
|
||||
score: 65,
|
||||
bucket: 'Investigate',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2026-02-01T10:00:00Z',
|
||||
trigger: 'scheduled',
|
||||
changedFactors: [],
|
||||
},
|
||||
{
|
||||
score: 78,
|
||||
bucket: 'ScheduleNext',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2026-02-10T10:00:00Z',
|
||||
trigger: 'evidence_update',
|
||||
changedFactors: ['rts'],
|
||||
},
|
||||
{
|
||||
score: 92,
|
||||
bucket: 'ActNow',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2026-02-20T10:00:00Z',
|
||||
trigger: 'evidence_update',
|
||||
changedFactors: ['rch'],
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function setupMockRoutes(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.route('https://authority.local/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://authority.local',
|
||||
authorization_endpoint: 'https://authority.local/connect/authorize',
|
||||
token_endpoint: 'https://authority.local/connect/token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://authority.local',
|
||||
authorization_endpoint: 'https://authority.local/connect/authorize',
|
||||
token_endpoint: 'https://authority.local/connect/token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
@@ -111,426 +149,114 @@ test.beforeEach(async ({ page }) => {
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/findings**', (route) =>
|
||||
await page.route('**/api/compare/baselines/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: mockFindings, total: mockFindings.length }),
|
||||
body: JSON.stringify({
|
||||
selectedDigest: '',
|
||||
selectionReason: 'No baseline selected for test fixture',
|
||||
alternatives: [],
|
||||
autoSelectEnabled: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/scores/batch', (route) =>
|
||||
await page.route('**/api/v1/findings/scores', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: mockScoreResults }),
|
||||
body: JSON.stringify({
|
||||
results: mockScoreResults,
|
||||
summary: {
|
||||
total: 3,
|
||||
byBucket: {
|
||||
ActNow: 1,
|
||||
ScheduleNext: 1,
|
||||
Investigate: 1,
|
||||
Watchlist: 0,
|
||||
},
|
||||
averageScore: 71.6667,
|
||||
calculationTimeMs: 8,
|
||||
},
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2026-02-26T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/findings/**/score-history**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockHistory),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// Ignore storage errors in restricted contexts.
|
||||
}
|
||||
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, stubSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test.describe.skip('Score Pill Component' /* TODO: Score pill not yet integrated into security findings page */, () => {
|
||||
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 });
|
||||
test('renders score pills in detail findings view', async ({ page }) => {
|
||||
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
|
||||
|
||||
// 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();
|
||||
});
|
||||
await expect(page.locator('.findings-list')).toBeVisible();
|
||||
await expect(page.locator('stella-score-pill')).toHaveCount(3);
|
||||
await expect(page.locator('stella-score-pill').filter({ hasText: '92' })).toBeVisible();
|
||||
await expect(page.locator('stella-score-pill').filter({ hasText: '78' })).toBeVisible();
|
||||
await expect(page.locator('stella-score-pill').filter({ hasText: '45' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe.skip('Score Breakdown Popover' /* TODO: Score breakdown popover not yet integrated into security findings page */, () => {
|
||||
test('opens on score pill click and shows all dimensions', async ({ page }) => {
|
||||
await page.goto('/findings');
|
||||
await page.waitForResponse('**/api/scores/batch');
|
||||
test('opens score breakdown popover with dimensions and factors', async ({ page }) => {
|
||||
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
|
||||
|
||||
// Click on the first score pill
|
||||
await page.locator('stella-score-pill').first().click();
|
||||
await page.locator('stella-score-pill').filter({ hasText: '92' }).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();
|
||||
});
|
||||
await expect(page.getByRole('dialog', { name: /Evidence score breakdown/i })).toBeVisible();
|
||||
await expect(page.getByText('Dimensions')).toBeVisible();
|
||||
await expect(page.getByText('High reachability via static analysis')).toBeVisible();
|
||||
await expect(page.locator('[aria-label=\"Score flags\"] > div > div')).toHaveCount(4);
|
||||
});
|
||||
|
||||
test.describe.skip('Score Badge Component' /* TODO: Score badge not yet integrated into security findings page */, () => {
|
||||
test('displays all flag types correctly', async ({ page }) => {
|
||||
await page.goto('/findings');
|
||||
await page.waitForResponse('**/api/scores/batch');
|
||||
test('filters findings by bucket chips', async ({ page }) => {
|
||||
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
|
||||
|
||||
// Check for live-signal badge (green)
|
||||
const liveSignalBadge = page.locator('stella-score-badge[type="live-signal"]').first();
|
||||
await expect(liveSignalBadge).toBeVisible();
|
||||
await page.locator('.bucket-summary .bucket-chip').filter({ hasText: /^Act Now/ }).click();
|
||||
|
||||
// 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);
|
||||
});
|
||||
await expect(page.locator('tbody tr.finding-row:visible')).toHaveCount(1);
|
||||
await expect(page.locator('tbody tr.finding-row:visible stella-score-pill')).toContainText('92');
|
||||
});
|
||||
|
||||
test.describe.skip('Findings List Score Integration' /* TODO: Score integration not yet in security findings page */, () => {
|
||||
test('loads scores automatically when findings load', async ({ page }) => {
|
||||
await page.goto('/findings');
|
||||
test('loads score history chart from API for active finding', async ({ page }) => {
|
||||
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
|
||||
|
||||
// Wait for both findings and scores to load
|
||||
await page.waitForResponse('**/api/findings**');
|
||||
const scoresResponse = await page.waitForResponse('**/api/scores/batch');
|
||||
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
|
||||
|
||||
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');
|
||||
});
|
||||
await expect(page.locator('[data-testid="score-history-panel"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="score-history-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe.skip('Bulk Triage View' /* TODO: Bulk triage view not yet implemented as a separate page */, () => {
|
||||
test('shows bucket summary cards with correct counts', async ({ page }) => {
|
||||
await page.goto('/findings/triage');
|
||||
await page.waitForResponse('**/api/scores/batch');
|
||||
test('sorts by score ascending and descending', async ({ page }) => {
|
||||
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
|
||||
|
||||
// Check bucket cards
|
||||
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
|
||||
await expect(actNowCard).toContainText('1');
|
||||
const scoreHeader = page.locator('th.col-score');
|
||||
|
||||
const scheduleNextCard = page.locator('.bucket-card').filter({ hasText: /schedule next/i });
|
||||
await expect(scheduleNextCard).toContainText('1');
|
||||
await scoreHeader.click();
|
||||
await expect(page.locator('tbody tr').first().locator('stella-score-pill')).toContainText('45');
|
||||
|
||||
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.skip('Score History Chart' /* TODO: Score history chart not yet integrated */, () => {
|
||||
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.skip('Accessibility' /* TODO: Accessibility tests depend on score components not yet integrated */, () => {
|
||||
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');
|
||||
});
|
||||
await scoreHeader.click();
|
||||
await expect(page.locator('tbody tr').first().locator('stella-score-pill')).toContainText('92');
|
||||
});
|
||||
|
||||
@@ -76,14 +76,14 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
|
||||
})
|
||||
);
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
await page.route('**/api/v1/orchestrator/workflows/**', (route) =>
|
||||
await page.route('**/api/v1/jobengine/workflows/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockWorkflow),
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/orchestrator/workflows/*/snapshots', (route) =>
|
||||
await page.route('**/api/v1/jobengine/workflows/*/snapshots', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -96,7 +96,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
|
||||
});
|
||||
|
||||
test('workflow visualization page loads and renders DAG nodes', async ({ page }) => {
|
||||
await page.goto('/release-orchestrator');
|
||||
await page.goto('/release-jobengine');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Inject workflow DAG visualization DOM
|
||||
@@ -142,7 +142,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
|
||||
});
|
||||
|
||||
test('time-travel controls render with playback buttons', async ({ page }) => {
|
||||
await page.goto('/release-orchestrator');
|
||||
await page.goto('/release-jobengine');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.evaluate((snapshots) => {
|
||||
@@ -185,7 +185,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
|
||||
});
|
||||
|
||||
test('time-travel step forward advances snapshot index', async ({ page }) => {
|
||||
await page.goto('/release-orchestrator');
|
||||
await page.goto('/release-jobengine');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.evaluate((snapshots) => {
|
||||
@@ -271,7 +271,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
|
||||
});
|
||||
|
||||
test('DAG node selection highlights related nodes', async ({ page }) => {
|
||||
await page.goto('/release-orchestrator');
|
||||
await page.goto('/release-jobengine');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.evaluate((wf) => {
|
||||
|
||||
Reference in New Issue
Block a user