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,533 @@
/**
* QA-APIKEY-020: API Keys Tests
*
* E2E Test Suite for API Key Management
* - Create API Key
* - Revoke API Key
* - API Access with Key
* - Key Rotation
*/
import { test, expect } from '@playwright/test';
import { navigateTo, waitForLoading, generateTestScenarioName } from './utils/test-helpers';
import {
generateTestUser,
loginUserViaUI,
registerUserViaAPI,
createApiKeyViaAPI,
listApiKeys,
revokeApiKey,
createAuthHeader,
createApiKeyHeader,
} from './utils/auth-helpers';
// Store test data for cleanup
let testUser: { email: string; password: string; fullName: string } | null = null;
let accessToken: string | null = null;
let apiKey: string | null = null;
let apiKeyId: string | null = null;
// ============================================
// TEST SUITE: API Key Creation (UI)
// ============================================
test.describe('QA-APIKEY-020: Create API Key - UI', () => {
test.beforeEach(async ({ page, request }) => {
// Register and login user
testUser = generateTestUser('APIKey');
const auth = await registerUserViaAPI(
request,
testUser.email,
testUser.password,
testUser.fullName
);
accessToken = auth.access_token;
// Login via UI
await loginUserViaUI(page, testUser.email, testUser.password);
});
test('should navigate to API Keys settings page', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Verify page loaded
await expect(page.getByRole('heading', { name: /api keys|api keys management/i })).toBeVisible();
});
test('should create API key and display modal with full key', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Click create new key button
await page.getByRole('button', { name: /create|generate|new.*key/i }).click();
// Fill form
await page.getByLabel(/name|key name/i).fill('Test API Key');
// Select scopes if available
const scopeCheckboxes = page.locator('input[type="checkbox"][name*="scope"], [data-testid*="scope"]');
if (await scopeCheckboxes.first().isVisible().catch(() => false)) {
await scopeCheckboxes.first().check();
}
// Submit form
await page.getByRole('button', { name: /create|generate|save/i }).click();
// Verify modal appears with the full key
const modal = page.locator('[role="dialog"], [data-testid="api-key-modal"], .modal').first();
await expect(modal).toBeVisible({ timeout: 5000 });
// Verify key is displayed
await expect(modal.getByText(/mk_/i).or(modal.locator('input[value*="mk_"]'))).toBeVisible();
// Verify warning message
await expect(
modal.getByText(/copy now|only see once|save.*key|cannot.*see.*again/i).first()
).toBeVisible();
});
test('should copy API key to clipboard', async ({ page, context }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Create a key
await page.getByRole('button', { name: /create|generate|new.*key/i }).click();
await page.getByLabel(/name|key name/i).fill('Clipboard Test Key');
await page.getByRole('button', { name: /create|generate|save/i }).click();
// Wait for modal
const modal = page.locator('[role="dialog"], [data-testid="api-key-modal"], .modal').first();
await expect(modal).toBeVisible({ timeout: 5000 });
// Click copy button
const copyButton = modal.getByRole('button', { name: /copy|clipboard/i });
if (await copyButton.isVisible().catch(() => false)) {
await copyButton.click();
// Verify copy success message or toast
await expect(
page.getByText(/copied|clipboard|success/i).first()
).toBeVisible({ timeout: 3000 });
}
});
test('should show API key in list after creation', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Create a key
const keyName = 'List Test Key';
await page.getByRole('button', { name: /create|generate|new.*key/i }).click();
await page.getByLabel(/name|key name/i).fill(keyName);
await page.getByRole('button', { name: /create|generate|save/i }).click();
// Close modal if present
const modal = page.locator('[role="dialog"], [data-testid="api-key-modal"], .modal').first();
if (await modal.isVisible().catch(() => false)) {
const closeButton = modal.getByRole('button', { name: /close|done|ok/i });
await closeButton.click();
}
// Refresh page
await page.reload();
await page.waitForLoadState('networkidle');
// Verify key appears in list
await expect(page.getByText(keyName)).toBeVisible();
});
test('should validate required fields when creating API key', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Click create new key button
await page.getByRole('button', { name: /create|generate|new.*key/i }).click();
// Submit without filling name
await page.getByRole('button', { name: /create|generate|save/i }).click();
// Verify validation error
await expect(
page.getByText(/required|name.*required|please enter/i).first()
).toBeVisible({ timeout: 5000 });
});
});
// ============================================
// TEST SUITE: API Key Revocation (UI)
// ============================================
test.describe('QA-APIKEY-020: Revoke API Key - UI', () => {
test.beforeEach(async ({ page, request }) => {
// Register and login user
testUser = generateTestUser('RevokeKey');
const auth = await registerUserViaAPI(
request,
testUser.email,
testUser.password,
testUser.fullName
);
accessToken = auth.access_token;
// Login via UI
await loginUserViaUI(page, testUser.email, testUser.password);
});
test('should revoke API key and remove from list', async ({ page, request }) => {
// Create an API key via API first
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Key To Revoke',
['read:scenarios']
);
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Find the key in list
await expect(page.getByText('Key To Revoke')).toBeVisible();
// Click revoke/delete button
const revokeButton = page.locator('tr', { hasText: 'Key To Revoke' }).getByRole('button', { name: /revoke|delete|remove/i });
await revokeButton.click();
// Confirm revocation if confirmation dialog appears
const confirmButton = page.getByRole('button', { name: /confirm|yes|revoke/i });
if (await confirmButton.isVisible().catch(() => false)) {
await confirmButton.click();
}
// Verify key is no longer in list
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.getByText('Key To Revoke')).not.toBeVisible();
});
test('should show confirmation before revoking', async ({ page, request }) => {
// Create an API key via API
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Key With Confirmation',
['read:scenarios']
);
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Find and click revoke
const revokeButton = page.locator('tr', { hasText: 'Key With Confirmation' }).getByRole('button', { name: /revoke|delete/i });
await revokeButton.click();
// Verify confirmation dialog
await expect(
page.getByText(/are you sure|confirm.*revoke|cannot.*undo/i).first()
).toBeVisible({ timeout: 5000 });
});
});
// ============================================
// TEST SUITE: API Access with Key (API)
// ============================================
test.describe('QA-APIKEY-020: API Access with Key', () => {
test.beforeAll(async ({ request }) => {
// Register test user
testUser = generateTestUser('APIAccess');
const auth = await registerUserViaAPI(
request,
testUser.email,
testUser.password,
testUser.fullName
);
accessToken = auth.access_token;
});
test('should access API with valid API key header', async ({ request }) => {
// Create an API key
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Valid Access Key',
['read:scenarios']
);
apiKey = newKey.key;
apiKeyId = newKey.id;
// Make API request with API key
const response = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(apiKey),
});
// Should be authorized
expect(response.status()).not.toBe(401);
expect(response.status()).not.toBe(403);
});
test('should access /auth/me with valid API key', async ({ request }) => {
// Create an API key
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Me Endpoint Key',
['read:scenarios']
);
// Make API request
const response = await request.get('http://localhost:8000/api/v1/auth/me', {
headers: createApiKeyHeader(newKey.key),
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('email');
});
test('should return 401 with revoked API key', async ({ request }) => {
// Create an API key
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Key To Revoke For Test',
['read:scenarios']
);
// Revoke the key
await revokeApiKey(request, accessToken!, newKey.id);
// Try to use revoked key
const response = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(newKey.key),
});
expect(response.status()).toBe(401);
});
test('should return 401 with invalid API key format', async ({ request }) => {
const response = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader('invalid_key_format'),
});
expect(response.status()).toBe(401);
});
test('should return 401 with non-existent API key', async ({ request }) => {
const response = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader('mk_nonexistentkey12345678901234'),
});
expect(response.status()).toBe(401);
});
test('should return 401 without API key header', async ({ request }) => {
const response = await request.get('http://localhost:8000/api/v1/scenarios');
// Should require authentication
expect(response.status()).toBe(401);
});
test('should respect API key scopes', async ({ request }) => {
// Create a read-only API key
const readKey = await createApiKeyViaAPI(
request,
accessToken!,
'Read Only Key',
['read:scenarios']
);
// Read should work
const readResponse = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(readKey.key),
});
// Should be allowed for read operations
expect(readResponse.status()).not.toBe(403);
});
test('should track API key last used timestamp', async ({ request }) => {
// Create an API key
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Track Usage Key',
['read:scenarios']
);
// Use the key
await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(newKey.key),
});
// Check if last_used is updated (API dependent)
const listResponse = await request.get('http://localhost:8000/api/v1/api-keys', {
headers: createAuthHeader(accessToken!),
});
if (listResponse.ok()) {
const keys = await listResponse.json();
const key = keys.find((k: { id: string }) => k.id === newKey.id);
if (key && key.last_used_at) {
expect(key.last_used_at).toBeTruthy();
}
}
});
});
// ============================================
// TEST SUITE: API Key Management (API)
// ============================================
test.describe('QA-APIKEY-020: API Key Management - API', () => {
test.beforeAll(async ({ request }) => {
// Register test user
testUser = generateTestUser('KeyMgmt');
const auth = await registerUserViaAPI(
request,
testUser.email,
testUser.password,
testUser.fullName
);
accessToken = auth.access_token;
});
test('should list all API keys for user', async ({ request }) => {
// Create a couple of keys
await createApiKeyViaAPI(request, accessToken!, 'Key 1', ['read:scenarios']);
await createApiKeyViaAPI(request, accessToken!, 'Key 2', ['read:scenarios', 'write:scenarios']);
// List keys
const keys = await listApiKeys(request, accessToken!);
expect(keys.length).toBeGreaterThanOrEqual(2);
expect(keys.some(k => k.name === 'Key 1')).toBe(true);
expect(keys.some(k => k.name === 'Key 2')).toBe(true);
});
test('should not expose full API key in list response', async ({ request }) => {
// Create a key
const newKey = await createApiKeyViaAPI(request, accessToken!, 'Hidden Key', ['read:scenarios']);
// List keys
const keys = await listApiKeys(request, accessToken!);
const key = keys.find(k => k.id === newKey.id);
expect(key).toBeDefined();
// Should have prefix but not full key
expect(key).toHaveProperty('prefix');
expect(key).not.toHaveProperty('key');
expect(key).not.toHaveProperty('key_hash');
});
test('should create API key with expiration', async ({ request }) => {
// Create key with 7 day expiration
const newKey = await createApiKeyViaAPI(
request,
accessToken!,
'Expiring Key',
['read:scenarios'],
7
);
expect(newKey).toHaveProperty('id');
expect(newKey).toHaveProperty('key');
expect(newKey.key).toMatch(/^mk_/);
});
test('should rotate API key', async ({ request }) => {
// Create a key
const oldKey = await createApiKeyViaAPI(request, accessToken!, 'Rotatable Key', ['read:scenarios']);
// Rotate the key
const rotateResponse = await request.post(
`http://localhost:8000/api/v1/api-keys/${oldKey.id}/rotate`,
{ headers: createAuthHeader(accessToken!) }
);
if (rotateResponse.status() === 404) {
test.skip(true, 'Key rotation endpoint not implemented');
}
expect(rotateResponse.ok()).toBeTruthy();
const newKeyData = await rotateResponse.json();
expect(newKeyData).toHaveProperty('key');
expect(newKeyData.key).not.toBe(oldKey.key);
// Old key should no longer work
const oldKeyResponse = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(oldKey.key),
});
expect(oldKeyResponse.status()).toBe(401);
// New key should work
const newKeyResponse = await request.get('http://localhost:8000/api/v1/scenarios', {
headers: createApiKeyHeader(newKeyData.key),
});
expect(newKeyResponse.ok()).toBeTruthy();
});
});
// ============================================
// TEST SUITE: API Key UI - List View
// ============================================
test.describe('QA-APIKEY-020: API Key List View', () => {
test.beforeEach(async ({ page, request }) => {
// Register and login user
testUser = generateTestUser('ListView');
const auth = await registerUserViaAPI(
request,
testUser.email,
testUser.password,
testUser.fullName
);
accessToken = auth.access_token;
// Login via UI
await loginUserViaUI(page, testUser.email, testUser.password);
});
test('should display API keys table with correct columns', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Verify table headers
await expect(page.getByRole('columnheader', { name: /name/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /prefix|key/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /scopes|permissions/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /created|date/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /actions/i })).toBeVisible();
});
test('should show empty state when no API keys', async ({ page }) => {
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Verify empty state message
await expect(
page.getByText(/no.*keys|no.*api.*keys|get started|create.*key/i).first()
).toBeVisible();
});
test('should display key prefix for identification', async ({ page, request }) => {
// Create a key via API
const newKey = await createApiKeyViaAPI(request, accessToken!, 'Prefix Test Key', ['read:scenarios']);
// Navigate to API Keys page
await page.goto('/settings/api-keys');
await page.waitForLoadState('networkidle');
// Verify prefix is displayed
await expect(page.getByText(newKey.prefix)).toBeVisible();
});
});