290 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
|