ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(middleware): add auth, error, rate limit tests

byarielm.fyi 37ede405 74f3b0ac

verified
+1298
+314
packages/api/src/middleware/auth.test.ts
··· 1 + /** 2 + * Auth Middleware Tests 3 + * Tests for authentication middleware error handling 4 + */ 5 + 6 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 7 + import { Hono } from 'hono'; 8 + import { authMiddleware, extractSessionId } from './auth'; 9 + import { createTestSession, deleteTestSession } from '../../__tests__/fixtures'; 10 + 11 + describe('Auth Middleware', () => { 12 + let app: Hono; 13 + 14 + beforeEach(() => { 15 + app = new Hono(); 16 + app.use('/protected/*', authMiddleware); 17 + app.get('/protected/test', (c) => { 18 + // Access context variables added by authMiddleware 19 + const did = (c.get as (key: string) => unknown)('did'); 20 + return c.json({ success: true, did: did as string }); 21 + }); 22 + }); 23 + 24 + describe('Missing Auth Headers', () => { 25 + it('rejects requests without session cookie', async () => { 26 + const res = await app.request('/protected/test'); 27 + 28 + expect(res.status).toBe(401); 29 + const body = await res.json() as { success: boolean; error?: string }; 30 + expect(body.success).toBe(false); 31 + expect(body.error).toContain('session'); 32 + }); 33 + 34 + it('rejects requests with empty cookie header', async () => { 35 + const res = await app.request('/protected/test', { 36 + headers: { 37 + Cookie: '', 38 + }, 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + }); 43 + 44 + it('rejects requests with malformed cookie header', async () => { 45 + const res = await app.request('/protected/test', { 46 + headers: { 47 + Cookie: 'malformed cookie string without equals', 48 + }, 49 + }); 50 + 51 + expect(res.status).toBe(401); 52 + }); 53 + }); 54 + 55 + describe('Invalid Session Tokens', () => { 56 + it('rejects expired sessions', async () => { 57 + const expiredSession = 'expired-session-id'; 58 + 59 + const res = await app.request('/protected/test', { 60 + headers: { 61 + Cookie: `atlast_session_dev=${expiredSession}`, 62 + }, 63 + }); 64 + 65 + expect(res.status).toBe(401); 66 + const body = await res.json() as { success: boolean }; 67 + }); 68 + 69 + it('rejects malformed session IDs', async () => { 70 + const malformedSession = 'not-a-valid-session-format'; 71 + 72 + const res = await app.request('/protected/test', { 73 + headers: { 74 + Cookie: `atlast_session_dev=${malformedSession}`, 75 + }, 76 + }); 77 + 78 + expect(res.status).toBe(401); 79 + }); 80 + 81 + it('rejects non-existent sessions', async () => { 82 + const nonExistentSession = 'session-that-does-not-exist'; 83 + 84 + const res = await app.request('/protected/test', { 85 + headers: { 86 + Cookie: `atlast_session_dev=${nonExistentSession}`, 87 + }, 88 + }); 89 + 90 + expect(res.status).toBe(401); 91 + }); 92 + 93 + it('rejects sessions with invalid characters', async () => { 94 + const invalidSession = 'session-with-<script>alert("xss")</script>'; 95 + 96 + const res = await app.request('/protected/test', { 97 + headers: { 98 + Cookie: `atlast_session_dev=${invalidSession}`, 99 + }, 100 + }); 101 + 102 + expect(res.status).toBe(401); 103 + }); 104 + }); 105 + 106 + describe('Valid Session Handling', () => { 107 + let validSession: string; 108 + 109 + beforeEach(async () => { 110 + validSession = await createTestSession('standard'); 111 + }); 112 + 113 + afterEach(async () => { 114 + await deleteTestSession(validSession); 115 + }); 116 + 117 + it('allows requests with valid session', async () => { 118 + const res = await app.request('/protected/test', { 119 + headers: { 120 + Cookie: `atlast_session_dev=${validSession}`, 121 + }, 122 + }); 123 + 124 + expect(res.status).toBe(200); 125 + const body = await res.json() as { success: boolean; did: string }; 126 + expect(body.success).toBe(true); 127 + expect(body.did).toBeDefined(); 128 + }); 129 + 130 + it('accepts both dev and production cookie names', async () => { 131 + // Test dev cookie 132 + const devRes = await app.request('/protected/test', { 133 + headers: { 134 + Cookie: `atlast_session_dev=${validSession}`, 135 + }, 136 + }); 137 + expect(devRes.status).toBe(200); 138 + 139 + // Test prod cookie 140 + const prodRes = await app.request('/protected/test', { 141 + headers: { 142 + Cookie: `atlast_session=${validSession}`, 143 + }, 144 + }); 145 + expect(prodRes.status).toBe(200); 146 + }); 147 + 148 + it('prioritizes correct cookie when multiple cookies present', async () => { 149 + const res = await app.request('/protected/test', { 150 + headers: { 151 + Cookie: `other_cookie=value; atlast_session_dev=${validSession}; another=value`, 152 + }, 153 + }); 154 + 155 + expect(res.status).toBe(200); 156 + }); 157 + }); 158 + 159 + describe('Session Context', () => { 160 + let validSession: string; 161 + 162 + beforeEach(async () => { 163 + validSession = await createTestSession('standard'); 164 + }); 165 + 166 + afterEach(async () => { 167 + await deleteTestSession(validSession); 168 + }); 169 + 170 + it('adds sessionId to context', async () => { 171 + const testApp = new Hono(); 172 + testApp.use('*', authMiddleware); 173 + testApp.get('/test', (c) => { 174 + const sessionId = (c.get as (key: string) => unknown)('sessionId'); 175 + return c.json({ sessionId: sessionId as string }); 176 + }); 177 + 178 + const res = await testApp.request('/test', { 179 + headers: { 180 + Cookie: `atlast_session_dev=${validSession}`, 181 + }, 182 + }); 183 + 184 + expect(res.status).toBe(200); 185 + const body = await res.json() as { sessionId: string }; 186 + expect(body.sessionId).toBe(validSession); 187 + }); 188 + 189 + it('adds did to context', async () => { 190 + const testApp = new Hono(); 191 + testApp.use('*', authMiddleware); 192 + testApp.get('/test', (c) => { 193 + const did = (c.get as (key: string) => unknown)('did'); 194 + return c.json({ did: did as string }); 195 + }); 196 + 197 + const res = await testApp.request('/test', { 198 + headers: { 199 + Cookie: `atlast_session_dev=${validSession}`, 200 + }, 201 + }); 202 + 203 + expect(res.status).toBe(200); 204 + const body = await res.json() as { did: string }; 205 + expect(body.did).toMatch(/^did:plc:/); 206 + }); 207 + }); 208 + 209 + describe('extractSessionId Helper', () => { 210 + it('extracts session from cookie header', () => { 211 + const mockContext = { 212 + req: { 213 + header: (name: string) => { 214 + if (name === 'cookie') { 215 + return 'atlast_session_dev=test-session-id'; 216 + } 217 + return undefined; 218 + }, 219 + }, 220 + }; 221 + 222 + const sessionId = extractSessionId(mockContext); 223 + expect(sessionId).toBe('test-session-id'); 224 + }); 225 + 226 + it('returns null when no cookie header', () => { 227 + const mockContext = { 228 + req: { 229 + header: () => undefined, 230 + }, 231 + }; 232 + 233 + const sessionId = extractSessionId(mockContext); 234 + expect(sessionId).toBeNull(); 235 + }); 236 + 237 + it('handles multiple cookies', () => { 238 + const mockContext = { 239 + req: { 240 + header: (name: string) => { 241 + if (name === 'cookie') { 242 + return 'other=value; atlast_session_dev=test-session; another=value'; 243 + } 244 + return undefined; 245 + }, 246 + }, 247 + }; 248 + 249 + const sessionId = extractSessionId(mockContext); 250 + expect(sessionId).toBe('test-session'); 251 + }); 252 + 253 + it('prefers production cookie over dev cookie', () => { 254 + const mockContext = { 255 + req: { 256 + header: (name: string) => { 257 + if (name === 'cookie') { 258 + return 'atlast_session=prod-session; atlast_session_dev=dev-session'; 259 + } 260 + return undefined; 261 + }, 262 + }, 263 + }; 264 + 265 + const sessionId = extractSessionId(mockContext); 266 + expect(sessionId).toBe('prod-session'); 267 + }); 268 + }); 269 + 270 + describe('Database Error Scenarios', () => { 271 + it('handles database connection failures during session lookup', async () => { 272 + // If database is down, session validation will fail 273 + // This should result in 401 (cannot authenticate) 274 + 275 + const res = await app.request('/protected/test', { 276 + headers: { 277 + Cookie: 'atlast_session_dev=any-session', 278 + }, 279 + }); 280 + 281 + // Will return 401 because session lookup will fail 282 + expect([401, 500]).toContain(res.status); 283 + }); 284 + }); 285 + 286 + describe('Concurrent Request Handling', () => { 287 + let validSession: string; 288 + 289 + beforeEach(async () => { 290 + validSession = await createTestSession('standard'); 291 + }); 292 + 293 + afterEach(async () => { 294 + await deleteTestSession(validSession); 295 + }); 296 + 297 + it('handles multiple concurrent requests with same session', async () => { 298 + const requests = Array.from({ length: 5 }, () => 299 + app.request('/protected/test', { 300 + headers: { 301 + Cookie: `atlast_session_dev=${validSession}`, 302 + }, 303 + }) 304 + ); 305 + 306 + const results = await Promise.all(requests); 307 + 308 + // All should succeed 309 + results.forEach((res) => { 310 + expect(res.status).toBe(200); 311 + }); 312 + }); 313 + }); 314 + });
+431
packages/api/src/middleware/error.test.ts
··· 1 + /** 2 + * Error Handler Middleware Tests 3 + * Tests for error handler middleware 4 + */ 5 + 6 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 7 + import { Hono, Context } from 'hono'; 8 + import { ZodError, z } from 'zod'; 9 + import { errorHandler } from './error'; 10 + import { 11 + ApiError, 12 + AuthenticationError, 13 + ValidationError, 14 + NotFoundError, 15 + DatabaseError, 16 + } from '../errors'; 17 + 18 + describe('Error Handler Middleware', () => { 19 + let app: Hono; 20 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 21 + 22 + beforeEach(() => { 23 + app = new Hono(); 24 + app.onError(errorHandler); 25 + 26 + // Spy on console.error to verify logging 27 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 28 + }); 29 + 30 + afterEach(() => { 31 + consoleErrorSpy.mockRestore(); 32 + }); 33 + 34 + describe('Validation Errors', () => { 35 + it('returns 400 for ValidationError', async () => { 36 + app.get('/test', () => { 37 + throw new ValidationError('Invalid input data'); 38 + }); 39 + 40 + const res = await app.request('/test'); 41 + 42 + expect(res.status).toBe(400); 43 + const body = await res.json() as { success: boolean; error: string }; 44 + expect(body.success).toBe(false); 45 + expect(body.error).toBe('Invalid input data'); 46 + }); 47 + 48 + it('returns 400 for ZodError with formatted message', async () => { 49 + app.get('/test', () => { 50 + const schema = z.object({ 51 + email: z.string().email(), 52 + age: z.number().min(18), 53 + }); 54 + 55 + // This will throw ZodError 56 + schema.parse({ email: 'invalid', age: 10 }); 57 + return null as never; // Unreachable 58 + }); 59 + 60 + const res = await app.request('/test'); 61 + 62 + expect(res.status).toBe(400); 63 + const body = await res.json() as { success: boolean; error: string; details: unknown[] }; 64 + expect(body.success).toBe(false); 65 + expect(body.error).toBeDefined(); 66 + expect(body.details).toBeDefined(); 67 + }); 68 + 69 + it('includes Zod error details', async () => { 70 + app.get('/test', () => { 71 + const schema = z.object({ 72 + username: z.string().min(3), 73 + }); 74 + 75 + schema.parse({ username: 'ab' }); 76 + return null as never; // Unreachable 77 + }); 78 + 79 + const res = await app.request('/test'); 80 + 81 + expect(res.status).toBe(400); 82 + const body = await res.json() as { details: unknown[] }; 83 + expect(body.details).toBeDefined(); 84 + expect(Array.isArray(body.details)).toBe(true); 85 + }); 86 + }); 87 + 88 + describe('Authentication Errors', () => { 89 + it('returns 401 for AuthenticationError', async () => { 90 + app.get('/test', () => { 91 + throw new AuthenticationError('Invalid session'); 92 + }); 93 + 94 + const res = await app.request('/test'); 95 + 96 + expect(res.status).toBe(401); 97 + const body = await res.json() as { success: boolean; error: string }; 98 + expect(body.success).toBe(false); 99 + expect(body.error).toContain('session has expired'); 100 + }); 101 + 102 + it('provides user-friendly message for authentication errors', async () => { 103 + app.get('/test', () => { 104 + throw new AuthenticationError('Technical auth error'); 105 + }); 106 + 107 + const res = await app.request('/test'); 108 + 109 + const body = await res.json() as { error: string }; 110 + // Should not expose technical details 111 + expect(body.error).not.toContain('Technical'); 112 + expect(body.error).toContain('log in again'); 113 + }); 114 + }); 115 + 116 + describe('Not Found Errors', () => { 117 + it('returns 404 for NotFoundError', async () => { 118 + app.get('/test', () => { 119 + throw new NotFoundError('Resource not found'); 120 + }); 121 + 122 + const res = await app.request('/test'); 123 + 124 + expect(res.status).toBe(404); 125 + const body = await res.json() as { success: boolean; error: string }; 126 + expect(body.success).toBe(false); 127 + expect(body.error).toBe('Resource not found'); 128 + }); 129 + }); 130 + 131 + describe('Database Errors', () => { 132 + it('returns 500 for DatabaseError', async () => { 133 + app.get('/test', () => { 134 + throw new DatabaseError('Connection failed'); 135 + }); 136 + 137 + const res = await app.request('/test'); 138 + 139 + expect(res.status).toBe(500); 140 + const body = await res.json() as { success: boolean; error: string }; 141 + expect(body.success).toBe(false); 142 + expect(body.error).toContain('Database operation failed'); 143 + }); 144 + 145 + it('provides generic message for database errors', async () => { 146 + app.get('/test', () => { 147 + throw new DatabaseError('SELECT * FROM users WHERE id = $1 -- sensitive query'); 148 + }); 149 + 150 + const res = await app.request('/test'); 151 + 152 + const body = await res.json() as { error: string }; 153 + // Should not expose database details 154 + expect(body.error).not.toContain('SELECT'); 155 + expect(body.error).toContain('try again later'); 156 + }); 157 + }); 158 + 159 + describe('API Errors', () => { 160 + it('returns correct status code for ApiError', async () => { 161 + app.get('/test', () => { 162 + throw new ApiError('Bad Request', 400); 163 + }); 164 + 165 + const res = await app.request('/test'); 166 + 167 + expect(res.status).toBe(400); 168 + const body = await res.json() as { success: boolean; error: string }; 169 + expect(body.success).toBe(false); 170 + expect(body.error).toBe('Bad Request'); 171 + }); 172 + 173 + it('includes details when provided', async () => { 174 + app.get('/test', () => { 175 + const details = JSON.stringify({ fields: ['email', 'username'] }); 176 + throw new ApiError('Validation failed', 400, details); 177 + }); 178 + 179 + const res = await app.request('/test'); 180 + 181 + const body = await res.json() as { details: string }; 182 + expect(body.details).toBeDefined(); 183 + const parsedDetails = JSON.parse(body.details); 184 + expect(parsedDetails.fields).toEqual(['email', 'username']); 185 + }); 186 + 187 + it('defaults to 500 for invalid status codes', async () => { 188 + app.get('/test', () => { 189 + throw new ApiError('Error', 999); // Invalid status code 190 + }); 191 + 192 + const res = await app.request('/test'); 193 + 194 + expect(res.status).toBe(500); 195 + }); 196 + 197 + it('handles various valid status codes', async () => { 198 + const statusCodes = [400, 401, 403, 404, 500, 503]; 199 + 200 + for (const code of statusCodes) { 201 + const testApp = new Hono(); 202 + testApp.onError(errorHandler); 203 + testApp.get('/test', () => { 204 + throw new ApiError(`Error ${code}`, code); 205 + }); 206 + 207 + const res = await testApp.request('/test'); 208 + expect(res.status).toBe(code); 209 + } 210 + }); 211 + }); 212 + 213 + describe('Generic Errors', () => { 214 + it('returns 500 for generic Error', async () => { 215 + app.get('/test', () => { 216 + throw new Error('Something went wrong'); 217 + }); 218 + 219 + const res = await app.request('/test'); 220 + 221 + expect(res.status).toBe(500); 222 + const body = await res.json() as { success: boolean; error: string }; 223 + expect(body.success).toBe(false); 224 + expect(body.error).toContain('try again later'); 225 + }); 226 + 227 + it('provides generic message for unknown errors', async () => { 228 + app.get('/test', () => { 229 + throw new Error('Internal server error with sensitive data'); 230 + }); 231 + 232 + const res = await app.request('/test'); 233 + 234 + const body = await res.json() as { error: string }; 235 + // Should not expose error details 236 + expect(body.error).not.toContain('sensitive data'); 237 + }); 238 + 239 + it('handles errors without message', async () => { 240 + app.get('/test', () => { 241 + throw new Error(); 242 + }); 243 + 244 + const res = await app.request('/test'); 245 + 246 + expect(res.status).toBe(500); 247 + const body = await res.json() as { success: boolean; error: string }; 248 + expect(body.success).toBe(false); 249 + expect(body.error).toBeDefined(); 250 + }); 251 + }); 252 + 253 + describe('Error Logging', () => { 254 + it('logs error details', async () => { 255 + app.get('/test', () => { 256 + throw new Error('Test error'); 257 + }); 258 + 259 + await app.request('/test'); 260 + 261 + expect(consoleErrorSpy).toHaveBeenCalled(); 262 + const logCall = consoleErrorSpy.mock.calls[0]; 263 + expect(logCall[0]).toBe('[ERROR]'); 264 + }); 265 + 266 + it('includes request metadata in logs', async () => { 267 + app.get('/test-path', () => { 268 + throw new Error('Test error'); 269 + }); 270 + 271 + await app.request('/test-path'); 272 + 273 + const logCall = consoleErrorSpy.mock.calls[0]; 274 + const logData = logCall[1] as Record<string, unknown>; 275 + 276 + expect(logData).toHaveProperty('timestamp'); 277 + expect(logData).toHaveProperty('path'); 278 + expect(logData.path).toBe('/test-path'); 279 + expect(logData).toHaveProperty('method'); 280 + expect(logData.method).toBe('GET'); 281 + }); 282 + 283 + it('includes user DID when authenticated', async () => { 284 + app.use('*', async (c, next) => { 285 + (c.set as (key: string, value: unknown) => void)('did', 'did:plc:test123'); 286 + await next(); 287 + }); 288 + 289 + app.get('/test', () => { 290 + throw new Error('Test error'); 291 + }); 292 + 293 + await app.request('/test'); 294 + 295 + const logCall = consoleErrorSpy.mock.calls[0]; 296 + const logData = logCall[1] as Record<string, unknown>; 297 + 298 + expect(logData.userDid).toBe('did:plc:test123'); 299 + }); 300 + 301 + it('logs "unauthenticated" when no user DID', async () => { 302 + app.get('/test', () => { 303 + throw new Error('Test error'); 304 + }); 305 + 306 + await app.request('/test'); 307 + 308 + const logCall = consoleErrorSpy.mock.calls[0]; 309 + const logData = logCall[1] as Record<string, unknown>; 310 + 311 + expect(logData.userDid).toBe('unauthenticated'); 312 + }); 313 + 314 + it('includes error stack trace', async () => { 315 + app.get('/test', () => { 316 + throw new Error('Test error'); 317 + }); 318 + 319 + await app.request('/test'); 320 + 321 + const logCall = consoleErrorSpy.mock.calls[0]; 322 + const logData = logCall[1] as Record<string, unknown>; 323 + 324 + expect(logData.stack).toBeDefined(); 325 + expect(typeof logData.stack).toBe('string'); 326 + }); 327 + }); 328 + 329 + describe('Error Response Format', () => { 330 + it('always includes success: false', async () => { 331 + app.get('/validation', () => { 332 + throw new ValidationError('Test'); 333 + }); 334 + app.get('/auth', () => { 335 + throw new AuthenticationError('Test'); 336 + }); 337 + app.get('/generic', () => { 338 + throw new Error('Test'); 339 + }); 340 + 341 + const endpoints = ['/validation', '/auth', '/generic']; 342 + 343 + for (const endpoint of endpoints) { 344 + const res = await app.request(endpoint); 345 + const body = await res.json() as { success: boolean }; 346 + expect(body.success).toBe(false); 347 + } 348 + }); 349 + 350 + it('always includes error message', async () => { 351 + app.get('/test', () => { 352 + throw new Error('Test error'); 353 + }); 354 + 355 + const res = await app.request('/test'); 356 + const body = await res.json() as { error: string }; 357 + 358 + expect(body.error).toBeDefined(); 359 + expect(typeof body.error).toBe('string'); 360 + expect(body.error.length).toBeGreaterThan(0); 361 + }); 362 + 363 + it('returns valid JSON even for malformed errors', async () => { 364 + app.get('/test', () => { 365 + const err = new Error('Test'); 366 + // @ts-expect-error - Testing malformed error 367 + err.statusCode = 'not-a-number'; 368 + throw err; 369 + }); 370 + 371 + const res = await app.request('/test'); 372 + 373 + expect(res.headers.get('content-type')).toContain('application/json'); 374 + const body = await res.json() as Record<string, unknown>; 375 + expect(body).toBeDefined(); 376 + }); 377 + }); 378 + 379 + describe('Edge Cases', () => { 380 + it('handles null errors', async () => { 381 + app.get('/test', () => { 382 + // Testing null throw 383 + throw null; // eslint-disable-line @typescript-eslint/no-throw-literal 384 + }); 385 + 386 + const res = await app.request('/test'); 387 + 388 + expect(res.status).toBe(500); 389 + }); 390 + 391 + it('handles errors thrown during error handling', async () => { 392 + // This is a meta-test: what if error handler itself throws? 393 + // The framework should catch it 394 + 395 + const badErrorHandler = (err: Error, c: Context) => { 396 + throw new Error('Error handler error'); 397 + }; 398 + 399 + const testApp = new Hono(); 400 + testApp.onError(badErrorHandler); 401 + testApp.get('/test', () => { 402 + throw new Error('Original error'); 403 + }); 404 + 405 + // This should not hang or crash 406 + await expect(testApp.request('/test')).resolves.toBeDefined(); 407 + }); 408 + 409 + it('handles circular references in error details', async () => { 410 + app.get('/test', () => { 411 + // Try to create a circular JSON string (will throw, which is expected) 412 + try { 413 + const circular: Record<string, unknown> = {}; 414 + circular.self = circular; 415 + JSON.stringify(circular); // This will throw 416 + } catch { 417 + // If stringify fails, pass a string instead 418 + throw new ApiError('Circular error', 500, 'details-with-circular-ref'); 419 + } 420 + return null as never; // Unreachable 421 + }); 422 + 423 + const res = await app.request('/test'); 424 + 425 + expect(res.status).toBe(500); 426 + // Should not throw during JSON serialization 427 + const body = await res.json() as Record<string, unknown>; 428 + expect(body).toBeDefined(); 429 + }); 430 + }); 431 + });
+553
packages/api/src/middleware/rateLimit.test.ts
··· 1 + /** 2 + * Rate Limit Middleware Tests 3 + * Tests for rate limiting logic and error handling 4 + */ 5 + 6 + import { describe, it, expect, beforeEach } from 'vitest'; 7 + import { Hono } from 'hono'; 8 + import { rateLimiter, apiRateLimit, searchRateLimit, followRateLimit } from './rateLimit'; 9 + 10 + describe('Rate Limit Middleware', () => { 11 + let app: Hono; 12 + 13 + beforeEach(() => { 14 + app = new Hono(); 15 + }); 16 + 17 + describe('Basic Rate Limiting', () => { 18 + it('allows requests under the limit', async () => { 19 + const limiter = rateLimiter({ 20 + maxRequests: 5, 21 + windowMs: 60000, 22 + }); 23 + 24 + app.use('/test', limiter); 25 + app.get('/test', (c) => c.json({ success: true })); 26 + 27 + // Send 5 requests (within limit) 28 + for (let i = 0; i < 5; i++) { 29 + const res = await app.request('/test', { 30 + headers: { 'x-forwarded-for': '127.0.0.1' }, 31 + }); 32 + expect(res.status).toBe(200); 33 + } 34 + }); 35 + 36 + it('blocks requests over the limit', async () => { 37 + const limiter = rateLimiter({ 38 + maxRequests: 3, 39 + windowMs: 60000, 40 + }); 41 + 42 + app.use('/test', limiter); 43 + app.get('/test', (c) => c.json({ success: true })); 44 + 45 + const ip = '192.168.1.1'; 46 + 47 + // Send 3 requests (at limit) 48 + for (let i = 0; i < 3; i++) { 49 + const res = await app.request('/test', { 50 + headers: { 'x-forwarded-for': ip }, 51 + }); 52 + expect(res.status).toBe(200); 53 + } 54 + 55 + // 4th request should be blocked 56 + const res = await app.request('/test', { 57 + headers: { 'x-forwarded-for': ip }, 58 + }); 59 + 60 + expect(res.status).toBe(429); 61 + const body = await res.json() as { success: boolean; error: string }; 62 + expect(body.success).toBe(false); 63 + expect(body.error).toBeDefined(); 64 + }); 65 + 66 + it('returns custom error message', async () => { 67 + const limiter = rateLimiter({ 68 + maxRequests: 1, 69 + windowMs: 60000, 70 + message: 'Custom rate limit message', 71 + }); 72 + 73 + app.use('/test', limiter); 74 + app.get('/test', (c) => c.json({ success: true })); 75 + 76 + const ip = '192.168.1.2'; 77 + 78 + // First request succeeds 79 + await app.request('/test', { 80 + headers: { 'x-forwarded-for': ip }, 81 + }); 82 + 83 + // Second request blocked with custom message 84 + const res = await app.request('/test', { 85 + headers: { 'x-forwarded-for': ip }, 86 + }); 87 + 88 + expect(res.status).toBe(429); 89 + const body = await res.json() as { error: string }; 90 + expect(body.error).toBe('Custom rate limit message'); 91 + }); 92 + }); 93 + 94 + describe('Rate Limit Headers', () => { 95 + it('includes X-RateLimit-Limit header', async () => { 96 + const limiter = rateLimiter({ 97 + maxRequests: 10, 98 + windowMs: 60000, 99 + }); 100 + 101 + app.use('/test', limiter); 102 + app.get('/test', (c) => c.json({ success: true })); 103 + 104 + const res = await app.request('/test', { 105 + headers: { 'x-forwarded-for': '10.0.0.1' }, 106 + }); 107 + 108 + expect(res.headers.get('X-RateLimit-Limit')).toBe('10'); 109 + }); 110 + 111 + it('includes X-RateLimit-Remaining header', async () => { 112 + const limiter = rateLimiter({ 113 + maxRequests: 5, 114 + windowMs: 60000, 115 + }); 116 + 117 + app.use('/test', limiter); 118 + app.get('/test', (c) => c.json({ success: true })); 119 + 120 + const ip = '10.0.0.2'; 121 + 122 + // First request 123 + const res1 = await app.request('/test', { 124 + headers: { 'x-forwarded-for': ip }, 125 + }); 126 + expect(res1.headers.get('X-RateLimit-Remaining')).toBe('4'); 127 + 128 + // Second request 129 + const res2 = await app.request('/test', { 130 + headers: { 'x-forwarded-for': ip }, 131 + }); 132 + expect(res2.headers.get('X-RateLimit-Remaining')).toBe('3'); 133 + }); 134 + 135 + it('includes X-RateLimit-Reset header', async () => { 136 + const limiter = rateLimiter({ 137 + maxRequests: 5, 138 + windowMs: 60000, 139 + }); 140 + 141 + app.use('/test', limiter); 142 + app.get('/test', (c) => c.json({ success: true })); 143 + 144 + const res = await app.request('/test', { 145 + headers: { 'x-forwarded-for': '10.0.0.3' }, 146 + }); 147 + 148 + const resetHeader = res.headers.get('X-RateLimit-Reset'); 149 + expect(resetHeader).toBeDefined(); 150 + 151 + const resetTime = parseInt(resetHeader!, 10); 152 + expect(resetTime).toBeGreaterThan(Date.now() / 1000); 153 + }); 154 + 155 + it('includes Retry-After header when rate limited', async () => { 156 + const limiter = rateLimiter({ 157 + maxRequests: 1, 158 + windowMs: 60000, 159 + }); 160 + 161 + app.use('/test', limiter); 162 + app.get('/test', (c) => c.json({ success: true })); 163 + 164 + const ip = '10.0.0.4'; 165 + 166 + // First request 167 + await app.request('/test', { 168 + headers: { 'x-forwarded-for': ip }, 169 + }); 170 + 171 + // Second request (rate limited) 172 + const res = await app.request('/test', { 173 + headers: { 'x-forwarded-for': ip }, 174 + }); 175 + 176 + expect(res.status).toBe(429); 177 + const retryAfter = res.headers.get('Retry-After'); 178 + expect(retryAfter).toBeDefined(); 179 + expect(parseInt(retryAfter!, 10)).toBeGreaterThan(0); 180 + }); 181 + 182 + it('includes retryAfter in response body', async () => { 183 + const limiter = rateLimiter({ 184 + maxRequests: 1, 185 + windowMs: 60000, 186 + }); 187 + 188 + app.use('/test', limiter); 189 + app.get('/test', (c) => c.json({ success: true })); 190 + 191 + const ip = '10.0.0.5'; 192 + 193 + await app.request('/test', { 194 + headers: { 'x-forwarded-for': ip }, 195 + }); 196 + 197 + const res = await app.request('/test', { 198 + headers: { 'x-forwarded-for': ip }, 199 + }); 200 + 201 + const body = await res.json() as { retryAfter: number }; 202 + expect(body.retryAfter).toBeDefined(); 203 + expect(typeof body.retryAfter).toBe('number'); 204 + expect(body.retryAfter).toBeGreaterThan(0); 205 + }); 206 + }); 207 + 208 + describe('Key Generation', () => { 209 + it('uses IP address by default', async () => { 210 + const limiter = rateLimiter({ 211 + maxRequests: 2, 212 + windowMs: 60000, 213 + }); 214 + 215 + app.use('/test', limiter); 216 + app.get('/test', (c) => c.json({ success: true })); 217 + 218 + // IP 1 - 2 requests 219 + await app.request('/test', { 220 + headers: { 'x-forwarded-for': '1.1.1.1' }, 221 + }); 222 + await app.request('/test', { 223 + headers: { 'x-forwarded-for': '1.1.1.1' }, 224 + }); 225 + 226 + // IP 2 - should still work (different key) 227 + const res = await app.request('/test', { 228 + headers: { 'x-forwarded-for': '2.2.2.2' }, 229 + }); 230 + 231 + expect(res.status).toBe(200); 232 + }); 233 + 234 + it('uses session ID when available', async () => { 235 + const limiter = rateLimiter({ 236 + maxRequests: 2, 237 + windowMs: 60000, 238 + }); 239 + 240 + app.use('*', (c, next) => { 241 + (c.set as (key: string, value: unknown) => void)('sessionId', 'session-123'); 242 + return next(); 243 + }); 244 + 245 + app.use('/test', limiter); 246 + app.get('/test', (c) => c.json({ success: true })); 247 + 248 + // Two requests with same session 249 + await app.request('/test', { 250 + headers: { 'x-forwarded-for': '3.3.3.3' }, 251 + }); 252 + const res = await app.request('/test', { 253 + headers: { 'x-forwarded-for': '3.3.3.3' }, 254 + }); 255 + 256 + // Both should count against same session 257 + expect(res.status).toBe(200); 258 + }); 259 + 260 + it('uses custom key generator', async () => { 261 + const limiter = rateLimiter({ 262 + maxRequests: 2, 263 + windowMs: 60000, 264 + keyGenerator: (c) => { 265 + return c.req.header('x-api-key') || 'default'; 266 + }, 267 + }); 268 + 269 + app.use('/test', limiter); 270 + app.get('/test', (c) => c.json({ success: true })); 271 + 272 + // Two requests with same API key 273 + await app.request('/test', { 274 + headers: { 'x-api-key': 'key-abc' }, 275 + }); 276 + await app.request('/test', { 277 + headers: { 'x-api-key': 'key-abc' }, 278 + }); 279 + 280 + // Third request should be blocked 281 + const res = await app.request('/test', { 282 + headers: { 'x-api-key': 'key-abc' }, 283 + }); 284 + 285 + expect(res.status).toBe(429); 286 + }); 287 + 288 + it('handles x-real-ip header', async () => { 289 + const limiter = rateLimiter({ 290 + maxRequests: 1, 291 + windowMs: 60000, 292 + }); 293 + 294 + app.use('/test', limiter); 295 + app.get('/test', (c) => c.json({ success: true })); 296 + 297 + const ip = '5.5.5.5'; 298 + 299 + // First request 300 + await app.request('/test', { 301 + headers: { 'x-real-ip': ip }, 302 + }); 303 + 304 + // Second request should be blocked (same IP) 305 + const res = await app.request('/test', { 306 + headers: { 'x-real-ip': ip }, 307 + }); 308 + 309 + expect(res.status).toBe(429); 310 + }); 311 + 312 + it('falls back to "unknown" when no IP or session', async () => { 313 + const limiter = rateLimiter({ 314 + maxRequests: 2, 315 + windowMs: 60000, 316 + }); 317 + 318 + app.use('/test', limiter); 319 + app.get('/test', (c) => c.json({ success: true })); 320 + 321 + // Requests without IP headers will share "unknown" key 322 + await app.request('/test'); 323 + await app.request('/test'); 324 + 325 + const res = await app.request('/test'); 326 + expect(res.status).toBe(429); 327 + }); 328 + }); 329 + 330 + describe('Window Management', () => { 331 + it('resets count after window expires', async () => { 332 + const limiter = rateLimiter({ 333 + maxRequests: 1, 334 + windowMs: 100, // Very short window for testing 335 + }); 336 + 337 + app.use('/test', limiter); 338 + app.get('/test', (c) => c.json({ success: true })); 339 + 340 + const ip = '6.6.6.6'; 341 + 342 + // First request 343 + const res1 = await app.request('/test', { 344 + headers: { 'x-forwarded-for': ip }, 345 + }); 346 + expect(res1.status).toBe(200); 347 + 348 + // Second request (immediately) should be blocked 349 + const res2 = await app.request('/test', { 350 + headers: { 'x-forwarded-for': ip }, 351 + }); 352 + expect(res2.status).toBe(429); 353 + 354 + // Wait for window to expire 355 + await new Promise((resolve) => setTimeout(resolve, 150)); 356 + 357 + // Third request should succeed (new window) 358 + const res3 = await app.request('/test', { 359 + headers: { 'x-forwarded-for': ip }, 360 + }); 361 + expect(res3.status).toBe(200); 362 + }); 363 + 364 + it('handles multiple windows for different keys', async () => { 365 + const limiter = rateLimiter({ 366 + maxRequests: 1, 367 + windowMs: 60000, 368 + }); 369 + 370 + app.use('/test', limiter); 371 + app.get('/test', (c) => c.json({ success: true })); 372 + 373 + // Different IPs should have independent windows 374 + const res1 = await app.request('/test', { 375 + headers: { 'x-forwarded-for': '7.7.7.7' }, 376 + }); 377 + const res2 = await app.request('/test', { 378 + headers: { 'x-forwarded-for': '8.8.8.8' }, 379 + }); 380 + const res3 = await app.request('/test', { 381 + headers: { 'x-forwarded-for': '9.9.9.9' }, 382 + }); 383 + 384 + expect(res1.status).toBe(200); 385 + expect(res2.status).toBe(200); 386 + expect(res3.status).toBe(200); 387 + }); 388 + }); 389 + 390 + describe('Predefined Rate Limiters', () => { 391 + it('apiRateLimit: 60 requests per minute', async () => { 392 + app.use('/api/*', apiRateLimit); 393 + app.get('/api/test', (c) => c.json({ success: true })); 394 + 395 + const res = await app.request('/api/test', { 396 + headers: { 'x-forwarded-for': '10.10.10.10' }, 397 + }); 398 + 399 + expect(res.status).toBe(200); 400 + expect(res.headers.get('X-RateLimit-Limit')).toBe('60'); 401 + }); 402 + 403 + it('searchRateLimit: 10 requests per minute', async () => { 404 + app.use('/search', searchRateLimit); 405 + app.post('/search', (c) => c.json({ success: true })); 406 + 407 + const res = await app.request('/search', { 408 + method: 'POST', 409 + headers: { 'x-forwarded-for': '10.10.10.11' }, 410 + }); 411 + 412 + expect(res.status).toBe(200); 413 + expect(res.headers.get('X-RateLimit-Limit')).toBe('10'); 414 + }); 415 + 416 + it('followRateLimit: 100 requests per hour', async () => { 417 + app.use('/follow', followRateLimit); 418 + app.post('/follow', (c) => c.json({ success: true })); 419 + 420 + const res = await app.request('/follow', { 421 + method: 'POST', 422 + headers: { 'x-forwarded-for': '10.10.10.12' }, 423 + }); 424 + 425 + expect(res.status).toBe(200); 426 + expect(res.headers.get('X-RateLimit-Limit')).toBe('100'); 427 + }); 428 + }); 429 + 430 + describe('Edge Cases', () => { 431 + it('handles concurrent requests from same key', async () => { 432 + const limiter = rateLimiter({ 433 + maxRequests: 5, 434 + windowMs: 60000, 435 + }); 436 + 437 + app.use('/test', limiter); 438 + app.get('/test', (c) => c.json({ success: true })); 439 + 440 + const ip = '11.11.11.11'; 441 + 442 + // Send 10 concurrent requests 443 + const requests = Array.from({ length: 10 }, () => 444 + app.request('/test', { 445 + headers: { 'x-forwarded-for': ip }, 446 + }) 447 + ); 448 + 449 + const results = await Promise.all(requests); 450 + 451 + const successCount = results.filter((r) => r.status === 200).length; 452 + const blockedCount = results.filter((r) => r.status === 429).length; 453 + 454 + // Some should succeed, some should be blocked 455 + expect(successCount).toBeLessThanOrEqual(5); 456 + expect(blockedCount).toBeGreaterThan(0); 457 + expect(successCount + blockedCount).toBe(10); 458 + }); 459 + 460 + it('handles zero maxRequests gracefully', async () => { 461 + const limiter = rateLimiter({ 462 + maxRequests: 0, 463 + windowMs: 60000, 464 + }); 465 + 466 + app.use('/test', limiter); 467 + app.get('/test', (c) => c.json({ success: true })); 468 + 469 + const res = await app.request('/test', { 470 + headers: { 'x-forwarded-for': '12.12.12.12' }, 471 + }); 472 + 473 + // Should immediately rate limit 474 + expect(res.status).toBe(429); 475 + }); 476 + 477 + it('handles very short window (1ms)', async () => { 478 + const limiter = rateLimiter({ 479 + maxRequests: 1, 480 + windowMs: 1, 481 + }); 482 + 483 + app.use('/test', limiter); 484 + app.get('/test', (c) => c.json({ success: true })); 485 + 486 + const ip = '13.13.13.13'; 487 + 488 + const res1 = await app.request('/test', { 489 + headers: { 'x-forwarded-for': ip }, 490 + }); 491 + expect(res1.status).toBe(200); 492 + 493 + // Wait 2ms 494 + await new Promise((resolve) => setTimeout(resolve, 2)); 495 + 496 + const res2 = await app.request('/test', { 497 + headers: { 'x-forwarded-for': ip }, 498 + }); 499 + // Should be allowed (new window) 500 + expect(res2.status).toBe(200); 501 + }); 502 + 503 + it('handles very large maxRequests', async () => { 504 + const limiter = rateLimiter({ 505 + maxRequests: 1000000, 506 + windowMs: 60000, 507 + }); 508 + 509 + app.use('/test', limiter); 510 + app.get('/test', (c) => c.json({ success: true })); 511 + 512 + const res = await app.request('/test', { 513 + headers: { 'x-forwarded-for': '14.14.14.14' }, 514 + }); 515 + 516 + expect(res.status).toBe(200); 517 + expect(res.headers.get('X-RateLimit-Remaining')).toBe('999999'); 518 + }); 519 + }); 520 + 521 + describe('Store Cleanup', () => { 522 + it('periodically cleans up expired entries', async () => { 523 + const limiter = rateLimiter({ 524 + maxRequests: 1, 525 + windowMs: 50, // Short window 526 + }); 527 + 528 + app.use('/test', limiter); 529 + app.get('/test', (c) => c.json({ success: true })); 530 + 531 + // Create many entries that will expire 532 + for (let i = 0; i < 100; i++) { 533 + await app.request('/test', { 534 + headers: { 'x-forwarded-for': `${i}.0.0.1` }, 535 + }); 536 + } 537 + 538 + // Wait for entries to expire 539 + await new Promise((resolve) => setTimeout(resolve, 100)); 540 + 541 + // Trigger cleanup by making more requests 542 + // (cleanup happens randomly with 1% probability, so we make many requests) 543 + for (let i = 0; i < 100; i++) { 544 + await app.request('/test', { 545 + headers: { 'x-forwarded-for': `${i}.0.0.1` }, 546 + }); 547 + } 548 + 549 + // If cleanup didn't crash, test passes 550 + expect(true).toBe(true); 551 + }); 552 + }); 553 + });