683 lines
19 KiB
TypeScript
683 lines
19 KiB
TypeScript
// -----------------------------------------------------------------------------
|
|
// unified-search-fixtures.ts
|
|
// Shared mock data & helpers for all unified search e2e test suites.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
import type { Page, Route } 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:read advisory-ai:view advisory-ai:operate 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:read',
|
|
'advisory-ai:view',
|
|
'advisory-ai:operate',
|
|
'findings:read',
|
|
'vex:read',
|
|
'policy:read',
|
|
'health:read',
|
|
]),
|
|
],
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page setup helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function setupBasicMocks(page: Page) {
|
|
const jsonStubUnlessDocument = (defaultGetBody: unknown = []): ((route: Route) => Promise<void>) => {
|
|
return async (route) => {
|
|
if (route.request().resourceType() === 'document') {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(route.request().method() === 'GET' ? defaultGetBody : {}),
|
|
});
|
|
};
|
|
};
|
|
|
|
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' });
|
|
});
|
|
|
|
await page.route('**/api/**', jsonStubUnlessDocument());
|
|
await page.route('**/gateway/**', jsonStubUnlessDocument());
|
|
await page.route('**/policy/**', jsonStubUnlessDocument());
|
|
await page.route('**/scanner/**', jsonStubUnlessDocument());
|
|
await page.route('**/concelier/**', jsonStubUnlessDocument());
|
|
await page.route('**/attestor/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/api/v1/search/suggestions/evaluate', async (route) => {
|
|
const body = (route.request().postDataJSON() as {
|
|
queries?: string[];
|
|
ambient?: { currentRoute?: string };
|
|
} | null) ?? {};
|
|
const queries = body.queries ?? [];
|
|
const currentScopeDomain = resolveMockScopeDomain(body.ambient?.currentRoute);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
suggestions: queries.map((query) => ({
|
|
query,
|
|
viable: true,
|
|
status: 'grounded',
|
|
code: 'retrieved_scope_weighted_evidence',
|
|
cardCount: 1,
|
|
leadingDomain: currentScopeDomain,
|
|
reason: 'Evidence is available for this suggestion.',
|
|
})),
|
|
coverage: {
|
|
currentScopeDomain,
|
|
currentScopeWeighted: true,
|
|
domains: [
|
|
{
|
|
domain: currentScopeDomain,
|
|
candidateCount: Math.max(1, queries.length),
|
|
visibleCardCount: Math.max(1, queries.length),
|
|
topScore: 0.9,
|
|
isCurrentScope: true,
|
|
hasVisibleResults: true,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
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[];
|
|
},
|
|
options?: {
|
|
suggestions?: Array<{ text: string; reason: string; domain?: string; candidateCount?: number }>;
|
|
contextAnswer?: {
|
|
status: 'grounded' | 'clarify' | 'insufficient';
|
|
code: string;
|
|
summary: string;
|
|
reason: string;
|
|
evidence: string;
|
|
citations?: Array<{ entityKey: string; title: string; domain: string; route?: string }>;
|
|
questions?: Array<{ query: string; kind?: string }>;
|
|
};
|
|
overflow?: {
|
|
currentScopeDomain: string;
|
|
reason: string;
|
|
cards: CardFixture[];
|
|
};
|
|
coverage?: {
|
|
currentScopeDomain?: string;
|
|
currentScopeWeighted: boolean;
|
|
domains: Array<{
|
|
domain: string;
|
|
candidateCount: number;
|
|
visibleCardCount: number;
|
|
topScore: number;
|
|
isCurrentScope: boolean;
|
|
hasVisibleResults: boolean;
|
|
}>;
|
|
};
|
|
},
|
|
) {
|
|
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',
|
|
},
|
|
suggestions: options?.suggestions,
|
|
contextAnswer: options?.contextAnswer,
|
|
overflow: options?.overflow,
|
|
coverage: options?.coverage,
|
|
};
|
|
}
|
|
|
|
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 timelineCard(opts: {
|
|
eventId: string;
|
|
title: string;
|
|
snippet: string;
|
|
score?: number;
|
|
}): CardFixture {
|
|
return {
|
|
entityKey: `timeline:${opts.eventId}`,
|
|
entityType: 'ops_event',
|
|
domain: 'timeline',
|
|
title: opts.title,
|
|
snippet: opts.snippet,
|
|
score: opts.score ?? 0.81,
|
|
actions: [
|
|
{
|
|
label: 'Open event',
|
|
actionType: 'navigate',
|
|
route: `/ops/timeline/${opts.eventId}`,
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
label: 'Open audit trail',
|
|
actionType: 'navigate',
|
|
route: `/evidence/audit-log/events/${opts.eventId}`,
|
|
isPrimary: false,
|
|
},
|
|
],
|
|
sources: ['timeline'],
|
|
};
|
|
}
|
|
|
|
export function releaseCard(opts: {
|
|
releaseId: string;
|
|
title: string;
|
|
snippet: string;
|
|
score?: number;
|
|
}): CardFixture {
|
|
return {
|
|
entityKey: `release:${opts.releaseId}`,
|
|
entityType: 'platform_entity',
|
|
domain: 'platform',
|
|
title: opts.title,
|
|
snippet: opts.snippet,
|
|
score: opts.score ?? 0.84,
|
|
actions: [
|
|
{
|
|
label: 'Open release',
|
|
actionType: 'navigate',
|
|
route: `/releases/${opts.releaseId}`,
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
label: 'Open approvals',
|
|
actionType: 'navigate',
|
|
route: '/releases/approvals',
|
|
isPrimary: false,
|
|
},
|
|
],
|
|
sources: ['platform'],
|
|
};
|
|
}
|
|
|
|
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'],
|
|
};
|
|
}
|
|
|
|
function resolveMockScopeDomain(currentRoute?: string): string {
|
|
const normalizedRoute = (currentRoute ?? '').trim().toLowerCase();
|
|
|
|
if (normalizedRoute.includes('/ops/policy/vex')
|
|
|| normalizedRoute.includes('/security/advisories-vex')
|
|
|| normalizedRoute.includes('/vex-hub')) {
|
|
return 'vex';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/ops/policy')) {
|
|
return 'policy';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/ops/operations/doctor')
|
|
|| normalizedRoute.includes('/ops/operations/system-health')) {
|
|
return 'knowledge';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/ops/timeline')
|
|
|| normalizedRoute.includes('/evidence/audit-log')
|
|
|| normalizedRoute.includes('/audit')) {
|
|
return 'timeline';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/releases')
|
|
|| normalizedRoute.includes('/mission-control')) {
|
|
return 'platform';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/ops/graph')
|
|
|| normalizedRoute.includes('/security/reach')) {
|
|
return 'graph';
|
|
}
|
|
|
|
if (normalizedRoute.includes('/ops/operations/jobs')
|
|
|| normalizedRoute.includes('/ops/operations/scheduler')) {
|
|
return 'ops_memory';
|
|
}
|
|
|
|
return 'findings';
|
|
}
|