release: v0.5.0 - Authentication, API Keys & Advanced Features
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:
533
frontend/e2e/apikeys.spec.ts
Normal file
533
frontend/e2e/apikeys.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user