/** * Integration Services — End-to-End Test Suite * * Live infrastructure tests that validate the full integration lifecycle: * 1. Docker compose health (fixtures + real services) * 2. Direct endpoint probes to each 3rd-party service * 3. Stella Ops connector plugin API (create, test, health, delete) * 4. UI verification (Hub counts, tab switching, list views) * 5. Advisory source catalog (74/74 healthy) * * Prerequisites: * - Main Stella Ops stack running (docker-compose.stella-ops.yml) * - Integration fixtures running (docker-compose.integration-fixtures.yml) * - Integration services running (docker-compose.integrations.yml) * * Usage: * PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test e2e/integrations.e2e.spec.ts */ import { execSync } from 'child_process'; import { test, expect } from './live-auth.fixture'; import { waitForAngular } from './helpers'; const SCREENSHOT_DIR = 'e2e/screenshots/integrations'; const BASE = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local'; const runId = process.env['E2E_RUN_ID'] || 'run1'; /** Retry on 504 gateway timeout (Valkey transport to Concelier) */ async function withRetry( fn: () => Promise, maxRetries = 2, delayMs = 3_000, ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const resp = await fn(); if (resp.status() !== 504) return resp; if (attempt < maxRetries) await new Promise(r => setTimeout(r, delayMs)); } return await fn(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function dockerHealthy(containerName: string): boolean { try { const out = execSync( `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, { encoding: 'utf-8', timeout: 5_000 }, ).trim(); return out.includes('(healthy)') || (out.startsWith('Up') && !out.includes('health: starting')); } catch { return false; } } function dockerRunning(containerName: string): boolean { try { const out = execSync( `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, { encoding: 'utf-8', timeout: 5_000 }, ).trim(); return out.startsWith('Up'); } catch { return false; } } async function snap(page: import('@playwright/test').Page, label: string) { await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true }); } // --------------------------------------------------------------------------- // 1. Compose Health // --------------------------------------------------------------------------- test.describe('Integration Services — Compose Health', () => { const fixtures = [ 'stellaops-harbor-fixture', 'stellaops-github-app-fixture', 'stellaops-advisory-fixture', ]; const services = [ 'stellaops-gitea', 'stellaops-jenkins', 'stellaops-nexus', 'stellaops-vault', 'stellaops-docker-registry', 'stellaops-minio', ]; for (const name of fixtures) { test(`fixture container ${name} is healthy`, () => { expect(dockerHealthy(name), `${name} should be healthy`).toBe(true); }); } for (const name of services) { test(`service container ${name} is running`, () => { expect(dockerRunning(name), `${name} should be running`).toBe(true); }); } test('core integrations-web service is healthy', () => { expect(dockerHealthy('stellaops-integrations-web')).toBe(true); }); test('core concelier service is healthy', () => { expect(dockerHealthy('stellaops-concelier')).toBe(true); }); }); // --------------------------------------------------------------------------- // 2. Direct Endpoint Probes // --------------------------------------------------------------------------- test.describe('Integration Services — Direct Endpoint Probes', () => { const probes: Array<{ name: string; url: string; expect: string | number }> = [ { name: 'Harbor fixture', url: 'http://127.1.1.6/api/v2.0/health', expect: 'healthy' }, { name: 'GitHub App fixture', url: 'http://127.1.1.7/api/v3/app', expect: 'Stella QA' }, { name: 'Advisory fixture', url: 'http://127.1.1.8/health', expect: 'healthy' }, { name: 'Gitea', url: 'http://127.1.2.1:3000/api/v1/version', expect: 'version' }, { name: 'Jenkins', url: 'http://127.1.2.2:8080/api/json', expect: 200 }, { name: 'Nexus', url: 'http://127.1.2.3:8081/service/rest/v1/status', expect: 200 }, { name: 'Vault', url: 'http://127.1.2.4:8200/v1/sys/health', expect: 200 }, { name: 'Docker Registry', url: 'http://127.1.2.5:5000/v2/', expect: 200 }, { name: 'MinIO', url: 'http://127.1.2.6:9000/minio/health/live', expect: 200 }, ]; for (const probe of probes) { test(`${probe.name} responds at ${new URL(probe.url).pathname}`, async ({ playwright }) => { const ctx = await playwright.request.newContext({ ignoreHTTPSErrors: true }); try { const resp = await ctx.get(probe.url, { timeout: 10_000 }); expect(resp.status(), `${probe.name} should return 2xx`).toBeLessThan(300); if (typeof probe.expect === 'string') { const body = await resp.text(); expect(body).toContain(probe.expect); } } finally { await ctx.dispose(); } }); } }); // --------------------------------------------------------------------------- // 3. Stella Ops Connector Lifecycle // --------------------------------------------------------------------------- test.describe('Integration Services — Connector Lifecycle', () => { const createdIds: string[] = []; const integrations = [ { name: `E2E Harbor Registry ${runId}`, type: 1, // Registry provider: 100, // Harbor endpoint: 'http://harbor-fixture.stella-ops.local', authRefUri: null, organizationId: 'e2e-test', extendedConfig: { scheduleType: 'manual', repositories: ['e2e/test'] }, tags: ['e2e'], }, { name: `E2E Docker Registry ${runId}`, type: 1, provider: 104, // DockerHub endpoint: 'http://docker-registry.stella-ops.local:5000', authRefUri: null, organizationId: null, extendedConfig: { scheduleType: 'manual' }, tags: ['e2e'], }, { name: `E2E Nexus Repository ${runId}`, type: 1, provider: 107, // Nexus endpoint: 'http://nexus.stella-ops.local:8081', authRefUri: null, organizationId: null, extendedConfig: { scheduleType: 'manual' }, tags: ['e2e'], }, { name: `E2E Gitea SCM ${runId}`, type: 2, // Scm provider: 203, // Gitea endpoint: 'http://gitea.stella-ops.local:3000', authRefUri: null, organizationId: 'e2e', extendedConfig: { scheduleType: 'manual', repositories: ['e2e/repo'] }, tags: ['e2e'], }, { name: `E2E Jenkins CI ${runId}`, type: 3, // CiCd provider: 302, // Jenkins endpoint: 'http://jenkins.stella-ops.local:8080', authRefUri: null, organizationId: null, extendedConfig: { scheduleType: 'manual' }, tags: ['e2e'], }, ]; test('GET /providers returns at least 8 connector plugins', async ({ apiRequest }) => { const resp = await apiRequest.get('/api/v1/integrations/providers'); expect(resp.status()).toBe(200); const providers = await resp.json(); expect(providers.length).toBeGreaterThanOrEqual(8); }); for (const integration of integrations) { test(`create ${integration.name} and auto-activate`, async ({ apiRequest }) => { const resp = await apiRequest.post('/api/v1/integrations', { data: integration }); expect(resp.status()).toBe(201); const body = await resp.json(); createdIds.push(body.id); expect(body.name).toContain('E2E'); // Auto-test should set status to Active (1) for reachable services // Accept Pending (0) if auto-test had transient network issues expect( [0, 1], `${integration.name} status should be Pending or Active, got ${body.status}`, ).toContain(body.status); }); } test('list integrations returns results for each type', async ({ apiRequest }) => { const registries = await apiRequest.get('/api/v1/integrations?type=1&pageSize=100'); const scm = await apiRequest.get('/api/v1/integrations?type=2&pageSize=100'); const cicd = await apiRequest.get('/api/v1/integrations?type=3&pageSize=100'); expect(registries.status()).toBe(200); expect(scm.status()).toBe(200); expect(cicd.status()).toBe(200); const regBody = await registries.json(); const scmBody = await scm.json(); const cicdBody = await cicd.json(); // At minimum, the E2E integrations we just created should be present expect(regBody.totalCount).toBeGreaterThanOrEqual(1); expect(scmBody.totalCount).toBeGreaterThanOrEqual(1); expect(cicdBody.totalCount).toBeGreaterThanOrEqual(1); }); test('test-connection succeeds on all created integrations', async ({ apiRequest }) => { for (const id of createdIds) { const resp = await apiRequest.post(`/api/v1/integrations/${id}/test`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.success, `test-connection for ${id} should succeed`).toBe(true); } }); test('health-check returns healthy on all created integrations', async ({ apiRequest }) => { for (const id of createdIds) { const resp = await apiRequest.get(`/api/v1/integrations/${id}/health`); expect(resp.status()).toBe(200); const body = await resp.json(); // HealthStatus.Healthy = 1 expect(body.status, `health for ${id} should be Healthy`).toBe(1); } }); test.afterAll(async ({ playwright }) => { // Clean up: get a fresh token and delete all e2e integrations if (createdIds.length === 0) return; const browser = await playwright.chromium.launch(); const page = await browser.newPage({ ignoreHTTPSErrors: true }); await page.goto(BASE, { waitUntil: 'load' }); if (page.url().includes('/welcome')) { await page.getByRole('button', { name: /sign in/i }).click(); await page.waitForURL('**/connect/authorize**', { timeout: 10_000 }); } const usernameField = page.getByRole('textbox', { name: /username/i }); if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) { await usernameField.fill('admin'); await page.getByRole('textbox', { name: /password/i }).fill('Admin@Stella2026!'); await page.getByRole('button', { name: /sign in/i }).click(); await page.waitForURL(`${BASE}/**`, { timeout: 15_000 }); } await page.waitForLoadState('domcontentloaded'); const token = await page.evaluate(() => { const s = sessionStorage.getItem('stellaops.auth.session.full'); return s ? JSON.parse(s)?.tokens?.accessToken : null; }); if (token) { for (const id of createdIds) { await page.evaluate( async ([id, token]) => { await fetch(`/api/v1/integrations/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); }, [id, token] as const, ); } } await browser.close(); }); }); // --------------------------------------------------------------------------- // 4. Advisory Sources // --------------------------------------------------------------------------- test.describe('Integration Services — Advisory Sources', () => { test('all advisory sources report healthy after check', async ({ apiRequest }) => { // Check-all fires 42+ concurrent HTTP probes to external sources, which loads the // Concelier heavily and degrades the Valkey transport for subsequent tests. test.skip(process.env['E2E_ACTIVE_SYNC'] !== '1', 'Advisory check-all gated (set E2E_ACTIVE_SYNC=1)'); const checkResp = await apiRequest.post('/api/v1/advisory-sources/check', { timeout: 120_000 }); expect(checkResp.status()).toBe(200); const result = await checkResp.json(); expect(result.totalChecked).toBeGreaterThanOrEqual(42); expect(result.failedCount, `Expected <=3 failed sources, got ${result.failedCount}`).toBeLessThanOrEqual(3); }); }); // --------------------------------------------------------------------------- // 4b. Advisory Source Sync Lifecycle // --------------------------------------------------------------------------- test.describe('Integration Services — Advisory Source Sync Lifecycle', () => { // Advisory endpoints go through Valkey transport which may need retries. // Give enough time for 3 retry attempts with 55s transport timeout each. test.beforeEach(async ({}, testInfo) => { testInfo.setTimeout(300_000); }); test('GET /catalog returns full source catalog with metadata', async ({ apiRequest }) => { const resp = await withRetry(() => apiRequest.get('/api/v1/advisory-sources/catalog')); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.totalCount).toBeGreaterThanOrEqual(42); expect(body.items.length).toBeGreaterThanOrEqual(42); // Verify each source has required fields const first = body.items[0]; expect(first.id).toBeTruthy(); expect(first.displayName).toBeTruthy(); expect(first.category).toBeTruthy(); expect(first.baseEndpoint).toBeTruthy(); expect(typeof first.enabledByDefault).toBe('boolean'); }); test('GET /status returns enabled/disabled state for all sources', async ({ apiRequest }) => { const resp = await withRetry(() => apiRequest.get('/api/v1/advisory-sources/status')); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.sources.length).toBeGreaterThanOrEqual(42); const enabledCount = body.sources.filter((s: any) => s.enabled).length; expect(enabledCount).toBeGreaterThan(0); }); test('POST /{sourceId}/enable then disable toggles source state', async ({ apiRequest }) => { const sourceId = 'nvd'; // Disable first const disableResp = await withRetry(() => apiRequest.post(`/api/v1/advisory-sources/${sourceId}/disable`)); expect(disableResp.status()).toBe(200); const disableBody = await disableResp.json(); expect(disableBody.enabled).toBe(false); // Verify disabled in status const statusResp1 = await withRetry(() => apiRequest.get('/api/v1/advisory-sources/status')); const status1 = await statusResp1.json(); const nvdStatus1 = status1.sources.find((s: any) => s.sourceId === sourceId); expect(nvdStatus1.enabled).toBe(false); // Re-enable const enableResp = await withRetry(() => apiRequest.post(`/api/v1/advisory-sources/${sourceId}/enable`)); expect(enableResp.status()).toBe(200); const enableBody = await enableResp.json(); expect(enableBody.enabled).toBe(true); // Verify enabled in status const statusResp2 = await withRetry(() => apiRequest.get('/api/v1/advisory-sources/status')); const status2 = await statusResp2.json(); const nvdStatus2 = status2.sources.find((s: any) => s.sourceId === sourceId); expect(nvdStatus2.enabled).toBe(true); }); // Sync triggers start background fetch jobs that degrade the Valkey transport. // Gate behind E2E_ACTIVE_SYNC=1 to prevent cascading 504 timeouts. test('POST /{sourceId}/sync triggers fetch job for a source', async ({ apiRequest }) => { test.skip(process.env['E2E_ACTIVE_SYNC'] !== '1', 'Sync tests gated (set E2E_ACTIVE_SYNC=1)'); const sourceId = 'redhat'; const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/sync`); expect(resp.status()).toBeLessThan(500); const body = await resp.json(); expect(body.sourceId).toBe(sourceId); expect(body.jobKind).toBe(`source:${sourceId}:fetch`); expect(['accepted', 'already_running', 'no_job_defined']).toContain(body.outcome); }); test('POST /sync triggers fetch for all enabled sources', async ({ apiRequest }) => { test.skip(process.env['E2E_ACTIVE_SYNC'] !== '1', 'Sync tests gated (set E2E_ACTIVE_SYNC=1)'); const resp = await apiRequest.post('/api/v1/advisory-sources/sync'); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.totalSources).toBeGreaterThan(0); expect(body.results).toBeDefined(); expect(body.results.length).toBe(body.totalSources); for (const r of body.results) { expect(r.sourceId).toBeTruthy(); expect(r.outcome).toBeTruthy(); } }); test('POST /{sourceId}/sync returns 404 for unknown source', async ({ apiRequest }) => { const resp = await withRetry(() => apiRequest.post('/api/v1/advisory-sources/nonexistent-source-xyz/sync')); expect(resp.status()).toBe(404); }); test('GET /summary returns freshness aggregation', async ({ apiRequest }) => { const resp = await withRetry(() => apiRequest.get('/api/v1/advisory-sources/summary')); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.totalSources).toBeGreaterThanOrEqual(1); expect(typeof body.healthySources).toBe('number'); expect(typeof body.staleSources).toBe('number'); expect(typeof body.unavailableSources).toBe('number'); expect(body.dataAsOf).toBeTruthy(); }); test('POST /{sourceId}/check returns connectivity result with details', async ({ apiRequest }) => { test.skip(process.env['E2E_ACTIVE_SYNC'] !== '1', 'Connectivity check gated (set E2E_ACTIVE_SYNC=1)'); const sourceId = 'nvd'; const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.sourceId).toBe(sourceId); expect(body.isHealthy).toBe(true); expect(body.checkedAt).toBeTruthy(); }); test('GET /{sourceId}/check-result returns last check result', async ({ apiRequest }) => { test.skip(process.env['E2E_ACTIVE_SYNC'] !== '1', 'Connectivity check gated (set E2E_ACTIVE_SYNC=1)'); const sourceId = 'nvd'; await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`); const resp = await apiRequest.get(`/api/v1/advisory-sources/${sourceId}/check-result`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.sourceId).toBe(sourceId); }); test('batch-enable and batch-disable work for multiple sources', async ({ apiRequest }) => { const sourceIds = ['nvd', 'osv', 'cve']; // Batch disable const disableResp = await withRetry(() => apiRequest.post('/api/v1/advisory-sources/batch-disable', { data: { sourceIds }, })); expect(disableResp.status()).toBe(200); const disableBody = await disableResp.json(); expect(disableBody.results.length).toBe(3); for (const r of disableBody.results) { expect(r.success).toBe(true); } // Batch re-enable const enableResp = await withRetry(() => apiRequest.post('/api/v1/advisory-sources/batch-enable', { data: { sourceIds }, })); expect(enableResp.status()).toBe(200); const enableBody = await enableResp.json(); expect(enableBody.results.length).toBe(3); for (const r of enableBody.results) { expect(r.success).toBe(true); } }); }); // --------------------------------------------------------------------------- // 4c. Integration Connector Full CRUD + Status Lifecycle // --------------------------------------------------------------------------- test.describe('Integration Services — Connector CRUD & Status', () => { let testId: string | null = null; test('create integration returns 201 with correct fields', async ({ apiRequest }) => { const resp = await apiRequest.post('/api/v1/integrations', { data: { name: `E2E CRUD Test ${runId}`, type: 1, provider: 100, endpoint: 'http://harbor-fixture.stella-ops.local', authRefUri: null, organizationId: 'crud-test', extendedConfig: { scheduleType: 'manual' }, tags: ['e2e', 'crud-test'], }, }); expect(resp.status()).toBe(201); const body = await resp.json(); testId = body.id; expect(body.id).toBeTruthy(); expect(body.name).toContain('E2E CRUD Test'); expect(body.type).toBe(1); expect(body.provider).toBe(100); expect(body.endpoint).toBe('http://harbor-fixture.stella-ops.local'); expect(body.hasAuth).toBe(false); expect(body.organizationId).toBe('crud-test'); expect(body.tags).toContain('e2e'); }); test('GET by ID returns the created integration', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.get(`/api/v1/integrations/${testId}`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.id).toBe(testId); expect(body.name).toContain('E2E CRUD Test'); }); test('POST test-connection transitions status to Active', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.post(`/api/v1/integrations/${testId}/test`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.success).toBe(true); expect(body.message).toContain('Harbor'); // Verify status changed to Active const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`); const integration = await getResp.json(); expect(integration.status).toBe(1); // Active }); test('GET health returns Healthy after health check', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.get(`/api/v1/integrations/${testId}/health`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.status).toBe(1); // Healthy expect(body.checkedAt).toBeTruthy(); }); test('GET impact returns workflow impact map', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.get(`/api/v1/integrations/${testId}/impact`); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.integrationId).toBe(testId); expect(body.type).toBe(1); // Registry expect(body.severity).toBeTruthy(); expect(body.impactedWorkflows).toBeDefined(); expect(body.impactedWorkflows.length).toBeGreaterThan(0); }); test('PUT update changes integration fields', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.put(`/api/v1/integrations/${testId}`, { data: { name: `E2E CRUD Updated ${runId}`, description: 'Updated by E2E test' }, }); expect(resp.status()).toBe(200); const body = await resp.json(); expect(body.name).toContain('E2E CRUD Updated'); expect(body.description).toBe('Updated by E2E test'); }); test('DELETE removes the integration', async ({ apiRequest }) => { expect(testId).toBeTruthy(); const resp = await apiRequest.delete(`/api/v1/integrations/${testId}`); // Accept 200 or 204 (No Content) expect(resp.status()).toBeLessThan(300); // Verify it's gone (404 or empty response) const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`); expect([404, 204, 200]).toContain(getResp.status()); }); test('GET /providers lists all loaded connector plugins', async ({ apiRequest }) => { const resp = await apiRequest.get('/api/v1/integrations/providers'); expect(resp.status()).toBe(200); const providers = await resp.json(); expect(providers.length).toBeGreaterThanOrEqual(8); // Verify known providers are present const names = providers.map((p: any) => p.name); expect(names).toContain('harbor'); expect(names).toContain('gitea'); expect(names).toContain('jenkins'); expect(names).toContain('nexus'); expect(names).toContain('docker-registry'); expect(names).toContain('gitlab-server'); }); }); // --------------------------------------------------------------------------- // 5. UI Verification // --------------------------------------------------------------------------- test.describe('Integration Services — UI Verification', () => { test('landing page loads integration hub', async ({ liveAuthPage: page }) => { await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'load', timeout: 60_000 }); await waitForAngular(page); // Verify the integration hub rendered (tabs visible = shell mounted) const hasTabs = await page.locator('[role="tab"]').first().isVisible({ timeout: 10_000 }).catch(() => false); const hasHeading = await page.locator('h1:has-text("Integrations")').isVisible({ timeout: 5_000 }).catch(() => false); expect(hasTabs || hasHeading, 'Integration hub should render').toBe(true); await snap(page, '01-landing'); }); test('Registries tab lists registry integrations', async ({ liveAuthPage: page }) => { await page.goto(`${BASE}/setup/integrations/registries`, { waitUntil: 'load', timeout: 60_000 }); await waitForAngular(page); // Wait for table data to load (auto-retry with timeout) const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 30_000 }); await snap(page, '02-registries-tab'); }); test('SCM tab lists SCM integrations', async ({ liveAuthPage: page }) => { await page.goto(`${BASE}/setup/integrations/scm`, { waitUntil: 'load', timeout: 60_000 }); await waitForAngular(page); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 30_000 }); await snap(page, '03-scm-tab'); }); test('CI/CD tab lists CI/CD integrations', async ({ liveAuthPage: page }) => { await page.goto(`${BASE}/setup/integrations/ci`, { waitUntil: 'load', timeout: 60_000 }); await waitForAngular(page); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 30_000 }); await snap(page, '04-cicd-tab'); }); test('tab switching navigates between all tabs', async ({ liveAuthPage: page }) => { await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'load', timeout: 60_000 }); await waitForAngular(page); const tabs = ['Registries', 'SCM', 'CI/CD', 'Runtimes / Hosts', 'Advisory & VEX', 'Secrets']; for (const tabName of tabs) { const tab = page.getByRole('tab', { name: tabName }); await tab.click(); await page.waitForTimeout(500); // Verify tab is now selected const isSelected = await tab.getAttribute('aria-selected'); expect(isSelected, `Tab "${tabName}" should be selected after click`).toBe('true'); } await snap(page, '05-tab-switching-final'); }); });