/**
* Unit tests for lib/auth/rate-limiter.js
*
* Tests token bucket algorithm, per-operation limits, refill, and reset.
*/
import test from 'brittle'
import { RateLimiter } from '../../lib/auth/rate-limiter.js'
function createLimiter (opts = {}) {
return new RateLimiter({
limits: {
auth: { tokens: 3, refillRate: 1, refillInterval: 1000 },
api: { tokens: 5, refillRate: 2, refillInterval: 1000 },
...opts.limits
},
...opts
})
}
// ========================================
// Basic Rate Limiting
// ========================================
test('check - allows requests within limit', async (t) => {
const limiter = createLimiter()
const result = await limiter.check('192.168.1.1', 'auth')
t.ok(result.allowed, 'first request allowed')
t.is(result.remaining, 2, '2 tokens remaining')
limiter.destroy()
})
test('check - depletes tokens', async (t) => {
const limiter = createLimiter()
await limiter.check('192.168.1.1', 'auth')
await limiter.check('192.168.1.1', 'auth')
const third = await limiter.check('192.168.1.1', 'auth')
t.ok(third.allowed, 'third request allowed (last token)')
t.is(third.remaining, 0, '0 tokens remaining')
limiter.destroy()
})
test('check - blocks when tokens exhausted', async (t) => {
const limiter = createLimiter()
await limiter.check('192.168.1.1', 'auth')
await limiter.check('192.168.1.1', 'auth')
await limiter.check('192.168.1.1', 'auth')
const fourth = await limiter.check('192.168.1.1', 'auth')
t.not(fourth.allowed, 'fourth request blocked')
t.is(fourth.remaining, 0, '0 tokens remaining')
t.ok(fourth.retryAfter > 0, 'has retryAfter')
limiter.destroy()
})
// ========================================
// Per-Key Isolation
// ========================================
test('check - different keys have independent limits', async (t) => {
const limiter = createLimiter()
// Exhaust key A
await limiter.check('key-a', 'auth')
await limiter.check('key-a', 'auth')
await limiter.check('key-a', 'auth')
const blockedA = await limiter.check('key-a', 'auth')
t.not(blockedA.allowed, 'key-a blocked')
// key B should still be allowed
const resultB = await limiter.check('key-b', 'auth')
t.ok(resultB.allowed, 'key-b still allowed')
limiter.destroy()
})
// ========================================
// Per-Operation Limits
// ========================================
test('check - different operations have different limits', async (t) => {
const limiter = createLimiter()
// Auth has 3 tokens, API has 5
const auth = await limiter.check('user1', 'auth')
const api = await limiter.check('user1', 'api')
t.is(auth.remaining, 2, 'auth: 2 remaining (of 3)')
t.is(api.remaining, 4, 'api: 4 remaining (of 5)')
limiter.destroy()
})
test('check - unknown operation allows request', async (t) => {
const limiter = createLimiter()
const result = await limiter.check('user1', 'unknown-op')
t.ok(result.allowed, 'unknown operation allowed')
t.is(result.remaining, Infinity, 'unlimited')
limiter.destroy()
})
// ========================================
// Token Refill
// ========================================
test('check - refills tokens after interval', async (t) => {
const limiter = createLimiter()
// Exhaust all 3 auth tokens
await limiter.check('user1', 'auth')
await limiter.check('user1', 'auth')
await limiter.check('user1', 'auth')
const blocked = await limiter.check('user1', 'auth')
t.not(blocked.allowed, 'blocked after exhaustion')
// Wait for refill (1 second interval, +1 token)
await new Promise(r => setTimeout(r, 1100))
const afterRefill = await limiter.check('user1', 'auth')
t.ok(afterRefill.allowed, 'allowed after refill')
limiter.destroy()
})
// ========================================
// Reset
// ========================================
test('reset - restores full token bucket', async (t) => {
const limiter = createLimiter()
// Exhaust tokens
await limiter.check('user1', 'auth')
await limiter.check('user1', 'auth')
await limiter.check('user1', 'auth')
// Reset
await limiter.reset('user1', 'auth')
// Should be back to full
const result = await limiter.check('user1', 'auth')
t.ok(result.allowed, 'allowed after reset')
t.is(result.remaining, 2, 'full bucket minus 1')
limiter.destroy()
})
// ========================================
// Stats
// ========================================
test('stats - counts active buckets', async (t) => {
const limiter = createLimiter()
await limiter.check('user1', 'auth')
await limiter.check('user2', 'api')
const stats = await limiter.stats()
t.is(stats.total, 2, '2 buckets')
t.is(stats.cached, 2, '2 cached')
t.ok(stats.byOperation.auth, 'has auth stats')
t.ok(stats.byOperation.api, 'has api stats')
limiter.destroy()
})
// ========================================
// Cleanup
// ========================================
test('cleanup - removes stale buckets', async (t) => {
const limiter = createLimiter()
// Manually insert a stale bucket (last refill > 1 hour ago)
limiter.cache.set('auth:old-user', {
tokens: 3,
lastRefill: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
limit: { tokens: 3, refillRate: 1, refillInterval: 1000 }
})
t.is(limiter.cache.size, 1, 'cache has stale entry')
await limiter.cleanup()
t.is(limiter.cache.size, 0, 'stale entry removed')
limiter.destroy()
})
test('cleanup - keeps active buckets', async (t) => {
const limiter = createLimiter()
await limiter.check('user1', 'auth')
await limiter.cleanup()
t.is(limiter.cache.size, 1, 'active bucket kept')
limiter.destroy()
})
// ========================================
// Destroy
// ========================================
test('destroy - clears cache', (t) => {
const limiter = createLimiter()
limiter.cache.set('test', {})
limiter.destroy()
t.is(limiter.cache.size, 0, 'cache cleared')
})