- tests/unit/test_ratelimit.py: 11 tests for Redis token bucket (CHAN-05) - allows requests under limit, rejects 31st request - per-tenant isolation, per-channel isolation - TTL key expiry and window reset - tests/integration/test_slack_flow.py: 15 tests for end-to-end Slack flow (CHAN-02) - normalization: bot token stripped, channel=slack, thread_id set - @mention: placeholder posted in-thread, Celery dispatched with placeholder_ts - DM flow: same pipeline triggered for channel_type=im - bot messages silently ignored (no infinite loop) - unknown workspace_id silently ignored - duplicate events (Slack retries) skipped via idempotency - tests/integration/test_agent_persona.py: 15 tests for persona in prompts (AGNT-01) - system prompt contains name, role, persona, AI transparency clause - model_preference forwarded to LLM pool - full messages array: [system, user] structure verified - tests/integration/test_ratelimit.py: 4 tests for rate limit integration - over-limit -> ephemeral rejection posted - over-limit -> Celery NOT dispatched, placeholder NOT posted - within-limit -> no rejection - ephemeral message includes actionable retry hint All 45 tests pass
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""
|
|
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
|