OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / rate-limiter.test.js

220 lines · javascript ref:
/**
 * 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')
})