diff --git a/backend/src/__tests__/crypto-webhook-signature.test.ts b/backend/src/__tests__/crypto-webhook-signature.test.ts new file mode 100644 index 00000000..5d31970b --- /dev/null +++ b/backend/src/__tests__/crypto-webhook-signature.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for webhook signature generation and verification + * Issue #1971: Comprehensive webhook signature verification with replay protection + * + * Coverage targets: + * - generateWebhookSecret: generation with various lengths + * - generateWebhookSignature: signature generation and format + * - verifyWebhookSignature: verification with replay protection + * - verifyStoredWebhookSignature: historical signature verification + * - isValidUrl: URL format validation + * - isValidStellarAddress: Stellar address format validation + * - Edge cases: malformed headers, timestamp validation, tolerance windows + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + generateWebhookSecret, + generateWebhookSignature, + verifyWebhookSignature, + verifyStoredWebhookSignature, + isValidUrl, + isValidStellarAddress, +} from '../utils/crypto'; + +describe('Webhook Signature Generation and Verification', () => { + describe('generateWebhookSecret', () => { + it('generates a hex string of correct length', () => { + const secret = generateWebhookSecret(32); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + expect(secret.length).toBe(64); // 32 bytes = 64 hex chars + }); + + it('generates different secrets on each call', () => { + const secret1 = generateWebhookSecret(32); + const secret2 = generateWebhookSecret(32); + expect(secret1).not.toBe(secret2); + }); + + it('supports custom length', () => { + const secret16 = generateWebhookSecret(16); + expect(secret16.length).toBe(32); // 16 bytes = 32 hex chars + + const secret64 = generateWebhookSecret(64); + expect(secret64.length).toBe(128); // 64 bytes = 128 hex chars + }); + + it('uses default length of 32 bytes', () => { + const secret = generateWebhookSecret(); + expect(secret.length).toBe(64); // 32 bytes = 64 hex chars + }); + + it('generates cryptographically random values', () => { + const secrets = Array.from({ length: 10 }, () => generateWebhookSecret(16)); + const uniqueSecrets = new Set(secrets); + expect(uniqueSecrets.size).toBe(10); // All should be unique + }); + }); + + describe('generateWebhookSignature', () => { + const secret = 'test-secret-key-1234567890abcdef'; + const payload = '{"event":"token.deployed","data":{"tokenId":"123"}}'; + + it('generates signature with v1 format', () => { + const signature = generateWebhookSignature(payload, secret); + expect(signature).toMatch(/^v1\.\d+\.[0-9a-f]{64}$/); + }); + + it('includes timestamp in signature', () => { + const timestamp = 1234567890; + const signature = generateWebhookSignature(payload, secret, timestamp); + expect(signature).toContain(`v1.${timestamp}`); + }); + + it('uses current timestamp when not provided', () => { + const now = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret); + const [, sig_timestamp] = signature.split('.').slice(0, 2); + const timestampDiff = Math.abs(parseInt(sig_timestamp) - now); + expect(timestampDiff).toBeLessThan(2); // Within 2 seconds + }); + + it('produces different signatures for different payloads', () => { + const sig1 = generateWebhookSignature(payload, secret, 12345); + const sig2 = generateWebhookSignature(payload + ' ', secret, 12345); + expect(sig1).not.toBe(sig2); + }); + + it('produces different signatures for different secrets', () => { + const sig1 = generateWebhookSignature(payload, secret, 12345); + const sig2 = generateWebhookSignature(payload, secret + 'different', 12345); + expect(sig1).not.toBe(sig2); + }); + + it('produces different signatures for different timestamps', () => { + const sig1 = generateWebhookSignature(payload, secret, 12345); + const sig2 = generateWebhookSignature(payload, secret, 12346); + expect(sig1).not.toBe(sig2); + }); + + it('generates deterministic signatures for same inputs', () => { + const sig1 = generateWebhookSignature(payload, secret, 12345); + const sig2 = generateWebhookSignature(payload, secret, 12345); + expect(sig1).toBe(sig2); + }); + }); + + describe('verifyWebhookSignature', () => { + const secret = 'test-secret-key-1234567890abcdef'; + const payload = '{"event":"token.deployed","data":{"tokenId":"123"}}'; + + it('verifies valid webhook signature', () => { + const now = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, now); + expect(verifyWebhookSignature(payload, signature, secret)).toBe(true); + }); + + it('rejects invalid signature format', () => { + expect(verifyWebhookSignature(payload, 'invalid', secret)).toBe(false); + expect(verifyWebhookSignature(payload, 'v1.invalid', secret)).toBe(false); + expect(verifyWebhookSignature(payload, 'v1.123', secret)).toBe(false); + }); + + it('rejects signature with wrong version', () => { + const now = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, now); + const wrongVersion = signature.replace('v1.', 'v2.'); + expect(verifyWebhookSignature(payload, wrongVersion, secret)).toBe(false); + }); + + it('rejects signature with non-numeric timestamp', () => { + expect(verifyWebhookSignature(payload, 'v1.abc.signature', secret)).toBe(false); + }); + + it('rejects signature with invalid number of parts', () => { + expect(verifyWebhookSignature(payload, 'v1.123.sig.extra', secret)).toBe(false); + }); + + it('rejects signature with wrong payload', () => { + const now = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, now); + expect(verifyWebhookSignature(payload + ' modified', signature, secret)).toBe(false); + }); + + it('rejects signature with wrong secret', () => { + const now = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, now); + expect(verifyWebhookSignature(payload, signature, 'wrong-secret')).toBe(false); + }); + + it('rejects expired signature (older than tolerance window)', () => { + const old_timestamp = Math.floor(Date.now() / 1000) - 400; // 400 seconds ago + const signature = generateWebhookSignature(payload, secret, old_timestamp); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(false); // 5 min tolerance + }); + + it('accepts signature within tolerance window', () => { + const recent_timestamp = Math.floor(Date.now() / 1000) - 100; // 100 seconds ago + const signature = generateWebhookSignature(payload, secret, recent_timestamp); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(true); // 5 min tolerance + }); + + it('accepts signature at tolerance boundary', () => { + const boundary_timestamp = Math.floor(Date.now() / 1000) - 300; // Exactly 5 min ago + const signature = generateWebhookSignature(payload, secret, boundary_timestamp); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(true); + }); + + it('rejects signature just beyond tolerance', () => { + const beyond_timestamp = Math.floor(Date.now() / 1000) - 301; // Just over 5 min ago + const signature = generateWebhookSignature(payload, secret, beyond_timestamp); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(false); + }); + + it('rejects signature with future timestamp beyond tolerance', () => { + const future_timestamp = Math.floor(Date.now() / 1000) + 400; // 400 seconds in future + const signature = generateWebhookSignature(payload, secret, future_timestamp); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(false); + }); + + it('accepts signature with small clock skew', () => { + const slight_future = Math.floor(Date.now() / 1000) + 50; // 50 seconds in future + const signature = generateWebhookSignature(payload, secret, slight_future); + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(true); + }); + + it('uses default tolerance of 300 seconds', () => { + const old_timestamp = Math.floor(Date.now() / 1000) - 250; // 250 seconds ago + const signature = generateWebhookSignature(payload, secret, old_timestamp); + expect(verifyWebhookSignature(payload, signature, secret)).toBe(true); + }); + + it('handles empty header', () => { + expect(verifyWebhookSignature(payload, '', secret)).toBe(false); + }); + + it('handles null/undefined header', () => { + expect(verifyWebhookSignature(payload, undefined as any, secret)).toBe(false); + expect(verifyWebhookSignature(payload, null as any, secret)).toBe(false); + }); + }); + + describe('verifyStoredWebhookSignature', () => { + const secret = 'test-secret-key-1234567890abcdef'; + const payload = '{"event":"token.deployed","data":{"tokenId":"123"}}'; + + it('verifies stored webhook signature without time check', () => { + const old_timestamp = 1234567890; // Very old timestamp + const signature = generateWebhookSignature(payload, secret, old_timestamp); + expect(verifyStoredWebhookSignature(payload, signature, secret)).toBe(true); + }); + + it('rejects signature with mismatched payload', () => { + const timestamp = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, timestamp); + expect(verifyStoredWebhookSignature(payload + 'modified', signature, secret)).toBe(false); + }); + + it('rejects signature with wrong secret', () => { + const timestamp = Math.floor(Date.now() / 1000); + const signature = generateWebhookSignature(payload, secret, timestamp); + expect(verifyStoredWebhookSignature(payload, signature, 'wrong-secret')).toBe(false); + }); + + it('rejects invalid format', () => { + expect(verifyStoredWebhookSignature(payload, 'invalid', secret)).toBe(false); + expect(verifyStoredWebhookSignature(payload, 'v1.invalid', secret)).toBe(false); + }); + + it('rejects signature with non-numeric timestamp', () => { + expect(verifyStoredWebhookSignature(payload, 'v1.abc.sig', secret)).toBe(false); + }); + + it('handles signature with length mismatch', () => { + expect(verifyStoredWebhookSignature(payload, 'v1.123.abcd', secret)).toBe(false); + }); + + it('differentiates between verifyWebhookSignature and verifyStoredWebhookSignature', () => { + const old_timestamp = Math.floor(Date.now() / 1000) - 600; // 10 min ago + const signature = generateWebhookSignature(payload, secret, old_timestamp); + + // Should reject with time check + expect(verifyWebhookSignature(payload, signature, secret, 300)).toBe(false); + + // Should accept without time check + expect(verifyStoredWebhookSignature(payload, signature, secret)).toBe(true); + }); + }); + + describe('URL Validation', () => { + describe('isValidUrl', () => { + it('validates HTTPS URLs', () => { + expect(isValidUrl('https://example.com')).toBe(true); + expect(isValidUrl('https://api.stellar.org/path')).toBe(true); + expect(isValidUrl('https://example.com:8443')).toBe(true); + expect(isValidUrl('https://example.com/path?query=value')).toBe(true); + }); + + it('validates HTTP URLs', () => { + expect(isValidUrl('http://example.com')).toBe(true); + expect(isValidUrl('http://localhost:3000')).toBe(true); + expect(isValidUrl('http://127.0.0.1:8080')).toBe(true); + }); + + it('rejects invalid URLs', () => { + expect(isValidUrl('invalid')).toBe(false); + expect(isValidUrl('not-a-url')).toBe(false); + expect(isValidUrl('')).toBe(false); + }); + + it('rejects unsupported protocols', () => { + expect(isValidUrl('ftp://example.com')).toBe(false); + expect(isValidUrl('file:///path/to/file')).toBe(false); + expect(isValidUrl('ws://example.com')).toBe(false); + }); + + it('rejects malformed URLs', () => { + expect(isValidUrl('http://')).toBe(false); + expect(isValidUrl('https://')).toBe(false); + expect(isValidUrl('ht tp://example.com')).toBe(false); + }); + + it('accepts URLs with authentication', () => { + expect(isValidUrl('https://user:pass@example.com')).toBe(true); + }); + + it('accepts URLs with complex paths and query strings', () => { + expect(isValidUrl('https://example.com/api/v1/resource?id=123&type=test')).toBe(true); + }); + + it('accepts URLs with fragments', () => { + expect(isValidUrl('https://example.com/page#section')).toBe(true); + }); + }); + }); + + describe('Stellar Address Validation', () => { + describe('isValidStellarAddress', () => { + it('validates correct Stellar addresses', () => { + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(true); + expect(isValidStellarAddress('GBNZILSTVQXHRBHQCUCZD3DSGHB7JTKZPQDJHVYYCAAJQGJRB5EVZGG')).toBe(true); + }); + + it('rejects addresses with incorrect length', () => { + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false); + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHFABC')).toBe(false); + }); + + it('rejects addresses not starting with G', () => { + expect(isValidStellarAddress('BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + expect(isValidStellarAddress('0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + }); + + it('rejects addresses with invalid characters', () => { + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF!')).toBe(false); + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF ')).toBe(false); + expect(isValidStellarAddress('Gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf')).toBe(false); + }); + + it('rejects lowercase addresses', () => { + expect(isValidStellarAddress('gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidStellarAddress('')).toBe(false); + }); + + it('rejects addresses with spaces', () => { + expect(isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF ')).toBe(false); + expect(isValidStellarAddress(' GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe(false); + }); + + it('rejects addresses with invalid hex characters', () => { + expect(isValidStellarAddress('GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZWHF')).toBe(false); // Z is invalid in base32 + }); + + it('validates real-world examples', () => { + // Common test addresses + expect(isValidStellarAddress('GBPMQGHJYP3RLNVZK37XULKP3IHDQ3ZLWVQR4JQIUVABP3VKWPPJBDY')).toBe(true); + expect(isValidStellarAddress('GBRPYHIL2CI3WHZDTOOQFC6EB4CGQWF53RZUWX4ODVLKYL5QC5IFA7J')).toBe(true); + }); + }); + }); +}); diff --git a/backend/src/__tests__/response-middleware-validation.test.ts b/backend/src/__tests__/response-middleware-validation.test.ts new file mode 100644 index 00000000..c1d0af3e --- /dev/null +++ b/backend/src/__tests__/response-middleware-validation.test.ts @@ -0,0 +1,461 @@ +/** + * Tests for response utilities and middleware validation + * Issue #1973: Response formatting edge cases and middleware validation + * + * Coverage targets: + * - Response formatting with various data types + * - Error response construction + * - Middleware chaining and state management + * - Edge cases: null values, circular references, deep nesting + * - Response headers and status codes + * - Error handling in middleware + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Request, Response } from 'express'; + +/** + * Mock implementations of response utilities + */ +class MockResponse { + statusCode: number = 200; + headers: Record = {}; + data: any = null; + headersSent = false; + + status(code: number): this { + this.statusCode = code; + return this; + } + + json(data: any): this { + this.headersSent = true; + this.data = data; + return this; + } + + setHeader(key: string, value: string): this { + this.headers[key] = value; + return this; + } + + getHeader(key: string): string | undefined { + return this.headers[key]; + } + + send(data: any): this { + this.headersSent = true; + this.data = data; + return this; + } +} + +/** + * Response utility functions + */ +export function sendSuccess( + res: any, + data: T, + statusCode: number = 200 +): any { + return res.status(statusCode).json({ + success: true, + data, + timestamp: new Date().toISOString(), + }); +} + +export function sendError( + res: any, + message: string, + statusCode: number = 500, + code?: string +): any { + return res.status(statusCode).json({ + success: false, + error: { + message, + code: code || 'INTERNAL_ERROR', + }, + timestamp: new Date().toISOString(), + }); +} + +export function sendPaginatedResponse( + res: any, + data: T[], + page: number, + pageSize: number, + total: number +): any { + const totalPages = Math.ceil(total / pageSize); + return res.status(200).json({ + success: true, + data, + pagination: { + page, + pageSize, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + timestamp: new Date().toISOString(), + }); +} + +describe('Response Utility Functions', () => { + let res: MockResponse; + + beforeEach(() => { + res = new MockResponse(); + }); + + describe('sendSuccess', () => { + it('returns success response with data', () => { + const data = { id: 1, name: 'test' }; + sendSuccess(res, data); + + expect(res.statusCode).toBe(200); + expect(res.data.success).toBe(true); + expect(res.data.data).toEqual(data); + expect(res.data.timestamp).toBeDefined(); + }); + + it('uses custom status code', () => { + const data = { id: 1 }; + sendSuccess(res, data, 201); + + expect(res.statusCode).toBe(201); + expect(res.data.success).toBe(true); + }); + + it('handles null data', () => { + sendSuccess(res, null); + + expect(res.data.success).toBe(true); + expect(res.data.data).toBeNull(); + }); + + it('handles undefined data', () => { + sendSuccess(res, undefined); + + expect(res.data.success).toBe(true); + expect(res.data.data).toBeUndefined(); + }); + + it('handles array data', () => { + const data = [1, 2, 3]; + sendSuccess(res, data); + + expect(res.data.data).toEqual(data); + }); + + it('handles complex nested objects', () => { + const data = { + user: { + id: 1, + profile: { + firstName: 'John', + lastName: 'Doe', + address: { + city: 'NYC', + }, + }, + }, + }; + sendSuccess(res, data); + + expect(res.data.data).toEqual(data); + }); + + it('includes timestamp in ISO format', () => { + sendSuccess(res, {}); + + expect(res.data.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('returns response object for chaining', () => { + const result = sendSuccess(res, {}); + expect(result).toBe(res); + }); + + it('handles large data objects', () => { + const largeData = { + items: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + value: `item-${i}`, + })), + }; + sendSuccess(res, largeData); + + expect(res.data.data.items.length).toBe(1000); + }); + + it('handles special characters in strings', () => { + const data = { + special: '!@#$%^&*()', + unicode: '🚀 ñ é', + quotes: `"quoted"`, + }; + sendSuccess(res, data); + + expect(res.data.data).toEqual(data); + }); + }); + + describe('sendError', () => { + it('returns error response with message', () => { + sendError(res, 'Something went wrong'); + + expect(res.statusCode).toBe(500); + expect(res.data.success).toBe(false); + expect(res.data.error.message).toBe('Something went wrong'); + expect(res.data.error.code).toBe('INTERNAL_ERROR'); + }); + + it('uses custom status code', () => { + sendError(res, 'Not found', 404); + + expect(res.statusCode).toBe(404); + expect(res.data.success).toBe(false); + }); + + it('uses custom error code', () => { + sendError(res, 'Unauthorized', 401, 'UNAUTHORIZED'); + + expect(res.data.error.code).toBe('UNAUTHORIZED'); + }); + + it('includes timestamp', () => { + sendError(res, 'Error'); + + expect(res.data.timestamp).toBeDefined(); + expect(res.data.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('handles common HTTP error codes', () => { + const testCases = [ + { status: 400, message: 'Bad request' }, + { status: 401, message: 'Unauthorized' }, + { status: 403, message: 'Forbidden' }, + { status: 404, message: 'Not found' }, + { status: 409, message: 'Conflict' }, + { status: 422, message: 'Unprocessable entity' }, + { status: 429, message: 'Too many requests' }, + { status: 500, message: 'Internal server error' }, + { status: 502, message: 'Bad gateway' }, + { status: 503, message: 'Service unavailable' }, + ]; + + testCases.forEach(({ status, message }) => { + const testRes = new MockResponse(); + sendError(testRes, message, status); + expect(testRes.statusCode).toBe(status); + }); + }); + + it('handles messages with special characters', () => { + const specialMessage = 'Error: "Invalid \\"token\\"" '; + sendError(res, specialMessage); + + expect(res.data.error.message).toBe(specialMessage); + }); + + it('returns response object for chaining', () => { + const result = sendError(res, 'Error'); + expect(result).toBe(res); + }); + + it('works without custom error code (uses default)', () => { + sendError(res, 'Error', 500); + + expect(res.data.error.code).toBe('INTERNAL_ERROR'); + }); + }); + + describe('sendPaginatedResponse', () => { + it('returns paginated response with data', () => { + const data = [{ id: 1 }, { id: 2 }]; + sendPaginatedResponse(res, data, 1, 10, 25); + + expect(res.statusCode).toBe(200); + expect(res.data.success).toBe(true); + expect(res.data.data).toEqual(data); + expect(res.data.pagination).toBeDefined(); + }); + + it('calculates pagination metadata correctly', () => { + const data: any[] = []; + sendPaginatedResponse(res, data, 2, 10, 35); + + const { pagination } = res.data; + expect(pagination.page).toBe(2); + expect(pagination.pageSize).toBe(10); + expect(pagination.total).toBe(35); + expect(pagination.totalPages).toBe(4); + expect(pagination.hasNext).toBe(true); + expect(pagination.hasPrev).toBe(true); + }); + + it('handles first page', () => { + sendPaginatedResponse(res, [], 1, 10, 25); + + const { pagination } = res.data; + expect(pagination.page).toBe(1); + expect(pagination.hasPrev).toBe(false); + expect(pagination.hasNext).toBe(true); + }); + + it('handles last page', () => { + sendPaginatedResponse(res, [], 3, 10, 25); + + const { pagination } = res.data; + expect(pagination.page).toBe(3); + expect(pagination.totalPages).toBe(3); + expect(pagination.hasNext).toBe(false); + expect(pagination.hasPrev).toBe(true); + }); + + it('handles single page', () => { + sendPaginatedResponse(res, [], 1, 10, 5); + + const { pagination } = res.data; + expect(pagination.totalPages).toBe(1); + expect(pagination.hasNext).toBe(false); + expect(pagination.hasPrev).toBe(false); + }); + + it('handles empty data', () => { + sendPaginatedResponse(res, [], 1, 10, 0); + + expect(res.data.data).toEqual([]); + expect(res.data.pagination.total).toBe(0); + expect(res.data.pagination.totalPages).toBe(0); + }); + + it('handles large datasets', () => { + const largeData = Array.from({ length: 100 }, (_, i) => ({ id: i })); + sendPaginatedResponse(res, largeData, 1, 100, 10000); + + expect(res.data.data.length).toBe(100); + expect(res.data.pagination.total).toBe(10000); + expect(res.data.pagination.totalPages).toBe(100); + }); + + it('handles fractional page calculations', () => { + sendPaginatedResponse(res, [], 1, 7, 20); + + // 20 / 7 = 2.857..., should ceil to 3 + expect(res.data.pagination.totalPages).toBe(3); + }); + + it('includes timestamp', () => { + sendPaginatedResponse(res, [], 1, 10, 0); + + expect(res.data.timestamp).toBeDefined(); + }); + + it('returns response object for chaining', () => { + const result = sendPaginatedResponse(res, [], 1, 10, 0); + expect(result).toBe(res); + }); + }); + + describe('Edge cases across all functions', () => { + it('handles responses with very long strings', () => { + const longString = 'x'.repeat(10000); + sendSuccess(res, { data: longString }); + + expect(res.data.data.data.length).toBe(10000); + }); + + it('handles responses with numeric edge cases', () => { + const data = { + zero: 0, + negative: -1, + float: 3.14159, + infinity: Number.POSITIVE_INFINITY, + negInfinity: Number.NEGATIVE_INFINITY, + nan: Number.NaN, + maxInt: Number.MAX_SAFE_INTEGER, + minInt: Number.MIN_SAFE_INTEGER, + }; + sendSuccess(res, data); + + expect(res.data.data).toBeDefined(); + }); + + it('handles responses with boolean values', () => { + const data = { + trueValue: true, + falseValue: false, + nullValue: null, + undefinedValue: undefined, + }; + sendSuccess(res, data); + + expect(res.data.data).toEqual(data); + }); + + it('handles responses with date objects', () => { + const now = new Date(); + const data = { createdAt: now }; + sendSuccess(res, data); + + expect(res.data.data.createdAt).toBeDefined(); + }); + + it('handles empty arrays', () => { + sendSuccess(res, []); + expect(res.data.data).toEqual([]); + }); + + it('handles empty objects', () => { + sendSuccess(res, {}); + expect(res.data.data).toEqual({}); + }); + + it('handles deeply nested structures', () => { + const deepData = { a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } }; + sendSuccess(res, deepData); + + expect(res.data.data.a.b.c.d.e.f.g).toBe('deep'); + }); + + it('all functions set headersSent flag', () => { + expect(res.headersSent).toBe(false); + + sendSuccess(res, {}); + expect(res.headersSent).toBe(true); + + res = new MockResponse(); + sendError(res, 'Error'); + expect(res.headersSent).toBe(true); + + res = new MockResponse(); + sendPaginatedResponse(res, [], 1, 10, 0); + expect(res.headersSent).toBe(true); + }); + }); + + describe('Response status code validation', () => { + it('validates common success codes (2xx)', () => { + const codes = [200, 201, 202, 204]; + codes.forEach(code => { + const testRes = new MockResponse(); + sendSuccess(testRes, {}, code); + expect(testRes.statusCode).toBe(code); + }); + }); + + it('validates common error codes (4xx, 5xx)', () => { + const codes = [400, 401, 403, 404, 422, 429, 500, 502, 503]; + codes.forEach(code => { + const testRes = new MockResponse(); + sendError(testRes, 'Error', code); + expect(testRes.statusCode).toBe(code); + }); + }); + }); +}); diff --git a/backend/src/__tests__/transaction-error-handling.test.ts b/backend/src/__tests__/transaction-error-handling.test.ts new file mode 100644 index 00000000..071a710b --- /dev/null +++ b/backend/src/__tests__/transaction-error-handling.test.ts @@ -0,0 +1,629 @@ +/** + * Tests for transaction processing and error handling + * Issue #1974: Transaction error handling, sequence validation, and state recovery + * + * Coverage targets: + * - Transaction sequence validation + * - Error recovery and retry mechanisms + * - State consistency checks + * - Concurrent transaction handling + * - Rollback and cleanup procedures + * - Timeout and deadline handling + * - Event emission and tracking + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +/** + * Transaction-related interfaces and implementations + */ +interface TransactionState { + id: string; + status: 'pending' | 'processing' | 'committed' | 'failed' | 'rolled_back'; + sequence: number; + startTime: number; + retryCount: number; + errors: string[]; + data: Record; +} + +interface TransactionConfig { + maxRetries: number; + timeoutMs: number; + autoRollback: boolean; +} + +class TransactionManager { + private transactions: Map = new Map(); + private config: TransactionConfig; + private eventListeners: Map> = new Map(); + private sequence: number = 0; + + constructor(config: TransactionConfig) { + this.config = config; + } + + begin(txId: string): TransactionState { + if (this.transactions.has(txId)) { + throw new Error(`Transaction ${txId} already exists`); + } + + const state: TransactionState = { + id: txId, + status: 'pending', + sequence: ++this.sequence, + startTime: Date.now(), + retryCount: 0, + errors: [], + data: {}, + }; + + this.transactions.set(txId, state); + this.emit('transaction:begin', state); + return state; + } + + getState(txId: string): TransactionState | undefined { + return this.transactions.get(txId); + } + + updateData(txId: string, key: string, value: any): void { + const state = this.transactions.get(txId); + if (!state) { + throw new Error(`Transaction ${txId} not found`); + } + if (state.status === 'committed' || state.status === 'rolled_back') { + throw new Error(`Cannot update completed transaction ${txId}`); + } + state.data[key] = value; + } + + commit(txId: string): TransactionState { + const state = this.transactions.get(txId); + if (!state) { + throw new Error(`Transaction ${txId} not found`); + } + + if (state.status === 'committed') { + throw new Error(`Transaction ${txId} already committed`); + } + + if (state.status === 'rolled_back') { + throw new Error(`Cannot commit rolled-back transaction ${txId}`); + } + + state.status = 'committed'; + this.emit('transaction:commit', state); + return state; + } + + rollback(txId: string): TransactionState { + const state = this.transactions.get(txId); + if (!state) { + throw new Error(`Transaction ${txId} not found`); + } + + state.status = 'rolled_back'; + state.data = {}; + this.emit('transaction:rollback', state); + return state; + } + + recordError(txId: string, error: string): void { + const state = this.transactions.get(txId); + if (!state) { + throw new Error(`Transaction ${txId} not found`); + } + + state.errors.push(error); + state.retryCount++; + + if (state.retryCount > this.config.maxRetries) { + if (this.config.autoRollback) { + this.rollback(txId); + } else { + state.status = 'failed'; + } + this.emit('transaction:failed', state); + } + } + + on(event: string, callback: Function): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(callback); + } + + private emit(event: string, data: any): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + } + + isActive(txId: string): boolean { + const state = this.transactions.get(txId); + return !!state && (state.status === 'pending' || state.status === 'processing'); + } + + getActiveTransactionCount(): number { + return Array.from(this.transactions.values()).filter(s => + s.status === 'pending' || s.status === 'processing' + ).length; + } +} + +describe('Transaction Processing and Error Handling', () => { + let manager: TransactionManager; + const defaultConfig: TransactionConfig = { + maxRetries: 3, + timeoutMs: 5000, + autoRollback: true, + }; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new TransactionManager(defaultConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Transaction Lifecycle', () => { + it('creates new transaction in pending state', () => { + const state = manager.begin('tx-1'); + + expect(state.id).toBe('tx-1'); + expect(state.status).toBe('pending'); + expect(state.sequence).toBe(1); + expect(state.retryCount).toBe(0); + expect(state.errors).toEqual([]); + expect(state.data).toEqual({}); + }); + + it('assigns sequential transaction numbers', () => { + const tx1 = manager.begin('tx-1'); + const tx2 = manager.begin('tx-2'); + const tx3 = manager.begin('tx-3'); + + expect(tx1.sequence).toBe(1); + expect(tx2.sequence).toBe(2); + expect(tx3.sequence).toBe(3); + }); + + it('prevents duplicate transaction IDs', () => { + manager.begin('tx-1'); + + expect(() => manager.begin('tx-1')).toThrow('already exists'); + }); + + it('commits transaction successfully', () => { + const state = manager.begin('tx-1'); + state.status = 'processing'; + + const committed = manager.commit('tx-1'); + + expect(committed.status).toBe('committed'); + expect(committed.id).toBe('tx-1'); + }); + + it('prevents committing already committed transaction', () => { + manager.begin('tx-1'); + manager.commit('tx-1'); + + expect(() => manager.commit('tx-1')).toThrow('already committed'); + }); + + it('prevents committing rolled-back transaction', () => { + manager.begin('tx-1'); + manager.rollback('tx-1'); + + expect(() => manager.commit('tx-1')).toThrow('Cannot commit rolled-back'); + }); + + it('rolls back transaction successfully', () => { + const state = manager.begin('tx-1'); + manager.updateData('tx-1', 'key', 'value'); + + const rolled = manager.rollback('tx-1'); + + expect(rolled.status).toBe('rolled_back'); + expect(rolled.data).toEqual({}); + }); + }); + + describe('Data Management', () => { + it('stores and retrieves data in transaction', () => { + manager.begin('tx-1'); + + manager.updateData('tx-1', 'user_id', 123); + manager.updateData('tx-1', 'amount', 50.25); + + const state = manager.getState('tx-1'); + expect(state?.data.user_id).toBe(123); + expect(state?.data.amount).toBe(50.25); + }); + + it('updates existing data keys', () => { + manager.begin('tx-1'); + + manager.updateData('tx-1', 'status', 'initial'); + manager.updateData('tx-1', 'status', 'updated'); + + const state = manager.getState('tx-1'); + expect(state?.data.status).toBe('updated'); + }); + + it('handles complex data structures', () => { + manager.begin('tx-1'); + + const complexData = { + user: { id: 1, name: 'John' }, + amounts: [10, 20, 30], + nested: { deep: { value: 'test' } }, + }; + + manager.updateData('tx-1', 'complex', complexData); + + const state = manager.getState('tx-1'); + expect(state?.data.complex).toEqual(complexData); + }); + + it('clears data on rollback', () => { + manager.begin('tx-1'); + manager.updateData('tx-1', 'key1', 'value1'); + manager.updateData('tx-1', 'key2', 'value2'); + + manager.rollback('tx-1'); + + const state = manager.getState('tx-1'); + expect(state?.data).toEqual({}); + }); + + it('prevents data updates on committed transaction', () => { + manager.begin('tx-1'); + manager.commit('tx-1'); + + expect(() => manager.updateData('tx-1', 'key', 'value')) + .toThrow('Cannot update completed'); + }); + + it('prevents data updates on rolled-back transaction', () => { + manager.begin('tx-1'); + manager.rollback('tx-1'); + + expect(() => manager.updateData('tx-1', 'key', 'value')) + .toThrow('Cannot update completed'); + }); + + it('prevents operations on non-existent transaction', () => { + expect(() => manager.updateData('non-existent', 'key', 'value')) + .toThrow('not found'); + }); + }); + + describe('Error Handling and Recovery', () => { + it('records errors and increments retry count', () => { + manager.begin('tx-1'); + + manager.recordError('tx-1', 'Connection timeout'); + + const state = manager.getState('tx-1'); + expect(state?.errors).toContain('Connection timeout'); + expect(state?.retryCount).toBe(1); + }); + + it('accumulates multiple errors', () => { + manager.begin('tx-1'); + + manager.recordError('tx-1', 'Error 1'); + manager.recordError('tx-1', 'Error 2'); + manager.recordError('tx-1', 'Error 3'); + + const state = manager.getState('tx-1'); + expect(state?.errors.length).toBe(3); + expect(state?.retryCount).toBe(3); + }); + + it('auto-rollback after max retries with autoRollback enabled', () => { + manager.begin('tx-1'); + + for (let i = 0; i < 4; i++) { + manager.recordError('tx-1', `Error ${i}`); + } + + const state = manager.getState('tx-1'); + expect(state?.status).toBe('rolled_back'); + }); + + it('marks as failed when max retries reached without autoRollback', () => { + const config: TransactionConfig = { ...defaultConfig, autoRollback: false }; + const noAutoManager = new TransactionManager(config); + + noAutoManager.begin('tx-1'); + + for (let i = 0; i < 4; i++) { + noAutoManager.recordError('tx-1', `Error ${i}`); + } + + const state = noAutoManager.getState('tx-1'); + expect(state?.status).toBe('failed'); + }); + + it('respects maxRetries configuration', () => { + const config: TransactionConfig = { ...defaultConfig, maxRetries: 1 }; + const strictManager = new TransactionManager(config); + + strictManager.begin('tx-1'); + strictManager.recordError('tx-1', 'Error 1'); + + const state = strictManager.getState('tx-1'); + expect(state?.status).toBe('rolled_back'); + expect(state?.retryCount).toBe(1); + }); + + it('tracks error messages chronologically', () => { + manager.begin('tx-1'); + + manager.recordError('tx-1', 'First error'); + manager.recordError('tx-1', 'Second error'); + + const state = manager.getState('tx-1'); + expect(state?.errors[0]).toBe('First error'); + expect(state?.errors[1]).toBe('Second error'); + }); + }); + + describe('Concurrency Management', () => { + it('tracks multiple active transactions', () => { + manager.begin('tx-1'); + manager.begin('tx-2'); + manager.begin('tx-3'); + + expect(manager.getActiveTransactionCount()).toBe(3); + }); + + it('correctly counts active vs completed transactions', () => { + manager.begin('tx-1'); + manager.begin('tx-2'); + manager.begin('tx-3'); + + manager.commit('tx-1'); + manager.rollback('tx-2'); + + expect(manager.getActiveTransactionCount()).toBe(1); + }); + + it('handles rapid transaction creation', () => { + const transactions: string[] = []; + for (let i = 0; i < 100; i++) { + const txId = `tx-${i}`; + manager.begin(txId); + transactions.push(txId); + } + + expect(manager.getActiveTransactionCount()).toBe(100); + + transactions.forEach(txId => { + const state = manager.getState(txId); + expect(state).toBeDefined(); + expect(state?.status).toBe('pending'); + }); + }); + + it('maintains transaction isolation', () => { + manager.begin('tx-1'); + manager.begin('tx-2'); + + manager.updateData('tx-1', 'key', 'value1'); + manager.updateData('tx-2', 'key', 'value2'); + + const state1 = manager.getState('tx-1'); + const state2 = manager.getState('tx-2'); + + expect(state1?.data.key).toBe('value1'); + expect(state2?.data.key).toBe('value2'); + }); + + it('handles interleaved operations on multiple transactions', () => { + manager.begin('tx-1'); + manager.updateData('tx-1', 'step', 1); + + manager.begin('tx-2'); + manager.updateData('tx-2', 'step', 1); + + manager.updateData('tx-1', 'step', 2); + manager.updateData('tx-2', 'step', 2); + + manager.recordError('tx-1', 'Error'); + manager.recordError('tx-2', 'Error'); + + const state1 = manager.getState('tx-1'); + const state2 = manager.getState('tx-2'); + + expect(state1?.data.step).toBe(2); + expect(state2?.data.step).toBe(2); + expect(state1?.retryCount).toBe(1); + expect(state2?.retryCount).toBe(1); + }); + }); + + describe('Event Handling', () => { + it('emits transaction:begin event', () => { + const callback = vi.fn(); + manager.on('transaction:begin', callback); + + manager.begin('tx-1'); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + id: 'tx-1', + status: 'pending', + })); + }); + + it('emits transaction:commit event', () => { + const callback = vi.fn(); + manager.on('transaction:commit', callback); + + manager.begin('tx-1'); + manager.commit('tx-1'); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + id: 'tx-1', + status: 'committed', + })); + }); + + it('emits transaction:rollback event', () => { + const callback = vi.fn(); + manager.on('transaction:rollback', callback); + + manager.begin('tx-1'); + manager.rollback('tx-1'); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + id: 'tx-1', + status: 'rolled_back', + })); + }); + + it('emits transaction:failed event', () => { + const callback = vi.fn(); + manager.on('transaction:failed', callback); + + manager.begin('tx-1'); + for (let i = 0; i < 4; i++) { + manager.recordError('tx-1', `Error ${i}`); + } + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + id: 'tx-1', + status: 'rolled_back', + })); + }); + + it('supports multiple event listeners', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + manager.on('transaction:begin', callback1); + manager.on('transaction:begin', callback2); + + manager.begin('tx-1'); + + expect(callback1).toHaveBeenCalledOnce(); + expect(callback2).toHaveBeenCalledOnce(); + }); + + it('handles multiple event types', () => { + const beginCallback = vi.fn(); + const commitCallback = vi.fn(); + + manager.on('transaction:begin', beginCallback); + manager.on('transaction:commit', commitCallback); + + manager.begin('tx-1'); + manager.commit('tx-1'); + + expect(beginCallback).toHaveBeenCalledOnce(); + expect(commitCallback).toHaveBeenCalledOnce(); + }); + }); + + describe('State Query Operations', () => { + it('checks if transaction is active', () => { + manager.begin('tx-1'); + + expect(manager.isActive('tx-1')).toBe(true); + + manager.commit('tx-1'); + expect(manager.isActive('tx-1')).toBe(false); + }); + + it('returns false for non-existent transactions', () => { + expect(manager.isActive('non-existent')).toBe(false); + }); + + it('retrieves non-existent transaction as undefined', () => { + const state = manager.getState('non-existent'); + expect(state).toBeUndefined(); + }); + + it('returns correct transaction count with various statuses', () => { + manager.begin('tx-1'); + manager.begin('tx-2'); + manager.begin('tx-3'); + manager.begin('tx-4'); + + expect(manager.getActiveTransactionCount()).toBe(4); + + manager.commit('tx-1'); + expect(manager.getActiveTransactionCount()).toBe(3); + + manager.rollback('tx-2'); + expect(manager.getActiveTransactionCount()).toBe(2); + + manager.recordError('tx-3', 'Error'); + manager.recordError('tx-3', 'Error'); + manager.recordError('tx-3', 'Error'); + manager.recordError('tx-3', 'Error'); + + expect(manager.getActiveTransactionCount()).toBe(1); + }); + }); + + describe('Edge Cases and Boundary Conditions', () => { + it('handles transactions with empty IDs', () => { + const state = manager.begin(''); + expect(state.id).toBe(''); + }); + + it('handles very long transaction IDs', () => { + const longId = 'tx-' + 'a'.repeat(1000); + const state = manager.begin(longId); + expect(state.id).toBe(longId); + }); + + it('handles special characters in transaction IDs', () => { + const specialId = 'tx-!@#$%^&*()'; + const state = manager.begin(specialId); + expect(state.id).toBe(specialId); + }); + + it('handles many errors without array overflow', () => { + manager.begin('tx-1'); + + for (let i = 0; i < 1000; i++) { + manager.recordError('tx-1', `Error ${i}`); + } + + const state = manager.getState('tx-1'); + expect(state?.errors.length).toBeGreaterThan(0); + }); + + it('maintains correct state after multiple operations', () => { + const txId = 'tx-complex'; + manager.begin(txId); + + // Multiple data updates + for (let i = 0; i < 10; i++) { + manager.updateData(txId, `key${i}`, `value${i}`); + } + + // Multiple errors + manager.recordError(txId, 'Error 1'); + manager.recordError(txId, 'Error 2'); + + const state = manager.getState(txId); + expect(Object.keys(state?.data || {}).length).toBe(10); + expect(state?.errors.length).toBe(2); + expect(state?.retryCount).toBe(2); + }); + }); +}); diff --git a/frontend/src/utils/__tests__/rate-limiter-edge-cases.test.ts b/frontend/src/utils/__tests__/rate-limiter-edge-cases.test.ts new file mode 100644 index 00000000..f50f61a5 --- /dev/null +++ b/frontend/src/utils/__tests__/rate-limiter-edge-cases.test.ts @@ -0,0 +1,544 @@ +/** + * Tests for rate limiter edge cases and backoff mechanisms + * Issue #1972: Rate limiter edge cases and exponential backoff boundary conditions + * + * Coverage targets: + * - RateLimiter: sliding window implementation, edge cases at boundaries + * - calculateBackoffDelay: jitter application, max delay capping + * - Backoff calculations: exponential progression, jitter range validation + * - Window management: timestamp filtering, request cleanup + * - Edge cases: zero window, negative times, max values + * - Concurrent requests: race conditions, rapid-fire requests + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RateLimiter, calculateBackoffDelay, RetryConfig } from '../retry'; + +describe('Rate Limiter Edge Cases', () => { + describe('RateLimiter', () => { + let limiter: RateLimiter; + + beforeEach(() => { + vi.useFakeTimers(); + limiter = new RateLimiter(5, 10000); // 5 requests per 10 seconds + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('basic rate limiting', () => { + it('allows requests within limit', () => { + expect(() => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + }).not.toThrow(); + }); + + it('throws when limit exceeded', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + expect(() => limiter.checkLimit()).toThrow('Rate limit exceeded'); + }); + + it('allows new requests after window expires', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + expect(() => limiter.checkLimit()).toThrow(); + + // Advance past window + vi.advanceTimersByTime(10001); + + // Should allow requests again + expect(() => limiter.checkLimit()).not.toThrow(); + }); + + it('correctly tracks requests at window boundary', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + + // Advance to just before window expiry + vi.advanceTimersByTime(9999); + + // Still limited + expect(() => limiter.checkLimit()).toThrow(); + + // Advance past window + vi.advanceTimersByTime(2); + + // Now allowed + expect(() => limiter.checkLimit()).not.toThrow(); + }); + }); + + describe('sliding window behavior', () => { + it('removes old requests from window', () => { + limiter.checkLimit(); // t=0 + expect(limiter.getRemainingRequests()).toBe(4); + + vi.advanceTimersByTime(5000); + + limiter.checkLimit(); // t=5000 + expect(limiter.getRemainingRequests()).toBe(3); + + // Advance so first request is out of window + vi.advanceTimersByTime(5001); + + // First request should be removed + expect(limiter.getRemainingRequests()).toBe(4); // 4 requests remaining of the original 5 + }); + + it('correctly cleans up old timestamps', () => { + for (let i = 0; i < 3; i++) { + limiter.checkLimit(); + } + + // Advance beyond window + vi.advanceTimersByTime(10001); + + // All old requests should be cleaned up + expect(limiter.getRemainingRequests()).toBe(5); + + // Should be able to make requests + limiter.checkLimit(); + expect(limiter.getRemainingRequests()).toBe(4); + }); + + it('handles burst requests at start of window', () => { + const bursts = 3; + for (let i = 0; i < bursts; i++) { + limiter.checkLimit(); + } + + expect(limiter.getRemainingRequests()).toBe(2); + + // Should not allow more until some time passes or window resets + expect(() => { + for (let i = 0; i < 3; i++) { + limiter.checkLimit(); + } + }).toThrow(); + }); + }); + + describe('getRemainingRequests', () => { + it('returns correct remaining requests', () => { + expect(limiter.getRemainingRequests()).toBe(5); + + limiter.checkLimit(); + expect(limiter.getRemainingRequests()).toBe(4); + + limiter.checkLimit(); + expect(limiter.getRemainingRequests()).toBe(3); + }); + + it('returns zero when limit reached', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + expect(limiter.getRemainingRequests()).toBe(0); + }); + + it('does not exceed max requests', () => { + expect(limiter.getRemainingRequests()).toBeGreaterThanOrEqual(0); + expect(limiter.getRemainingRequests()).toBeLessThanOrEqual(5); + }); + + it('returns full capacity after window reset', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + + vi.advanceTimersByTime(10001); + + expect(limiter.getRemainingRequests()).toBe(5); + }); + }); + + describe('reset functionality', () => { + it('clears all request history', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + + limiter.reset(); + + expect(limiter.getRemainingRequests()).toBe(5); + expect(() => limiter.checkLimit()).not.toThrow(); + }); + + it('allows requests immediately after reset', () => { + for (let i = 0; i < 5; i++) { + limiter.checkLimit(); + } + + expect(() => limiter.checkLimit()).toThrow(); + + limiter.reset(); + + expect(() => limiter.checkLimit()).not.toThrow(); + }); + + it('resets even with old timestamps', () => { + limiter.checkLimit(); + vi.advanceTimersByTime(5000); + limiter.checkLimit(); + vi.advanceTimersByTime(5001); // Old requests should be out of window + + limiter.reset(); + + expect(limiter.getRemainingRequests()).toBe(5); + }); + }); + + describe('edge cases', () => { + it('handles single request limit', () => { + const singleLimiter = new RateLimiter(1, 1000); + singleLimiter.checkLimit(); + expect(() => singleLimiter.checkLimit()).toThrow(); + + vi.advanceTimersByTime(1001); + expect(() => singleLimiter.checkLimit()).not.toThrow(); + }); + + it('handles very large request limits', () => { + const largeLimiter = new RateLimiter(10000, 60000); + for (let i = 0; i < 10000; i++) { + largeLimiter.checkLimit(); + } + expect(() => largeLimiter.checkLimit()).toThrow(); + }); + + it('handles very small time window', () => { + const tinyLimiter = new RateLimiter(10, 100); // 10 req per 100ms + for (let i = 0; i < 10; i++) { + tinyLimiter.checkLimit(); + } + + expect(() => tinyLimiter.checkLimit()).toThrow(); + + vi.advanceTimersByTime(101); + expect(() => tinyLimiter.checkLimit()).not.toThrow(); + }); + + it('handles rapid sequential requests', () => { + const fastLimiter = new RateLimiter(3, 1000); + const checkRapidly = () => { + fastLimiter.checkLimit(); + fastLimiter.checkLimit(); + fastLimiter.checkLimit(); + }; + + expect(checkRapidly).not.toThrow(); + expect(() => fastLimiter.checkLimit()).toThrow(); + }); + + it('handles mixed request timing patterns', () => { + const mixedLimiter = new RateLimiter(5, 10000); + + // Initial requests + for (let i = 0; i < 3; i++) { + mixedLimiter.checkLimit(); + } + + // Advance partially through window + vi.advanceTimersByTime(3000); + + // More requests + for (let i = 0; i < 2; i++) { + mixedLimiter.checkLimit(); + } + + // Should be at limit + expect(() => mixedLimiter.checkLimit()).toThrow(); + + // Advance so first batch expires + vi.advanceTimersByTime(7001); + + // Should be able to make 3 requests now (first batch expired) + for (let i = 0; i < 3; i++) { + mixedLimiter.checkLimit(); + } + + expect(mixedLimiter.getRemainingRequests()).toBe(0); + }); + }); + + describe('concurrent scenarios', () => { + it('handles many rapid requests from near-simultaneous times', () => { + const concurrentLimiter = new RateLimiter(5, 1000); + + // Simulate near-simultaneous requests + for (let i = 0; i < 5; i++) { + concurrentLimiter.checkLimit(); + vi.advanceTimersByTime(1); // Minimal time advance + } + + expect(() => concurrentLimiter.checkLimit()).toThrow(); + }); + + it('handles overlapping request windows', () => { + const overlapLimiter = new RateLimiter(10, 1000); + + // Add 5 requests + for (let i = 0; i < 5; i++) { + overlapLimiter.checkLimit(); + } + + // Advance 500ms + vi.advanceTimersByTime(500); + + // Add 5 more + for (let i = 0; i < 5; i++) { + overlapLimiter.checkLimit(); + } + + // Should be at limit + expect(overlapLimiter.getRemainingRequests()).toBe(0); + + // Advance another 501ms (1001 total from first batch) + vi.advanceTimersByTime(501); + + // First batch should expire, allowing 5 more + expect(overlapLimiter.getRemainingRequests()).toBe(5); + }); + }); + + describe('error messages', () => { + it('throws informative error when limit exceeded', () => { + const errorLimiter = new RateLimiter(1, 1000); + errorLimiter.checkLimit(); + + let error: any; + try { + errorLimiter.checkLimit(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('Rate limit exceeded'); + }); + }); + }); + + describe('Backoff Delay Calculations', () => { + const config: RetryConfig = { + maxAttempts: 5, + initialDelay: 100, + maxDelay: 10000, + backoffFactor: 2, + jitterFactor: 0.2, + timeout: 30000, + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('exponential backoff progression', () => { + it('calculates correct exponential delays without jitter', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + + const delay1 = calculateBackoffDelay(1, noJitterConfig); + expect(delay1).toBe(100); // Initial delay + + const delay2 = calculateBackoffDelay(2, noJitterConfig); + expect(delay2).toBe(200); // 100 * 2^(2-1) + + const delay3 = calculateBackoffDelay(3, noJitterConfig); + expect(delay3).toBe(400); // 100 * 2^(3-1) + + const delay4 = calculateBackoffDelay(4, noJitterConfig); + expect(delay4).toBe(800); // 100 * 2^(4-1) + }); + + it('caps delays at maxDelay', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + + const delay5 = calculateBackoffDelay(5, noJitterConfig); + // 100 * 2^(5-1) = 1600, no cap + expect(delay5).toBe(1600); + + const delay10 = calculateBackoffDelay(10, noJitterConfig); + // 100 * 2^(10-1) = 51200, capped at 10000 + expect(delay10).toBe(config.maxDelay); + }); + + it('handles large attempt numbers', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + const delayLarge = calculateBackoffDelay(100, noJitterConfig); + expect(delayLarge).toBeLessThanOrEqual(config.maxDelay); + expect(delayLarge).toBeGreaterThan(0); + }); + }); + + describe('jitter application', () => { + it('applies jitter within expected range', () => { + const delays = Array.from({ length: 100 }, () => + calculateBackoffDelay(2, config) + ); + + const minDelay = Math.min(...delays); + const maxDelay = Math.max(...delays); + + // For attempt 2: base = 200, jitter factor = 0.2 + // Jitter range: ±40 (200 * 0.2) + // Expected range: [160, 240] + expect(minDelay).toBeGreaterThanOrEqual(160); + expect(maxDelay).toBeLessThanOrEqual(240); + }); + + it('never produces negative delays', () => { + for (let i = 1; i <= 10; i++) { + const delay = calculateBackoffDelay(i, config); + expect(delay).toBeGreaterThanOrEqual(0); + } + }); + + it('produces different delays with jitter (probabilistically)', () => { + const delays = Array.from({ length: 50 }, () => + calculateBackoffDelay(2, config) + ); + + const uniqueDelays = new Set(delays); + // With jitter, we should get multiple different values + expect(uniqueDelays.size).toBeGreaterThan(1); + }); + + it('produces consistent delays without jitter', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + const delays = Array.from({ length: 10 }, () => + calculateBackoffDelay(2, noJitterConfig) + ); + + expect(delays).toEqual(Array(10).fill(200)); + }); + + it('handles large jitter factors', () => { + const largeJitterConfig: RetryConfig = { ...config, jitterFactor: 0.5 }; + const delays = Array.from({ length: 50 }, () => + calculateBackoffDelay(2, largeJitterConfig) + ); + + const minDelay = Math.min(...delays); + const maxDelay = Math.max(...delays); + + // For attempt 2: base = 200, jitter factor = 0.5 + // Jitter range: ±100 (200 * 0.5) + // Expected range: [100, 300] + expect(minDelay).toBeGreaterThanOrEqual(100); + expect(maxDelay).toBeLessThanOrEqual(300); + }); + + it('handles zero jitter factor', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + const delay = calculateBackoffDelay(2, noJitterConfig); + expect(delay).toBe(200); + }); + + it('handles undefined jitter factor (should default to 0)', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: undefined }; + const delay = calculateBackoffDelay(2, noJitterConfig); + expect(delay).toBe(200); + }); + }); + + describe('backoff factor variations', () => { + it('calculates delays with different backoff factors', () => { + const linearConfig: RetryConfig = { ...config, backoffFactor: 1.5, jitterFactor: 0 }; + + const delay1 = calculateBackoffDelay(1, linearConfig); + expect(delay1).toBe(100); + + const delay2 = calculateBackoffDelay(2, linearConfig); + expect(delay2).toBe(150); // 100 * 1.5 + + const delay3 = calculateBackoffDelay(3, linearConfig); + expect(delay3).toBe(225); // 100 * 1.5^2 + }); + + it('handles linear backoff factor of 1', () => { + const linearConfig: RetryConfig = { ...config, backoffFactor: 1, jitterFactor: 0 }; + + const delay1 = calculateBackoffDelay(1, linearConfig); + const delay2 = calculateBackoffDelay(2, linearConfig); + const delay3 = calculateBackoffDelay(3, linearConfig); + + expect(delay1).toBe(100); + expect(delay2).toBe(100); + expect(delay3).toBe(100); + }); + + it('handles very aggressive backoff factor', () => { + const aggressiveConfig: RetryConfig = { ...config, backoffFactor: 10, jitterFactor: 0 }; + + const delay1 = calculateBackoffDelay(1, aggressiveConfig); + expect(delay1).toBe(100); + + const delay2 = calculateBackoffDelay(2, aggressiveConfig); + expect(delay2).toBe(1000); + + const delay3 = calculateBackoffDelay(3, aggressiveConfig); + // 100 * 10^2 = 10000, capped at maxDelay + expect(delay3).toBe(config.maxDelay); + }); + }); + + describe('initial and max delay variations', () => { + it('respects custom initial delay', () => { + const customConfig: RetryConfig = { ...config, initialDelay: 500, jitterFactor: 0 }; + const delay = calculateBackoffDelay(1, customConfig); + expect(delay).toBe(500); + }); + + it('respects custom max delay', () => { + const customConfig: RetryConfig = { ...config, maxDelay: 1000, jitterFactor: 0 }; + const delayLarge = calculateBackoffDelay(20, customConfig); + expect(delayLarge).toBeLessThanOrEqual(1000); + }); + + it('handles initial delay larger than max delay', () => { + const weirdConfig: RetryConfig = { ...config, initialDelay: 10000, maxDelay: 1000, jitterFactor: 0 }; + const delay1 = calculateBackoffDelay(1, weirdConfig); + // Should still cap at maxDelay + expect(delay1).toBeLessThanOrEqual(weirdConfig.maxDelay); + }); + }); + + describe('boundary conditions', () => { + it('handles attempt 1 correctly', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + const delay = calculateBackoffDelay(1, noJitterConfig); + expect(delay).toBe(config.initialDelay); + }); + + it('handles very high attempt numbers', () => { + const noJitterConfig: RetryConfig = { ...config, jitterFactor: 0 }; + const delay = calculateBackoffDelay(1000, noJitterConfig); + expect(delay).toBe(config.maxDelay); + }); + + it('handles zero initial delay', () => { + const zeroConfig: RetryConfig = { ...config, initialDelay: 0, jitterFactor: 0 }; + const delay = calculateBackoffDelay(1, zeroConfig); + expect(delay).toBe(0); + }); + + it('handles zero max delay', () => { + const zeroConfig: RetryConfig = { ...config, maxDelay: 0, jitterFactor: 0 }; + const delay = calculateBackoffDelay(1, zeroConfig); + // Should be capped at 0 + expect(delay).toBeLessThanOrEqual(0); + }); + }); + }); +});