search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -0,0 +1,981 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-cross-domain.e2e.spec.ts
|
||||
// E2E tests for cross-domain natural language queries (410 cases),
|
||||
// domain filtering, keyboard shortcuts, ambient context, edge cases,
|
||||
// and comprehensive accessibility.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
doctorCard,
|
||||
docsCard,
|
||||
emptyResponse,
|
||||
findingCard,
|
||||
mockSearchApi,
|
||||
mockSearchApiDynamic,
|
||||
policyCard,
|
||||
secretCard,
|
||||
vexCard,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-domain fixtures (multi-domain responses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const troubleshootDbResponse = buildResponse('cannot connect to database', [
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Postgres Connectivity',
|
||||
snippet: 'Validates TCP/TLS connectivity to the primary Postgres cluster.',
|
||||
score: 0.95,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.pool',
|
||||
title: 'Postgres Connection Pool',
|
||||
snippet: 'Connection pool utilization and health thresholds.',
|
||||
score: 0.80,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'db/local-postgres.md',
|
||||
title: 'Local Postgres Setup',
|
||||
snippet: 'Guide for configuring local PostgreSQL for development.',
|
||||
score: 0.60,
|
||||
}),
|
||||
], {
|
||||
summary: 'Database connectivity issue. Run `stella doctor run check.postgres.*` to diagnose. See Postgres setup docs.',
|
||||
template: 'troubleshoot',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const troubleshootAuthResponse = buildResponse('authentication failed', [
|
||||
doctorCard({
|
||||
checkId: 'check.auth.config',
|
||||
title: 'Auth Configuration',
|
||||
snippet: 'Validates OIDC provider configuration and token service health.',
|
||||
score: 0.92,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.auth.oidc',
|
||||
title: 'OIDC Provider Connectivity',
|
||||
snippet: 'Checks connectivity to the configured OIDC identity provider.',
|
||||
score: 0.85,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/authority/architecture.md',
|
||||
title: 'Authority Architecture',
|
||||
snippet: 'OAuth 2.1 / OIDC authority module with DPoP authentication.',
|
||||
score: 0.55,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootSigningResponse = buildResponse('attestation signing failed HSM timeout', [
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.hsm',
|
||||
title: 'HSM PKCS#11 Availability',
|
||||
snippet: 'HSM module connectivity check — timeout detected.',
|
||||
score: 0.94,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.attestation.keymaterial',
|
||||
title: 'Signing Key Expiration',
|
||||
snippet: 'Checks key material health and expiration status.',
|
||||
score: 0.82,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/signer/architecture.md',
|
||||
title: 'Signer Architecture',
|
||||
snippet: 'Key management, HSM integration, and signing ceremony documentation.',
|
||||
score: 0.60,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootTimestampResponse = buildResponse('TSA endpoint not responding', [
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.availability',
|
||||
title: 'TSA Availability',
|
||||
snippet: 'RFC-3161 TSA endpoint health probe — connection timeout.',
|
||||
score: 0.96,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.failover-ready',
|
||||
title: 'TSA Failover Ready',
|
||||
snippet: 'Checks if failover TSA endpoint is configured and responsive.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootIntegrationResponse = buildResponse('OCI registry credentials expired', [
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.credentials',
|
||||
title: 'OCI Registry Credentials',
|
||||
snippet: 'Registry credentials validation — token expired.',
|
||||
score: 0.93,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.registry',
|
||||
title: 'OCI Registry',
|
||||
snippet: 'Non-destructive HEAD probe to verify registry availability.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const howToScanResponse = buildResponse('how to scan a container', [
|
||||
docsCard({
|
||||
docPath: 'quickstart.md',
|
||||
title: 'Quick Start Guide',
|
||||
snippet: 'Step-by-step: scanning your first container image with Stella Ops.',
|
||||
score: 0.90,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/scanner/architecture.md',
|
||||
title: 'Scanner Architecture',
|
||||
snippet: 'Container image scanning pipeline, SBOM generation, and vulnerability detection.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const howToDoctorResponse = buildResponse('how to run doctor checks', [
|
||||
docsCard({
|
||||
docPath: 'modules/doctor/architecture.md',
|
||||
title: 'Doctor Architecture',
|
||||
snippet: 'Health check plugin system with 134+ checks across 18 plugins.',
|
||||
score: 0.88,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Example: Postgres Connectivity',
|
||||
snippet: 'Run: stella doctor run check.postgres.connectivity',
|
||||
score: 0.65,
|
||||
}),
|
||||
]);
|
||||
|
||||
const navigationResponse = buildResponse('open settings', [
|
||||
docsCard({
|
||||
docPath: 'setup/overview',
|
||||
title: 'Setup & Configuration',
|
||||
snippet: 'Navigate to /setup to configure topology, agents, and integrations.',
|
||||
score: 0.85,
|
||||
}),
|
||||
]);
|
||||
|
||||
const cliResponse = buildResponse('stella release create', [
|
||||
docsCard({
|
||||
docPath: 'API_CLI_REFERENCE.md',
|
||||
title: 'CLI Reference: stella release create',
|
||||
snippet: 'Creates a new release version. Usage: stella release create --name v1.2.0 --target production',
|
||||
score: 0.92,
|
||||
}),
|
||||
]);
|
||||
|
||||
const conceptResponse = buildResponse('what is a VEX statement', [
|
||||
docsCard({
|
||||
docPath: 'VEX_CONSENSUS_GUIDE.md',
|
||||
title: 'VEX Consensus Guide',
|
||||
snippet: 'A VEX statement declares whether a product is affected by a specific vulnerability.',
|
||||
score: 0.95,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'GLOSSARY.md',
|
||||
title: 'Glossary: VEX',
|
||||
snippet: 'Vulnerability Exploitability eXchange — a machine-readable format for communicating vulnerability status.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const multiDomainCveResponse = buildResponse('CVE-2024-21626 runc VEX', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626: Container Escape via runc',
|
||||
snippet: 'CRITICAL container escape vulnerability in runc 1.1.10.',
|
||||
severity: 'critical',
|
||||
score: 0.97,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2024-21626 — Not Affected',
|
||||
snippet: 'Vendor VEX: component_not_present in production image.',
|
||||
score: 0.85,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/scanner/architecture.md',
|
||||
title: 'Container Scanning Documentation',
|
||||
snippet: 'Guide for scanning container images and handling runc-related findings.',
|
||||
score: 0.60,
|
||||
}),
|
||||
], {
|
||||
summary: 'CVE-2024-21626: CRITICAL finding with VEX "not_affected" from vendor. 3 results across findings, VEX, and docs.',
|
||||
template: 'cve_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings', 'vex', 'knowledge'],
|
||||
});
|
||||
|
||||
const policyFindingCrossResponse = buildResponse('release blocked by reachable CVE', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 (REACHABLE)',
|
||||
snippet: 'Container escape — reachability confirmed by static + runtime analysis.',
|
||||
severity: 'critical',
|
||||
score: 0.95,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'reachable-cve-gate-block',
|
||||
title: 'ReachableCveGate: BLOCKED',
|
||||
snippet: 'Release promotion blocked. Reachable critical CVE without VEX justification.',
|
||||
score: 0.90,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'under_investigation',
|
||||
title: 'VEX: Under Investigation',
|
||||
snippet: 'No VEX justification available yet. Under investigation by vendor.',
|
||||
score: 0.70,
|
||||
}),
|
||||
], {
|
||||
summary: 'Release BLOCKED: CVE-2024-21626 is reachable, VEX under investigation. ReachableCveGate triggered.',
|
||||
template: 'mixed_overview',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings', 'policy', 'vex'],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Cross-Domain Queries', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.1 Troubleshooting Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Troubleshooting Queries', () => {
|
||||
const dbTroubleshoot = [
|
||||
'cannot connect to database',
|
||||
'database connection failing',
|
||||
'connection pool exhausted',
|
||||
'postgres connectivity lost',
|
||||
'migration failed',
|
||||
'slow query performance',
|
||||
];
|
||||
|
||||
for (const query of dbTroubleshoot) {
|
||||
test(`DB: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const authTroubleshoot = [
|
||||
'authentication failed',
|
||||
'token expired',
|
||||
'error 403 forbidden',
|
||||
'OIDC provider connectivity',
|
||||
];
|
||||
|
||||
for (const query of authTroubleshoot) {
|
||||
test(`Auth: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootAuthResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const signingTroubleshoot = [
|
||||
'signing failed',
|
||||
'attestation signing failed HSM timeout',
|
||||
'HSM not available',
|
||||
'cosign key material',
|
||||
];
|
||||
|
||||
for (const query of signingTroubleshoot) {
|
||||
test(`Signing: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootSigningResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const timestampTroubleshoot = [
|
||||
'TSA endpoint not responding',
|
||||
'TSA response time degraded',
|
||||
'TSA certificate about to expire',
|
||||
'OCSP responder unreachable',
|
||||
'CRL distribution endpoint down',
|
||||
'timestamp token approaching expiry',
|
||||
'system clock not synced NTP',
|
||||
'Rekor time correlation drift',
|
||||
];
|
||||
|
||||
for (const query of timestampTroubleshoot) {
|
||||
test(`TSA: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootTimestampResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const integrationTroubleshoot = [
|
||||
'OCI registry credentials expired',
|
||||
'registry push denied insufficient permissions',
|
||||
'S3 bucket access denied',
|
||||
'Slack API rate limited',
|
||||
'Git provider SSH key rejected',
|
||||
'LDAP bind failed wrong credentials',
|
||||
'secrets manager Vault sealed',
|
||||
];
|
||||
|
||||
for (const query of integrationTroubleshoot) {
|
||||
test(`Integration: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootIntegrationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const agentTroubleshoot = [
|
||||
'agent not responding',
|
||||
'agent version mismatch in cluster',
|
||||
'agent certificate expired',
|
||||
'agent resource utilization critical',
|
||||
'stale agent not reporting',
|
||||
'agent task failure rate above threshold',
|
||||
'cluster health degraded',
|
||||
'quorum lost',
|
||||
];
|
||||
|
||||
for (const query of agentTroubleshoot) {
|
||||
test(`Agent: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const complianceTroubleshoot = [
|
||||
'compliance evidence integrity violation',
|
||||
'provenance chain validation failed',
|
||||
'audit readiness check failed',
|
||||
'evidence generation rate dropped',
|
||||
'export readiness not met',
|
||||
'evidence tampering detected',
|
||||
];
|
||||
|
||||
for (const query of complianceTroubleshoot) {
|
||||
test(`Compliance: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const cryptoTroubleshoot = [
|
||||
'eIDAS compliance check failing',
|
||||
'FIPS module not loaded',
|
||||
'HSM PKCS#11 module unavailable',
|
||||
'GOST crypto provider not found',
|
||||
'SM2/SM3/SM4 provider missing',
|
||||
];
|
||||
|
||||
for (const query of cryptoTroubleshoot) {
|
||||
test(`Crypto: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootSigningResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.2 How-To & Workflow Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('How-To Queries', () => {
|
||||
const howToQueries = [
|
||||
'how to scan a container',
|
||||
'how to create a release',
|
||||
'how to promote to production',
|
||||
'how to triage a finding',
|
||||
'how to suppress a vulnerability',
|
||||
'how to generate SBOM',
|
||||
'how to write a VEX statement',
|
||||
'how to configure notifications',
|
||||
'how to set up policy gates',
|
||||
'how to export evidence',
|
||||
'how to verify attestation',
|
||||
'how to configure air gap mode',
|
||||
'how to rotate signing keys',
|
||||
'how to run doctor checks',
|
||||
'how to create policy exception',
|
||||
'how to investigate reachability',
|
||||
'how to configure Prometheus',
|
||||
'how to set up email alerts',
|
||||
'how to configure escalation',
|
||||
'how to deploy offline',
|
||||
];
|
||||
|
||||
for (const query of howToQueries) {
|
||||
test(`"${query}" returns documentation cards`, async ({ page }) => {
|
||||
const fixture = query.includes('doctor') ? howToDoctorResponse : howToScanResponse;
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.3 Navigation & Feature Discovery
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Navigation Queries', () => {
|
||||
const navQueries = [
|
||||
'open settings',
|
||||
'go to findings',
|
||||
'show dashboard',
|
||||
'open security view',
|
||||
'go to policy gates',
|
||||
'open VEX hub',
|
||||
'show release history',
|
||||
'open agent fleet',
|
||||
'go to evidence center',
|
||||
'open timeline',
|
||||
'go to triage inbox',
|
||||
'open approval queue',
|
||||
'show integrations',
|
||||
'open policy studio',
|
||||
'open doctor diagnostics',
|
||||
'where is the audit log',
|
||||
'find the compliance dashboard',
|
||||
'open mission control',
|
||||
];
|
||||
|
||||
for (const query of navQueries) {
|
||||
test(`nav: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, navigationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.4 CLI Command Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CLI Command Searches', () => {
|
||||
const cliQueries = [
|
||||
'stella release create',
|
||||
'stella release promote',
|
||||
'stella scan graph',
|
||||
'stella policy validate-yaml',
|
||||
'stella policy simulate',
|
||||
'stella doctor run',
|
||||
'stella vex generate',
|
||||
'stella evidence export',
|
||||
'stella attest sign',
|
||||
'stella verify',
|
||||
'stella db migrate',
|
||||
'stella airgap prepare',
|
||||
'stella agent status',
|
||||
'stella crypto keygen',
|
||||
'stella sbom generate',
|
||||
'stella notify test',
|
||||
'stella feeds sync',
|
||||
'stella delta compare',
|
||||
'stella reachability check',
|
||||
'stella auth login',
|
||||
];
|
||||
|
||||
for (const query of cliQueries) {
|
||||
test(`CLI: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, cliResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.5 Concept & Explanation Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Concept Queries', () => {
|
||||
const conceptQueries = [
|
||||
'what is a VEX statement',
|
||||
'explain SBOM',
|
||||
'what is reachability analysis',
|
||||
'explain attestation',
|
||||
'what is a policy gate',
|
||||
'explain risk budget',
|
||||
'what is severity fusion',
|
||||
'what is deterministic replay',
|
||||
'explain provenance',
|
||||
'what is sealed mode',
|
||||
'what is content addressable storage',
|
||||
'explain smart diff',
|
||||
'what is a linkset',
|
||||
'what is the findings ledger',
|
||||
'explain reciprocal rank fusion',
|
||||
'what is a verdict',
|
||||
'explain storm breaker',
|
||||
'what is a dead letter queue',
|
||||
'explain circuit breaker pattern',
|
||||
'what is DPoP authentication',
|
||||
];
|
||||
|
||||
for (const query of conceptQueries) {
|
||||
test(`concept: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, conceptResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.8 Multi-Domain Operational Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Multi-Domain Operational Queries', () => {
|
||||
const multiDomainQueries = [
|
||||
'CVE-2024-21626 runc escape reachability VEX',
|
||||
'log4j affected not_affected VEX',
|
||||
'how to suppress CVE-2023-44487',
|
||||
'VEX not_affected but policy still blocks',
|
||||
'reachability shows vulnerable code not in execute path',
|
||||
];
|
||||
|
||||
for (const query of multiDomainQueries) {
|
||||
test(`multi-domain: "${query}" renders cards from multiple domains`, async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
test('multi-domain synthesis covers all domains', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 runc VEX');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CVE-2024-21626');
|
||||
});
|
||||
|
||||
const policyFindingQueries = [
|
||||
'release blocked by reachable CVE and no VEX',
|
||||
'policy violation critical CVE-2024-3094',
|
||||
'promote release with blocked findings',
|
||||
'VEX consensus disagreement blocking release',
|
||||
];
|
||||
|
||||
for (const query of policyFindingQueries) {
|
||||
test(`policy+finding: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyFindingCrossResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const operationalQueries = [
|
||||
'SBOM incomplete missing Go dependencies',
|
||||
'binary analysis found crypto weakness',
|
||||
'environment drift detected after deployment',
|
||||
'policy determinism check failed in sealed mode',
|
||||
'feed mirror stale advisory data 7 days old',
|
||||
'CI integration broken OIDC token expired',
|
||||
'scheduler missed nightly scan job',
|
||||
'agent fleet partial quorum during upgrade',
|
||||
'Prometheus not collecting scanner metrics',
|
||||
'trust anchor expired blocking attestation',
|
||||
'evidence staleness exceeding policy TTL',
|
||||
'findings backlog prioritization by EPSS',
|
||||
];
|
||||
|
||||
for (const query of operationalQueries) {
|
||||
test(`operational: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Filtering Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Domain Filtering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('domain filter chips render for multi-domain results', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 VEX');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
const filters = resultsContainer.locator('[data-role="domain-filter"], .search__filters .search__filter, .search__filter, .chip');
|
||||
const count = await filters.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('clicking domain filter narrows results', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 VEX');
|
||||
await waitForResults(page);
|
||||
|
||||
const filters = page.locator('.search__filter, [data-role="domain-filter"]');
|
||||
if ((await filters.count()) > 0) {
|
||||
await filters.first().click();
|
||||
// Results should still be visible (filtered view)
|
||||
await expect(page.locator('.search__results')).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard Navigation Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Keyboard Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('ArrowDown moves through entity cards', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
const input = await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
// Navigate down through cards
|
||||
await input.press('ArrowDown');
|
||||
await input.press('ArrowDown');
|
||||
await input.press('ArrowDown');
|
||||
|
||||
// Cards should still be visible
|
||||
await expect(page.locator('app-entity-card').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Escape closes results, second Escape blurs input', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
// First Escape closes results
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.search__results')).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('Enter on selected card triggers primary action', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
const input = await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
await input.press('ArrowDown');
|
||||
|
||||
const navigationPromise = page.waitForURL(/\/security\/(triage|advisories-vex)/, { timeout: 10_000 });
|
||||
await page.keyboard.press('Enter');
|
||||
await navigationPromise;
|
||||
expect(page.url()).toMatch(/\/security\/(triage|advisories-vex)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty State & Edge Cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Edge Cases', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('empty query does not trigger search', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse(''));
|
||||
await waitForShell(page);
|
||||
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
await input.fill('');
|
||||
|
||||
// Short wait — results should NOT appear for empty query
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.search__results')).toBeHidden();
|
||||
});
|
||||
|
||||
test('single character does not trigger search (min 2 chars)', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('a'));
|
||||
await waitForShell(page);
|
||||
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
await input.fill('a');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.search__results .search__cards')).toBeHidden();
|
||||
});
|
||||
|
||||
test('no results shows empty state message', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('xyznonexistent999'));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'xyznonexistent999');
|
||||
await waitForResults(page);
|
||||
|
||||
const entityCards = page.locator('app-entity-card');
|
||||
await expect(entityCards).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
const noResults = page.locator('.search__results').getByText(/no results/i);
|
||||
await expect(noResults).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('very long query string does not crash UI', async ({ page }) => {
|
||||
const longQuery = 'CVE-2024-21626 '.repeat(50).trim();
|
||||
await mockSearchApi(page, emptyResponse(longQuery));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, longQuery);
|
||||
|
||||
// Should not throw; results container should appear
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('special characters in query are handled safely', async ({ page }) => {
|
||||
const specialQuery = '<script>alert(1)</script> CVE-2024-21626 "test" & | ; $()';
|
||||
await mockSearchApi(page, emptyResponse(specialQuery));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, specialQuery);
|
||||
|
||||
// Should not crash or inject scripts
|
||||
await page.waitForTimeout(500);
|
||||
// Verify page is still functional
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rapid typing triggers debounce (only one API call)', async ({ page }) => {
|
||||
let apiCallCount = 0;
|
||||
await page.route('**/search/query**', (route) => {
|
||||
apiCallCount++;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(emptyResponse('test')),
|
||||
});
|
||||
});
|
||||
|
||||
await waitForShell(page);
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
|
||||
// Type rapidly, character by character
|
||||
for (const char of 'CVE-2024-21626') {
|
||||
await input.type(char, { delay: 20 });
|
||||
}
|
||||
|
||||
// Wait for debounce to settle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should have made fewer calls than characters typed (debounce at 200ms)
|
||||
expect(apiCallCount).toBeLessThan(14);
|
||||
});
|
||||
|
||||
test('API error gracefully handled', async ({ page }) => {
|
||||
await page.route('**/search/query**', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal Server Error' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
|
||||
// Wait and verify the page doesn't crash
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('network timeout gracefully handled', async ({ page }) => {
|
||||
await page.route('**/search/query**', (route) => route.abort('timedout'));
|
||||
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('search input has proper ARIA attributes', async ({ page }) => {
|
||||
await waitForShell(page);
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await expect(input).toHaveAttribute('aria-label', /search/i);
|
||||
await expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||
});
|
||||
|
||||
test('entity cards are present in the results list', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const cards = page.locator('.search__cards app-entity-card');
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('synthesis panel has role="region" and aria-label', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel [role="region"]');
|
||||
if ((await synthesis.count()) > 0) {
|
||||
await expect(synthesis).toHaveAttribute('aria-label', /synthesis|summary/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('results listbox has proper ARIA', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
const listbox = page.locator('#search-results, [role="listbox"]');
|
||||
await expect(listbox).toBeVisible();
|
||||
});
|
||||
|
||||
test('findings results pass axe-core WCAG 2.0 AA', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
|
||||
if (serious.length > 0) {
|
||||
console.log(
|
||||
'[a11y] violations:',
|
||||
JSON.stringify(serious.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })), null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
expect(serious, `${serious.length} critical/serious a11y violations`).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('doctor results pass axe-core', async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'database connection');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
expect(serious).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('empty state passes axe-core', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('nonexistent'));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'nonexistent');
|
||||
await waitForResults(page);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
expect(serious).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-doctor.e2e.spec.ts
|
||||
// E2E tests for Doctor Check domain queries (180 cases from test-cases.md).
|
||||
// Verifies: doctor entity cards, check code rendering, run actions,
|
||||
// synthesis templates, severity badges, and domain filtering.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
doctorCard,
|
||||
docsCard,
|
||||
mockSearchApi,
|
||||
mockSearchApiDynamic,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: doctor response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const postgresCheckResponse = buildResponse('database connection failing', [
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Postgres Connectivity',
|
||||
snippet: 'Validates TCP/TLS connectivity to the primary Postgres cluster.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.pool',
|
||||
title: 'Postgres Connection Pool',
|
||||
snippet: 'Connection pool utilization is within healthy thresholds.',
|
||||
score: 0.72,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.migrations',
|
||||
title: 'Postgres Migration Status',
|
||||
snippet: 'All EF Core migrations have been applied successfully.',
|
||||
score: 0.65,
|
||||
}),
|
||||
], {
|
||||
summary: 'Found 3 doctor checks related to database connectivity. Run `stella doctor run check.postgres.*` to diagnose.',
|
||||
template: 'doctor_check',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const timestampingCheckResponse = buildResponse('TSA certificate expiry', [
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.certificate-expiry',
|
||||
title: 'TSA Certificate Expiry',
|
||||
snippet: 'Checks if the TSA signing certificate is approaching expiration.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.chain-valid',
|
||||
title: 'TSA Chain Valid',
|
||||
snippet: 'Validates the full certificate chain from leaf to root CA.',
|
||||
score: 0.78,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.root-expiry',
|
||||
title: 'TSA Root Expiry',
|
||||
snippet: 'Checks if the root CA certificate is approaching expiration.',
|
||||
score: 0.70,
|
||||
}),
|
||||
], {
|
||||
summary: 'Found 3 timestamping certificate checks. TSA infrastructure health is critical for eIDAS compliance.',
|
||||
template: 'doctor_check',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const integrationCheckResponse = buildResponse('OCI registry connectivity', [
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.registry',
|
||||
title: 'OCI Registry',
|
||||
snippet: 'Non-destructive HEAD probe to verify OCI registry availability.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.referrers',
|
||||
title: 'OCI Registry Referrers API',
|
||||
snippet: 'Checks if the registry supports the OCI Referrers API for artifact linking.',
|
||||
score: 0.75,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.credentials',
|
||||
title: 'OCI Registry Credentials',
|
||||
snippet: 'Validates stored registry credentials have not expired.',
|
||||
score: 0.68,
|
||||
}),
|
||||
]);
|
||||
|
||||
const binaryAnalysisCheckResponse = buildResponse('debuginfod available', [
|
||||
doctorCard({
|
||||
checkId: 'check.binaryanalysis.debuginfod.available',
|
||||
title: 'Debuginfod Availability',
|
||||
snippet: 'Verifies debuginfod server is reachable for symbol resolution.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.binaryanalysis.buildinfo.cache',
|
||||
title: 'Buildinfo Cache',
|
||||
snippet: 'Checks buildinfo cache hit rate and staleness.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const observabilityCheckResponse = buildResponse('OTLP endpoint check', [
|
||||
doctorCard({
|
||||
checkId: 'check.telemetry.otlp.endpoint',
|
||||
title: 'OTLP Endpoint Check',
|
||||
snippet: 'Validates the OpenTelemetry collector endpoint is accepting spans.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.metrics.prometheus.scrape',
|
||||
title: 'Prometheus Scrape Check',
|
||||
snippet: 'Verifies Prometheus scrape target is healthy and returning metrics.',
|
||||
score: 0.70,
|
||||
}),
|
||||
]);
|
||||
|
||||
const complianceCheckResponse = buildResponse('audit readiness check', [
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.audit-readiness',
|
||||
title: 'Audit Readiness',
|
||||
snippet: 'All evidence chains, attestations, and provenance records are complete for audit.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.evidence-integrity',
|
||||
title: 'Evidence Tamper Check',
|
||||
snippet: 'Verifies evidence locker integrity via Merkle tree verification.',
|
||||
score: 0.82,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.provenance-completeness',
|
||||
title: 'Provenance Completeness',
|
||||
snippet: 'Checks that all artifacts have complete provenance chains.',
|
||||
score: 0.75,
|
||||
}),
|
||||
]);
|
||||
|
||||
const agentCheckResponse = buildResponse('agent heartbeat freshness', [
|
||||
doctorCard({
|
||||
checkId: 'check.agent.heartbeat.freshness',
|
||||
title: 'Agent Heartbeat Freshness',
|
||||
snippet: 'Checks that all registered agents have reported within the configured interval.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.agent.cluster.quorum',
|
||||
title: 'Agent Cluster Quorum',
|
||||
snippet: 'Validates that the agent cluster has sufficient members for quorum.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const cryptoCheckResponse = buildResponse('FIPS compliance check', [
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.fips',
|
||||
title: 'FIPS 140-2 Compliance',
|
||||
snippet: 'Verifies FIPS-validated cryptographic modules are loaded and active.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.hsm',
|
||||
title: 'HSM PKCS#11 Availability',
|
||||
snippet: 'Checks HSM module connectivity and signing key availability.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const scannerCheckResponse = buildResponse('scanner queue check', [
|
||||
doctorCard({
|
||||
checkId: 'check.scanner.queue',
|
||||
title: 'Scanner Queue Health',
|
||||
snippet: 'Scanner job queue depth and processing rate are within normal thresholds.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.scanner.resources',
|
||||
title: 'Scanner Resource Utilization',
|
||||
snippet: 'CPU and memory usage for scanner workers are within configured limits.',
|
||||
score: 0.74,
|
||||
}),
|
||||
]);
|
||||
|
||||
const releaseCheckResponse = buildResponse('promotion gates check', [
|
||||
doctorCard({
|
||||
checkId: 'check.release.promotion.gates',
|
||||
title: 'Promotion Gate Health',
|
||||
snippet: 'All promotion gates are correctly configured and responding.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.release.rollback.readiness',
|
||||
title: 'Rollback Readiness',
|
||||
snippet: 'Rollback procedures are verified and ready for execution.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexCheckResponse = buildResponse('VEX schema compliance check', [
|
||||
doctorCard({
|
||||
checkId: 'check.vex.schema',
|
||||
title: 'VEX Schema Compliance',
|
||||
snippet: 'Validates all stored VEX documents conform to their declared schema.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.vex.issuer-trust',
|
||||
title: 'VEX Issuer Trust',
|
||||
snippet: 'Checks that VEX issuer trust tiers are configured and up to date.',
|
||||
score: 0.73,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Doctor Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.1 Database & Infrastructure Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Database & Infrastructure Checks', () => {
|
||||
const queries = [
|
||||
'check.postgres.connectivity',
|
||||
'database connection failing',
|
||||
'postgres migrations pending',
|
||||
'connection pool exhausted',
|
||||
'disk space running low',
|
||||
'evidence locker write check',
|
||||
'backup directory writable',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns doctor entity cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// At least one card should have doctor-related content
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.2 Security & Auth Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Security & Auth Checks', () => {
|
||||
const queries = [
|
||||
'authentication config check',
|
||||
'OIDC provider connectivity',
|
||||
'signing key health',
|
||||
'token service health',
|
||||
'certificate chain validation',
|
||||
'FIPS compliance check',
|
||||
'HSM availability check',
|
||||
'eIDAS compliance check',
|
||||
'GOST availability check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns doctor cards`, async ({ page }) => {
|
||||
const fixture = query.includes('FIPS') || query.includes('HSM') || query.includes('eIDAS') || query.includes('GOST')
|
||||
? cryptoCheckResponse
|
||||
: postgresCheckResponse;
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.3 Compliance, Agent & Notification Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Compliance, Agent & Notification Checks', () => {
|
||||
const complianceQueries = [
|
||||
'audit readiness check',
|
||||
'evidence integrity check',
|
||||
'provenance completeness',
|
||||
'attestation signing health',
|
||||
'evidence generation rate',
|
||||
'export readiness check',
|
||||
'compliance framework check',
|
||||
];
|
||||
|
||||
for (const query of complianceQueries) {
|
||||
test(`compliance: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, complianceCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const agentQueries = [
|
||||
'agent heartbeat freshness',
|
||||
'agent capacity check',
|
||||
'stale agent detection',
|
||||
'agent cluster health',
|
||||
'agent cluster quorum',
|
||||
'agent version consistency',
|
||||
'agent certificate expiry',
|
||||
'agent task backlog',
|
||||
];
|
||||
|
||||
for (const query of agentQueries) {
|
||||
test(`agent: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, agentCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const notifyQueries = [
|
||||
'email notification check',
|
||||
'Slack connectivity check',
|
||||
'Teams notification check',
|
||||
'notification queue health',
|
||||
'webhook connectivity',
|
||||
];
|
||||
|
||||
for (const query of notifyQueries) {
|
||||
test(`notify: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, integrationCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.4 Environment & Release Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Environment & Release Checks', () => {
|
||||
const queries = [
|
||||
'environment connectivity',
|
||||
'environment drift',
|
||||
'network policy enforcement',
|
||||
'deployment health check',
|
||||
'active release health',
|
||||
'promotion gates check',
|
||||
'rollback readiness',
|
||||
'release schedule check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, releaseCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.5 Timestamping & Certificate Lifecycle Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Timestamping & Certificate Lifecycle Checks', () => {
|
||||
const queries = [
|
||||
'TSA availability check',
|
||||
'TSA response time',
|
||||
'TSA valid response check',
|
||||
'TSA failover ready',
|
||||
'TSA certificate expiry',
|
||||
'TSA root expiry check',
|
||||
'TSA chain validation',
|
||||
'OCSP responder check',
|
||||
'CRL distribution check',
|
||||
'revocation cache freshness',
|
||||
'OCSP stapling enabled',
|
||||
'evidence staleness check',
|
||||
'timestamp approaching expiry',
|
||||
'TST algorithm deprecated',
|
||||
'retimestamp pending',
|
||||
'EU trust list freshness',
|
||||
'QTS providers qualified',
|
||||
'system time synced',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns timestamping doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, timestampingCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.6 Integration & External Connectivity Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Integration & External Connectivity Checks', () => {
|
||||
const queries = [
|
||||
'OCI registry connectivity',
|
||||
'OCI referrers API check',
|
||||
'OCI push authorization',
|
||||
'OCI pull authorization',
|
||||
'OCI registry credentials',
|
||||
'S3 object storage check',
|
||||
'SMTP connectivity check',
|
||||
'Slack webhook check',
|
||||
'Teams webhook check',
|
||||
'Git provider connectivity',
|
||||
'LDAP connectivity check',
|
||||
'CI system connectivity',
|
||||
'secrets manager connectivity',
|
||||
'integration webhook health',
|
||||
'cannot push policy to OCI',
|
||||
'Git provider auth failing',
|
||||
'object storage write failing',
|
||||
'secrets vault unreachable',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns integration doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, integrationCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.7 Binary Analysis & Corpus Health Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Binary Analysis & Corpus Health Checks', () => {
|
||||
const queries = [
|
||||
'debuginfod available',
|
||||
'ddeb repo enabled',
|
||||
'buildinfo cache health',
|
||||
'symbol recovery fallback',
|
||||
'corpus mirror freshness',
|
||||
'corpus KPI baseline exists',
|
||||
'binary analysis not working',
|
||||
'symbol table missing',
|
||||
'debug symbols not found',
|
||||
'buildinfo cache expired',
|
||||
'Go binary stripped no debug',
|
||||
'PE authenticode verification failed',
|
||||
'corpus mirror out of date',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns binary analysis doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.8 Observability, Logging & Operations Deep Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Observability & Operations Checks', () => {
|
||||
const queries = [
|
||||
'OTLP exporter not sending',
|
||||
'log directory not writable',
|
||||
'log rotation not configured',
|
||||
'Prometheus not scraping metrics',
|
||||
'dead letter queue growing',
|
||||
'job queue backlog increasing',
|
||||
'scheduler not processing',
|
||||
'traces not appearing in Jaeger',
|
||||
'metrics endpoint 404',
|
||||
'OpenTelemetry collector down',
|
||||
'dead letter messages accumulating',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns observability doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, observabilityCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.9 Scanner, Reachability & Storage Deep Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Scanner & Storage Deep Checks', () => {
|
||||
const queries = [
|
||||
'scanner queue backed up',
|
||||
'SBOM generation failing',
|
||||
'vulnerability scan timing out',
|
||||
'witness graph corruption',
|
||||
'slice cache miss rate high',
|
||||
'reachability computation stalled',
|
||||
'scanner resource utilization high',
|
||||
'disk space critical on evidence locker',
|
||||
'evidence locker write failure',
|
||||
'postgres connection pool exhausted',
|
||||
'database migrations not applied',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns scanner/storage doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, scannerCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Doctor-specific rendering verification
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor cards show "Run Check" action button', async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'database connection');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('Run Check');
|
||||
});
|
||||
|
||||
test('doctor synthesis uses doctor_check template', async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'postgres connectivity');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('stella doctor run');
|
||||
});
|
||||
|
||||
test('VEX doctor checks return VEX-specific cards', async ({ page }) => {
|
||||
await mockSearchApi(page, vexCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'VEX schema compliance');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('VEX Schema');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-findings.e2e.spec.ts
|
||||
// E2E tests for Findings domain queries (200 cases from test-cases.md).
|
||||
// Verifies: CVE entity cards, severity badges, PURL extraction, GHSA,
|
||||
// secret detection, reachability, binary analysis, triage workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
docsCard,
|
||||
findingCard,
|
||||
secretCard,
|
||||
mockSearchApi,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: findings response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cveSearchResponse = buildResponse('CVE-2024-21626', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626: Container Escape via runc',
|
||||
snippet: 'A container escape vulnerability in runc allows attackers to escape container isolation.',
|
||||
severity: 'critical',
|
||||
score: 0.97,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 in pkg:golang/runc@1.1.10',
|
||||
snippet: 'Affected package: runc 1.1.10. Fixed in 1.1.12.',
|
||||
severity: 'critical',
|
||||
score: 0.90,
|
||||
}),
|
||||
], {
|
||||
summary: 'CVE-2024-21626: CRITICAL container escape in runc. 2 affected packages found. Exploit available.',
|
||||
template: 'cve_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const highSeverityResponse = buildResponse('high severity findings', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-0056',
|
||||
title: 'CVE-2024-0056: .NET SQL Injection',
|
||||
snippet: '.NET data provider allows SQL injection via crafted connection strings.',
|
||||
severity: 'high',
|
||||
score: 0.88,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'CVE-2023-38545: curl SOCKS5 Overflow',
|
||||
snippet: 'Heap-based buffer overflow in curl SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
score: 0.85,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-32233',
|
||||
title: 'CVE-2023-32233: Linux kernel nf_tables',
|
||||
snippet: 'Use-after-free in Linux kernel nf_tables allows local privilege escalation.',
|
||||
severity: 'high',
|
||||
score: 0.82,
|
||||
}),
|
||||
]);
|
||||
|
||||
const purlSearchResponse = buildResponse('pkg:npm/lodash@4.17.21', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-23337',
|
||||
title: 'CVE-2021-23337: lodash Command Injection',
|
||||
snippet: 'lodash template function allows command injection via template strings.',
|
||||
severity: 'high',
|
||||
score: 0.91,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2020-28500',
|
||||
title: 'CVE-2020-28500: lodash ReDoS',
|
||||
snippet: 'Regular expression denial of service in lodash.trimEnd.',
|
||||
severity: 'medium',
|
||||
score: 0.78,
|
||||
}),
|
||||
], {
|
||||
summary: 'pkg:npm/lodash@4.17.21: 2 known vulnerabilities. 1 HIGH, 1 MEDIUM.',
|
||||
template: 'package_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const ghsaSearchResponse = buildResponse('GHSA-xxxx-yyyy-zzzz', [
|
||||
findingCard({
|
||||
cveId: 'GHSA-xxxx-yyyy-zzzz',
|
||||
title: 'GHSA-xxxx-yyyy-zzzz: Remote Code Execution',
|
||||
snippet: 'GitHub Security Advisory for remote code execution vulnerability.',
|
||||
severity: 'critical',
|
||||
score: 0.93,
|
||||
}),
|
||||
]);
|
||||
|
||||
const secretDetectionResponse = buildResponse('AWS access key exposed', [
|
||||
secretCard({
|
||||
title: 'AWS Access Key Exposed',
|
||||
snippet: 'AWS Access Key ID AKIA*** detected in src/config/settings.ts:42',
|
||||
severity: 'critical',
|
||||
score: 0.96,
|
||||
}),
|
||||
secretCard({
|
||||
title: 'AWS Secret Access Key',
|
||||
snippet: 'AWS Secret Access Key detected adjacent to Access Key ID.',
|
||||
severity: 'critical',
|
||||
score: 0.94,
|
||||
}),
|
||||
], {
|
||||
summary: 'CRITICAL: 2 AWS credential detections. Immediate key rotation required.',
|
||||
template: 'secret_detection',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const reachabilityResponse = buildResponse('reachable CVE findings', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 (REACHABLE)',
|
||||
snippet: 'Static + runtime analysis confirms vulnerable code path is reachable from container entrypoint.',
|
||||
severity: 'critical',
|
||||
score: 0.98,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'CVE-2023-44487 (REACHABLE)',
|
||||
snippet: 'HTTP/2 Rapid Reset — runtime OTel trace confirms active HTTP/2 usage.',
|
||||
severity: 'high',
|
||||
score: 0.90,
|
||||
}),
|
||||
], {
|
||||
summary: '2 REACHABLE findings. Both confirmed by runtime observation. Prioritize remediation.',
|
||||
template: 'reachability_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const binaryAnalysisResponse = buildResponse('stripped Go binary vulnerability', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'CVE-2024-24790 in Go binary (stripped)',
|
||||
snippet: 'net/netip ParseAddr vulnerability detected in stripped Go binary via symbol reconstruction.',
|
||||
severity: 'high',
|
||||
score: 0.84,
|
||||
}),
|
||||
]);
|
||||
|
||||
const triageWorkflowResponse = buildResponse('findings in active triage', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 — Triage: ACTIVE',
|
||||
snippet: 'Container escape vulnerability under active investigation. Assigned to security team.',
|
||||
severity: 'critical',
|
||||
score: 0.95,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'CVE-2023-44487 — Triage: BLOCKED',
|
||||
snippet: 'HTTP/2 Rapid Reset — blocking shipment until VEX or patch applied.',
|
||||
severity: 'high',
|
||||
score: 0.88,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'CVE-2021-44228 — Triage: MUTED (VEX)',
|
||||
snippet: 'Log4Shell muted by authoritative vendor VEX: component_not_present.',
|
||||
severity: 'critical',
|
||||
score: 0.80,
|
||||
}),
|
||||
], {
|
||||
summary: '3 findings in triage: 1 ACTIVE, 1 BLOCKED, 1 MUTED. Review blocked findings for release gate.',
|
||||
template: 'triage_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const cweSearchResponse = buildResponse('SQL injection vulnerability', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-0056',
|
||||
title: 'CVE-2024-0056: SQL Injection (CWE-89)',
|
||||
snippet: 'SQL injection via .NET data provider connection string manipulation.',
|
||||
severity: 'high',
|
||||
}),
|
||||
]);
|
||||
|
||||
const namedVulnResponse = buildResponse('Log4Shell', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell (CVE-2021-44228)',
|
||||
snippet: 'Remote code execution in Apache Log4j 2.x via JNDI lookup injection.',
|
||||
severity: 'critical',
|
||||
score: 0.99,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j followup (CVE-2021-45046)',
|
||||
snippet: 'Incomplete fix for CVE-2021-44228 in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
score: 0.85,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Findings Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.1 CVE Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CVE Searches', () => {
|
||||
const cveQueries = [
|
||||
'CVE-2024-21626',
|
||||
'CVE-2024-3094',
|
||||
'CVE-2023-44487',
|
||||
'CVE-2021-44228',
|
||||
'CVE-2024-6387',
|
||||
'CVE-2023-4863',
|
||||
'CVE-2024-0056',
|
||||
'CVE-2023-38545',
|
||||
];
|
||||
|
||||
for (const query of cveQueries) {
|
||||
test(`"${query}" returns finding entity cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, cveSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Cards should show in findings domain
|
||||
const firstCard = cards.first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('CVE card shows severity badge', async ({ page }) => {
|
||||
await mockSearchApi(page, cveSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 2);
|
||||
|
||||
// Look for severity indicator
|
||||
const resultsText = await page.locator('.search__results').textContent();
|
||||
expect(resultsText?.toLowerCase()).toContain('critical');
|
||||
});
|
||||
|
||||
const namedVulnQueries = [
|
||||
'Log4Shell',
|
||||
'Heartbleed',
|
||||
'Spring4Shell',
|
||||
'Shellshock',
|
||||
'POODLE',
|
||||
];
|
||||
|
||||
for (const query of namedVulnQueries) {
|
||||
test(`named vulnerability: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, namedVulnResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CVE by severity / CVSS / EPSS
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Severity & Score Searches', () => {
|
||||
const queries = [
|
||||
'critical vulnerabilities',
|
||||
'high severity findings',
|
||||
'CVSS score 9.8',
|
||||
'CVSS greater than 7',
|
||||
'exploit available',
|
||||
'zero day vulnerability',
|
||||
'EPSS score high',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns severity-filtered findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, highSeverityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CWE-based searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CWE & Weakness Type Searches', () => {
|
||||
const cweQueries = [
|
||||
'remote code execution',
|
||||
'SQL injection vulnerability',
|
||||
'buffer overflow',
|
||||
'cross site scripting',
|
||||
'privilege escalation',
|
||||
'denial of service',
|
||||
'path traversal',
|
||||
'deserialization vulnerability',
|
||||
'SSRF vulnerability',
|
||||
'use after free',
|
||||
'null pointer dereference',
|
||||
'race condition',
|
||||
];
|
||||
|
||||
for (const query of cweQueries) {
|
||||
test(`"${query}" returns CWE-typed findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, cweSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.2 PURL & Package Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('PURL & Package Searches', () => {
|
||||
const purlQueries = [
|
||||
'pkg:npm/lodash@4.17.21',
|
||||
'pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0',
|
||||
'pkg:pypi/django@4.2',
|
||||
'pkg:cargo/tokio@1.28',
|
||||
'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
'pkg:nuget/Newtonsoft.Json@13.0.3',
|
||||
'pkg:gem/actionpack@7.0',
|
||||
'pkg:npm/express@4.18',
|
||||
];
|
||||
|
||||
for (const query of purlQueries) {
|
||||
test(`PURL: "${query}" returns package findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('PURL search shows package_summary synthesis', async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'pkg:npm/lodash@4.17.21');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('lodash');
|
||||
});
|
||||
|
||||
const packageNameQueries = [
|
||||
'npm lodash vulnerability',
|
||||
'jackson-databind CVE',
|
||||
'spring framework vulnerability',
|
||||
'golang net/http vulnerability',
|
||||
'python requests vulnerability',
|
||||
'docker runc vulnerability',
|
||||
'kubernetes vulnerability',
|
||||
'nginx CVE',
|
||||
'postgresql vulnerability',
|
||||
'redis vulnerability',
|
||||
];
|
||||
|
||||
for (const query of packageNameQueries) {
|
||||
test(`package name: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.3 GHSA & Source Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('GHSA & Advisory Source Searches', () => {
|
||||
const queries = [
|
||||
'GHSA-xxxx-yyyy-zzzz',
|
||||
'GitHub advisory',
|
||||
'NVD advisory',
|
||||
'CISA advisory',
|
||||
'Red Hat security advisory',
|
||||
'Debian security advisory',
|
||||
'Ubuntu security notice',
|
||||
'advisories published today',
|
||||
'recently discovered vulnerabilities',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns advisory source findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, ghsaSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.4 Secret Detection & Credential Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Secret Detection Searches', () => {
|
||||
const secretQueries = [
|
||||
'AWS access key exposed',
|
||||
'GitHub personal access token',
|
||||
'private SSH key in repository',
|
||||
'database password hardcoded',
|
||||
'Slack webhook URL leaked',
|
||||
'Azure connection string exposed',
|
||||
'Docker registry credentials',
|
||||
'JWT secret key in code',
|
||||
'Stripe API key leaked',
|
||||
'Google Cloud service account key',
|
||||
'npm auth token',
|
||||
'PKCS#12 certificate with private key',
|
||||
'environment file with secrets',
|
||||
'Terraform state with credentials',
|
||||
'Kubernetes secret in YAML',
|
||||
'PGP private key committed',
|
||||
'OAuth client secret exposed',
|
||||
'encryption key in code',
|
||||
'all secret detections this week',
|
||||
];
|
||||
|
||||
for (const query of secretQueries) {
|
||||
test(`secret: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, secretDetectionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('secret detection synthesis shows rotation warning', async ({ page }) => {
|
||||
await mockSearchApi(page, secretDetectionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'AWS access key');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CRITICAL');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.5 Reachability & Runtime Analysis Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Reachability Searches', () => {
|
||||
const reachQueries = [
|
||||
'reachable CVE findings',
|
||||
'unreachable vulnerabilities',
|
||||
'conditional reachability',
|
||||
'unknown reachability status',
|
||||
'static path analysis',
|
||||
'runtime hit confirmed',
|
||||
'runtime sink hit',
|
||||
'static analysis confirmed by runtime',
|
||||
'OTel trace confirms vulnerable path',
|
||||
'hot symbol detected at runtime',
|
||||
'vulnerable function in execute path',
|
||||
'no callstack to vulnerable code',
|
||||
'entry point to sink path',
|
||||
'reachability proof document',
|
||||
];
|
||||
|
||||
for (const query of reachQueries) {
|
||||
test(`reachability: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, reachabilityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('reachability synthesis confirms runtime observation', async ({ page }) => {
|
||||
await mockSearchApi(page, reachabilityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'reachable CVE');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('REACHABLE');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.6 Binary & Crypto Analysis Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Binary & Crypto Analysis Searches', () => {
|
||||
const binaryQueries = [
|
||||
'stripped Go binary vulnerability',
|
||||
'Mach-O binary CVE',
|
||||
'Windows PE vulnerability',
|
||||
'Authenticode signature invalid',
|
||||
'native library vulnerability',
|
||||
'statically linked vulnerable code',
|
||||
'shared library CVE',
|
||||
'musl libc vulnerability',
|
||||
'glibc vulnerability',
|
||||
];
|
||||
|
||||
for (const query of binaryQueries) {
|
||||
test(`binary: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const cryptoQueries = [
|
||||
'weak cipher algorithm detected',
|
||||
'deprecated TLS version',
|
||||
'insecure hash function MD5',
|
||||
'SHA1 deprecation warning',
|
||||
'RSA key too short',
|
||||
'self-signed certificate in production',
|
||||
'certificate about to expire',
|
||||
];
|
||||
|
||||
for (const query of cryptoQueries) {
|
||||
test(`crypto: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.7 Triage Workflow & Status Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Triage Workflow Searches', () => {
|
||||
const triageQueries = [
|
||||
'findings in active triage',
|
||||
'blocked shipment findings',
|
||||
'findings needing exception',
|
||||
'muted by reachability',
|
||||
'muted by VEX status',
|
||||
'compensated findings',
|
||||
'ship verdict findings',
|
||||
'block verdict findings',
|
||||
'exception granted findings',
|
||||
'pending scan results',
|
||||
'running scans',
|
||||
'failed scan results',
|
||||
'findings without evidence',
|
||||
'unresolved findings older than 30 days',
|
||||
'findings blocking production release',
|
||||
];
|
||||
|
||||
for (const query of triageQueries) {
|
||||
test(`triage: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, triageWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('triage synthesis shows lane summary', async ({ page }) => {
|
||||
await mockSearchApi(page, triageWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'findings in active triage');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('ACTIVE');
|
||||
});
|
||||
});
|
||||
});
|
||||
485
src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts
Normal file
485
src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-fixtures.ts
|
||||
// Shared mock data & helpers for all unified search e2e test suites.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth / config fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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 vex:read policy:read health: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',
|
||||
};
|
||||
|
||||
export 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'],
|
||||
};
|
||||
|
||||
export const shellSession = {
|
||||
...policyAuthorSession,
|
||||
scopes: [
|
||||
...new Set([
|
||||
...policyAuthorSession.scopes,
|
||||
'ui.read',
|
||||
'admin',
|
||||
'advisory:search',
|
||||
'advisory:read',
|
||||
'search:read',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
'policy:read',
|
||||
'health:read',
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page setup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setupBasicMocks(page: Page) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (text.includes('status of 404') || text.includes('HttpErrorResponse')) return;
|
||||
console.log('[browser:error]', 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' });
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupAuthenticatedSession(page: Page) {
|
||||
await page.addInitScript((stubSession) => {
|
||||
(window as any).__stellaopsTestSession = stubSession;
|
||||
}, shellSession);
|
||||
}
|
||||
|
||||
export async function waitForShell(page: Page) {
|
||||
await page.goto('/');
|
||||
await page.locator('aside.sidebar').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search interaction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Intercepts unified search and replies with the given fixture. */
|
||||
export async function mockSearchApi(page: Page, responseBody: unknown) {
|
||||
await page.route('**/search/query**', (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responseBody),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts unified search and replies with a fixture chosen at
|
||||
* runtime based on `q`/`query` from JSON body or URL params. This allows a single test
|
||||
* to issue multiple different queries and receive different mock responses.
|
||||
*/
|
||||
export async function mockSearchApiDynamic(
|
||||
page: Page,
|
||||
responseMap: Record<string, unknown>,
|
||||
fallback?: unknown,
|
||||
) {
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
try {
|
||||
const request = route.request();
|
||||
let q = '';
|
||||
|
||||
const rawPostData = request.postData();
|
||||
if (rawPostData) {
|
||||
try {
|
||||
const body = JSON.parse(rawPostData);
|
||||
q = String(body.q ?? body.query ?? '');
|
||||
} catch {
|
||||
q = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
const url = new URL(request.url());
|
||||
q = String(url.searchParams.get('q') ?? url.searchParams.get('query') ?? '');
|
||||
}
|
||||
|
||||
const key = Object.keys(responseMap).find((k) => q.toLowerCase().includes(k.toLowerCase()));
|
||||
const payload = key ? responseMap[key] : (fallback ?? emptyResponse(q));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(fallback ?? emptyResponse('')),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function typeInSearch(page: Page, query: string) {
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.focus();
|
||||
await input.fill(query);
|
||||
return input;
|
||||
}
|
||||
|
||||
export async function waitForResults(page: Page) {
|
||||
await page.locator('.search__results').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
const loading = page.locator('.search__loading');
|
||||
if ((await loading.count()) > 0) {
|
||||
await loading.first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => {
|
||||
// Ignore timeout here; some result states intentionally do not render loading.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForEntityCards(page: Page, count?: number) {
|
||||
const cards = page.locator(
|
||||
'.search__cards app-entity-card:visible, [role="list"] [role="listitem"]:visible, [role="listbox"] [role="option"]:visible',
|
||||
);
|
||||
const waitTimeoutMs = 8_000;
|
||||
const waitForCards = async () => {
|
||||
if (count !== undefined) {
|
||||
await cards.nth(count - 1).waitFor({ state: 'visible', timeout: waitTimeoutMs });
|
||||
} else {
|
||||
await cards.first().waitFor({ state: 'visible', timeout: waitTimeoutMs });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await waitForCards();
|
||||
} catch (firstError) {
|
||||
// Retry once by re-triggering the current query input in case the
|
||||
// debounced search call was dropped under long stress runs.
|
||||
try {
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
if ((await input.count()) > 0) {
|
||||
const searchInput = input.first();
|
||||
const current = await searchInput.inputValue();
|
||||
if (current.trim().length >= 2) {
|
||||
await searchInput.focus();
|
||||
await searchInput.fill(current);
|
||||
await searchInput.press(' ');
|
||||
await searchInput.press('Backspace');
|
||||
await searchInput.press('Enter').catch(() => {
|
||||
// Some environments do not bind Enter on the input.
|
||||
});
|
||||
await waitForResults(page);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore retry interaction errors and still attempt one more card wait.
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForCards();
|
||||
} catch {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock response builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CardFixture {
|
||||
entityKey: string;
|
||||
entityType: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
severity?: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
actionType: string;
|
||||
route?: string;
|
||||
command?: string;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
sources: string[];
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildResponse(
|
||||
query: string,
|
||||
cards: CardFixture[],
|
||||
synthesis?: {
|
||||
summary: string;
|
||||
template: string;
|
||||
confidence: string;
|
||||
sourceCount: number;
|
||||
domainsCovered: string[];
|
||||
},
|
||||
) {
|
||||
return {
|
||||
query,
|
||||
topK: 10,
|
||||
cards,
|
||||
synthesis: synthesis ?? {
|
||||
summary: `Found ${cards.length} result(s) for "${query}".`,
|
||||
template: cards.length > 0 ? 'mixed_overview' : 'empty',
|
||||
confidence: cards.length >= 3 ? 'high' : cards.length >= 1 ? 'medium' : 'low',
|
||||
sourceCount: cards.length,
|
||||
domainsCovered: [...new Set(cards.map((c) => c.domain))],
|
||||
},
|
||||
diagnostics: {
|
||||
ftsMatches: cards.length + 2,
|
||||
vectorMatches: cards.length,
|
||||
entityCardCount: cards.length,
|
||||
durationMs: 38,
|
||||
usedVector: true,
|
||||
mode: 'hybrid',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyResponse(query: string) {
|
||||
return buildResponse(query, [], {
|
||||
summary: 'No results found for the given query.',
|
||||
template: 'empty',
|
||||
confidence: 'high',
|
||||
sourceCount: 0,
|
||||
domainsCovered: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain-specific card factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function doctorCard(opts: {
|
||||
checkId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `doctor:${opts.checkId}`,
|
||||
entityType: 'doctor',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.85,
|
||||
actions: [
|
||||
{ label: 'Run Check', actionType: 'run', command: `stella doctor run ${opts.checkId}`, isPrimary: true },
|
||||
{ label: 'View Docs', actionType: 'navigate', route: '/ops/operations/data-integrity', isPrimary: false },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
|
||||
export function findingCard(opts: {
|
||||
cveId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `cve:${opts.cveId}`,
|
||||
entityType: 'finding',
|
||||
domain: 'findings',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.92,
|
||||
severity: opts.severity,
|
||||
actions: [
|
||||
{ label: 'View Finding', actionType: 'navigate', route: `/security/triage?q=${opts.cveId}`, isPrimary: true },
|
||||
{ label: 'Copy CVE', actionType: 'copy', command: opts.cveId, isPrimary: false },
|
||||
],
|
||||
sources: ['findings'],
|
||||
};
|
||||
}
|
||||
|
||||
export function secretCard(opts: {
|
||||
title: string;
|
||||
snippet: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `secret:${opts.title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
entityType: 'finding',
|
||||
domain: 'findings',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.88,
|
||||
severity: opts.severity,
|
||||
actions: [
|
||||
{ label: 'View Secret', actionType: 'navigate', route: '/security/triage?type=secret', isPrimary: true },
|
||||
],
|
||||
sources: ['findings'],
|
||||
};
|
||||
}
|
||||
|
||||
export function vexCard(opts: {
|
||||
cveId: string;
|
||||
status: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `vex:${opts.cveId}`,
|
||||
entityType: 'vex_statement',
|
||||
domain: 'vex',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.80,
|
||||
actions: [
|
||||
{
|
||||
label: 'View VEX',
|
||||
actionType: 'navigate',
|
||||
route: `/security/advisories-vex?q=${opts.cveId}`,
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
sources: ['vex'],
|
||||
metadata: { status: opts.status },
|
||||
};
|
||||
}
|
||||
|
||||
export function policyCard(opts: {
|
||||
ruleId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `policy:${opts.ruleId}`,
|
||||
entityType: 'policy_rule',
|
||||
domain: 'policy',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.78,
|
||||
actions: [
|
||||
{
|
||||
label: 'View Rule',
|
||||
actionType: 'navigate',
|
||||
route: `/ops/policy?rule=${opts.ruleId}`,
|
||||
isPrimary: true,
|
||||
},
|
||||
{ label: 'Simulate', actionType: 'navigate', route: `/ops/policy/simulate`, isPrimary: false },
|
||||
],
|
||||
sources: ['policy'],
|
||||
};
|
||||
}
|
||||
|
||||
export function docsCard(opts: {
|
||||
docPath: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `docs:${opts.docPath}`,
|
||||
entityType: 'docs',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.70,
|
||||
actions: [
|
||||
{ label: 'Open', actionType: 'navigate', route: `/docs/${opts.docPath}`, isPrimary: true },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
|
||||
export function apiCard(opts: {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `api:${opts.method}:${opts.endpoint}`,
|
||||
entityType: 'api',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.75,
|
||||
actions: [
|
||||
{ label: 'Try API', actionType: 'curl', command: `curl -X ${opts.method} ${opts.endpoint}`, isPrimary: true },
|
||||
{ label: 'Open Docs', actionType: 'navigate', route: `/docs/api`, isPrimary: false },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-vex-policy.e2e.spec.ts
|
||||
// E2E tests for VEX (100 cases) and Policy (100 cases) domain queries.
|
||||
// Verifies: VEX status/justification/trust/consensus cards, policy gate/verdict
|
||||
// cards, risk budget, sealed mode, unknowns, observation states.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
docsCard,
|
||||
findingCard,
|
||||
policyCard,
|
||||
vexCard,
|
||||
mockSearchApi,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VEX Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const vexStatusResponse = buildResponse('VEX not affected', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2024-21626 — Not Affected',
|
||||
snippet: 'Vendor confirms: vulnerable code not present in this build. Justification: component_not_present.',
|
||||
score: 0.92,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2023-44487 — Not Affected',
|
||||
snippet: 'HTTP/2 Rapid Reset not exploitable: vulnerable_code_not_in_execute_path.',
|
||||
score: 0.85,
|
||||
}),
|
||||
], {
|
||||
summary: '2 VEX statements with status "not_affected". Both with verified justifications.',
|
||||
template: 'vex_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexAffectedResponse = buildResponse('VEX affected', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'affected',
|
||||
title: 'VEX: CVE-2024-3094 — Affected',
|
||||
snippet: 'XZ Utils backdoor confirmed affected. Upgrade to xz 5.6.1-2 required.',
|
||||
score: 0.95,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexFixedResponse = buildResponse('VEX fixed', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
status: 'fixed',
|
||||
title: 'VEX: CVE-2021-44228 — Fixed',
|
||||
snippet: 'Log4Shell fixed in log4j-core 2.17.1. All deployments updated.',
|
||||
score: 0.88,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexUnderInvestigationResponse = buildResponse('VEX under investigation', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-6387',
|
||||
status: 'under_investigation',
|
||||
title: 'VEX: CVE-2024-6387 — Under Investigation',
|
||||
snippet: 'OpenSSH regreSSHion applicability being assessed across fleet.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexJustificationResponse = buildResponse('vulnerable code not present', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: vulnerable_code_not_present',
|
||||
snippet: 'Justification: The vulnerable code path in runc was removed in our fork.',
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2023-4863',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: vulnerable_code_not_in_execute_path',
|
||||
snippet: 'Justification: libwebp is linked but image decoding disabled at build time.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexTrustResponse = buildResponse('authoritative VEX source', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX from Docker Inc. (Authoritative)',
|
||||
snippet: 'Trust tier: AUTHORITATIVE. Vendor PSIRT issued. DSSE signed. Verified.',
|
||||
score: 0.96,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX from Community (Trusted)',
|
||||
snippet: 'Trust tier: TRUSTED. Community analysis. PGP signed. Verified.',
|
||||
score: 0.72,
|
||||
}),
|
||||
], {
|
||||
summary: '2 VEX sources for CVE-2024-21626. Authoritative vendor source agrees with community. High confidence.',
|
||||
template: 'vex_trust',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexConflictResponse = buildResponse('VEX consensus conflict', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'affected',
|
||||
title: 'VEX Conflict: Vendor says AFFECTED',
|
||||
snippet: 'Vendor PSIRT: affected. Trust tier: Authoritative.',
|
||||
score: 0.90,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'not_affected',
|
||||
title: 'VEX Conflict: Community says NOT_AFFECTED',
|
||||
snippet: 'Community analysis: not_affected. Trust tier: Trusted. Justification: component_not_present.',
|
||||
score: 0.75,
|
||||
}),
|
||||
], {
|
||||
summary: 'HARD CONFLICT: Vendor (affected) vs Community (not_affected) for CVE-2024-3094. Manual review required.',
|
||||
template: 'vex_conflict',
|
||||
confidence: 'low',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexFormatResponse = buildResponse('OpenVEX document', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'OpenVEX: CVE-2024-21626',
|
||||
snippet: 'Format: OpenVEX v0.2. DSSE-signed. Schema validated.',
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexWorkflowResponse = buildResponse('generate VEX document', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'Generated VEX for CVE-2024-21626',
|
||||
snippet: 'Run: stella vex generate --cve CVE-2024-21626 --status not_affected',
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'VEX_CONSENSUS_GUIDE.md',
|
||||
title: 'VEX Consensus Guide',
|
||||
snippet: 'Guide for creating and managing VEX documents in Stella Ops.',
|
||||
score: 0.65,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const policyManagementResponse = buildResponse('create policy rule', [
|
||||
policyCard({
|
||||
ruleId: 'severity-gate-critical',
|
||||
title: 'Block Critical Vulnerabilities',
|
||||
snippet: 'Blocks release promotion when CRITICAL severity findings are present without VEX.',
|
||||
score: 0.90,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'sbom-attestation-required',
|
||||
title: 'Require SBOM Attestation',
|
||||
snippet: 'Requires signed SBOM attestation predicate before promotion to production.',
|
||||
score: 0.82,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/policy/architecture.md',
|
||||
title: 'Policy Engine Architecture',
|
||||
snippet: 'Policy rules, packs, gates, and evaluation pipeline documentation.',
|
||||
score: 0.65,
|
||||
}),
|
||||
], {
|
||||
summary: '2 policy rules and 1 documentation result. Use Policy Studio to create or edit rules.',
|
||||
template: 'policy_rule',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['policy', 'knowledge'],
|
||||
});
|
||||
|
||||
const policyGateResponse = buildResponse('VEX trust gate evaluation', [
|
||||
policyCard({
|
||||
ruleId: 'vex-trust-gate',
|
||||
title: 'VEX Trust Gate',
|
||||
snippet: 'Validates VEX source trust tier meets minimum threshold before accepting as evidence.',
|
||||
score: 0.92,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'reachable-cve-gate',
|
||||
title: 'Reachable CVE Gate',
|
||||
snippet: 'Blocks if CVE is reachable and VEX status is "affected" or missing.',
|
||||
score: 0.85,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'execution-evidence-gate',
|
||||
title: 'Execution Evidence Gate',
|
||||
snippet: 'Requires signed execution evidence (SEE-03) for all promoted artifacts.',
|
||||
score: 0.80,
|
||||
}),
|
||||
], {
|
||||
summary: '3 policy gates found. VexTrustGate, ReachableCveGate, and ExecutionEvidenceGate.',
|
||||
template: 'policy_gate',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['policy'],
|
||||
});
|
||||
|
||||
const policyVerdictResponse = buildResponse('policy verdict blocked', [
|
||||
policyCard({
|
||||
ruleId: 'verdict-block-cve-2024',
|
||||
title: 'Verdict: BLOCKED — CVE-2024-21626',
|
||||
snippet: 'ReachableCveGate blocked promotion. Finding is reachable with no VEX justification.',
|
||||
score: 0.95,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'verdict-guarded-cve-2023',
|
||||
title: 'Verdict: GUARDED PASS — CVE-2023-44487',
|
||||
snippet: 'GuardedPass with runtime monitoring. Beacon verification rate: 94%.',
|
||||
score: 0.82,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyRiskBudgetResponse = buildResponse('risk budget remaining', [
|
||||
policyCard({
|
||||
ruleId: 'risk-budget-project-alpha',
|
||||
title: 'Risk Budget: Project Alpha',
|
||||
snippet: 'Budget: 45/100 points consumed. 55 remaining. Burn rate: 3.2 points/week.',
|
||||
score: 0.88,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'unknowns-budget',
|
||||
title: 'Unknowns Budget',
|
||||
snippet: 'Unknowns: 12/20 allowed. U-RCH: 5, U-VEX: 3, U-FEED: 2, U-PROV: 2.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyObservationResponse = buildResponse('observation pending determinization', [
|
||||
policyCard({
|
||||
ruleId: 'obs-pending-det',
|
||||
title: 'Observation: Pending Determinization',
|
||||
snippet: 'CVE discovered but evidence incomplete. Guardrail-based evaluation active.',
|
||||
score: 0.85,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'obs-disputed',
|
||||
title: 'Observation: Disputed',
|
||||
snippet: 'Multiple signals conflict. Runtime says reachable, static says unreachable. Human review required.',
|
||||
score: 0.78,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'obs-stale',
|
||||
title: 'Observation: Stale Requires Refresh',
|
||||
snippet: 'Evidence decayed past TTL threshold. Auto-triggered refresh in progress.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policySealedModeResponse = buildResponse('sealed mode locked dependencies', [
|
||||
policyCard({
|
||||
ruleId: 'sealed-mode-config',
|
||||
title: 'Sealed Mode Configuration',
|
||||
snippet: 'Dependencies locked, evidence frozen, no external network calls during evaluation.',
|
||||
score: 0.90,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'contracts/sealed-mode.md',
|
||||
title: 'Sealed Mode Contract',
|
||||
snippet: 'Deterministic, offline-first evaluation mode for air-gap environments.',
|
||||
score: 0.75,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyGateLevelResponse = buildResponse('G3 high risk gate level', [
|
||||
policyCard({
|
||||
ruleId: 'g3-high-risk',
|
||||
title: 'G3 — High Risk Gate',
|
||||
snippet: 'Requires: security scan, migration plan, load checks, observability updates, release captain sign-off.',
|
||||
score: 0.88,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyDecisionResponse = buildResponse('why was release blocked', [
|
||||
policyCard({
|
||||
ruleId: 'decision-audit-release-42',
|
||||
title: 'Release #42 Decision Audit',
|
||||
snippet: 'BLOCKED by ReachableCveGate: CVE-2024-21626 is reachable. No VEX. No exception. Risk budget exceeded.',
|
||||
score: 0.95,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VEX Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — VEX Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.1 VEX Status & Justification Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Status Searches', () => {
|
||||
const statusTests: Array<{ query: string; fixture: unknown }> = [
|
||||
{ query: 'VEX not affected', fixture: vexStatusResponse },
|
||||
{ query: 'VEX affected', fixture: vexAffectedResponse },
|
||||
{ query: 'VEX fixed', fixture: vexFixedResponse },
|
||||
{ query: 'VEX under investigation', fixture: vexUnderInvestigationResponse },
|
||||
];
|
||||
|
||||
for (const { query, fixture } of statusTests) {
|
||||
test(`"${query}" returns VEX cards with correct status`, async ({ page }) => {
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
const cards = await waitForEntityCards(page);
|
||||
expect(await cards.count()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
|
||||
const justificationQueries = [
|
||||
'component not present justification',
|
||||
'vulnerable code not present',
|
||||
'code not in execute path',
|
||||
'code not executable',
|
||||
'adversary cannot control code',
|
||||
'inline mitigations exist',
|
||||
];
|
||||
|
||||
for (const query of justificationQueries) {
|
||||
test(`justification: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexJustificationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const generalVexQueries = [
|
||||
'VEX for CVE-2024-21626',
|
||||
'VEX for log4j',
|
||||
'VEX from vendor',
|
||||
'VEX from community',
|
||||
'trusted VEX statements',
|
||||
'authoritative VEX',
|
||||
'VEX impact statement',
|
||||
'VEX action required',
|
||||
'VEX expiring soon',
|
||||
'VEX override',
|
||||
];
|
||||
|
||||
for (const query of generalVexQueries) {
|
||||
test(`general: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexStatusResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.2 VEX Format & Workflow
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Format & Workflow', () => {
|
||||
const formatQueries = [
|
||||
'OpenVEX document',
|
||||
'CSAF VEX document',
|
||||
'CycloneDX VEX',
|
||||
'StellaOps canonical VEX',
|
||||
'VEX from SPDX format',
|
||||
];
|
||||
|
||||
for (const query of formatQueries) {
|
||||
test(`format: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexFormatResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const workflowQueries = [
|
||||
'generate VEX document',
|
||||
'ingest VEX statement',
|
||||
'VEX hub search',
|
||||
'VEX studio create',
|
||||
'export VEX bundle',
|
||||
'VEX consensus handling',
|
||||
'VEX linked to finding',
|
||||
'VEX suppresses finding',
|
||||
'VEX as evidence',
|
||||
'VEX attestation',
|
||||
'VEX policy evaluation',
|
||||
'VEX mirror',
|
||||
'VEX feed subscription',
|
||||
'VEX document lifecycle',
|
||||
];
|
||||
|
||||
for (const query of workflowQueries) {
|
||||
test(`workflow: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.3 VEX Trust, Signature & Freshness
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Trust & Signature', () => {
|
||||
const trustQueries = [
|
||||
'authoritative VEX source',
|
||||
'trusted community VEX',
|
||||
'untrusted VEX statement',
|
||||
'vendor PSIRT VEX',
|
||||
'distributor VEX statement',
|
||||
'community VEX source',
|
||||
'internal organization VEX',
|
||||
'aggregator VEX source',
|
||||
'VEX with high trust score',
|
||||
'VEX issuer reputation',
|
||||
];
|
||||
|
||||
for (const query of trustQueries) {
|
||||
test(`trust: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexTrustResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const signatureQueries = [
|
||||
'DSSE signed VEX document',
|
||||
'cosign verified VEX',
|
||||
'PGP signed VEX statement',
|
||||
'X.509 signed VEX document',
|
||||
'unverified VEX signature',
|
||||
'failed VEX signature verification',
|
||||
];
|
||||
|
||||
for (const query of signatureQueries) {
|
||||
test(`signature: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexTrustResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const freshnessQueries = [
|
||||
'VEX freshness stale',
|
||||
'VEX freshness expired',
|
||||
'VEX superseded by newer',
|
||||
'fresh VEX statements only',
|
||||
'VEX document age over 90 days',
|
||||
];
|
||||
|
||||
for (const query of freshnessQueries) {
|
||||
test(`freshness: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexStatusResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.4 VEX Consensus & Conflict
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Consensus & Conflict', () => {
|
||||
const conflictQueries = [
|
||||
'VEX consensus conflict',
|
||||
'hard conflict between VEX sources',
|
||||
'soft conflict VEX disagreement',
|
||||
'vendor says not_affected community says affected',
|
||||
'VEX consensus engine result',
|
||||
'trust-weighted VEX merge',
|
||||
'VEX confidence score low',
|
||||
'VEX confidence high agreement',
|
||||
'multiple issuers same CVE',
|
||||
];
|
||||
|
||||
for (const query of conflictQueries) {
|
||||
test(`conflict: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('VEX conflict synthesis shows low confidence', async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'VEX consensus conflict');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CONFLICT');
|
||||
});
|
||||
|
||||
const transitionQueries = [
|
||||
'VEX status transition history',
|
||||
'affected changed to not_affected',
|
||||
'under_investigation resolved to fixed',
|
||||
'VEX linked to SBOM component',
|
||||
'VEX for CPE product match',
|
||||
'VEX suppressing active finding',
|
||||
'VEX impact on policy gate',
|
||||
'VEX used as evidence in release',
|
||||
'VEX document schema validation failure',
|
||||
];
|
||||
|
||||
for (const query of transitionQueries) {
|
||||
test(`transition: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Policy Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.1 Policy Management Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Policy Management', () => {
|
||||
const queries = [
|
||||
'create policy rule',
|
||||
'policy pack install',
|
||||
'validate policy YAML',
|
||||
'policy simulation',
|
||||
'push policy to OCI',
|
||||
'pull policy from registry',
|
||||
'policy pack bundle',
|
||||
'block critical vulnerabilities',
|
||||
'require SBOM attestation',
|
||||
'require VEX for all CVEs',
|
||||
'maximum CVSS score allowed',
|
||||
'block exploit available',
|
||||
'require reachability proof',
|
||||
'policy for production environment',
|
||||
'policy exception request',
|
||||
'policy waiver',
|
||||
'risk budget remaining',
|
||||
'policy violation list',
|
||||
'policy determinism verification',
|
||||
'policy lint check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns policy cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyManagementResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.2 Policy Evaluation & Decisioning
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Policy Evaluation', () => {
|
||||
const queries = [
|
||||
'evaluate policy for container',
|
||||
'policy APPROVE decision',
|
||||
'policy REJECT decision',
|
||||
'conditional approval',
|
||||
'blocked by policy',
|
||||
'override policy violation',
|
||||
'severity fusion scoring',
|
||||
'attestation report for release',
|
||||
'promotion gate evaluation',
|
||||
'policy snapshot comparison',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`evaluation: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyVerdictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('policy decision shows audit detail', async ({ page }) => {
|
||||
await mockSearchApi(page, policyDecisionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'why was release blocked');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.3 Gate-Level Evaluation & Verdict Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Gate-Level Evaluation', () => {
|
||||
const gateQueries = [
|
||||
'VEX trust gate evaluation',
|
||||
'reachable CVE gate blocked',
|
||||
'execution evidence gate result',
|
||||
'beacon rate gate threshold',
|
||||
'drift gate unreviewed changes',
|
||||
'unknowns gate budget exceeded',
|
||||
];
|
||||
|
||||
for (const query of gateQueries) {
|
||||
test(`gate: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyGateResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const verdictQueries = [
|
||||
'policy verdict pass',
|
||||
'policy verdict guarded pass',
|
||||
'policy verdict blocked',
|
||||
'policy verdict ignored',
|
||||
'policy verdict warned',
|
||||
'policy verdict deferred',
|
||||
'policy verdict escalated',
|
||||
'policy verdict requires VEX',
|
||||
];
|
||||
|
||||
for (const query of verdictQueries) {
|
||||
test(`verdict: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyVerdictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const gateLevelQueries = [
|
||||
'G0 no-risk gate level',
|
||||
'G1 low risk gate level',
|
||||
'G2 moderate risk gate level',
|
||||
'G3 high risk gate level',
|
||||
'G4 safety critical gate level',
|
||||
];
|
||||
|
||||
for (const query of gateLevelQueries) {
|
||||
test(`gate level: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyGateLevelResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.4 Risk Budget, Unknowns & Sealed Mode
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Risk Budget & Unknowns', () => {
|
||||
const budgetQueries = [
|
||||
'risk budget remaining for project',
|
||||
'risk budget burn rate',
|
||||
'unknowns budget exceeded',
|
||||
];
|
||||
|
||||
for (const query of budgetQueries) {
|
||||
test(`budget: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const unknownCodeQueries = [
|
||||
'unknown reachability reason',
|
||||
'unknown identity ambiguous package',
|
||||
'unknown provenance cannot map binary',
|
||||
'VEX conflict unknown',
|
||||
'feed gap unknown source missing',
|
||||
'config unknown feature not observable',
|
||||
'analyzer limit language not supported',
|
||||
];
|
||||
|
||||
for (const query of unknownCodeQueries) {
|
||||
test(`unknown code: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const observationQueries = [
|
||||
'observation pending determinization',
|
||||
'observation determined',
|
||||
'observation disputed',
|
||||
'observation stale requires refresh',
|
||||
'observation manual review required',
|
||||
'observation suppressed',
|
||||
];
|
||||
|
||||
for (const query of observationQueries) {
|
||||
test(`observation: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyObservationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const sealedModeQueries = [
|
||||
'sealed mode locked dependencies',
|
||||
'sealed mode frozen evidence',
|
||||
'deterministic replay manifest',
|
||||
'no external network during evaluation',
|
||||
];
|
||||
|
||||
for (const query of sealedModeQueries) {
|
||||
test(`sealed mode: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policySealedModeResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const tierQueries = [
|
||||
'uncertainty tier T1',
|
||||
'uncertainty tier T2',
|
||||
'uncertainty tier T3',
|
||||
'uncertainty tier T4',
|
||||
];
|
||||
|
||||
for (const query of tierQueries) {
|
||||
test(`uncertainty: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyObservationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Policy-specific rendering
|
||||
// -------------------------------------------------------------------------
|
||||
test('policy cards show "Simulate" action', async ({ page }) => {
|
||||
await mockSearchApi(page, policyManagementResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'block critical vulnerabilities');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('Simulate');
|
||||
});
|
||||
|
||||
test('risk budget synthesis shows budget numbers', async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'risk budget remaining');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
493
src/Web/StellaOps.Web/tests/e2e/unified-search.e2e.spec.ts
Normal file
493
src/Web/StellaOps.Web/tests/e2e/unified-search.e2e.spec.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user