Ctrl K
ComunidadGuías…

tests/account-rate-limit.unit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { Context } from 'hono'
import { Hono } from 'hono/tiny'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { app as validatePasswordComplianceApp } from '../supabase/functions/_backend/private/validate_password_compliance.ts'
import {
  clearFailedAccountAuth,
  isAccountRateLimited,
  isIPRateLimited,
  normalizeRateLimitAccountIdentifier,
  recordFailedAccountAuth,
} from '../supabase/functions/_backend/utils/rate_limit.ts'

type CacheStore = Map<string, Response>

let previousCaches: typeof globalThis.caches | undefined

function installMemoryCache() {
  const store: CacheStore = new Map()
  const cache = {
    match: async (request: Request) => store.get(request.url)?.clone(),
    put: async (request: Request, response: Response) => {
      store.set(request.url, response.clone())
    },
  }

  previousCaches = globalThis.caches
  Object.defineProperty(globalThis, 'caches', {
    configurable: true,
    value: {
      open: async () => cache,
    },
  })

  return store
}

async function withContext<T>(handler: (c: Context) => Promise<T>, ip: string) {
  const app = new Hono()
  app.get('/', async (c) => {
    c.set('requestId', 'account-rate-limit-test')
    const result = await handler(c)
    return c.json(result ?? { ok: true })
  })

  const response = await app.request('http://rate-limit.test/', {
    headers: {
      'cf-connecting-ip': ip,
    },
  })

  return await response.json<T>()
}

describe('account failed-auth rate limiting', () => {
  beforeEach(() => {
    installMemoryCache()
  })

  afterEach(() => {
    Object.defineProperty(globalThis, 'caches', {
      configurable: true,
      value: previousCaches,
    })
  })

  it('normalizes account identifiers before hashing', () => {
    expect(normalizeRateLimitAccountIdentifier('  Victim@Example.COM  ')).toBe('victim@example.com')
  })

  it('limits by account across different client IPs', async () => {
    for (let attempt = 0; attempt < 19; attempt++) {
      await withContext(c => recordFailedAccountAuth(c, 'Victim@Example.COM'), `198.51.100.${attempt + 1}`)
    }

    const beforeLimit = await withContext(
      c => isAccountRateLimited(c, 'victim@example.com'),
      '203.0.113.2',
    )
    expect(beforeLimit.limited).toBe(false)

    await withContext(c => recordFailedAccountAuth(c, 'victim@example.com'), '203.0.113.3')

    const afterSecondAttempt = await withContext(
      c => isAccountRateLimited(c, 'VICTIM@example.com'),
      '203.0.113.4',
    )
    expect(afterSecondAttempt.limited).toBe(true)
  })

  it('clears the account limiter after successful verification', async () => {
    for (let attempt = 0; attempt < 20; attempt++) {
      await withContext(c => recordFailedAccountAuth(c, 'clear-me@example.com'), `198.51.100.${attempt + 1}`)
    }

    const beforeClear = await withContext(
      c => isAccountRateLimited(c, 'clear-me@example.com'),
      '198.51.100.3',
    )
    expect(beforeClear.limited).toBe(true)

    await withContext(c => clearFailedAccountAuth(c, 'clear-me@example.com'), '198.51.100.4')

    const afterClear = await withContext(
      c => isAccountRateLimited(c, 'clear-me@example.com'),
      '198.51.100.5',
    )
    expect(afterClear.limited).toBe(false)
  })

  it('blocks password compliance verification by account across different client IPs', async () => {
    for (let attempt = 0; attempt < 20; attempt++) {
      await withContext(c => recordFailedAccountAuth(c, 'password-policy@example.com'), `198.51.100.${attempt + 1}`)
    }

    const app = new Hono()
    app.route('/validate_password_compliance', validatePasswordComplianceApp)
    app.onError((error, _c) => {
      const errorDetails = error as unknown as {
        cause?: { error?: string, moreInfo?: { reason?: string } }
        status?: unknown
      }
      const status = typeof errorDetails.status === 'number' ? errorDetails.status : 500
      const cause = errorDetails.cause
      return new Response(JSON.stringify({ error: cause?.error, reason: cause?.moreInfo?.reason }), {
        headers: { 'Content-Type': 'application/json' },
        status,
      })
    })

    const response = await app.request('http://rate-limit.test/validate_password_compliance', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'cf-connecting-ip': '203.0.113.20',
      },
      body: JSON.stringify({
        email: 'password-policy@example.com',
        password: 'WrongPassword123!',
        org_id: '00000000-0000-4000-8000-000000000000',
      }),
    })

    expect(response.status).toBe(429)
    const responseBody = await response.json() as { error?: string, reason?: string }
    expect(responseBody.error).toBe('too_many_requests')
    expect(responseBody.reason).toBe('too_many_failed_account_auth_attempts')

    const freshIpStatus = await withContext(
      c => isIPRateLimited(c),
      '203.0.113.20',
    )
    expect(freshIpStatus.limited).toBe(false)
  })
})