Add advisory source catalog UI, mirror wizard, and mirror dashboard
Source catalog component: browsable catalog of 75 advisory sources grouped by 14 categories with search, filter, enable/disable toggles, batch operations, health checks, and category descriptions. Mirror domain builder: 3-step wizard (select sources → configure domain → review & create) with category-level selection, auto-naming, format choice, rate limits, signing options, and optional immediate generation. Mirror dashboard: domain cards with staleness indicators, regenerate and delete actions, consumer config panel, endpoint viewer, and empty-state CTA leading to the wizard. Catalog mirror header: mode badge, domain stats, and quick-access buttons for mirror configuration integrated into the source catalog. Supporting: source management API client (9 endpoints), mirror management API client (12 endpoints), integration hub route wiring, onboarding hub advisory section, security page health display fix, E2E Playwright tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Advisory & VEX Source Management — E2E Tests
|
||||
*
|
||||
* Verifies the source catalog UI, enable/disable toggles, connectivity checks,
|
||||
* onboarding hub section, and security page health display.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: mock API responses for deterministic E2E
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CATALOG = {
|
||||
items: [
|
||||
{ id: 'nvd', displayName: 'NVD', category: 'Primary', type: 'Upstream', description: 'NIST National Vulnerability Database', baseEndpoint: 'https://services.nvd.nist.gov', requiresAuth: true, credentialEnvVar: 'NVD_API_KEY', credentialUrl: 'https://nvd.nist.gov/developers/request-an-api-key', documentationUrl: 'https://nvd.nist.gov/developers', defaultPriority: 10, regions: [], tags: ['cve'], enabledByDefault: true },
|
||||
{ id: 'osv', displayName: 'OSV', category: 'Primary', type: 'Upstream', description: 'Open Source Vulnerabilities', baseEndpoint: 'https://api.osv.dev', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: 'https://osv.dev', defaultPriority: 20, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'ghsa', displayName: 'GitHub Security Advisories', category: 'Primary', type: 'Upstream', description: 'GitHub Advisory Database', baseEndpoint: 'https://api.github.com', requiresAuth: true, credentialEnvVar: 'GITHUB_TOKEN', credentialUrl: null, documentationUrl: null, defaultPriority: 30, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'redhat', displayName: 'Red Hat Security', category: 'Vendor', type: 'Upstream', description: 'Red Hat Product Security advisories', baseEndpoint: 'https://access.redhat.com', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 50, regions: [], tags: ['vendor'], enabledByDefault: true },
|
||||
{ id: 'debian', displayName: 'Debian Security Tracker', category: 'Distribution', type: 'Upstream', description: 'Debian Security Bug Tracker', baseEndpoint: 'https://security-tracker.debian.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 60, regions: [], tags: ['distro'], enabledByDefault: true },
|
||||
{ id: 'ubuntu', displayName: 'Ubuntu Security', category: 'Distribution', type: 'Upstream', description: 'Ubuntu CVE Tracker', baseEndpoint: 'https://ubuntu.com/security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 61, regions: [], tags: ['distro'], enabledByDefault: false },
|
||||
{ id: 'npm-audit', displayName: 'npm Audit', category: 'Ecosystem', type: 'Upstream', description: 'npm advisory feed', baseEndpoint: 'https://registry.npmjs.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 70, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'certfr', displayName: 'CERT-FR', category: 'Cert', type: 'Upstream', description: 'French national CERT advisories', baseEndpoint: 'https://www.cert.ssi.gouv.fr', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 80, regions: ['eu'], tags: ['cert'], enabledByDefault: false },
|
||||
{ id: 'csaf-aggregator', displayName: 'CSAF Trusted Provider', category: 'Csaf', type: 'Upstream', description: 'OASIS CSAF trusted provider', baseEndpoint: 'https://csaf.data.security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 90, regions: [], tags: ['csaf'], enabledByDefault: true },
|
||||
{ id: 'kev', displayName: 'CISA KEV', category: 'Threat', type: 'Upstream', description: 'Known Exploited Vulnerabilities catalog', baseEndpoint: 'https://www.cisa.gov', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 15, regions: ['us'], tags: ['exploit'], enabledByDefault: true },
|
||||
{ id: 'stella-mirror', displayName: 'StellaOps Mirror', category: 'Mirror', type: 'Mirror', description: 'Local offline mirror', baseEndpoint: 'http://mirror.local', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 100, regions: [], tags: ['mirror'], enabledByDefault: false },
|
||||
],
|
||||
totalCount: 11,
|
||||
};
|
||||
|
||||
const MOCK_STATUS = {
|
||||
sources: MOCK_CATALOG.items.map((s) => ({
|
||||
sourceId: s.id,
|
||||
enabled: s.enabledByDefault,
|
||||
lastCheck: s.enabledByDefault
|
||||
? { sourceId: s.id, status: 'Healthy', checkedAt: new Date().toISOString(), latency: '00:00:00.1500000', isHealthy: true, possibleReasons: [], remediationSteps: [] }
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const MOCK_ADVISORY_SOURCES = {
|
||||
items: [
|
||||
{ sourceId: 'aaa-1111', sourceKey: 'nvd', sourceName: 'NVD', sourceFamily: 'primary', sourceUrl: null, priority: 10, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 600, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 42, errorCount: 0, totalAdvisories: 1000, signedAdvisories: 0, unsignedAdvisories: 1000, signatureFailureCount: 0 },
|
||||
{ sourceId: 'bbb-2222', sourceKey: 'osv', sourceName: 'OSV', sourceFamily: 'primary', sourceUrl: null, priority: 20, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 1200, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 30, errorCount: 0, totalAdvisories: 500, signedAdvisories: 0, unsignedAdvisories: 500, signatureFailureCount: 0 },
|
||||
{ sourceId: 'ccc-3333', sourceKey: 'ghsa', sourceName: 'GitHub Advisory DB', sourceFamily: 'primary', sourceUrl: null, priority: 30, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 7200, freshnessSlaSeconds: 3600, freshnessStatus: 'warning', signatureStatus: 'unsigned', lastError: null, syncCount: 10, errorCount: 1, totalAdvisories: 200, signedAdvisories: 0, unsignedAdvisories: 200, signatureFailureCount: 0 },
|
||||
],
|
||||
totalCount: 3,
|
||||
dataAsOf: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function setupSourceApiMocks(page: import('@playwright/test').Page) {
|
||||
// Source management API mocks
|
||||
page.route('**/api/v1/sources/catalog', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CATALOG) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/status', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/enable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/disable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalChecked: 11, healthyCount: 8, failedCount: 3 }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const url = route.request().url();
|
||||
const sourceId = url.split('/sources/')[1]?.split('/check')[0] ?? 'unknown';
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId,
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.0850000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check-result', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId: 'nvd',
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.1000000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-enable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'ubuntu', success: true }, { sourceId: 'certfr', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-disable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'nvd', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Advisory sources API mock (for security page)
|
||||
page.route('**/api/v1/advisory-sources?*', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ADVISORY_SOURCES) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/advisory-sources/summary', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalSources: 3, healthySources: 2, warningSources: 1, staleSources: 0, unavailableSources: 0, disabledSources: 0, conflictingSources: 0, dataAsOf: new Date().toISOString() }),
|
||||
});
|
||||
});
|
||||
|
||||
// Integration service provider catalog
|
||||
page.route('**/api/v1/integrations/providers', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ provider: 'Harbor', name: 'Harbor', type: 'Registry' },
|
||||
{ provider: 'GitHubApp', name: 'GitHub App', type: 'Scm' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all for other security/integration endpoints
|
||||
page.route('**/api/v2/security/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], table: [] }) });
|
||||
});
|
||||
|
||||
page.route('**/api/v2/integrations/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
}
|
||||
|
||||
function setupErrorCollector(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Source catalog renders with categories and source rows
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Advisory Source Catalog', () => {
|
||||
test('renders catalog with category sections and source rows', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify page heading
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toContain('advisory');
|
||||
|
||||
// Verify category sections exist
|
||||
for (const category of ['Primary', 'Vendor', 'Distribution', 'Ecosystem', 'Cert', 'Csaf', 'Threat', 'Mirror']) {
|
||||
const categoryText = await page.locator('body').innerText();
|
||||
expect(categoryText).toContain(category);
|
||||
}
|
||||
|
||||
// Verify source names render
|
||||
expect(body).toContain('NVD');
|
||||
expect(body).toContain('OSV');
|
||||
expect(body).toContain('GitHub Security Advisories');
|
||||
expect(body).toContain('Red Hat Security');
|
||||
|
||||
// Verify stats bar shows counts
|
||||
expect(body).toMatch(/\d+\s*(enabled|healthy|failed)/i);
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('enable/disable toggle updates source state', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a toggle/button for a disabled source (ubuntu)
|
||||
const ubuntuRow = page.locator('text=Ubuntu Security').locator('..');
|
||||
await expect(ubuntuRow).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Look for a toggle or enable button in the row
|
||||
const toggle = ubuntuRow.locator('button, input[type="checkbox"], .toggle').first();
|
||||
if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('check button shows connectivity result', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a "Check" button on a source row
|
||||
const checkBtn = page.locator('button').filter({ hasText: /check/i }).first();
|
||||
if (await checkBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify status badge appears after check
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toMatch(/healthy|degraded|failed|unchecked/);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Check All button triggers bulk connectivity check', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const checkAllBtn = page.locator('button').filter({ hasText: /check all/i }).first();
|
||||
if (await checkAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkAllBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Enable All for a category enables all sources in that category', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find an "Enable All" button within a category section
|
||||
const enableAllBtn = page.locator('button').filter({ hasText: /enable all/i }).first();
|
||||
if (await enableAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await enableAllBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Onboarding hub shows Advisory & VEX Sources section
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Onboarding Hub - Advisory Sources', () => {
|
||||
test('shows Advisory & VEX Sources section with Configure button', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/setup/integrations/onboarding', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toContain('Advisory & VEX Sources');
|
||||
|
||||
// Verify the "Configure Sources" button exists
|
||||
const configureBtn = page.locator('button').filter({ hasText: /configure sources/i });
|
||||
await expect(configureBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click it and verify navigation to catalog
|
||||
await configureBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
expect(page.url()).toContain('advisory-vex-sources');
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Security page shows real source names with status
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Security Page - Advisory Health', () => {
|
||||
test('Advisories & VEX Health shows real source names', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/security', { timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
|
||||
// Verify the section exists
|
||||
expect(body).toContain('Advisories & VEX Health');
|
||||
|
||||
// Verify real source names from mock (not "offline - unknown")
|
||||
const healthSection = page.locator('text=Advisories & VEX Health').locator('..').locator('..');
|
||||
const healthText = await healthSection.innerText().catch(() => body);
|
||||
|
||||
// Should contain real source names from the advisory-sources API
|
||||
const hasRealSources = healthText.includes('NVD') || healthText.includes('OSV') || healthText.includes('GitHub');
|
||||
const hasOfflineUnknown = (healthText.match(/offline.*unknown/gi) || []).length;
|
||||
|
||||
// We expect real sources OR at least no "offline - unknown" for all entries
|
||||
expect(hasRealSources || hasOfflineUnknown === 0).toBeTruthy();
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user