549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
// -----------------------------------------------------------------------------
|
|
// smoke.spec.ts
|
|
// Sprint: SPRINT_5100_0009_0011_ui_tests
|
|
// Tasks: UI-5100-007, UI-5100-008, UI-5100-009, UI-5100-010
|
|
// Description: E2E smoke tests for critical user journeys
|
|
// -----------------------------------------------------------------------------
|
|
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
|
|
/**
|
|
* E2E Smoke Tests for Critical User Journeys
|
|
* Task UI-5100-007: Login → view dashboard → success
|
|
* Task UI-5100-008: View scan results → navigate to SBOM → success
|
|
* Task UI-5100-009: Apply policy → view verdict → success
|
|
* Task UI-5100-010: User without permissions → denied access → correct error message
|
|
*/
|
|
|
|
const mockConfig = {
|
|
authority: {
|
|
issuer: 'https://authority.local',
|
|
clientId: 'stellaops-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 findings:read',
|
|
audience: 'https://scanner.local',
|
|
},
|
|
apiBaseUrls: {
|
|
authority: 'https://authority.local',
|
|
scanner: 'https://scanner.local',
|
|
policy: 'https://policy.local',
|
|
concelier: 'https://concelier.local',
|
|
attestor: 'https://attestor.local',
|
|
},
|
|
quickstartMode: true,
|
|
};
|
|
|
|
// Mock data for tests
|
|
const mockScanResults = {
|
|
items: [
|
|
{
|
|
id: 'scan-001',
|
|
imageRef: 'stellaops/demo:v1.0.0',
|
|
digest: 'sha256:abc123def456',
|
|
status: 'completed',
|
|
createdAt: '2025-12-24T10:00:00Z',
|
|
completedAt: '2025-12-24T10:05:00Z',
|
|
packageCount: 142,
|
|
vulnerabilityCount: 7,
|
|
},
|
|
{
|
|
id: 'scan-002',
|
|
imageRef: 'stellaops/api:v2.0.0',
|
|
digest: 'sha256:789xyz000',
|
|
status: 'completed',
|
|
createdAt: '2025-12-24T11:00:00Z',
|
|
completedAt: '2025-12-24T11:03:00Z',
|
|
packageCount: 89,
|
|
vulnerabilityCount: 2,
|
|
},
|
|
],
|
|
total: 2,
|
|
};
|
|
|
|
const mockSbom = {
|
|
bomFormat: 'CycloneDX',
|
|
specVersion: '1.6',
|
|
metadata: {
|
|
component: {
|
|
type: 'container',
|
|
name: 'stellaops/demo',
|
|
version: 'v1.0.0',
|
|
},
|
|
},
|
|
components: [
|
|
{
|
|
type: 'library',
|
|
name: 'lodash',
|
|
version: '4.17.21',
|
|
purl: 'pkg:npm/lodash@4.17.21',
|
|
},
|
|
{
|
|
type: 'library',
|
|
name: 'express',
|
|
version: '4.18.2',
|
|
purl: 'pkg:npm/express@4.18.2',
|
|
},
|
|
],
|
|
vulnerabilities: [
|
|
{
|
|
id: 'CVE-2024-1234',
|
|
source: { name: 'NVD' },
|
|
ratings: [{ severity: 'critical', score: 9.8 }],
|
|
affects: [{ ref: 'pkg:npm/lodash@4.17.21' }],
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockVerdict = {
|
|
passed: true,
|
|
policyName: 'default-policy',
|
|
imageRef: 'stellaops/demo:v1.0.0',
|
|
digest: 'sha256:abc123def456',
|
|
checks: [
|
|
{ name: 'no-critical', passed: true, message: 'No critical vulnerabilities' },
|
|
{ name: 'sbom-complete', passed: true, message: 'SBOM is complete' },
|
|
{ name: 'signature-valid', passed: true, message: 'Signature verified' },
|
|
],
|
|
failureReasons: [],
|
|
};
|
|
|
|
const mockDashboard = {
|
|
summary: {
|
|
totalScans: 156,
|
|
completedScans: 150,
|
|
pendingScans: 6,
|
|
criticalVulnerabilities: 12,
|
|
highVulnerabilities: 45,
|
|
totalPolicies: 8,
|
|
activePolicies: 5,
|
|
},
|
|
recentScans: mockScanResults.items,
|
|
};
|
|
|
|
test.describe('UI-5100-007: Login → Dashboard Smoke Test', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupBasicMocks(page);
|
|
});
|
|
|
|
test('sign in button is visible on landing page', async ({ page }) => {
|
|
await page.goto('/');
|
|
const signInButton = page.getByRole('button', { name: /sign in/i });
|
|
await expect(signInButton).toBeVisible();
|
|
});
|
|
|
|
test('clicking sign in redirects to authority', async ({ page }) => {
|
|
await page.goto('/');
|
|
const signInButton = page.getByRole('button', { name: /sign in/i });
|
|
await expect(signInButton).toBeVisible();
|
|
|
|
const [request] = await Promise.all([
|
|
page.waitForRequest('https://authority.local/connect/authorize*'),
|
|
signInButton.click({ noWaitAfter: true }),
|
|
]);
|
|
|
|
expect(request.url()).toContain('authority.local');
|
|
expect(request.url()).toContain('authorize');
|
|
});
|
|
|
|
test('authenticated user sees dashboard', async ({ page }) => {
|
|
await setupAuthenticatedSession(page);
|
|
await page.route('**/api/dashboard*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockDashboard),
|
|
})
|
|
);
|
|
|
|
await page.goto('/dashboard');
|
|
// Dashboard elements should be visible
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
test.describe('UI-5100-008: Scan Results → SBOM Smoke Test', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupBasicMocks(page);
|
|
await setupAuthenticatedSession(page);
|
|
});
|
|
|
|
test('scan results list displays scans', async ({ page }) => {
|
|
await page.route('**/api/scans*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans');
|
|
// Should show scan results
|
|
await expect(page.getByText('stellaops/demo:v1.0.0')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('stellaops/api:v2.0.0')).toBeVisible();
|
|
});
|
|
|
|
test('clicking scan navigates to details', async ({ page }) => {
|
|
await page.route('**/api/scans', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults.items[0]),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001/sbom*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSbom),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans');
|
|
await page.getByText('stellaops/demo:v1.0.0').click();
|
|
|
|
// Should navigate to scan details
|
|
await expect(page).toHaveURL(/\/scans\/scan-001/);
|
|
});
|
|
|
|
test('scan details shows SBOM components', async ({ page }) => {
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults.items[0]),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001/sbom*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSbom),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// SBOM data should be visible
|
|
await expect(
|
|
page.getByText(/lodash|express|components/i).first()
|
|
).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('vulnerability count is displayed', async ({ page }) => {
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults.items[0]),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// Should show vulnerability count (7 from mock data)
|
|
await expect(page.getByText(/7|vulnerabilities/i).first()).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
test.describe('UI-5100-009: Apply Policy → View Verdict Smoke Test', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupBasicMocks(page);
|
|
await setupAuthenticatedSession(page);
|
|
});
|
|
|
|
test('policy application triggers verification', async ({ page }) => {
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockScanResults.items[0]),
|
|
})
|
|
);
|
|
|
|
let verifyRequested = false;
|
|
await page.route('**/api/verify*', (route) => {
|
|
verifyRequested = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockVerdict),
|
|
});
|
|
});
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// Find and click verify/apply policy button if present
|
|
const verifyButton = page.getByRole('button', { name: /verify|apply.*policy/i });
|
|
if (await verifyButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await verifyButton.click();
|
|
expect(verifyRequested).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('verdict shows pass status', async ({ page }) => {
|
|
await page.route('**/api/verify*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockVerdict),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
...mockScanResults.items[0],
|
|
verdict: mockVerdict,
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// Should show pass indicator or policy check results
|
|
const passIndicators = page.locator('text=/pass|✓|success|compliant/i');
|
|
if ((await passIndicators.count()) > 0) {
|
|
await expect(passIndicators.first()).toBeVisible({ timeout: 10000 });
|
|
}
|
|
});
|
|
|
|
test('verdict shows check details', async ({ page }) => {
|
|
await page.route('**/api/verify*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockVerdict),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
...mockScanResults.items[0],
|
|
verdict: mockVerdict,
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// Check details might be visible (depends on UI implementation)
|
|
const checkNames = ['no-critical', 'sbom-complete', 'signature-valid'];
|
|
for (const checkName of checkNames) {
|
|
const checkElement = page.getByText(new RegExp(checkName.replace('-', '\\s*'), 'i'));
|
|
if ((await checkElement.count()) > 0) {
|
|
await expect(checkElement.first()).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('failed verdict shows failure reasons', async ({ page }) => {
|
|
const failedVerdict = {
|
|
...mockVerdict,
|
|
passed: false,
|
|
checks: [
|
|
{ name: 'no-critical', passed: false, message: '2 critical vulnerabilities found' },
|
|
],
|
|
failureReasons: ['Critical vulnerability CVE-2024-9999 found'],
|
|
};
|
|
|
|
await page.route('**/api/verify*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(failedVerdict),
|
|
})
|
|
);
|
|
await page.route('**/api/scans/scan-001*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
...mockScanResults.items[0],
|
|
verdict: failedVerdict,
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans/scan-001');
|
|
|
|
// Should show failure indicator
|
|
const failIndicators = page.locator('text=/fail|✗|error|non-compliant|CVE/i');
|
|
if ((await failIndicators.count()) > 0) {
|
|
await expect(failIndicators.first()).toBeVisible({ timeout: 10000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('UI-5100-010: Permission Denied Smoke Test', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupBasicMocks(page);
|
|
});
|
|
|
|
test('unauthenticated user redirected to login', async ({ page }) => {
|
|
// Don't set up authenticated session
|
|
await page.goto('/dashboard');
|
|
|
|
// Should redirect to login or show sign in
|
|
const signInVisible = await page
|
|
.getByRole('button', { name: /sign in/i })
|
|
.isVisible({ timeout: 10000 })
|
|
.catch(() => false);
|
|
|
|
const redirectedToAuth = page.url().includes('auth') || page.url().includes('login');
|
|
|
|
expect(signInVisible || redirectedToAuth).toBe(true);
|
|
});
|
|
|
|
test('unauthorized API request shows error message', async ({ page }) => {
|
|
await setupAuthenticatedSession(page);
|
|
|
|
// Return 403 Forbidden
|
|
await page.route('**/api/scans*', (route) =>
|
|
route.fulfill({
|
|
status: 403,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: 'Forbidden',
|
|
message: 'You do not have permission to access this resource',
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans');
|
|
|
|
// Should show error message
|
|
const errorMessages = page.locator(
|
|
'text=/permission|forbidden|denied|unauthorized|access/i'
|
|
);
|
|
await expect(errorMessages.first()).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('insufficient scope shows appropriate error', async ({ page }) => {
|
|
// Set up session without required scopes
|
|
await setupAuthenticatedSession(page, { scope: 'openid profile' }); // Missing findings:read
|
|
|
|
await page.route('**/api/scans*', (route) =>
|
|
route.fulfill({
|
|
status: 403,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: 'insufficient_scope',
|
|
message: 'Required scope: findings:read',
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans');
|
|
|
|
// Should show scope-related error
|
|
const scopeError = page.locator('text=/scope|permission|access/i');
|
|
if ((await scopeError.count()) > 0) {
|
|
await expect(scopeError.first()).toBeVisible({ timeout: 10000 });
|
|
}
|
|
});
|
|
|
|
test('expired token triggers re-authentication', async ({ page }) => {
|
|
await setupAuthenticatedSession(page);
|
|
|
|
// Return 401 Unauthorized
|
|
await page.route('**/api/scans*', (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: 'invalid_token',
|
|
message: 'Token has expired',
|
|
}),
|
|
})
|
|
);
|
|
|
|
await page.goto('/scans');
|
|
|
|
// Should show login option or redirect
|
|
await page.waitForTimeout(2000); // Give time for redirect/UI update
|
|
|
|
const signInVisible = await page
|
|
.getByRole('button', { name: /sign in/i })
|
|
.isVisible()
|
|
.catch(() => false);
|
|
const errorVisible = await page
|
|
.locator('text=/expired|session|sign in again/i')
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
expect(signInVisible || errorVisible).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Helper functions
|
|
|
|
async function setupBasicMocks(page: Page) {
|
|
page.on('console', (message) => {
|
|
console.log('[browser]', message.type(), message.text());
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
console.log('[pageerror]', error.message);
|
|
});
|
|
|
|
await page.route('**/config.json', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockConfig),
|
|
})
|
|
);
|
|
|
|
// Block actual auth requests
|
|
await page.route('https://authority.local/**', (route) => {
|
|
if (route.request().url().includes('authorize')) {
|
|
// Let authorize requests through to verify URL construction
|
|
return route.abort();
|
|
}
|
|
return route.fulfill({ status: 400, body: 'blocked' });
|
|
});
|
|
}
|
|
|
|
async function setupAuthenticatedSession(page: Page, options?: { scope?: string }) {
|
|
const mockToken = {
|
|
access_token: 'mock-access-token',
|
|
id_token: 'mock-id-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
scope: options?.scope ?? 'openid profile email ui.read findings:read',
|
|
};
|
|
|
|
await page.addInitScript((tokenData) => {
|
|
// Mock authenticated session
|
|
(window as any).__stellaopsTestSession = {
|
|
isAuthenticated: true,
|
|
accessToken: tokenData.access_token,
|
|
idToken: tokenData.id_token,
|
|
expiresAt: Date.now() + tokenData.expires_in * 1000,
|
|
};
|
|
|
|
// Override fetch to add auth header
|
|
const originalFetch = window.fetch;
|
|
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
|
const headers = new Headers(init?.headers);
|
|
if (!headers.has('Authorization')) {
|
|
headers.set('Authorization', `Bearer ${tokenData.access_token}`);
|
|
}
|
|
return originalFetch(input, { ...init, headers });
|
|
};
|
|
}, mockToken);
|
|
}
|