462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
import {
|
|
exceptionUserSession,
|
|
exceptionApproverSession,
|
|
exceptionAdminSession,
|
|
} from '../../src/app/testing/auth-fixtures';
|
|
|
|
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 exceptions:read exceptions:manage exceptions:approve admin',
|
|
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 mockException = {
|
|
exceptionId: 'exc-test-001',
|
|
name: 'test-exception',
|
|
displayName: 'Test Exception E2E',
|
|
description: 'E2E test exception',
|
|
type: 'vulnerability',
|
|
severity: 'high',
|
|
status: 'pending_review',
|
|
scope: {
|
|
type: 'global',
|
|
vulnIds: ['CVE-2024-9999'],
|
|
},
|
|
justification: {
|
|
text: 'This is a test exception for E2E testing',
|
|
},
|
|
timebox: {
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
},
|
|
labels: {},
|
|
createdBy: 'user-exception-requester',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
function setupMockRoutes(page) {
|
|
// Mock config
|
|
page.route('**/config.json', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockConfig),
|
|
})
|
|
);
|
|
|
|
// Mock exception list API
|
|
page.route('**/api/v1/exceptions?*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ items: [mockException], total: 1 }),
|
|
})
|
|
);
|
|
|
|
// Mock exception detail API
|
|
page.route(`**/api/v1/exceptions/${mockException.exceptionId}`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockException),
|
|
})
|
|
);
|
|
|
|
// Mock exception create API
|
|
page.route('**/api/v1/exceptions', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
const newException = {
|
|
...mockException,
|
|
exceptionId: 'exc-new-001',
|
|
status: 'draft',
|
|
};
|
|
route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(newException),
|
|
});
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
|
|
// Mock exception transition API
|
|
page.route('**/api/v1/exceptions/*/transition', (route) => {
|
|
const approvedException = {
|
|
...mockException,
|
|
status: 'approved',
|
|
};
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(approvedException),
|
|
});
|
|
});
|
|
|
|
// Mock exception update API
|
|
page.route('**/api/v1/exceptions/*', async (route) => {
|
|
if (route.request().method() === 'PATCH' || route.request().method() === 'PUT') {
|
|
const updatedException = {
|
|
...mockException,
|
|
description: 'Updated description',
|
|
};
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(updatedException),
|
|
});
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
|
|
// Mock SSE events
|
|
page.route('**/api/v1/exceptions/events', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: '',
|
|
})
|
|
);
|
|
|
|
// Block authority
|
|
page.route('https://authority.local/**', (route) => route.abort());
|
|
}
|
|
|
|
test.describe.skip('Exception Lifecycle - User Flow' /* TODO: Exception wizard UI not yet implemented */, () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionUserSession);
|
|
|
|
await setupMockRoutes(page);
|
|
});
|
|
|
|
test('create exception flow', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Open wizard
|
|
const createButton = page.getByRole('button', { name: /create exception/i });
|
|
await expect(createButton).toBeVisible();
|
|
await createButton.click();
|
|
|
|
// Wizard should be visible
|
|
await expect(page.getByRole('dialog', { name: /exception wizard/i })).toBeVisible();
|
|
|
|
// Fill in basic info
|
|
await page.getByLabel('Title').fill('Test Exception');
|
|
await page.getByLabel('Justification').fill('This is a test justification');
|
|
|
|
// Select severity
|
|
await page.getByLabel('Severity').selectOption('high');
|
|
|
|
// Fill scope (CVEs)
|
|
await page.getByLabel('CVE IDs').fill('CVE-2024-9999');
|
|
|
|
// Set expiry
|
|
await page.getByLabel('Expires in days').fill('30');
|
|
|
|
// Submit
|
|
const submitButton = page.getByRole('button', { name: /submit|create/i });
|
|
await expect(submitButton).toBeEnabled();
|
|
await submitButton.click();
|
|
|
|
// Wizard should close
|
|
await expect(page.getByRole('dialog', { name: /exception wizard/i })).toBeHidden();
|
|
|
|
// Exception should appear in list
|
|
await expect(page.getByText('Test Exception E2E')).toBeVisible();
|
|
});
|
|
|
|
test('displays exception list', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Exception should be visible in list
|
|
await expect(page.getByText('Test Exception E2E')).toBeVisible();
|
|
await expect(page.getByText('CVE-2024-9999')).toBeVisible();
|
|
});
|
|
|
|
test('opens exception detail panel', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Click on exception to view detail
|
|
await page.getByText('Test Exception E2E').click();
|
|
|
|
// Detail panel should open
|
|
await expect(page.getByText('This is a test exception for E2E testing')).toBeVisible();
|
|
await expect(page.getByText('CVE-2024-9999')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe.skip('Exception Lifecycle - Approval Flow' /* TODO: Exception approval queue UI not yet implemented */, () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionApproverSession);
|
|
|
|
await setupMockRoutes(page);
|
|
});
|
|
|
|
test('approval queue shows pending exceptions', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Pending exception should be visible
|
|
await expect(page.getByText('Test Exception E2E')).toBeVisible();
|
|
await expect(page.getByText(/pending/i)).toBeVisible();
|
|
});
|
|
|
|
test('approve exception', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Select exception
|
|
const checkbox = page.getByRole('checkbox', { name: /select exception/i }).first();
|
|
await checkbox.check();
|
|
|
|
// Approve button should be enabled
|
|
const approveButton = page.getByRole('button', { name: /approve/i });
|
|
await expect(approveButton).toBeEnabled();
|
|
await approveButton.click();
|
|
|
|
// Confirmation or success message
|
|
await expect(page.getByText(/approved/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('reject exception requires comment', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Select exception
|
|
const checkbox = page.getByRole('checkbox', { name: /select exception/i }).first();
|
|
await checkbox.check();
|
|
|
|
// Try to reject without comment
|
|
const rejectButton = page.getByRole('button', { name: /reject/i });
|
|
await rejectButton.click();
|
|
|
|
// Error message should appear
|
|
await expect(page.getByText(/comment.*required/i)).toBeVisible();
|
|
|
|
// Add comment
|
|
await page.getByLabel(/rejection comment/i).fill('Does not meet policy requirements');
|
|
|
|
// Reject should now work
|
|
await rejectButton.click();
|
|
|
|
// Success or confirmation
|
|
await expect(page.getByText(/rejected/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
test.describe.skip('Exception Lifecycle - Admin Flow' /* TODO: Exception admin UI not yet implemented */, () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionAdminSession);
|
|
|
|
await setupMockRoutes(page);
|
|
});
|
|
|
|
test('edit exception details', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Open exception detail
|
|
await page.getByText('Test Exception E2E').click();
|
|
|
|
// Edit description
|
|
const descriptionField = page.getByLabel(/description/i);
|
|
await descriptionField.fill('Updated description for E2E test');
|
|
|
|
// Save changes
|
|
const saveButton = page.getByRole('button', { name: /save/i });
|
|
await expect(saveButton).toBeEnabled();
|
|
await saveButton.click();
|
|
|
|
// Success message or confirmation
|
|
await expect(page.getByText(/saved|updated/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('extend exception expiry', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Open exception detail
|
|
await page.getByText('Test Exception E2E').click();
|
|
|
|
// Find extend button
|
|
const extendButton = page.getByRole('button', { name: /extend/i });
|
|
await expect(extendButton).toBeVisible();
|
|
|
|
// Set extension days
|
|
await page.getByLabel(/extend.*days/i).fill('14');
|
|
await extendButton.click();
|
|
|
|
// Confirmation
|
|
await expect(page.getByText(/extended/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('exception transition workflow', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Open exception detail
|
|
await page.getByText('Test Exception E2E').click();
|
|
|
|
// Transition button should be available
|
|
const transitionButton = page.getByRole('button', { name: /approve|activate/i }).first();
|
|
await expect(transitionButton).toBeVisible();
|
|
await transitionButton.click();
|
|
|
|
// Confirmation or success
|
|
await expect(page.getByText(/approved|activated/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
test.describe.skip('Exception Lifecycle - Role-Based Access' /* TODO: Exception RBAC UI not yet implemented */, () => {
|
|
test('user without approve scope cannot see approval queue', async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionUserSession);
|
|
|
|
await setupMockRoutes(page);
|
|
|
|
await page.goto('/policy/exceptions');
|
|
|
|
// Should redirect or show access denied
|
|
await expect(
|
|
page.getByText(/access denied|not authorized|forbidden/i)
|
|
).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('approver can access approval queue', async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionApproverSession);
|
|
|
|
await setupMockRoutes(page);
|
|
|
|
await page.goto('/policy/exceptions');
|
|
|
|
// Should show approval queue
|
|
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe.skip('Exception Export' /* TODO: Exception export UI not yet implemented */, () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((session) => {
|
|
try {
|
|
window.sessionStorage.clear();
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, exceptionAdminSession);
|
|
|
|
await setupMockRoutes(page);
|
|
|
|
// Mock export API
|
|
page.route('**/api/v1/exports/exceptions*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
reportId: 'report-001',
|
|
downloadUrl: '/downloads/exception-report.json',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
test('export exception report', async ({ page }) => {
|
|
await page.goto('/policy/exceptions');
|
|
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Find export button
|
|
const exportButton = page.getByRole('button', { name: /export/i });
|
|
await expect(exportButton).toBeVisible();
|
|
await exportButton.click();
|
|
|
|
// Export dialog or confirmation
|
|
await expect(page.getByText(/export|download/i)).toBeVisible();
|
|
|
|
// Verify download initiated (check for link or success message)
|
|
const downloadLink = page.getByRole('link', { name: /download/i });
|
|
await expect(downloadLink).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|