Files
git.stella-ops.org/src/Web/StellaOps.Web/tests/e2e/unified-search.e2e.spec.ts

494 lines
16 KiB
TypeScript

// -----------------------------------------------------------------------------
// unified-search.e2e.spec.ts
// E2E tests for the Unified Search feature.
// Tests search input, entity cards, domain filters, keyboard navigation,
// synthesis panel, empty state, and accessibility.
// -----------------------------------------------------------------------------
import { expect, test, type Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { policyAuthorSession } from '../../src/app/testing';
// ---------------------------------------------------------------------------
// Shared mock data
// ---------------------------------------------------------------------------
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stella-ops-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 advisory:search advisory:read search:read findings:read',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://policy.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
gateway: 'https://gateway.local',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'https://authority.local/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'advisory:search',
'advisory:read',
'search:read',
'findings:read',
'vex:read',
]),
],
};
// ---------------------------------------------------------------------------
// Mock API response fixtures
// ---------------------------------------------------------------------------
const searchResultsResponse = {
query: 'CVE-2024-21626',
topK: 10,
cards: [
{
entityKey: 'cve:CVE-2024-21626',
entityType: 'finding',
domain: 'findings',
title: 'CVE-2024-21626: Container Escape via runc',
snippet: 'A container escape vulnerability in runc allows...',
score: 0.95,
severity: 'critical',
actions: [
{
label: 'View Finding',
actionType: 'navigate',
route: '/security/triage?q=CVE-2024-21626',
isPrimary: true,
},
{
label: 'Copy CVE',
actionType: 'copy',
command: 'CVE-2024-21626',
isPrimary: false,
},
],
sources: ['findings'],
},
{
entityKey: 'vex:CVE-2024-21626',
entityType: 'vex_statement',
domain: 'vex',
title: 'VEX: CVE-2024-21626 - Not Affected',
snippet: 'Product not affected by CVE-2024-21626...',
score: 0.82,
actions: [
{
label: 'View VEX',
actionType: 'navigate',
route: '/security/advisories-vex?q=CVE-2024-21626',
isPrimary: true,
},
],
sources: ['vex'],
},
{
entityKey: 'docs:container-deployment',
entityType: 'docs',
domain: 'knowledge',
title: 'Container Deployment Guide',
snippet: 'Guide for deploying containers securely...',
score: 0.65,
actions: [
{
label: 'Open',
actionType: 'navigate',
route: '/docs/deploy.md#overview',
isPrimary: true,
},
],
sources: ['knowledge'],
},
],
synthesis: {
summary:
'Results for CVE-2024-21626: 1 finding, 1 VEX statement, 1 knowledge result. CRITICAL severity finding detected.',
template: 'cve_summary',
confidence: 'high',
sourceCount: 3,
domainsCovered: ['findings', 'vex', 'knowledge'],
},
diagnostics: {
ftsMatches: 5,
vectorMatches: 3,
entityCardCount: 3,
durationMs: 42,
usedVector: true,
mode: 'hybrid',
},
};
const emptySearchResponse = {
query: 'xyznonexistent999',
topK: 10,
cards: [],
synthesis: {
summary: 'No results found for the given query.',
template: 'empty',
confidence: 'high',
sourceCount: 0,
domainsCovered: [],
},
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 5,
usedVector: true,
mode: 'hybrid',
},
};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
async function setupBasicMocks(page: Page) {
page.on('console', (message) => {
if (message.type() === 'error') {
console.log('[browser:error]', message.text());
}
});
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('https://authority.local/**', (route) => {
const url = route.request().url();
if (url.includes('/.well-known/openid-configuration')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
});
}
if (url.includes('/.well-known/jwks.json')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
});
}
if (url.includes('authorize')) {
return route.abort();
}
return route.fulfill({ status: 400, body: 'blocked' });
});
}
async function setupAuthenticatedSession(page: Page) {
await page.addInitScript((stubSession) => {
(window as any).__stellaopsTestSession = stubSession;
}, shellSession);
}
/**
* Intercepts POST requests to the unified search endpoint and replies with
* the provided fixture payload.
*/
async function mockSearchApi(page: Page, responseBody: unknown) {
await page.route('**/search/query**', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(responseBody),
});
}
return route.continue();
});
}
/**
* Focuses the global search input and types a query.
* Returns the search input locator for further assertions.
*/
async function typeInSearchInput(page: Page, query: string) {
const searchInput = page.locator('app-global-search input[type="text"]');
await searchInput.focus();
await searchInput.fill(query);
return searchInput;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Unified Search', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
});
// -------------------------------------------------------------------------
// 1. Search input is visible and focusable
// -------------------------------------------------------------------------
test('search input is visible and focusable', async ({ page }) => {
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
const searchInput = page.locator('app-global-search input[type="text"]');
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await expect(searchInput).toHaveAttribute('placeholder', /search everything/i);
await searchInput.focus();
await expect(searchInput).toBeFocused();
});
// -------------------------------------------------------------------------
// 2. Typing a query shows entity cards
// -------------------------------------------------------------------------
test('typing a query shows entity cards with correct titles', async ({ page }) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
// Verify entity cards render
const entityCards = page.locator('app-entity-card');
await expect(entityCards).toHaveCount(3, { timeout: 10_000 });
// Verify card titles match the mock data
const cardTitles = await entityCards.allTextContents();
const combinedText = cardTitles.join(' ');
expect(combinedText).toContain('CVE-2024-21626');
expect(combinedText).toContain('Container Escape via runc');
expect(combinedText).toContain('VEX');
expect(combinedText).toContain('Container Deployment Guide');
});
// -------------------------------------------------------------------------
// 3. Domain filter chips work
// -------------------------------------------------------------------------
test('domain filter chips are present and clickable', async ({ page }) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
// Domain filter chips should be rendered (findings, vex, knowledge)
const filterChips = resultsContainer.locator('[data-role="domain-filter"], .search__filters .search__filter, .search__filter, .chip');
const chipCount = await filterChips.count();
expect(chipCount).toBeGreaterThanOrEqual(1);
// Click the first chip and verify it toggles (gets an active/selected class)
const firstChip = filterChips.first();
await firstChip.click();
// After clicking a filter chip, results should still be visible
// (the component filters client-side or re-fetches)
await expect(resultsContainer).toBeVisible();
});
// -------------------------------------------------------------------------
// 4. Keyboard navigation
// -------------------------------------------------------------------------
test('keyboard navigation: ArrowDown moves selection, Escape closes results', async ({
page,
}) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
const searchInput = await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
// Press ArrowDown to move selection to the first entity card
await searchInput.press('ArrowDown');
// First card should receive a visual active/selected indicator
const firstCard = page.locator('app-entity-card').first();
await expect(firstCard).toBeVisible();
// Press ArrowDown again to move to the second card
await searchInput.press('ArrowDown');
// Press Escape to close the results panel
await page.keyboard.press('Escape');
await expect(resultsContainer).toBeHidden({ timeout: 5_000 });
});
test('keyboard navigation: Enter on selected card triggers primary action', async ({
page,
}) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
const searchInput = await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
// Navigate to the first card
await searchInput.press('ArrowDown');
const initialUrl = page.url();
await page.keyboard.press('Enter');
// Enter should trigger the selected card primary route.
await expect.poll(() => page.url(), { timeout: 10_000 }).not.toBe(initialUrl);
});
// -------------------------------------------------------------------------
// 5. Synthesis panel renders
// -------------------------------------------------------------------------
test('synthesis panel renders summary text after search', async ({ page }) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
// Synthesis panel should render
const synthesisPanel = page.locator('app-synthesis-panel');
await expect(synthesisPanel).toBeVisible({ timeout: 10_000 });
// Verify the synthesis summary text is present
const synthesisText = await synthesisPanel.textContent();
expect(synthesisText).toContain('CVE-2024-21626');
expect(synthesisText).toContain('CRITICAL');
// Verify domain coverage indicators
expect(synthesisText).toContain('finding');
expect(synthesisText).toContain('VEX');
});
// -------------------------------------------------------------------------
// 6. Empty state
// -------------------------------------------------------------------------
test('empty state shows "No results" message when API returns zero cards', async ({
page,
}) => {
await mockSearchApi(page, emptySearchResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearchInput(page, 'xyznonexistent999');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
// No entity cards should be rendered
const entityCards = page.locator('app-entity-card');
await expect(entityCards).toHaveCount(0, { timeout: 5_000 });
// A "No results" or empty state message should be visible
const noResultsText = resultsContainer.getByText(/no results/i);
await expect(noResultsText).toBeVisible({ timeout: 5_000 });
});
// -------------------------------------------------------------------------
// 7. Accessibility (axe-core)
// -------------------------------------------------------------------------
test('search results pass axe-core accessibility checks', async ({ page }) => {
await mockSearchApi(page, searchResultsResponse);
await page.goto('/');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearchInput(page, 'CVE-2024-21626');
const resultsContainer = page.locator('.search__results');
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
// Wait for entity cards to be fully rendered before scanning
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
// Run axe-core against the search results region
const a11yResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('.search')
.analyze();
const violations = a11yResults.violations;
// Log violations for debugging but allow the test to surface issues
if (violations.length > 0) {
console.log(
'[a11y] Unified search violations:',
JSON.stringify(
violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
})),
null,
2,
),
);
}
// Fail on serious or critical a11y violations
const serious = violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
);
expect(
serious,
`Expected no critical/serious a11y violations but found ${serious.length}: ${serious.map((v) => v.id).join(', ')}`,
).toHaveLength(0);
});
});