release: v0.5.0 - Authentication, API Keys & Advanced Features
Some checks failed
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled

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
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 19:22:47 +02:00
parent 9b9297b7dc
commit cc60ba17ea
49 changed files with 9847 additions and 176 deletions

View File

@@ -0,0 +1,345 @@
/**
* 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');
});
}

View File

@@ -48,10 +48,17 @@ export async function createScenarioViaAPI(
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();
@@ -63,9 +70,17 @@ export async function createScenarioViaAPI(
*/
export async function deleteScenarioViaAPI(
request: APIRequestContext,
scenarioId: string
scenarioId: string,
accessToken?: string
) {
const response = await request.delete(`${API_BASE_URL}/scenarios/${scenarioId}`);
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());
@@ -76,9 +91,17 @@ export async function deleteScenarioViaAPI(
*/
export async function startScenarioViaAPI(
request: APIRequestContext,
scenarioId: string
scenarioId: string,
accessToken?: string
) {
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/start`);
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();
}
@@ -88,9 +111,17 @@ export async function startScenarioViaAPI(
*/
export async function stopScenarioViaAPI(
request: APIRequestContext,
scenarioId: string
scenarioId: string,
accessToken?: string
) {
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/stop`);
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();
}
@@ -101,12 +132,19 @@ export async function stopScenarioViaAPI(
export async function sendTestLogs(
request: APIRequestContext,
scenarioId: string,
logs: unknown[]
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();