/** * Authentication Test Utilities * * Provides reusable mocking patterns, factories, and helpers for authentication tests. * These utilities standardize how we mock OAuth responses, create test users, * set up fetch mocks, and generate test tokens. * * @module @dotdo/postgres-shared/auth-test-utils * * @example * ```typescript * import { * createMockFetch, * createMockUser, * testUsers, * tokens, * createSuccessfulOAuthResponse, * } from '@dotdo/postgres-shared/auth-test-utils' * * describe('auth tests', () => { * const { mockFn, install, restore } = createMockFetch() * * beforeEach(() => install()) * afterEach(() => restore()) * * it('validates tokens', async () => { * mockFn.mockResolvedValueOnce(createSuccessfulOAuthResponse(testUsers.valid)) * // ... test code * }) * }) * ``` */ import type { AuthenticatedUser, AuthTokenValidationResult } from './auth.js' // ============================================================================ // TYPES // ============================================================================ /** * Mock function interface compatible with Vitest's Mock type. * Using a generic interface allows these utilities to work without * importing vitest directly. */ export interface MockFunction { (...args: TArgs): TReturn mockReset(): void mockClear(): void mockResolvedValue(value: TReturn extends Promise ? U : TReturn): this mockResolvedValueOnce(value: TReturn extends Promise ? U : TReturn): this mockRejectedValue(error: unknown): this mockRejectedValueOnce(error: unknown): this mockImplementation(fn: (...args: TArgs) => TReturn): this mockImplementationOnce(fn: (...args: TArgs) => TReturn): this mockReturnValue(value: TReturn): this mockReturnValueOnce(value: TReturn): this mock: { calls: TArgs[] results: Array<{ type: 'return' | 'throw' | 'incomplete'; value: unknown }> instances: unknown[] lastCall?: TArgs | undefined } } /** * Interface for configuring mock fetch behavior */ export interface MockFetchConfig { /** The mock function instance */ mockFn: MockFunction<[string | URL, RequestInit?], Promise> /** Store the original fetch for restoration */ originalFetch?: typeof globalThis.fetch } /** * Standard OAuth response structure for mocking */ export interface MockOAuthResponse { ok: boolean status?: number statusText?: string json: () => Promise<{ user?: AuthenticatedUser error?: string expires?: string }> } /** * Standard SQL query response structure for mocking */ export interface MockSQLResponse { ok: boolean status?: number statusText?: string json: () => Promise<{ rows?: unknown[] fields?: Array<{ name: string; dataTypeID: number }> rowCount?: number command?: string error?: { message: string; code?: string } }> } /** * JWT payload structure for test token generation */ export interface JWTPayload { /** Subject (user ID) */ sub?: string /** Role claim */ role?: string /** Issued at timestamp (seconds) */ iat?: number /** Expiration timestamp (seconds) */ exp?: number /** Not before timestamp (seconds) */ nbf?: number /** Audience */ aud?: string | string[] /** Issuer */ iss?: string /** JWT ID */ jti?: string /** Additional custom claims */ [key: string]: unknown } // ============================================================================ // MOCK FETCH UTILITIES // ============================================================================ /** * Creates a mock fetch configuration. * Note: The actual mock function must be provided by the test framework (e.g., vi.fn()). * * @param createMockFn - Factory function to create a mock (e.g., vi.fn) * @returns Mock fetch configuration with install/restore utilities * * @example * ```typescript * import { vi } from 'vitest' * import { createMockFetchConfig } from '@dotdo/postgres-shared/auth-test-utils' * * const { mockFn, install, restore } = createMockFetchConfig(() => vi.fn()) * * beforeEach(() => install()) * afterEach(() => restore()) * ``` */ export function createMockFetchConfig( createMockFn: () => T ): MockFetchConfig & { install: () => void restore: () => void reset: () => void } { const mockFn = createMockFn() as unknown as MockFunction<[string | URL, RequestInit?], Promise> const originalFetch = globalThis.fetch return { mockFn, originalFetch, install: () => { globalThis.fetch = mockFn as unknown as typeof fetch }, restore: () => { globalThis.fetch = originalFetch }, reset: () => { mockFn.mockReset() }, } } // ============================================================================ // USER FACTORIES // ============================================================================ /** * Creates a mock authenticated user with default values. * * @param overrides - Partial user object to override defaults * @returns A complete AuthenticatedUser object * * @example * ```typescript * const user = createMockUser({ id: 'custom-id', email: 'custom@example.com' }) * ``` */ export function createMockUser(overrides: Partial = {}): AuthenticatedUser { const result: AuthenticatedUser = { id: overrides.id ?? `user-${Math.random().toString(36).slice(2, 10)}`, email: overrides.email ?? 'test@example.com', } if (overrides.name !== undefined) { result.name = overrides.name } if (overrides.metadata !== undefined) { result.metadata = overrides.metadata } return result } /** * Creates multiple mock users. * * @param count - Number of users to create * @param overrides - Partial user object to apply to all users * @returns Array of AuthenticatedUser objects * * @example * ```typescript * const users = createMockUsers(3, { metadata: { role: 'admin' } }) * ``` */ export function createMockUsers( count: number, overrides: Partial = {} ): AuthenticatedUser[] { return Array.from({ length: count }, (_, i) => createMockUser({ id: `user-${i + 1}`, email: `user${i + 1}@example.com`, ...overrides, }) ) } /** * Pre-defined test users for common scenarios. * Use these for consistent test data across test files. */ export const testUsers = { /** Standard valid user with all fields populated */ valid: createMockUser({ id: 'valid-user', email: 'valid@example.com', name: 'Valid User', }), /** Admin user for authorization tests */ admin: createMockUser({ id: 'admin-user', email: 'admin@example.com', name: 'Admin User', metadata: { role: 'admin' }, }), /** User with minimal data (no name or metadata) */ minimal: createMockUser({ id: 'minimal-user', email: 'minimal@example.com', }), /** User for service account tests */ service: createMockUser({ id: 'service-account', email: 'service@internal.example.com', metadata: { type: 'service', scopes: ['read', 'write'] }, }), /** User with special characters in ID for edge case testing */ specialChars: createMockUser({ id: 'user+special@chars.com', email: 'special@example.com', name: 'Special Chars User', }), } as const // ============================================================================ // OAUTH RESPONSE FACTORIES // ============================================================================ /** * Creates a successful OAuth validation response. * * @param user - The authenticated user to return * @param expires - Optional expiration date string * @returns MockOAuthResponse object for fetch mock * * @example * ```typescript * mockFetch.mockResolvedValueOnce(createSuccessfulOAuthResponse(testUsers.valid)) * ``` */ export function createSuccessfulOAuthResponse( user: AuthenticatedUser, expires?: string ): MockOAuthResponse { return { ok: true, status: 200, statusText: 'OK', json: async () => ({ user, ...(expires && { expires }), }), } } /** * Creates a failed OAuth validation response (401 Unauthorized). * * @param error - Error message to include * @returns MockOAuthResponse object for fetch mock * * @example * ```typescript * mockFetch.mockResolvedValueOnce(createUnauthorizedOAuthResponse('Token expired')) * ``` */ export function createUnauthorizedOAuthResponse(error = 'Invalid token'): MockOAuthResponse { return { ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ error }), } } /** * Creates a rate-limited OAuth response (429 Too Many Requests). * * @param retryAfter - Optional retry-after value in seconds * @returns MockOAuthResponse object for fetch mock * * @example * ```typescript * mockFetch.mockResolvedValueOnce(createRateLimitedOAuthResponse(60)) * ``` */ export function createRateLimitedOAuthResponse(retryAfter?: number): MockOAuthResponse { return { ok: false, status: 429, statusText: 'Too Many Requests', json: async () => ({ error: `Rate limited${retryAfter ? `. Retry after ${retryAfter} seconds` : ''}`, }), } } /** * Creates an OAuth server error response (500). * * @param error - Error message * @returns MockOAuthResponse object for fetch mock */ export function createOAuthServerErrorResponse(error = 'Internal server error'): MockOAuthResponse { return { ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({ error }), } } /** * Creates an OAuth network error for rejected fetch promises. * * @param message - Error message * @returns Error object for mockRejectedValue */ export function createOAuthNetworkError(message = 'Network error'): Error { return new Error(message) } // ============================================================================ // SQL RESPONSE FACTORIES // ============================================================================ /** * Creates a successful SQL query response. * * @param rows - Array of result rows * @param options - Additional response options * @returns MockSQLResponse object for fetch mock */ export function createSuccessfulSQLResponse( rows: unknown[] = [], options: { command?: string fields?: Array<{ name: string; dataTypeID: number }> } = {} ): MockSQLResponse { return { ok: true, status: 200, statusText: 'OK', json: async () => ({ rows, fields: options.fields ?? [], rowCount: rows.length, command: options.command ?? 'SELECT', }), } } /** * Creates an empty SQL query response. */ export function createEmptySQLResponse(): MockSQLResponse { return createSuccessfulSQLResponse([]) } /** * Creates an SQL error response. * * @param message - Error message * @param code - PostgreSQL error code * @param status - HTTP status code */ export function createSQLErrorResponse( message: string, code = '42601', status = 400 ): MockSQLResponse { return { ok: false, status, statusText: status === 400 ? 'Bad Request' : 'Error', json: async () => ({ error: { message, code }, }), } } // ============================================================================ // TOKEN UTILITIES // ============================================================================ /** * Pre-defined test tokens for common scenarios. * Use these for consistent token values across tests. */ export const tokens = { /** A valid-looking test token */ valid: 'valid-token-abc123', /** An explicitly invalid token */ invalid: 'invalid-token', /** A token representing an expired session */ expired: 'expired-token-xyz789', /** A malformed token */ malformed: 'not-a-real-token!!!', /** An empty token */ empty: '', /** A JWT-style token (structure only, not valid signature) */ jwtStyle: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature', } as const /** * Generates a unique token for testing. * * @param prefix - Optional prefix for the token * @returns A unique test token string */ export function generateTestToken(prefix = 'test'): string { return `${prefix}-${Math.random().toString(36).slice(2, 18)}` } /** * Base64 URL encodes a string. * Used for JWT token generation. */ export function base64UrlEncode(str: string): string { if (typeof btoa === 'undefined') { // Node.js environment return Buffer.from(str).toString('base64url') } return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } /** * Creates a test JWT token structure (for testing purposes). * * Note: This creates a JWT with a fake signature. Use for testing * JWT parsing and structure validation, not for cryptographic verification. * * @param payload - JWT payload claims * @param options - Token generation options * @returns A JWT-formatted string * * @example * ```typescript * const token = createTestJWTStructure({ * sub: 'user-123', * role: 'authenticated', * exp: Math.floor(Date.now() / 1000) + 3600, * }) * ``` */ export function createTestJWTStructure( payload: JWTPayload, options: { algorithm?: string fakeSignature?: string } = {} ): string { const header = { alg: options.algorithm ?? 'HS256', typ: 'JWT', } const headerB64 = base64UrlEncode(JSON.stringify(header)) const payloadB64 = base64UrlEncode(JSON.stringify(payload)) const signature = options.fakeSignature ?? 'fake-signature-for-testing' return `${headerB64}.${payloadB64}.${signature}` } /** * Creates a signed test JWT token using HMAC-SHA256. * Requires Web Crypto API (available in modern browsers and Node.js 16+). * * @param payload - JWT payload claims * @param secret - Signing secret (minimum 32 bytes recommended) * @param algorithm - Algorithm to use (default: HS256) * @returns Promise resolving to a signed JWT string * * @example * ```typescript * const token = await createSignedTestJWT( * { sub: 'user-123', exp: Math.floor(Date.now() / 1000) + 3600 }, * 'your-32-byte-or-longer-secret-key' * ) * ``` */ export async function createSignedTestJWT( payload: JWTPayload, secret: string, algorithm: 'HS256' | 'HS384' | 'HS512' = 'HS256' ): Promise { const header = { alg: algorithm, typ: 'JWT' } const headerB64 = base64UrlEncode(JSON.stringify(header)) const payloadB64 = base64UrlEncode(JSON.stringify(payload)) const message = `${headerB64}.${payloadB64}` const encoder = new TextEncoder() const messageBytes = encoder.encode(message) const keyBytes = encoder.encode(secret) const hashAlgorithm = algorithm === 'HS256' ? 'SHA-256' : algorithm === 'HS384' ? 'SHA-384' : 'SHA-512' const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'HMAC', hash: hashAlgorithm }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageBytes) const signatureBytes = new Uint8Array(signature) let signatureStr: string if (typeof Buffer !== 'undefined') { signatureStr = Buffer.from(signatureBytes).toString('base64url') } else { signatureStr = base64UrlEncode(String.fromCharCode(...signatureBytes)) } return `${message}.${signatureStr}` } /** * Creates JWT payload with sensible defaults for testing. * Automatically sets iat to now and exp to 1 hour from now if not provided. * * @param overrides - Partial payload to override defaults * @returns Complete JWT payload */ export function createTestJWTPayload(overrides: Partial = {}): JWTPayload { const now = Math.floor(Date.now() / 1000) return { sub: overrides.sub ?? 'test-user', role: overrides.role ?? 'authenticated', iat: overrides.iat ?? now, exp: overrides.exp ?? now + 3600, // 1 hour from now ...overrides, } } /** * Creates an expired JWT payload for testing token expiration. * * @param overrides - Partial payload to override * @returns JWT payload that is already expired */ export function createExpiredJWTPayload(overrides: Partial = {}): JWTPayload { const now = Math.floor(Date.now() / 1000) return { sub: overrides.sub ?? 'test-user', role: overrides.role ?? 'authenticated', iat: now - 7200, // 2 hours ago exp: now - 3600, // 1 hour ago (expired) ...overrides, } } /** * Creates a "not yet valid" JWT payload for testing nbf claim. * * @param overrides - Partial payload to override * @returns JWT payload that is not yet valid */ export function createNotYetValidJWTPayload(overrides: Partial = {}): JWTPayload { const now = Math.floor(Date.now() / 1000) return { sub: overrides.sub ?? 'test-user', role: overrides.role ?? 'authenticated', iat: now, nbf: now + 3600, // Not valid until 1 hour from now exp: now + 7200, // Expires 2 hours from now ...overrides, } } // ============================================================================ // REQUEST FACTORIES // ============================================================================ /** * Creates a Request with Bearer token authentication. * * @param url - Request URL * @param token - Bearer token * @param options - Additional request options * @returns Request object with Authorization header */ export function createAuthenticatedRequest( url = 'https://example.com/api', token: string = tokens.valid, options: RequestInit = {} ): Request { return new Request(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}`, }, }) } /** * Creates a Request without authentication. * * @param url - Request URL * @param options - Additional request options */ export function createUnauthenticatedRequest( url = 'https://example.com/api', options: RequestInit = {} ): Request { return new Request(url, options) } /** * Creates a Request with Basic auth (not Bearer). * Useful for testing auth type validation. * * @param url - Request URL * @param credentials - Base64 encoded credentials * @param options - Additional request options */ export function createBasicAuthRequest( url = 'https://example.com/api', credentials = 'dXNlcjpwYXNz', // user:pass options: RequestInit = {} ): Request { return new Request(url, { ...options, headers: { ...options.headers, Authorization: `Basic ${credentials}`, }, }) } /** * Creates a Request with API key in X-API-Key header. * * @param url - Request URL * @param apiKey - API key value * @param options - Additional request options */ export function createApiKeyRequest( url = 'https://example.com/api', apiKey: string, options: RequestInit = {} ): Request { return new Request(url, { ...options, headers: { ...options.headers, 'X-API-Key': apiKey, }, }) } // ============================================================================ // TOKEN VALIDATION RESULT FACTORIES // ============================================================================ /** * Creates a successful token validation result. * * @param user - The authenticated user * @param expiresAt - Optional expiration date */ export function createValidTokenResult( user: AuthenticatedUser, expiresAt?: Date ): AuthTokenValidationResult { const result: AuthTokenValidationResult = { valid: true, user, } if (expiresAt !== undefined) { result.expiresAt = expiresAt } return result } /** * Creates a failed token validation result. * * @param error - Error message */ export function createInvalidTokenResult(error: string): AuthTokenValidationResult { return { valid: false, error, } } // ============================================================================ // MOCK FETCH SEQUENCE BUILDER // ============================================================================ /** * Builder for creating sequences of mock fetch responses. * Useful for tests that make multiple fetch calls in sequence. * * @example * ```typescript * const sequence = new MockFetchSequenceBuilder(mockFn) * sequence * .addOAuthSuccess(testUsers.valid) * .addSQLSuccess([{ id: 1, name: 'Test' }]) * .apply() * ``` */ export class MockFetchSequenceBuilder { private responses: Array = [] private mockFn: MockFunction constructor(mockFn: MockFunction) { this.mockFn = mockFn } /** Add an OAuth success response to the sequence */ addOAuthSuccess(user: AuthenticatedUser, expires?: string): this { this.responses.push(createSuccessfulOAuthResponse(user, expires)) return this } /** Add an OAuth failure response (401) */ addOAuthFailure(error = 'Invalid token'): this { this.responses.push(createUnauthorizedOAuthResponse(error)) return this } /** Add an OAuth rate limit response (429) */ addOAuthRateLimit(retryAfter?: number): this { this.responses.push(createRateLimitedOAuthResponse(retryAfter)) return this } /** Add an OAuth network error */ addOAuthNetworkError(message?: string): this { this.responses.push(createOAuthNetworkError(message)) return this } /** Add a successful SQL response */ addSQLSuccess(rows: unknown[] = []): this { this.responses.push(createSuccessfulSQLResponse(rows)) return this } /** Add an empty SQL response */ addEmptySQL(): this { this.responses.push(createEmptySQLResponse()) return this } /** Add an SQL error response */ addSQLError(message: string, code?: string): this { this.responses.push(createSQLErrorResponse(message, code)) return this } /** Apply the sequence to the mock function */ apply(): void { for (const response of this.responses) { if (response instanceof Error) { this.mockFn.mockRejectedValueOnce(response) } else { this.mockFn.mockResolvedValueOnce(response) } } } /** Reset and clear the sequence */ reset(): void { this.responses = [] this.mockFn.mockReset() } /** Get the number of responses in the sequence */ get length(): number { return this.responses.length } } /** * Creates a new mock fetch sequence builder. * * @param mockFn - The mock function to configure */ export function createMockFetchSequence(mockFn: MockFunction): MockFetchSequenceBuilder { return new MockFetchSequenceBuilder(mockFn) } // ============================================================================ // RATE LIMITING TEST HELPERS // ============================================================================ /** * Creates a rate limit test configuration. * * @param maxQueries - Maximum queries in window * @param windowMs - Window size in milliseconds */ export interface RateLimitTestConfig { maxQueries: number windowMs: number keyFn?: (request: unknown) => string } /** * Default rate limit configuration for tests. * Uses conservative limits to easily trigger rate limiting. */ export const defaultRateLimitTestConfig: RateLimitTestConfig = { maxQueries: 5, windowMs: 60000, // 1 minute } /** * Strict rate limit configuration for testing rate limit behavior. * Allows only 1 request per window. */ export const strictRateLimitTestConfig: RateLimitTestConfig = { maxQueries: 1, windowMs: 60000, } // ============================================================================ // ASSERTION HELPERS // ============================================================================ /** * Assertion helper: checks if mock fetch was called with expected Authorization header. * * @param mockFn - The mock function to check * @param expectedToken - The expected token value * @param callIndex - Which call to check (default: 0) * @throws Error if assertion fails */ export function assertAuthorizationHeader( mockFn: MockFunction, expectedToken: string, callIndex = 0 ): void { const call = mockFn.mock.calls[callIndex] if (!call) { throw new Error(`No fetch call at index ${callIndex}`) } const options = call[1] as RequestInit | undefined const headers = options?.headers as Record | Headers | undefined let authHeader: string | null = null if (headers instanceof Headers) { authHeader = headers.get('Authorization') } else if (headers) { authHeader = headers.Authorization ?? headers.authorization ?? null } const expected = `Bearer ${expectedToken}` if (authHeader !== expected) { throw new Error( `Expected Authorization header "${expected}" but got "${authHeader}"` ) } } /** * Assertion helper: checks if mock fetch was called with expected URL. * * @param mockFn - The mock function to check * @param expectedUrl - Expected URL string or RegExp * @param callIndex - Which call to check */ export function assertFetchUrl( mockFn: MockFunction, expectedUrl: string | RegExp, callIndex = 0 ): void { const call = mockFn.mock.calls[callIndex] if (!call) { throw new Error(`No fetch call at index ${callIndex}`) } const url = call[0] as string if (typeof expectedUrl === 'string') { if (url !== expectedUrl) { throw new Error(`Expected fetch to "${expectedUrl}" but got "${url}"`) } } else { if (!expectedUrl.test(url)) { throw new Error(`Expected fetch URL to match ${expectedUrl} but got "${url}"`) } } } /** * Assertion helper: checks response status code. * * @param response - Response to check * @param expectedStatus - Expected HTTP status code */ export function assertStatus(response: Response, expectedStatus: number): void { if (response.status !== expectedStatus) { throw new Error( `Expected status ${expectedStatus} but got ${response.status}` ) } } /** * Assertion helper: checks response JSON body contains expected values. * * @param response - Response to check * @param expectedBody - Expected body properties */ export async function assertJsonBody( response: Response, expectedBody: Record ): Promise { const body = (await response.clone().json()) as Record for (const [key, value] of Object.entries(expectedBody)) { if (body[key] !== value) { throw new Error( `Expected body.${key} to be ${JSON.stringify(value)} but got ${JSON.stringify(body[key])}` ) } } } /** * Assertion helper: checks that response is unauthorized (401). * * @param response - Response to check */ export function assertUnauthorized(response: Response): void { assertStatus(response, 401) } /** * Assertion helper: checks that response is forbidden (403). * * @param response - Response to check */ export function assertForbidden(response: Response): void { assertStatus(response, 403) } /** * Assertion helper: checks that response is rate limited (429). * * @param response - Response to check */ export function assertRateLimited(response: Response): void { assertStatus(response, 429) }