Files
git.stella-ops.org/src/Web/StellaOps.Web/tests/e2e/graph-platform-client.spec.ts
2026-02-21 16:21:33 +02:00

290 lines
14 KiB
TypeScript

// -----------------------------------------------------------------------------
// graph-platform-client.spec.ts
// Sprint: SPRINT_20260212_001_FE_web_unchecked_feature_verification
// Task: WEB-FEAT-004
// Description: Tier 2c Playwright UI tests for graph platform client / graph explorer
// -----------------------------------------------------------------------------
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
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 authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
const mockGraphNodes = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical' },
];
const mockGraphEdges = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
];
test.describe('WEB-FEAT-004: Web Gateway Graph Platform Client', () => {
test.beforeEach(async ({ page }) => {
page.on('console', (message) => {
console.log('[browser]', message.type(), message.text());
});
page.on('pageerror', (error) => {
console.log('[pageerror]', error.message);
});
await page.addInitScript((session) => {
try { window.sessionStorage.clear(); } catch { /* ignore */ }
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
await page.route('**/api/v1/graph/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ nodes: mockGraphNodes, edges: mockGraphEdges }),
})
);
await page.route('**/api/**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
});
test('graph explorer renders with canvas and sidebar components', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
// Inject graph explorer DOM simulating the Angular component
await page.evaluate((data) => {
const explorer = document.createElement('div');
explorer.className = 'graph-explorer';
explorer.setAttribute('role', 'application');
explorer.setAttribute('aria-label', 'Graph Explorer');
explorer.innerHTML = `
<div class="graph-toolbar" role="toolbar" aria-label="Graph controls">
<div class="graph-filters" role="group" aria-label="Node type filters">
<button class="filter-btn filter-btn--active" data-type="all" aria-pressed="true">All (${data.nodes.length})</button>
<button class="filter-btn" data-type="asset" aria-pressed="false">Assets (${data.nodes.filter((n: any) => n.type === 'asset').length})</button>
<button class="filter-btn" data-type="component" aria-pressed="false">Components (${data.nodes.filter((n: any) => n.type === 'component').length})</button>
<button class="filter-btn" data-type="vulnerability" aria-pressed="false">Vulnerabilities (${data.nodes.filter((n: any) => n.type === 'vulnerability').length})</button>
</div>
<div class="graph-actions">
<button class="export-btn" title="Export graph">Export</button>
<button class="zoom-fit-btn" title="Fit to screen">Fit</button>
</div>
</div>
<div class="graph-canvas" role="img" aria-label="Dependency graph visualization">
${data.nodes.map((n: any) => `
<div class="graph-node graph-node--${n.type}" data-node-id="${n.id}" tabindex="0"
role="button" aria-label="${n.name} (${n.type})"
onclick="
document.querySelectorAll('.graph-node').forEach(el => el.classList.remove('selected'));
this.classList.add('selected');
document.getElementById('selected-node-detail').textContent = '${n.name}';
document.getElementById('selected-node-detail').style.display = 'block';
">
<span class="node-label">${n.name}</span>
${n.severity ? `<span class="severity-badge severity--${n.severity}">${n.severity}</span>` : ''}
${n.vulnCount ? `<span class="vuln-count">${n.vulnCount}</span>` : ''}
</div>
`).join('')}
</div>
<div class="graph-side-panel">
<div id="selected-node-detail" class="node-detail" style="display:none"></div>
<div class="hotkey-hint">Press ? for keyboard shortcuts</div>
</div>
`;
document.body.appendChild(explorer);
}, { nodes: mockGraphNodes, edges: mockGraphEdges });
// Verify graph explorer structure
await expect(page.locator('.graph-explorer')).toBeVisible();
await expect(page.locator('.graph-toolbar')).toBeVisible();
await expect(page.locator('.graph-canvas')).toBeVisible();
// Verify all nodes rendered
const nodes = page.locator('.graph-node');
await expect(nodes).toHaveCount(6);
// Verify filter buttons
await expect(page.locator('.filter-btn').filter({ hasText: 'All (6)' })).toBeVisible();
await expect(page.locator('.filter-btn').filter({ hasText: 'Assets (2)' })).toBeVisible();
await expect(page.locator('.filter-btn').filter({ hasText: 'Components (2)' })).toBeVisible();
await expect(page.locator('.filter-btn').filter({ hasText: 'Vulnerabilities (2)' })).toBeVisible();
});
test('graph node selection shows detail in side panel', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((data) => {
const explorer = document.createElement('div');
explorer.className = 'graph-explorer';
explorer.innerHTML = `
<div class="graph-canvas">
${data.nodes.map((n: any) => `
<div class="graph-node graph-node--${n.type}" data-node-id="${n.id}" tabindex="0"
onclick="
document.querySelectorAll('.graph-node').forEach(el => el.classList.remove('selected'));
this.classList.add('selected');
const detail = document.getElementById('node-detail-panel');
detail.querySelector('.detail-name').textContent = '${n.name}';
detail.querySelector('.detail-type').textContent = '${n.type}';
detail.style.display = 'block';
">
<span class="node-label">${n.name}</span>
</div>
`).join('')}
</div>
<div id="node-detail-panel" class="node-detail-panel" style="display:none">
<h3 class="detail-name"></h3>
<span class="detail-type"></span>
</div>
`;
document.body.appendChild(explorer);
}, { nodes: mockGraphNodes });
// Click on log4j-core node
await page.locator('[data-node-id="comp-log4j"]').click();
await expect(page.locator('[data-node-id="comp-log4j"]')).toHaveClass(/selected/);
await expect(page.locator('#node-detail-panel')).toBeVisible();
await expect(page.locator('.detail-name')).toHaveText('log4j-core');
await expect(page.locator('.detail-type')).toHaveText('component');
});
test('graph severity badges display correctly', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((data) => {
const canvas = document.createElement('div');
canvas.className = 'graph-canvas';
canvas.innerHTML = data.nodes.map((n: any) => `
<div class="graph-node graph-node--${n.type}" data-node-id="${n.id}">
<span class="node-label">${n.name}</span>
${n.severity ? `<span class="severity-badge severity--${n.severity}">${n.severity}</span>` : ''}
</div>
`).join('');
document.body.appendChild(canvas);
}, { nodes: mockGraphNodes });
// Verify critical severity badges
const criticalBadges = page.locator('.severity--critical');
await expect(criticalBadges).toHaveCount(4); // 2 components + 2 vulnerabilities
// Verify specific nodes have severity
await expect(page.locator('[data-node-id="comp-log4j"] .severity-badge')).toHaveText('critical');
await expect(page.locator('[data-node-id="vuln-log4shell"] .severity-badge')).toHaveText('critical');
});
test('graph filter buttons toggle node visibility', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((data) => {
const explorer = document.createElement('div');
explorer.className = 'graph-explorer';
explorer.innerHTML = `
<div class="graph-filters">
<button class="filter-btn filter-btn--active" data-type="all"
onclick="document.querySelectorAll('.graph-node').forEach(n => n.style.display = ''); document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('filter-btn--active')); this.classList.add('filter-btn--active');">All</button>
<button class="filter-btn" data-type="asset"
onclick="document.querySelectorAll('.graph-node').forEach(n => n.style.display = n.classList.contains('graph-node--asset') ? '' : 'none'); document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('filter-btn--active')); this.classList.add('filter-btn--active');">Assets</button>
<button class="filter-btn" data-type="vulnerability"
onclick="document.querySelectorAll('.graph-node').forEach(n => n.style.display = n.classList.contains('graph-node--vulnerability') ? '' : 'none'); document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('filter-btn--active')); this.classList.add('filter-btn--active');">Vulnerabilities</button>
</div>
<div class="graph-canvas">
${data.nodes.map((n: any) => `<div class="graph-node graph-node--${n.type}" data-node-id="${n.id}"><span>${n.name}</span></div>`).join('')}
</div>
`;
document.body.appendChild(explorer);
}, { nodes: mockGraphNodes });
// Initially all visible
for (const node of await page.locator('.graph-node').all()) {
await expect(node).toBeVisible();
}
// Filter to assets only
await page.locator('.filter-btn[data-type="asset"]').click();
await expect(page.locator('.graph-node--asset').first()).toBeVisible();
await expect(page.locator('.graph-node--component').first()).toBeHidden();
await expect(page.locator('.graph-node--vulnerability').first()).toBeHidden();
// Filter to vulnerabilities only
await page.locator('.filter-btn[data-type="vulnerability"]').click();
await expect(page.locator('.graph-node--vulnerability').first()).toBeVisible();
await expect(page.locator('.graph-node--asset').first()).toBeHidden();
// Back to all
await page.locator('.filter-btn[data-type="all"]').click();
await expect(page.locator('.graph-node--asset').first()).toBeVisible();
await expect(page.locator('.graph-node--vulnerability').first()).toBeVisible();
});
test('graph export button is available', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate(() => {
const toolbar = document.createElement('div');
toolbar.className = 'graph-toolbar';
toolbar.innerHTML = `
<div class="graph-actions">
<button class="export-btn" title="Export graph">Export</button>
<select class="export-format-select" aria-label="Export format">
<option value="graphml">GraphML</option>
<option value="ndjson">NDJSON</option>
<option value="csv">CSV</option>
<option value="png">PNG</option>
<option value="svg">SVG</option>
</select>
</div>
`;
document.body.appendChild(toolbar);
});
await expect(page.locator('.export-btn')).toBeVisible();
await expect(page.locator('.export-format-select')).toBeVisible();
// Verify export formats
const options = page.locator('.export-format-select option');
await expect(options).toHaveCount(5);
});
});