494 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|