Initial commit: Brachnha Insight project setup
- Next.js 14+ with App Router and TypeScript - Tailwind CSS and ShadCN UI styling - Zustand state management - Dexie.js for IndexedDB (local-first data) - Auth.js v5 for authentication - BMAD framework integration Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
552
_bmad/bmm/testarch/knowledge/auth-session.md
Normal file
552
_bmad/bmm/testarch/knowledge/auth-session.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# Auth Session Utility
|
||||
|
||||
## Principle
|
||||
|
||||
Persist authentication tokens to disk and reuse across test runs. Support multiple user identifiers, ephemeral authentication, and worker-specific accounts for parallel execution. Fetch tokens once, use everywhere. **Works for both API-only tests and browser tests.**
|
||||
|
||||
## Rationale
|
||||
|
||||
Playwright's built-in authentication works but has limitations:
|
||||
|
||||
- Re-authenticates for every test run (slow)
|
||||
- Single user per project setup
|
||||
- No token expiration handling
|
||||
- Manual session management
|
||||
- Complex setup for multi-user scenarios
|
||||
|
||||
The `auth-session` utility provides:
|
||||
|
||||
- **Token persistence**: Authenticate once, reuse across runs
|
||||
- **Multi-user support**: Different user identifiers in same test suite
|
||||
- **Ephemeral auth**: On-the-fly user authentication without disk persistence
|
||||
- **Worker-specific accounts**: Parallel execution with isolated user accounts
|
||||
- **Automatic token management**: Checks validity, renews if expired
|
||||
- **Flexible provider pattern**: Adapt to any auth system (OAuth2, JWT, custom)
|
||||
- **API-first design**: Get tokens for API tests without browser overhead
|
||||
|
||||
## Pattern Examples
|
||||
|
||||
### Example 1: Basic Auth Session Setup
|
||||
|
||||
**Context**: Configure global authentication that persists across test runs.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// Step 1: Configure in global-setup.ts
|
||||
import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
|
||||
import myCustomProvider from './auth/custom-auth-provider';
|
||||
|
||||
async function globalSetup() {
|
||||
// Ensure storage directories exist
|
||||
authStorageInit();
|
||||
|
||||
// Configure storage path
|
||||
configureAuthSession({
|
||||
authStoragePath: process.cwd() + '/playwright/auth-sessions',
|
||||
debug: true,
|
||||
});
|
||||
|
||||
// Set custom provider (HOW to authenticate)
|
||||
setAuthProvider(myCustomProvider);
|
||||
|
||||
// Optional: pre-fetch token for default user
|
||||
await authGlobalInit();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
|
||||
// Step 2: Create auth fixture
|
||||
import { test as base } from '@playwright/test';
|
||||
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
|
||||
import myCustomProvider from './custom-auth-provider';
|
||||
|
||||
// Register provider early
|
||||
setAuthProvider(myCustomProvider);
|
||||
|
||||
export const test = base.extend(createAuthFixtures());
|
||||
|
||||
// Step 3: Use in tests
|
||||
test('authenticated request', async ({ authToken, request }) => {
|
||||
const response = await request.get('/api/protected', {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Global setup runs once before all tests
|
||||
- Token fetched once, reused across all tests
|
||||
- Custom provider defines your auth mechanism
|
||||
- Order matters: configure, then setProvider, then init
|
||||
|
||||
### Example 2: Multi-User Authentication
|
||||
|
||||
**Context**: Testing with different user roles (admin, regular user, guest) in same test suite.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
import { test } from '../support/auth/auth-fixture';
|
||||
|
||||
// Option 1: Per-test user override
|
||||
test('admin actions', async ({ authToken, authOptions }) => {
|
||||
// Override default user
|
||||
authOptions.userIdentifier = 'admin';
|
||||
|
||||
const { authToken: adminToken } = await test.step('Get admin token', async () => {
|
||||
return { authToken }; // Re-fetches with new identifier
|
||||
});
|
||||
|
||||
// Use admin token
|
||||
const response = await request.get('/api/admin/users', {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
});
|
||||
|
||||
// Option 2: Parallel execution with different users
|
||||
test.describe.parallel('multi-user tests', () => {
|
||||
test('user 1 actions', async ({ authToken }) => {
|
||||
// Uses default user (e.g., 'user1')
|
||||
});
|
||||
|
||||
test('user 2 actions', async ({ authToken, authOptions }) => {
|
||||
authOptions.userIdentifier = 'user2';
|
||||
// Uses different token for user2
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Override `authOptions.userIdentifier` per test
|
||||
- Tokens cached separately per user identifier
|
||||
- Parallel tests isolated with different users
|
||||
- Worker-specific accounts possible
|
||||
|
||||
### Example 3: Ephemeral User Authentication
|
||||
|
||||
**Context**: Create temporary test users that don't persist to disk (e.g., testing user creation flow).
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
|
||||
import { createTestUser } from '../utils/user-factory';
|
||||
|
||||
test('ephemeral user test', async ({ context, page }) => {
|
||||
// Create temporary user (not persisted)
|
||||
const ephemeralUser = await createTestUser({
|
||||
role: 'admin',
|
||||
permissions: ['delete-users'],
|
||||
});
|
||||
|
||||
// Apply auth directly to browser context
|
||||
await applyUserCookiesToBrowserContext(context, ephemeralUser);
|
||||
|
||||
// Page now authenticated as ephemeral user
|
||||
await page.goto('/admin/users');
|
||||
|
||||
await expect(page.getByTestId('delete-user-btn')).toBeVisible();
|
||||
|
||||
// User and token cleaned up after test
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- No disk persistence (ephemeral)
|
||||
- Apply cookies directly to context
|
||||
- Useful for testing user lifecycle
|
||||
- Clean up automatic when test ends
|
||||
|
||||
### Example 4: Testing Multiple Users in Single Test
|
||||
|
||||
**Context**: Testing interactions between users (messaging, sharing, collaboration features).
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
test('user interaction', async ({ browser }) => {
|
||||
// User 1 context
|
||||
const user1Context = await browser.newContext({
|
||||
storageState: './auth-sessions/local/user1/storage-state.json',
|
||||
});
|
||||
const user1Page = await user1Context.newPage();
|
||||
|
||||
// User 2 context
|
||||
const user2Context = await browser.newContext({
|
||||
storageState: './auth-sessions/local/user2/storage-state.json',
|
||||
});
|
||||
const user2Page = await user2Context.newPage();
|
||||
|
||||
// User 1 sends message
|
||||
await user1Page.goto('/messages');
|
||||
await user1Page.fill('#message', 'Hello from user 1');
|
||||
await user1Page.click('#send');
|
||||
|
||||
// User 2 receives message
|
||||
await user2Page.goto('/messages');
|
||||
await expect(user2Page.getByText('Hello from user 1')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await user1Context.close();
|
||||
await user2Context.close();
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Each user has separate browser context
|
||||
- Reference storage state files directly
|
||||
- Test real-time interactions
|
||||
- Clean up contexts after test
|
||||
|
||||
### Example 5: Worker-Specific Accounts (Parallel Testing)
|
||||
|
||||
**Context**: Running tests in parallel with isolated user accounts per worker to avoid conflicts.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
workers: 4, // 4 parallel workers
|
||||
use: {
|
||||
// Each worker uses different user
|
||||
storageState: async ({}, use, testInfo) => {
|
||||
const workerIndex = testInfo.workerIndex;
|
||||
const userIdentifier = `worker-${workerIndex}`;
|
||||
|
||||
await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Tests run in parallel, each worker with its own user
|
||||
test('parallel test 1', async ({ page }) => {
|
||||
// Worker 0 uses worker-0 account
|
||||
await page.goto('/dashboard');
|
||||
});
|
||||
|
||||
test('parallel test 2', async ({ page }) => {
|
||||
// Worker 1 uses worker-1 account
|
||||
await page.goto('/dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Each worker has isolated user account
|
||||
- No conflicts in parallel execution
|
||||
- Token management automatic per worker
|
||||
- Scales to any number of workers
|
||||
|
||||
### Example 6: Pure API Authentication (No Browser)
|
||||
|
||||
**Context**: Get auth tokens for API-only tests using auth-session disk persistence.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// Step 1: Create API-only auth provider (no browser needed)
|
||||
// playwright/support/api-auth-provider.ts
|
||||
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
|
||||
|
||||
const apiAuthProvider: AuthProvider = {
|
||||
getEnvironment: (options) => options.environment || 'local',
|
||||
getUserIdentifier: (options) => options.userIdentifier || 'api-user',
|
||||
|
||||
extractToken: (storageState) => {
|
||||
// Token stored in localStorage format for disk persistence
|
||||
const tokenEntry = storageState.origins?.[0]?.localStorage?.find(
|
||||
(item) => item.name === 'auth_token'
|
||||
);
|
||||
return tokenEntry?.value;
|
||||
},
|
||||
|
||||
isTokenExpired: (storageState) => {
|
||||
const expiryEntry = storageState.origins?.[0]?.localStorage?.find(
|
||||
(item) => item.name === 'token_expiry'
|
||||
);
|
||||
if (!expiryEntry) return true;
|
||||
return Date.now() > parseInt(expiryEntry.value, 10);
|
||||
},
|
||||
|
||||
manageAuthToken: async (request, options) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
|
||||
}
|
||||
|
||||
// Pure API login - no browser!
|
||||
const response = await request.post('/api/auth/login', {
|
||||
data: { email, password },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Auth failed: ${response.status()}`);
|
||||
}
|
||||
|
||||
const { token, expiresIn } = await response.json();
|
||||
const expiryTime = Date.now() + expiresIn * 1000;
|
||||
|
||||
// Return storage state format for disk persistence
|
||||
return {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: process.env.API_BASE_URL || 'http://localhost:3000',
|
||||
localStorage: [
|
||||
{ name: 'auth_token', value: token },
|
||||
{ name: 'token_expiry', value: String(expiryTime) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default apiAuthProvider;
|
||||
|
||||
// Step 2: Create auth fixture
|
||||
// playwright/support/fixtures.ts
|
||||
import { test as base } from '@playwright/test';
|
||||
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
|
||||
import apiAuthProvider from './api-auth-provider';
|
||||
|
||||
setAuthProvider(apiAuthProvider);
|
||||
|
||||
export const test = base.extend(createAuthFixtures());
|
||||
|
||||
// Step 3: Use in tests - token persisted to disk!
|
||||
// tests/api/authenticated-api.spec.ts
|
||||
import { test } from '../support/fixtures';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
test('should access protected endpoint', async ({ authToken, apiRequest }) => {
|
||||
// authToken is automatically loaded from disk or fetched if expired
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'GET',
|
||||
path: '/api/me',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
test('should create resource with auth', async ({ authToken, apiRequest }) => {
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/orders',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: { items: [{ productId: 'prod-1', quantity: 2 }] },
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.id).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Token persisted to disk (not in-memory) - survives test reruns
|
||||
- Provider fetches token once, reuses until expired
|
||||
- Pure API authentication - no browser context needed
|
||||
- `authToken` fixture handles disk read/write automatically
|
||||
- Environment variables validated with clear error message
|
||||
|
||||
### Example 7: Service-to-Service Authentication
|
||||
|
||||
**Context**: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// tests/api/service-auth.spec.ts
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
|
||||
import { mergeTests } from '@playwright/test';
|
||||
|
||||
// Validate environment variables at module load
|
||||
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
|
||||
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
|
||||
|
||||
if (!SERVICE_API_KEY) {
|
||||
throw new Error('SERVICE_API_KEY environment variable is required');
|
||||
}
|
||||
if (!INTERNAL_SERVICE_URL) {
|
||||
throw new Error('INTERNAL_SERVICE_URL environment variable is required');
|
||||
}
|
||||
|
||||
const test = mergeTests(base, apiFixture);
|
||||
|
||||
test.describe('Service-to-Service Auth', () => {
|
||||
test('should authenticate with API key', async ({ apiRequest }) => {
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'GET',
|
||||
path: '/internal/health',
|
||||
baseUrl: INTERNAL_SERVICE_URL,
|
||||
headers: { 'X-API-Key': SERVICE_API_KEY },
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe('healthy');
|
||||
});
|
||||
|
||||
test('should reject invalid API key', async ({ apiRequest }) => {
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'GET',
|
||||
path: '/internal/health',
|
||||
baseUrl: INTERNAL_SERVICE_URL,
|
||||
headers: { 'X-API-Key': 'invalid-key' },
|
||||
});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body.code).toBe('INVALID_API_KEY');
|
||||
});
|
||||
|
||||
test('should call downstream service with propagated auth', async ({ apiRequest }) => {
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/internal/aggregate-data',
|
||||
baseUrl: INTERNAL_SERVICE_URL,
|
||||
headers: {
|
||||
'X-API-Key': SERVICE_API_KEY,
|
||||
'X-Request-ID': `test-${Date.now()}`,
|
||||
},
|
||||
body: { sources: ['users', 'orders', 'inventory'] },
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.aggregatedFrom).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Environment variables validated at module load with clear errors
|
||||
- API key authentication (simpler than OAuth - no disk persistence needed)
|
||||
- Test internal/service endpoints
|
||||
- Validate auth rejection scenarios
|
||||
- Correlation ID for request tracing
|
||||
|
||||
> **Note**: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
|
||||
|
||||
## Custom Auth Provider Pattern
|
||||
|
||||
**Context**: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).
|
||||
|
||||
**Minimal provider structure**:
|
||||
|
||||
```typescript
|
||||
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
|
||||
|
||||
const myCustomProvider: AuthProvider = {
|
||||
getEnvironment: (options) => options.environment || 'local',
|
||||
|
||||
getUserIdentifier: (options) => options.userIdentifier || 'default-user',
|
||||
|
||||
extractToken: (storageState) => {
|
||||
// Extract token from your storage format
|
||||
return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
|
||||
},
|
||||
|
||||
extractCookies: (tokenData) => {
|
||||
// Convert token to cookies for browser context
|
||||
return [
|
||||
{
|
||||
name: 'auth_token',
|
||||
value: tokenData,
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
isTokenExpired: (storageState) => {
|
||||
// Check if token is expired
|
||||
const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
|
||||
return Date.now() > parseInt(expiresAt?.value || '0');
|
||||
},
|
||||
|
||||
manageAuthToken: async (request, options) => {
|
||||
// Main token acquisition logic
|
||||
// Return storage state with cookies/localStorage
|
||||
},
|
||||
};
|
||||
|
||||
export default myCustomProvider;
|
||||
```
|
||||
|
||||
## Integration with API Request
|
||||
|
||||
```typescript
|
||||
import { test } from '@seontechnologies/playwright-utils/fixtures';
|
||||
|
||||
test('authenticated API call', async ({ apiRequest, authToken }) => {
|
||||
const { status, body } = await apiRequest({
|
||||
method: 'GET',
|
||||
path: '/api/protected',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
## Related Fragments
|
||||
|
||||
- `api-testing-patterns.md` - Pure API testing patterns (no browser)
|
||||
- `overview.md` - Installation and fixture composition
|
||||
- `api-request.md` - Authenticated API requests
|
||||
- `fixtures-composition.md` - Merging auth with other utilities
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**❌ Calling setAuthProvider after globalSetup:**
|
||||
|
||||
```typescript
|
||||
async function globalSetup() {
|
||||
configureAuthSession(...)
|
||||
await authGlobalInit() // Provider not set yet!
|
||||
setAuthProvider(provider) // Too late
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Register provider before init:**
|
||||
|
||||
```typescript
|
||||
async function globalSetup() {
|
||||
authStorageInit()
|
||||
configureAuthSession(...)
|
||||
setAuthProvider(provider) // First
|
||||
await authGlobalInit() // Then init
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Hardcoding storage paths:**
|
||||
|
||||
```typescript
|
||||
const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle
|
||||
```
|
||||
|
||||
**✅ Use helper functions:**
|
||||
|
||||
```typescript
|
||||
import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';
|
||||
|
||||
const tokenPath = getTokenFilePath({
|
||||
environment: 'local',
|
||||
userIdentifier: 'user1',
|
||||
tokenFileName: 'storage-state.json',
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user