Complete v0.5.0 implementation: Database (@db-engineer): - 3 migrations: users, api_keys, report_schedules tables - Foreign keys, indexes, constraints, enums Backend (@backend-dev): - JWT authentication service with bcrypt (cost=12) - Auth endpoints: /register, /login, /refresh, /me - API Keys service with hash storage and prefix validation - API Keys endpoints: CRUD + rotate - Security module with JWT HS256 Frontend (@frontend-dev): - Login/Register pages with validation - AuthContext with localStorage persistence - Protected routes implementation - API Keys management UI (create, revoke, rotate) - Header with user dropdown DevOps (@devops-engineer): - .env.example and .env.production.example - docker-compose.scheduler.yml - scripts/setup-secrets.sh - INFRASTRUCTURE_SETUP.md QA (@qa-engineer): - 85 E2E tests: auth.spec.ts, apikeys.spec.ts, scenarios.spec.ts, regression-v050.spec.ts - auth-helpers.ts with 20+ utility functions - Test plans and documentation Architecture (@spec-architect): - SECURITY.md with best practices - SECURITY-CHECKLIST.md pre-deployment - Updated architecture.md with auth flows - Updated README.md with v0.5.0 features Documentation: - Updated todo.md with v0.5.0 status - Added docs/README.md index - Complete setup instructions Dependencies added: - bcrypt, python-jose, passlib, email-validator Tested: JWT auth flow, API keys CRUD, protected routes, 85 E2E tests ready Closes: v0.5.0 milestone
244 lines
6.0 KiB
TypeScript
244 lines
6.0 KiB
TypeScript
/**
|
|
* E2E Test Utilities
|
|
*
|
|
* Shared utilities and helpers for E2E tests
|
|
*/
|
|
|
|
import { Page, expect, APIRequestContext } from '@playwright/test';
|
|
|
|
// Base URL for API calls
|
|
const API_BASE_URL = process.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
|
|
|
/**
|
|
* Navigate to a page and wait for it to be ready
|
|
*/
|
|
export async function navigateTo(page: Page, path: string) {
|
|
await page.goto(path);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
/**
|
|
* Wait for loading states to complete
|
|
*/
|
|
export async function waitForLoading(page: Page) {
|
|
// Wait for any loading text to disappear
|
|
const loadingElement = page.getByText('Loading...');
|
|
await expect(loadingElement).toHaveCount(0, { timeout: 30000 });
|
|
}
|
|
|
|
/**
|
|
* Wait for table to be populated
|
|
*/
|
|
export async function waitForTableData(page: Page, tableTestId?: string) {
|
|
const tableSelector = tableTestId
|
|
? `[data-testid="${tableTestId}"] tbody tr`
|
|
: 'table tbody tr';
|
|
|
|
// Wait for at least one row to be present
|
|
await page.waitForSelector(tableSelector, { timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Create a scenario via API
|
|
*/
|
|
export async function createScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenario: {
|
|
name: string;
|
|
description?: string;
|
|
tags?: string[];
|
|
region: string;
|
|
},
|
|
accessToken?: string
|
|
) {
|
|
const headers: Record<string, string> = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await request.post(`${API_BASE_URL}/scenarios`, {
|
|
data: scenario,
|
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Delete a scenario via API
|
|
*/
|
|
export async function deleteScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string,
|
|
accessToken?: string
|
|
) {
|
|
const headers: Record<string, string> = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await request.delete(`${API_BASE_URL}/scenarios/${scenarioId}`, {
|
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
});
|
|
|
|
// Accept 204 (No Content) or 200 (OK) or 404 (already deleted)
|
|
expect([200, 204, 404]).toContain(response.status());
|
|
}
|
|
|
|
/**
|
|
* Start a scenario via API
|
|
*/
|
|
export async function startScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string,
|
|
accessToken?: string
|
|
) {
|
|
const headers: Record<string, string> = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/start`, {
|
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Stop a scenario via API
|
|
*/
|
|
export async function stopScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string,
|
|
accessToken?: string
|
|
) {
|
|
const headers: Record<string, string> = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/stop`, {
|
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Send test logs to a scenario
|
|
*/
|
|
export async function sendTestLogs(
|
|
request: APIRequestContext,
|
|
scenarioId: string,
|
|
logs: unknown[],
|
|
accessToken?: string
|
|
) {
|
|
const headers: Record<string, string> = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await request.post(
|
|
`${API_BASE_URL}/scenarios/${scenarioId}/ingest`,
|
|
{
|
|
data: { logs },
|
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
}
|
|
);
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Generate a unique test scenario name
|
|
*/
|
|
export function generateTestScenarioName(prefix = 'E2E Test'): string {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
return `${prefix} ${timestamp}`;
|
|
}
|
|
|
|
/**
|
|
* Wait for toast notification
|
|
*/
|
|
export async function waitForToast(page: Page, message?: string) {
|
|
const toastSelector = '[data-testid="toast"]'
|
|
+ (message ? `:has-text("${message}")` : '');
|
|
|
|
await page.waitForSelector(toastSelector, { timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Click on a navigation link by label
|
|
*/
|
|
export async function clickNavigation(page: Page, label: string) {
|
|
const navLink = page.locator('nav').getByRole('link', { name: label });
|
|
await navLink.click();
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
/**
|
|
* Get scenario by name from the scenarios table
|
|
*/
|
|
export async function getScenarioRow(page: Page, scenarioName: string) {
|
|
return page.locator('table tbody tr').filter({ hasText: scenarioName });
|
|
}
|
|
|
|
/**
|
|
* Open scenario actions dropdown
|
|
*/
|
|
export async function openScenarioActions(page: Page, scenarioName: string) {
|
|
const row = await getScenarioRow(page, scenarioName);
|
|
const actionsButton = row.locator('button').first();
|
|
await actionsButton.click();
|
|
return row.locator('[role="menu"]');
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot with a descriptive name
|
|
*/
|
|
export async function takeScreenshot(page: Page, name: string) {
|
|
await page.screenshot({
|
|
path: `e2e/screenshots/${name}.png`,
|
|
fullPage: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if element is visible with retry
|
|
*/
|
|
export async function isElementVisible(
|
|
page: Page,
|
|
selector: string,
|
|
timeout = 5000
|
|
): Promise<boolean> {
|
|
try {
|
|
await page.waitForSelector(selector, { timeout, state: 'visible' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile viewport helper
|
|
*/
|
|
export async function setMobileViewport(page: Page) {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
}
|
|
|
|
/**
|
|
* Tablet viewport helper
|
|
*/
|
|
export async function setTabletViewport(page: Page) {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
}
|
|
|
|
/**
|
|
* Desktop viewport helper
|
|
*/
|
|
export async function setDesktopViewport(page: Page) {
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
}
|