forked from 0xWheatyz/SPARC
Add rate limiting dashboard to admin panel
- Enhance GET /admin/rate-limits with per-IP breakdown, 24h throttled count, and hourly time-series of rejected requests - Add _rejected_log deque for time-series tracking of throttled requests - Add AdminRateLimits React page with auto-refresh (configurable 15s/30s/1m), summary cards, throttled-over-time bar chart, endpoint table, per-IP table - Add TypeScript types (RateLimitStatsResponse) and adminApi.getRateLimits() - Wire up /admin/rate-limits route and nav link (admin-only) - Expand unit tests to 10 cases: auth, empty state, per-IP breakdown, throttled_24h count, time-series structure, response shape contract Closes leeworks-agents/SPARC#1686 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,10 @@ def client():
|
||||
def reset_stats():
|
||||
"""Reset rate limit stats between tests."""
|
||||
api._rate_limit_stats.clear()
|
||||
api._rejected_log.clear()
|
||||
yield
|
||||
api._rate_limit_stats.clear()
|
||||
api._rejected_log.clear()
|
||||
|
||||
|
||||
def _mock_admin():
|
||||
@@ -50,8 +52,7 @@ class TestRateLimitAdminEndpoint:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_non_admin_rejected(self, client):
|
||||
"""Non-admin users should get 403."""
|
||||
# Without overriding the dependency, it should fail auth
|
||||
"""Non-admin users should get 401/403."""
|
||||
response = client.get("/admin/rate-limits")
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
@@ -77,6 +78,9 @@ class TestRateLimitAdminEndpoint:
|
||||
for rl in data["rate_limits"]:
|
||||
assert rl["total_requests"] == 0
|
||||
assert rl["rejected_requests"] == 0
|
||||
assert rl["by_ip"] == []
|
||||
assert data["throttled_24h"] == 0
|
||||
assert data["throttled_over_time"] == []
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -107,3 +111,68 @@ class TestRateLimitAdminEndpoint:
|
||||
assert isinstance(rl["limit"], str)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_per_ip_breakdown(self, client):
|
||||
"""Stats should include per-IP breakdown with total and rejected counts."""
|
||||
api._track_rate_limit_request("/auth/login", "10.0.0.1")
|
||||
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
|
||||
api._track_rate_limit_request("/auth/login", "10.0.0.2")
|
||||
|
||||
app.dependency_overrides[api.get_current_admin] = _mock_admin
|
||||
try:
|
||||
response = client.get("/admin/rate-limits")
|
||||
data = response.json()
|
||||
login_stats = next(rl for rl in data["rate_limits"] if rl["endpoint"] == "/auth/login")
|
||||
by_ip = login_stats["by_ip"]
|
||||
assert len(by_ip) == 2
|
||||
ip1 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.1")
|
||||
assert ip1["total"] == 2
|
||||
assert ip1["rejected"] == 1
|
||||
ip2 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.2")
|
||||
assert ip2["total"] == 1
|
||||
assert ip2["rejected"] == 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_throttled_24h_count(self, client):
|
||||
"""Should report total throttled requests in the last 24 hours."""
|
||||
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
|
||||
api._track_rate_limit_request("/auth/register", "10.0.0.2", rejected=True)
|
||||
|
||||
app.dependency_overrides[api.get_current_admin] = _mock_admin
|
||||
try:
|
||||
response = client.get("/admin/rate-limits")
|
||||
data = response.json()
|
||||
assert data["throttled_24h"] == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_throttled_over_time_structure(self, client):
|
||||
"""Throttled-over-time should be a list of {timestamp, count} buckets."""
|
||||
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
|
||||
|
||||
app.dependency_overrides[api.get_current_admin] = _mock_admin
|
||||
try:
|
||||
response = client.get("/admin/rate-limits")
|
||||
data = response.json()
|
||||
assert len(data["throttled_over_time"]) >= 1
|
||||
entry = data["throttled_over_time"][0]
|
||||
assert "timestamp" in entry
|
||||
assert "count" in entry
|
||||
assert entry["count"] >= 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape_matches_contract(self, client):
|
||||
"""The full response should match the expected shape for the frontend."""
|
||||
app.dependency_overrides[api.get_current_admin] = _mock_admin
|
||||
try:
|
||||
response = client.get("/admin/rate-limits")
|
||||
data = response.json()
|
||||
# Top-level keys
|
||||
assert set(data.keys()) == {"rate_limits", "throttled_24h", "throttled_over_time"}
|
||||
# Each rate_limit entry
|
||||
for rl in data["rate_limits"]:
|
||||
assert set(rl.keys()) == {"endpoint", "limit", "total_requests", "rejected_requests", "by_ip"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
Reference in New Issue
Block a user