""" Tests for services/matrix-bridge-dagi/app/rate_limit.py (H1) Coverage: - basic allow / room limit / sender limit - independent room and sender counters - sliding window prune (old events don't block) - reset() clears buckets - stats() reflects live state - constructor validation """ import sys import time from pathlib import Path _BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi" if str(_BRIDGE) not in sys.path: sys.path.insert(0, str(_BRIDGE)) from app.rate_limit import InMemoryRateLimiter # noqa: E402 ROOM = "!room1:server" ROOM2 = "!room2:server" SENDER = "@alice:server" SENDER2 = "@bob:server" def test_allows_first_message(): rl = InMemoryRateLimiter(room_rpm=5, sender_rpm=5) allowed, limit_type = rl.check(ROOM, SENDER) assert allowed is True assert limit_type is None def test_room_limit_blocks_at_threshold(): rl = InMemoryRateLimiter(room_rpm=3, sender_rpm=100) for _ in range(3): allowed, _ = rl.check(ROOM, SENDER) assert allowed # 4th from same room (different sender) should be blocked allowed, limit_type = rl.check(ROOM, SENDER2) assert allowed is False assert limit_type == "room" def test_sender_limit_blocks_at_threshold(): rl = InMemoryRateLimiter(room_rpm=100, sender_rpm=2) allowed, _ = rl.check(ROOM, SENDER) assert allowed allowed, _ = rl.check(ROOM2, SENDER) assert allowed # 3rd from same sender (different room) allowed, limit_type = rl.check("!room3:server", SENDER) assert allowed is False assert limit_type == "sender" def test_room_checked_before_sender(): """When both would exceed, 'room' is reported first.""" rl = InMemoryRateLimiter(room_rpm=1, sender_rpm=1) rl.check(ROOM, SENDER) # fills both allowed, limit_type = rl.check(ROOM, SENDER) assert not allowed assert limit_type == "room" def test_independent_rooms_dont_interfere(): rl = InMemoryRateLimiter(room_rpm=2, sender_rpm=100) rl.check(ROOM, SENDER) rl.check(ROOM, SENDER) # room1 full — room2 still ok allowed, limit_type = rl.check(ROOM2, SENDER) assert allowed is True def test_independent_senders_dont_interfere(): rl = InMemoryRateLimiter(room_rpm=100, sender_rpm=1) rl.check(ROOM, SENDER) # alice full — bob still ok allowed, _ = rl.check(ROOM, SENDER2) assert allowed is True def test_window_prune_allows_after_expiry(monkeypatch): """Events older than 60s should not count against the limit.""" rl = InMemoryRateLimiter(room_rpm=2, sender_rpm=100) # Fill the room bucket rl.check(ROOM, SENDER) rl.check(ROOM, SENDER) # Verify blocked ok, lt = rl.check(ROOM, SENDER2) assert not ok and lt == "room" # Fast-forward time by 61 seconds original_time = time.monotonic start = original_time() monkeypatch.setattr(time, "monotonic", lambda: start + 61.0) # Should be allowed again allowed, _ = rl.check(ROOM, SENDER2) assert allowed is True def test_reset_room_clears_bucket(): rl = InMemoryRateLimiter(room_rpm=1, sender_rpm=100) rl.check(ROOM, SENDER) ok, lt = rl.check(ROOM, SENDER2) assert not ok and lt == "room" rl.reset(room_id=ROOM) ok, _ = rl.check(ROOM, SENDER2) assert ok is True def test_reset_sender_clears_bucket(): rl = InMemoryRateLimiter(room_rpm=100, sender_rpm=1) rl.check(ROOM, SENDER) ok, lt = rl.check(ROOM2, SENDER) assert not ok and lt == "sender" rl.reset(sender=SENDER) ok, _ = rl.check(ROOM2, SENDER) assert ok is True def test_stats_reflects_active_buckets(): rl = InMemoryRateLimiter(room_rpm=10, sender_rpm=10) rl.check(ROOM, SENDER) rl.check(ROOM2, SENDER2) s = rl.stats() assert s["active_rooms"] == 2 assert s["active_senders"] == 2 assert s["room_rpm_limit"] == 10 assert s["sender_rpm_limit"] == 10 def test_stats_stale_buckets_not_counted(monkeypatch): rl = InMemoryRateLimiter(room_rpm=10, sender_rpm=10) rl.check(ROOM, SENDER) original_time = time.monotonic start = original_time() monkeypatch.setattr(time, "monotonic", lambda: start + 61.0) s = rl.stats() assert s["active_rooms"] == 0 assert s["active_senders"] == 0 def test_constructor_validates_limits(): import pytest with pytest.raises(ValueError): InMemoryRateLimiter(room_rpm=0, sender_rpm=5) with pytest.raises(ValueError): InMemoryRateLimiter(room_rpm=5, sender_rpm=-1) def test_sender_bucket_not_charged_when_room_blocked(): """When room blocks, sender quota must not decrease.""" rl = InMemoryRateLimiter(room_rpm=1, sender_rpm=2) rl.check(ROOM, SENDER) # fills room (1/1), sender (1/2) # room blocked — sender should NOT be decremented rl.check(ROOM, SENDER) # blocked by room rl.check(ROOM, SENDER) # blocked by room # Sender still has 1 slot left in a fresh room ok, lt = rl.check(ROOM2, SENDER) assert ok is True # sender only used 1/2 of its quota