From 2e2a6f18cbedd4cdef9ab183c18eaaa45a092e55 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:33:32 +0000 Subject: [PATCH] test: verify /poll endpoint rate limiting returns 429 (BUG-S3) Tests the actual rateLimit middleware with production mode via dynamic import. Covers: under-limit allows, over-limit returns 429, window reset, per-IP isolation, and poll endpoint config (30 req/1s window). Co-Authored-By: Claude Opus 4.6 --- server/src/middleware/rate-limit.test.ts | 134 +++++++++++++---------- 1 file changed, 76 insertions(+), 58 deletions(-) diff --git a/server/src/middleware/rate-limit.test.ts b/server/src/middleware/rate-limit.test.ts index 1ed1f84..cca2263 100644 --- a/server/src/middleware/rate-limit.test.ts +++ b/server/src/middleware/rate-limit.test.ts @@ -1,97 +1,115 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Hono } from 'hono' -// We need to test in "production" mode since rate limiting is skipped in dev -// Instead of manipulating env, test the rate limit function behavior directly - describe('rateLimit', () => { - let originalEnv: string | undefined + let rateLimit: typeof import('./rate-limit.js')['rateLimit'] + let cleanup: ReturnType - beforeEach(() => { - originalEnv = process.env.NODE_ENV + beforeEach(async () => { + vi.resetModules() + // Set production mode before importing so isDev = false process.env.NODE_ENV = 'production' + const mod = await import('./rate-limit.js') + rateLimit = mod.rateLimit + cleanup = mod.cleanupInterval }) afterEach(() => { - process.env.NODE_ENV = originalEnv + process.env.NODE_ENV = 'test' + clearInterval(cleanup) vi.restoreAllMocks() }) it('allows requests under the limit', async () => { - // Dynamic import so it picks up the production NODE_ENV - // But the module caches isDev at import time, so we need a fresh import - // Instead, directly test with a Hono app + middleware const app = new Hono() + app.get('/test', rateLimit(10_000, 3), (c) => c.json({ ok: true })) - // Simple inline rate limiter for testing (mirrors rate-limit.ts logic without isDev check) - const hitCounts = new Map() - const testRateLimit = (windowMs: number, maxHits: number) => { - return async (c: any, next: any) => { - const key = 'test-ip' - const now = Date.now() - const entry = hitCounts.get(key) - if (!entry || now > entry.resetAt) { - hitCounts.set(key, { count: 1, resetAt: now + windowMs }) - } else { - entry.count++ - if (entry.count > maxHits) { - return c.json({ error: 'Too many requests' }, 429) - } - } - await next() - } - } - - app.get('/test', testRateLimit(10_000, 3), (c) => c.json({ ok: true })) - - // First 3 requests should succeed for (let i = 0; i < 3; i++) { - const res = await app.request('/test') + const res = await app.request('/test', { + headers: { 'x-real-ip': '1.2.3.4' }, + }) + expect(res.status).toBe(200) + } + }) + + it('returns 429 after exceeding the limit', async () => { + const app = new Hono() + app.get('/test', rateLimit(10_000, 3), (c) => c.json({ ok: true })) + + // First 3 requests succeed + for (let i = 0; i < 3; i++) { + const res = await app.request('/test', { + headers: { 'x-real-ip': '1.2.3.4' }, + }) expect(res.status).toBe(200) } // 4th request should be rate limited - const res = await app.request('/test') + const res = await app.request('/test', { + headers: { 'x-real-ip': '1.2.3.4' }, + }) expect(res.status).toBe(429) + const body = await res.json() as { error: string; retryAfterSec: number } + expect(body.error).toContain('Too many requests') + expect(body.retryAfterSec).toBeGreaterThan(0) + expect(res.headers.get('Retry-After')).toBeTruthy() }) it('resets after window expires', async () => { vi.useFakeTimers() - - const hitCounts = new Map() const app = new Hono() - const testRateLimit = (windowMs: number, maxHits: number) => { - return async (c: any, next: any) => { - const key = 'test-ip' - const now = Date.now() - const entry = hitCounts.get(key) - if (!entry || now > entry.resetAt) { - hitCounts.set(key, { count: 1, resetAt: now + windowMs }) - } else { - entry.count++ - if (entry.count > maxHits) { - return c.json({ error: 'Too many requests' }, 429) - } - } - await next() - } - } - - app.get('/test', testRateLimit(1_000, 2), (c) => c.json({ ok: true })) + app.get('/test', rateLimit(1_000, 2), (c) => c.json({ ok: true })) // Exhaust limit - await app.request('/test') - await app.request('/test') - const blocked = await app.request('/test') + await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } }) + await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } }) + const blocked = await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } }) expect(blocked.status).toBe(429) // Advance past window vi.advanceTimersByTime(1_100) // Should be allowed again - const res = await app.request('/test') + const res = await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } }) expect(res.status).toBe(200) vi.useRealTimers() }) + + it('tracks different IPs independently', async () => { + const app = new Hono() + app.get('/test', rateLimit(10_000, 2), (c) => c.json({ ok: true })) + + // IP A uses 2 requests + await app.request('/test', { headers: { 'x-real-ip': '10.0.0.1' } }) + await app.request('/test', { headers: { 'x-real-ip': '10.0.0.1' } }) + const blockedA = await app.request('/test', { headers: { 'x-real-ip': '10.0.0.1' } }) + expect(blockedA.status).toBe(429) + + // IP B should still be allowed + const res = await app.request('/test', { headers: { 'x-real-ip': '10.0.0.2' } }) + expect(res.status).toBe(200) + }) + + it('poll endpoint config: 429 after 30 requests in 1s window', async () => { + const app = new Hono() + // Matches the actual /poll endpoint configuration + app.get('/poll', rateLimit(1_000, 30), (c) => c.json({ pending: false })) + + // 30 requests succeed + for (let i = 0; i < 30; i++) { + const res = await app.request('/poll', { + headers: { 'x-real-ip': '5.5.5.5' }, + }) + expect(res.status).toBe(200) + } + + // 31st request returns 429 + const res = await app.request('/poll', { + headers: { 'x-real-ip': '5.5.5.5' }, + }) + expect(res.status).toBe(429) + const body = await res.json() as { error: string } + expect(body.error).toContain('Too many requests') + }) })