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:
490
frontend/e2e/auth.spec.ts
Normal file
490
frontend/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* QA-AUTH-019: Authentication Tests
|
||||
*
|
||||
* E2E Test Suite for Authentication Flow
|
||||
* - Registration
|
||||
* - Login
|
||||
* - Protected Routes
|
||||
* - Logout
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { navigateTo, waitForLoading } from './utils/test-helpers';
|
||||
import {
|
||||
generateTestEmail,
|
||||
generateTestUser,
|
||||
loginUserViaUI,
|
||||
registerUserViaUI,
|
||||
logoutUser,
|
||||
isAuthenticated,
|
||||
waitForAuthRedirect,
|
||||
clearAuthToken,
|
||||
} from './utils/auth-helpers';
|
||||
|
||||
// ============================================
|
||||
// TEST SUITE: Registration
|
||||
// ============================================
|
||||
test.describe('QA-AUTH-019: Registration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should register new user successfully', async ({ page }) => {
|
||||
const testUser = generateTestUser('Registration');
|
||||
|
||||
// Fill registration form
|
||||
await page.getByLabel(/full name|name/i).fill(testUser.fullName);
|
||||
await page.getByLabel(/email/i).fill(testUser.email);
|
||||
await page.getByLabel(/^password$/i).fill(testUser.password);
|
||||
await page.getByLabel(/confirm password|repeat password/i).fill(testUser.password);
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify redirect to dashboard
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
// Verify user is authenticated
|
||||
expect(await isAuthenticated(page)).toBe(true);
|
||||
});
|
||||
|
||||
test('should show error for duplicate email', async ({ page, request }) => {
|
||||
const testEmail = generateTestEmail('duplicate');
|
||||
const testUser = generateTestUser();
|
||||
|
||||
// Register first user
|
||||
await registerUserViaUI(page, testEmail, testUser.password, testUser.fullName);
|
||||
|
||||
// Logout and try to register again with same email
|
||||
await logoutUser(page);
|
||||
await page.goto('/register');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill form with same email
|
||||
await page.getByLabel(/full name|name/i).fill('Another Name');
|
||||
await page.getByLabel(/email/i).fill(testEmail);
|
||||
await page.getByLabel(/^password$/i).fill('AnotherPassword123!');
|
||||
await page.getByLabel(/confirm password|repeat password/i).fill('AnotherPassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify error message
|
||||
await expect(
|
||||
page.getByText(/email already exists|already registered|duplicate|account exists/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should stay on register page
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
|
||||
test('should show error for password mismatch', async ({ page }) => {
|
||||
const testUser = generateTestUser('Mismatch');
|
||||
|
||||
// Fill registration form with mismatched passwords
|
||||
await page.getByLabel(/full name|name/i).fill(testUser.fullName);
|
||||
await page.getByLabel(/email/i).fill(testUser.email);
|
||||
await page.getByLabel(/^password$/i).fill(testUser.password);
|
||||
await page.getByLabel(/confirm password|repeat password/i).fill('DifferentPassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify error message about password mismatch
|
||||
await expect(
|
||||
page.getByText(/password.*match|password.*mismatch|passwords.*not.*match/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should stay on register page
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
|
||||
test('should show error for invalid email format', async ({ page }) => {
|
||||
// Fill registration form with invalid email
|
||||
await page.getByLabel(/full name|name/i).fill('Test User');
|
||||
await page.getByLabel(/email/i).fill('invalid-email-format');
|
||||
await page.getByLabel(/^password$/i).fill('ValidPassword123!');
|
||||
await page.getByLabel(/confirm password|repeat password/i).fill('ValidPassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify error message about invalid email
|
||||
await expect(
|
||||
page.getByText(/valid email|invalid email|email format|email address/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should stay on register page
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
|
||||
test('should show error for weak password', async ({ page }) => {
|
||||
// Fill registration form with weak password
|
||||
await page.getByLabel(/full name|name/i).fill('Test User');
|
||||
await page.getByLabel(/email/i).fill(generateTestEmail());
|
||||
await page.getByLabel(/^password$/i).fill('123');
|
||||
await page.getByLabel(/confirm password|repeat password/i).fill('123');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify error message about weak password
|
||||
await expect(
|
||||
page.getByText(/password.*too short|weak password|password.*at least|password.*minimum/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
// Submit empty form
|
||||
await page.getByRole('button', { name: /register|sign up|create account/i }).click();
|
||||
|
||||
// Verify validation errors for required fields
|
||||
await expect(
|
||||
page.getByText(/required|please fill|field is empty/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should navigate to login page from register', async ({ page }) => {
|
||||
// Find and click login link
|
||||
const loginLink = page.getByRole('link', { name: /sign in|login|already have account/i });
|
||||
await loginLink.click();
|
||||
|
||||
// Verify navigation to login page
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /login|sign in/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// TEST SUITE: Login
|
||||
// ============================================
|
||||
test.describe('QA-AUTH-019: Login', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should login with valid credentials', async ({ page, request }) => {
|
||||
// First register a user
|
||||
const testUser = generateTestUser('Login');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
// Clear and navigate to login
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill login form
|
||||
await page.getByLabel(/email/i).fill(testUser.email);
|
||||
await page.getByLabel(/password/i).fill(testUser.password);
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Verify redirect to dashboard
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
// Verify user is authenticated
|
||||
expect(await isAuthenticated(page)).toBe(true);
|
||||
});
|
||||
|
||||
test('should show error for invalid credentials', async ({ page }) => {
|
||||
// Fill login form with invalid credentials
|
||||
await page.getByLabel(/email/i).fill('invalid@example.com');
|
||||
await page.getByLabel(/password/i).fill('wrongpassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Verify error message
|
||||
await expect(
|
||||
page.getByText(/invalid.*credential|incorrect.*password|wrong.*email|authentication.*failed/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should stay on login page
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('should show error for non-existent user', async ({ page }) => {
|
||||
// Fill login form with non-existent email
|
||||
await page.getByLabel(/email/i).fill(generateTestEmail('nonexistent'));
|
||||
await page.getByLabel(/password/i).fill('SomePassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Verify error message
|
||||
await expect(
|
||||
page.getByText(/invalid.*credential|user.*not found|account.*not exist/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should validate email format', async ({ page }) => {
|
||||
// Fill login form with invalid email format
|
||||
await page.getByLabel(/email/i).fill('not-an-email');
|
||||
await page.getByLabel(/password/i).fill('SomePassword123!');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Verify validation error
|
||||
await expect(
|
||||
page.getByText(/valid email|invalid email|email format/i).first()
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should navigate to register page from login', async ({ page }) => {
|
||||
// Find and click register link
|
||||
const registerLink = page.getByRole('link', { name: /sign up|register|create account/i });
|
||||
await registerLink.click();
|
||||
|
||||
// Verify navigation to register page
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
await expect(page.getByRole('heading', { name: /register|sign up/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to forgot password page', async ({ page }) => {
|
||||
// Find and click forgot password link
|
||||
const forgotLink = page.getByRole('link', { name: /forgot.*password|reset.*password/i });
|
||||
|
||||
if (await forgotLink.isVisible().catch(() => false)) {
|
||||
await forgotLink.click();
|
||||
|
||||
// Verify navigation to forgot password page
|
||||
await expect(page).toHaveURL(/\/forgot-password|reset-password/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// TEST SUITE: Protected Routes
|
||||
// ============================================
|
||||
test.describe('QA-AUTH-019: Protected Routes', () => {
|
||||
test('should redirect to login when accessing /scenarios without auth', async ({ page }) => {
|
||||
// Clear any existing auth
|
||||
await clearAuthToken(page);
|
||||
|
||||
// Try to access protected route directly
|
||||
await page.goto('/scenarios');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should redirect to login
|
||||
await waitForAuthRedirect(page, '/login');
|
||||
await expect(page.getByRole('heading', { name: /login|sign in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing /profile without auth', async ({ page }) => {
|
||||
await clearAuthToken(page);
|
||||
|
||||
await page.goto('/profile');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await waitForAuthRedirect(page, '/login');
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing /settings without auth', async ({ page }) => {
|
||||
await clearAuthToken(page);
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await waitForAuthRedirect(page, '/login');
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing /settings/api-keys without auth', async ({ page }) => {
|
||||
await clearAuthToken(page);
|
||||
|
||||
await page.goto('/settings/api-keys');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await waitForAuthRedirect(page, '/login');
|
||||
});
|
||||
|
||||
test('should allow access to /scenarios with valid auth', async ({ page, request }) => {
|
||||
// Register and login a user
|
||||
const testUser = generateTestUser('Protected');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
// Login via UI
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
|
||||
// Now try to access protected route
|
||||
await page.goto('/scenarios');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should stay on scenarios page
|
||||
await expect(page).toHaveURL('/scenarios');
|
||||
await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should persist auth state after page refresh', async ({ page, request }) => {
|
||||
// Register and login
|
||||
const testUser = generateTestUser('Persist');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
|
||||
// Refresh page
|
||||
await page.reload();
|
||||
await waitForLoading(page);
|
||||
|
||||
// Should still be authenticated and on dashboard
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
expect(await isAuthenticated(page)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// TEST SUITE: Logout
|
||||
// ============================================
|
||||
test.describe('QA-AUTH-019: Logout', () => {
|
||||
test('should logout and redirect to login', async ({ page, request }) => {
|
||||
// Register and login
|
||||
const testUser = generateTestUser('Logout');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
|
||||
// Verify logged in
|
||||
expect(await isAuthenticated(page)).toBe(true);
|
||||
|
||||
// Logout
|
||||
await logoutUser(page);
|
||||
|
||||
// Verify redirect to login
|
||||
await expect(page).toHaveURL('/login');
|
||||
await expect(page.getByRole('heading', { name: /login|sign in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should clear tokens on logout', async ({ page, request }) => {
|
||||
// Register and login
|
||||
const testUser = generateTestUser('ClearTokens');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
|
||||
// Logout
|
||||
await logoutUser(page);
|
||||
|
||||
// Check local storage is cleared
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('refresh_token'));
|
||||
|
||||
expect(accessToken).toBeNull();
|
||||
expect(refreshToken).toBeNull();
|
||||
});
|
||||
|
||||
test('should not access protected routes after logout', async ({ page, request }) => {
|
||||
// Register and login
|
||||
const testUser = generateTestUser('AfterLogout');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
await logoutUser(page);
|
||||
|
||||
// Try to access protected route
|
||||
await page.goto('/scenarios');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should redirect to login
|
||||
await waitForAuthRedirect(page, '/login');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// TEST SUITE: Token Management
|
||||
// ============================================
|
||||
test.describe('QA-AUTH-019: Token Management', () => {
|
||||
test('should refresh token when expired', async ({ page, request }) => {
|
||||
// This test verifies the token refresh mechanism
|
||||
// Implementation depends on how the frontend handles token expiration
|
||||
test.skip(true, 'Token refresh testing requires controlled token expiration');
|
||||
});
|
||||
|
||||
test('should store tokens in localStorage', async ({ page, request }) => {
|
||||
const testUser = generateTestUser('TokenStorage');
|
||||
const registerResponse = await request.post('http://localhost:8000/api/v1/auth/register', {
|
||||
data: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
full_name: testUser.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerResponse.ok()) {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await loginUserViaUI(page, testUser.email, testUser.password);
|
||||
|
||||
// Check tokens are stored
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('refresh_token'));
|
||||
|
||||
expect(accessToken).toBeTruthy();
|
||||
expect(refreshToken).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user