""" Unit tests for the Redis token bucket rate limiter. Tests CHAN-05: Rate limiting enforces per-tenant, per-channel limits. These tests use fakeredis to run without a live Redis instance. """ from __future__ import annotations import asyncio import fakeredis import pytest import pytest_asyncio from router.ratelimit import RateLimitExceeded, check_rate_limit @pytest_asyncio.fixture async def fake_redis(): """Provide a fake async Redis client backed by fakeredis.""" r = fakeredis.aioredis.FakeRedis(decode_responses=True) yield r await r.aclose() class TestTokenBucketAllows: """Tests for requests within the rate limit.""" async def test_single_request_allowed(self, fake_redis) -> None: """A single request is always allowed.""" result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) assert result is True async def test_requests_under_limit_all_allowed(self, fake_redis) -> None: """All 29 requests within a 30-request limit are allowed.""" for i in range(29): result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) assert result is True, f"Request {i + 1} was unexpectedly rejected" async def test_exactly_at_limit_allowed(self, fake_redis) -> None: """The 30th request (exactly at limit) is allowed.""" for _ in range(30): result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) assert result is True class TestTokenBucketRejects: """Tests for requests exceeding the rate limit.""" async def test_31st_request_rejected(self, fake_redis) -> None: """The 31st request in a 30-request window is rejected.""" # Fill up the bucket for _ in range(30): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) # 31st request must raise with pytest.raises(RateLimitExceeded) as exc_info: await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) assert exc_info.value.tenant_id == "tenant_a" assert exc_info.value.channel == "slack" async def test_rate_limit_exceeded_has_remaining_seconds(self, fake_redis) -> None: """RateLimitExceeded must expose remaining_seconds attribute.""" for _ in range(30): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60) with pytest.raises(RateLimitExceeded) as exc_info: await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60) assert isinstance(exc_info.value.remaining_seconds, int) assert exc_info.value.remaining_seconds >= 0 async def test_continued_requests_after_exceeded_also_rejected(self, fake_redis) -> None: """Additional requests after exceeding the limit continue to be rejected.""" for _ in range(30): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) for _ in range(5): with pytest.raises(RateLimitExceeded): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) class TestTokenBucketTenantIsolation: """Tests that rate limit counters are isolated per tenant.""" async def test_tenant_a_limit_independent_of_tenant_b(self, fake_redis) -> None: """Exhausting tenant A's limit does not affect tenant B.""" # Exhaust tenant A's limit for _ in range(30): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) with pytest.raises(RateLimitExceeded): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) # Tenant B should still be allowed result = await check_rate_limit("tenant_b", "slack", fake_redis, limit=30) assert result is True async def test_channel_limits_are_independent(self, fake_redis) -> None: """Exhausting Slack limit does not affect Telegram limit.""" for _ in range(30): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) with pytest.raises(RateLimitExceeded): await check_rate_limit("tenant_a", "slack", fake_redis, limit=30) # A different channel should still be allowed result = await check_rate_limit("tenant_a", "telegram", fake_redis, limit=30) assert result is True async def test_multiple_tenants_independent_counters(self, fake_redis) -> None: """Multiple tenants maintain separate counter keys.""" tenants = ["tenant_a", "tenant_b", "tenant_c"] # Each tenant makes 15 requests for tenant in tenants: for _ in range(15): result = await check_rate_limit(tenant, "slack", fake_redis, limit=30) assert result is True, f"Unexpected rejection for {tenant}" # None should be at the limit yet for tenant in tenants: result = await check_rate_limit(tenant, "slack", fake_redis, limit=30) assert result is True class TestTokenBucketWindowReset: """Tests for rate limit window expiry.""" async def test_limit_resets_after_window_expires(self, fake_redis) -> None: """After the TTL expires, the rate limit resets and requests are allowed again.""" # Use a very short window (1 second) to test expiry for _ in range(5): await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1) with pytest.raises(RateLimitExceeded): await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1) # Manually expire the key to simulate window reset from shared.redis_keys import rate_limit_key key = rate_limit_key("tenant_a", "slack") await fake_redis.delete(key) # After key expiry, requests should be allowed again result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1) assert result is True async def test_rate_limit_key_has_ttl(self, fake_redis) -> None: """Rate limit key must have a TTL set (window expiry).""" from shared.redis_keys import rate_limit_key key = rate_limit_key("tenant_a", "slack") await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60) ttl = await fake_redis.ttl(key) # TTL should be set (positive) — key will expire at end of window assert ttl > 0 assert ttl <= 60