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
346 lines
8.7 KiB
TypeScript
346 lines
8.7 KiB
TypeScript
/**
|
|
* Authentication Helpers for E2E Tests
|
|
*
|
|
* Shared utilities for authentication testing
|
|
* v0.5.0 - JWT and API Key Authentication Support
|
|
*/
|
|
|
|
import { Page, APIRequestContext, expect } from '@playwright/test';
|
|
|
|
// Base URLs
|
|
const API_BASE_URL = process.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
|
const FRONTEND_URL = process.env.TEST_BASE_URL || 'http://localhost:5173';
|
|
|
|
// Test user storage for cleanup
|
|
const testUsers: { email: string; password: string }[] = [];
|
|
|
|
/**
|
|
* Register a new user via API
|
|
*/
|
|
export async function registerUser(
|
|
request: APIRequestContext,
|
|
email: string,
|
|
password: string,
|
|
fullName: string
|
|
): Promise<{ user: { id: string; email: string }; access_token: string; refresh_token: string }> {
|
|
const response = await request.post(`${API_BASE_URL}/auth/register`, {
|
|
data: {
|
|
email,
|
|
password,
|
|
full_name: fullName,
|
|
},
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
const data = await response.json();
|
|
|
|
// Track for cleanup
|
|
testUsers.push({ email, password });
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Login user via API
|
|
*/
|
|
export async function loginUser(
|
|
request: APIRequestContext,
|
|
email: string,
|
|
password: string
|
|
): Promise<{ access_token: string; refresh_token: string; token_type: string }> {
|
|
const response = await request.post(`${API_BASE_URL}/auth/login`, {
|
|
data: {
|
|
email,
|
|
password,
|
|
},
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Login user via UI
|
|
*/
|
|
export async function loginUserViaUI(
|
|
page: Page,
|
|
email: string,
|
|
password: string
|
|
): Promise<void> {
|
|
await page.goto('/login');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Fill login form
|
|
await page.getByLabel(/email/i).fill(email);
|
|
await page.getByLabel(/password/i).fill(password);
|
|
|
|
// Submit form
|
|
await page.getByRole('button', { name: /login|sign in/i }).click();
|
|
|
|
// Wait for redirect to dashboard
|
|
await page.waitForURL('/', { timeout: 10000 });
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Register user via UI
|
|
*/
|
|
export async function registerUserViaUI(
|
|
page: Page,
|
|
email: string,
|
|
password: string,
|
|
fullName: string
|
|
): Promise<void> {
|
|
await page.goto('/register');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Fill registration form
|
|
await page.getByLabel(/full name|name/i).fill(fullName);
|
|
await page.getByLabel(/email/i).fill(email);
|
|
await page.getByLabel(/^password$/i).fill(password);
|
|
await page.getByLabel(/confirm password|repeat password/i).fill(password);
|
|
|
|
// Submit form
|
|
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
|
|
|
// Wait for redirect to dashboard
|
|
await page.waitForURL('/', { timeout: 10000 });
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
|
|
// Track for cleanup
|
|
testUsers.push({ email, password });
|
|
}
|
|
|
|
/**
|
|
* Logout user via UI
|
|
*/
|
|
export async function logoutUser(page: Page): Promise<void> {
|
|
// Click on user dropdown
|
|
const userDropdown = page.locator('[data-testid="user-dropdown"]').or(
|
|
page.locator('header').getByText(/user|profile|account/i).first()
|
|
);
|
|
|
|
if (await userDropdown.isVisible().catch(() => false)) {
|
|
await userDropdown.click();
|
|
|
|
// Click logout
|
|
const logoutButton = page.getByRole('menuitem', { name: /logout|sign out/i }).or(
|
|
page.getByText(/logout|sign out/i).first()
|
|
);
|
|
await logoutButton.click();
|
|
}
|
|
|
|
// Wait for redirect to login
|
|
await page.waitForURL('/login', { timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Create authentication header with JWT token
|
|
*/
|
|
export function createAuthHeader(accessToken: string): { Authorization: string } {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create API Key header
|
|
*/
|
|
export function createApiKeyHeader(apiKey: string): { 'X-API-Key': string } {
|
|
return {
|
|
'X-API-Key': apiKey,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current user info via API
|
|
*/
|
|
export async function getCurrentUser(
|
|
request: APIRequestContext,
|
|
accessToken: string
|
|
): Promise<{ id: string; email: string; full_name: string }> {
|
|
const response = await request.get(`${API_BASE_URL}/auth/me`, {
|
|
headers: createAuthHeader(accessToken),
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Refresh access token
|
|
*/
|
|
export async function refreshToken(
|
|
request: APIRequestContext,
|
|
refreshToken: string
|
|
): Promise<{ access_token: string; refresh_token: string }> {
|
|
const response = await request.post(`${API_BASE_URL}/auth/refresh`, {
|
|
data: { refresh_token: refreshToken },
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Create an API key via API
|
|
*/
|
|
export async function createApiKeyViaAPI(
|
|
request: APIRequestContext,
|
|
accessToken: string,
|
|
name: string,
|
|
scopes: string[] = ['read:scenarios'],
|
|
expiresDays?: number
|
|
): Promise<{ id: string; name: string; key: string; prefix: string; scopes: string[] }> {
|
|
const data: { name: string; scopes: string[]; expires_days?: number } = {
|
|
name,
|
|
scopes,
|
|
};
|
|
|
|
if (expiresDays !== undefined) {
|
|
data.expires_days = expiresDays;
|
|
}
|
|
|
|
const response = await request.post(`${API_BASE_URL}/api-keys`, {
|
|
data,
|
|
headers: createAuthHeader(accessToken),
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* List API keys via API
|
|
*/
|
|
export async function listApiKeys(
|
|
request: APIRequestContext,
|
|
accessToken: string
|
|
): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; is_active: boolean }>> {
|
|
const response = await request.get(`${API_BASE_URL}/api-keys`, {
|
|
headers: createAuthHeader(accessToken),
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Revoke API key via API
|
|
*/
|
|
export async function revokeApiKey(
|
|
request: APIRequestContext,
|
|
accessToken: string,
|
|
apiKeyId: string
|
|
): Promise<void> {
|
|
const response = await request.delete(`${API_BASE_URL}/api-keys/${apiKeyId}`, {
|
|
headers: createAuthHeader(accessToken),
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
}
|
|
|
|
/**
|
|
* Validate API key via API
|
|
*/
|
|
export async function validateApiKey(
|
|
request: APIRequestContext,
|
|
apiKey: string
|
|
): Promise<boolean> {
|
|
const response = await request.get(`${API_BASE_URL}/auth/me`, {
|
|
headers: createApiKeyHeader(apiKey),
|
|
});
|
|
|
|
return response.ok();
|
|
}
|
|
|
|
/**
|
|
* Generate unique test email
|
|
*/
|
|
export function generateTestEmail(prefix = 'test'): string {
|
|
const timestamp = Date.now();
|
|
const random = Math.random().toString(36).substring(2, 8);
|
|
return `${prefix}.${timestamp}.${random}@test.mockupaws.com`;
|
|
}
|
|
|
|
/**
|
|
* Generate unique test user data
|
|
*/
|
|
export function generateTestUser(prefix = 'Test'): {
|
|
email: string;
|
|
password: string;
|
|
fullName: string;
|
|
} {
|
|
const timestamp = Date.now();
|
|
return {
|
|
email: `user.${timestamp}@test.mockupaws.com`,
|
|
password: 'TestPassword123!',
|
|
fullName: `${prefix} User ${timestamp}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all test users (cleanup function)
|
|
*/
|
|
export async function cleanupTestUsers(request: APIRequestContext): Promise<void> {
|
|
for (const user of testUsers) {
|
|
try {
|
|
// Try to login and delete user (if API supports it)
|
|
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
|
|
data: { email: user.email, password: user.password },
|
|
});
|
|
|
|
if (loginResponse.ok()) {
|
|
const { access_token } = await loginResponse.json();
|
|
// Delete user - endpoint may vary
|
|
await request.delete(`${API_BASE_URL}/auth/me`, {
|
|
headers: createAuthHeader(access_token),
|
|
});
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
testUsers.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Check if user is authenticated on the page
|
|
*/
|
|
export async function isAuthenticated(page: Page): Promise<boolean> {
|
|
// Check for user dropdown or authenticated state indicators
|
|
const userDropdown = page.locator('[data-testid="user-dropdown"]');
|
|
const logoutButton = page.getByRole('button', { name: /logout/i });
|
|
|
|
const hasUserDropdown = await userDropdown.isVisible().catch(() => false);
|
|
const hasLogoutButton = await logoutButton.isVisible().catch(() => false);
|
|
|
|
return hasUserDropdown || hasLogoutButton;
|
|
}
|
|
|
|
/**
|
|
* Wait for auth redirect
|
|
*/
|
|
export async function waitForAuthRedirect(page: Page, expectedPath: string = '/login'): Promise<void> {
|
|
await page.waitForURL(expectedPath, { timeout: 5000 });
|
|
}
|
|
|
|
/**
|
|
* Set local storage token (for testing protected routes)
|
|
*/
|
|
export async function setAuthToken(page: Page, token: string): Promise<void> {
|
|
await page.evaluate((t) => {
|
|
localStorage.setItem('access_token', t);
|
|
}, token);
|
|
}
|
|
|
|
/**
|
|
* Clear local storage token
|
|
*/
|
|
export async function clearAuthToken(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
});
|
|
}
|