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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5e0bc1dc00
commit
2e2a6f18cb
@@ -1,97 +1,115 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { Hono } from 'hono'
|
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', () => {
|
describe('rateLimit', () => {
|
||||||
let originalEnv: string | undefined
|
let rateLimit: typeof import('./rate-limit.js')['rateLimit']
|
||||||
|
let cleanup: ReturnType<typeof setInterval>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
originalEnv = process.env.NODE_ENV
|
vi.resetModules()
|
||||||
|
// Set production mode before importing so isDev = false
|
||||||
process.env.NODE_ENV = 'production'
|
process.env.NODE_ENV = 'production'
|
||||||
|
const mod = await import('./rate-limit.js')
|
||||||
|
rateLimit = mod.rateLimit
|
||||||
|
cleanup = mod.cleanupInterval
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.NODE_ENV = originalEnv
|
process.env.NODE_ENV = 'test'
|
||||||
|
clearInterval(cleanup)
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows requests under the limit', async () => {
|
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()
|
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<string, { count: number; resetAt: number }>()
|
|
||||||
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++) {
|
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)
|
expect(res.status).toBe(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4th request should be rate limited
|
// 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)
|
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 () => {
|
it('resets after window expires', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const testRateLimit = (windowMs: number, maxHits: number) => {
|
app.get('/test', rateLimit(1_000, 2), (c) => c.json({ ok: true }))
|
||||||
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 }))
|
|
||||||
|
|
||||||
// Exhaust limit
|
// Exhaust limit
|
||||||
await app.request('/test')
|
await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } })
|
||||||
await app.request('/test')
|
await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } })
|
||||||
const blocked = await app.request('/test')
|
const blocked = await app.request('/test', { headers: { 'x-real-ip': '1.2.3.4' } })
|
||||||
expect(blocked.status).toBe(429)
|
expect(blocked.status).toBe(429)
|
||||||
|
|
||||||
// Advance past window
|
// Advance past window
|
||||||
vi.advanceTimersByTime(1_100)
|
vi.advanceTimersByTime(1_100)
|
||||||
|
|
||||||
// Should be allowed again
|
// 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)
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
vi.useRealTimers()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user