OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / pairing-rate-limit.test.js

104 lines · javascript ref:
/**
 * Unit tests for the per-peer pair-attempt rate limiter.
 *
 * Exercises the _checkPeerRateLimit primitive in isolation — no swarm, no
 * Protomux. This lets us verify the token-bucket math without standing up
 * a full pairing testnet (which test/integration/pairing.test.js covers).
 */

import test from 'brittle'
import { PairingManager } from 'p2p-hiverelay-client/pairing.js'

function makeManager (opts = {}) {
  // PairingManager uses very few methods on `client` — only inside
  // createPairingCode/claimPairingCode, neither of which we exercise here.
  const stubClient = { _started: false }
  return new PairingManager(stubClient, opts)
}

test('pair rate limit: default cap is 6/min/peer', (t) => {
  const mgr = makeManager()
  t.is(mgr.maxAttemptsPerMinutePerPeer, 6)
  t.is(mgr.rateLimitWindowMs, 60_000)
})

test('pair rate limit: 6 attempts in-window pass, 7th is blocked', (t) => {
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 6, rateLimitWindowMs: 60_000 })
  const peer = 'a'.repeat(64)

  for (let i = 1; i <= 6; i++) {
    t.ok(mgr._checkPeerRateLimit(peer), `attempt ${i} allowed`)
  }
  t.absent(mgr._checkPeerRateLimit(peer), 'attempt 7 blocked (cap hit)')
  t.absent(mgr._checkPeerRateLimit(peer), 'subsequent attempts also blocked')
})

test('pair rate limit: null/empty peerKey is always allowed (no identity to bucket)', (t) => {
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 1 })
  // null → we can't rate-limit without an identity; allow and move on
  for (let i = 0; i < 5; i++) {
    t.ok(mgr._checkPeerRateLimit(null), 'null peer always allowed')
    t.ok(mgr._checkPeerRateLimit(''), 'empty peer always allowed')
  }
})

test('pair rate limit: disabled when maxAttemptsPerMinutePerPeer <= 0', (t) => {
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 0 })
  const peer = 'b'.repeat(64)
  // No cap — 100 attempts still pass
  for (let i = 0; i < 100; i++) {
    t.ok(mgr._checkPeerRateLimit(peer), 'attempt ' + i)
  }
})

test('pair rate limit: different peers have independent buckets', (t) => {
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 2 })
  const peerA = 'a'.repeat(64)
  const peerB = 'b'.repeat(64)

  t.ok(mgr._checkPeerRateLimit(peerA))
  t.ok(mgr._checkPeerRateLimit(peerA))
  t.absent(mgr._checkPeerRateLimit(peerA), 'A hit cap')

  t.ok(mgr._checkPeerRateLimit(peerB), 'B still has budget')
  t.ok(mgr._checkPeerRateLimit(peerB))
  t.absent(mgr._checkPeerRateLimit(peerB), 'B hit cap independently')
})

test('pair rate limit: window resets after rateLimitWindowMs', async (t) => {
  // Tiny window so we can exercise the reset synchronously.
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 2, rateLimitWindowMs: 80 })
  const peer = 'c'.repeat(64)

  t.ok(mgr._checkPeerRateLimit(peer))
  t.ok(mgr._checkPeerRateLimit(peer))
  t.absent(mgr._checkPeerRateLimit(peer), 'hit cap')

  await new Promise(resolve => setTimeout(resolve, 120))

  t.ok(mgr._checkPeerRateLimit(peer), 'post-window: bucket refills')
  t.ok(mgr._checkPeerRateLimit(peer), 'second attempt in new window')
  t.absent(mgr._checkPeerRateLimit(peer), 'cap applies in new window too')
})

test('pair rate limit: _prunePeerAttempts drops stale entries', async (t) => {
  const mgr = makeManager({ maxAttemptsPerMinutePerPeer: 6, rateLimitWindowMs: 50 })
  mgr._checkPeerRateLimit('x'.repeat(64))
  mgr._checkPeerRateLimit('y'.repeat(64))
  t.is(mgr._peerAttempts.size, 2)

  // Wait past 2x the window (stale threshold) so prune can reclaim them.
  await new Promise(resolve => setTimeout(resolve, 150))
  mgr._prunePeerAttempts()
  t.is(mgr._peerAttempts.size, 0, 'stale buckets evicted')
})

test('pair rate limit: destroy() clears the bucket map', (t) => {
  const mgr = makeManager()
  mgr._checkPeerRateLimit('a'.repeat(64))
  mgr._checkPeerRateLimit('b'.repeat(64))
  t.is(mgr._peerAttempts.size, 2)
  mgr.destroy()
  t.is(mgr._peerAttempts.size, 0)
})