Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 3dfa651f2d 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>
2026-05-19 15:39:45 +00:00
7 changed files with 412 additions and 65 deletions
+54 -24
View File
@@ -5,8 +5,9 @@ Provides REST API endpoints for analyzing company patent portfolios.
from __future__ import annotations from __future__ import annotations
from collections import deque
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Annotated, List from typing import TYPE_CHECKING, Annotated, List
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -36,28 +37,16 @@ from SPARC.auth import (
) )
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
# Validated company name type: 2-128 chars, alphanumeric + spaces/hyphens/ampersands/periods only. # Validated company name type: 2-100 chars, alphanumeric + spaces/hyphens/ampersands/periods only.
CompanyName = Annotated[ CompanyName = Annotated[
str, str,
StringConstraints( StringConstraints(
min_length=2, min_length=2,
max_length=128, max_length=100,
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$",
), ),
] ]
# Reusable Query constraint for optional company_name filter parameters.
_COMPANY_NAME_FILTER_QUERY = Query(
default=None,
min_length=2,
max_length=128,
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$",
description=(
"Company name filter (2-128 chars; alphanumeric, spaces, hyphens, "
"periods, and ampersands only)"
),
)
# Pydantic models for API # Pydantic models for API
class CompanyAnalysisResponse(BaseModel): class CompanyAnalysisResponse(BaseModel):
@@ -260,6 +249,9 @@ app.state.limiter = limiter
# In-memory rate limit statistics # In-memory rate limit statistics
_rate_limit_stats: dict[str, dict] = {} _rate_limit_stats: dict[str, dict] = {}
# Time-series log of rejected requests (capped to last 24 h worth of entries).
_rejected_log: deque[dict] = deque(maxlen=100_000)
def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None: def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None:
"""Record a request against a rate-limited endpoint.""" """Record a request against a rate-limited endpoint."""
@@ -274,6 +266,11 @@ def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) ->
_rate_limit_stats[key]["total_requests"] += 1 _rate_limit_stats[key]["total_requests"] += 1
if rejected: if rejected:
_rate_limit_stats[key]["rejected_requests"] += 1 _rate_limit_stats[key]["rejected_requests"] += 1
_rejected_log.append({
"endpoint": endpoint,
"ip": ip,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
ip_stats = _rate_limit_stats[key].setdefault("by_ip", {}) ip_stats = _rate_limit_stats[key].setdefault("by_ip", {})
if ip not in ip_stats: if ip not in ip_stats:
ip_stats[ip] = {"total": 0, "rejected": 0} ip_stats[ip] = {"total": 0, "rejected": 0}
@@ -501,7 +498,7 @@ async def add_tracked_company(
@app.delete("/admin/tracked/{company_name}", tags=["Admin"]) @app.delete("/admin/tracked/{company_name}", tags=["Admin"])
async def remove_tracked_company( async def remove_tracked_company(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_admin), _: UserResponse = Depends(get_current_admin),
): ):
"""Remove a company from the tracked list (admin only).""" """Remove a company from the tracked list (admin only)."""
@@ -519,10 +516,12 @@ async def get_rate_limit_stats(
"""Get rate limit status and usage statistics (admin only). """Get rate limit status and usage statistics (admin only).
Returns current rate limit configuration and request statistics Returns current rate limit configuration and request statistics
for all rate-limited endpoints. for all rate-limited endpoints, including per-IP breakdown and
a time-series of throttled (rejected) requests in the last 24 hours.
Returns: Returns:
List of rate limit stats per endpoint with total/rejected counts Rate limit stats per endpoint, per-IP breakdown, and throttled
request history bucketed by hour.
""" """
rate_limits_config = { rate_limits_config = {
"/auth/register": {"limit": "5/minute"}, "/auth/register": {"limit": "5/minute"},
@@ -532,14 +531,45 @@ async def get_rate_limit_stats(
results = [] results = []
for endpoint, conf in rate_limits_config.items(): for endpoint, conf in rate_limits_config.items():
stats = _rate_limit_stats.get(endpoint, {}) stats = _rate_limit_stats.get(endpoint, {})
by_ip_raw = stats.get("by_ip", {})
by_ip = [
{"ip": ip, "total": counts["total"], "rejected": counts["rejected"]}
for ip, counts in by_ip_raw.items()
]
results.append({ results.append({
"endpoint": endpoint, "endpoint": endpoint,
"limit": conf["limit"], "limit": conf["limit"],
"total_requests": stats.get("total_requests", 0), "total_requests": stats.get("total_requests", 0),
"rejected_requests": stats.get("rejected_requests", 0), "rejected_requests": stats.get("rejected_requests", 0),
"by_ip": by_ip,
}) })
return {"rate_limits": results} # Build hourly buckets of throttled requests for the last 24 hours
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=24)
hourly_buckets: dict[str, int] = {}
throttled_24h = 0
for entry in _rejected_log:
ts_str = entry["timestamp"]
try:
ts = datetime.fromisoformat(ts_str)
except (ValueError, TypeError):
continue
if ts >= cutoff:
throttled_24h += 1
bucket = ts.strftime("%Y-%m-%dT%H:00:00Z")
hourly_buckets[bucket] = hourly_buckets.get(bucket, 0) + 1
throttled_over_time = [
{"timestamp": k, "count": v}
for k, v in sorted(hourly_buckets.items())
]
return {
"rate_limits": results,
"throttled_24h": throttled_24h,
"throttled_over_time": throttled_over_time,
}
@app.get("/admin/alerts", tags=["Admin"]) @app.get("/admin/alerts", tags=["Admin"])
@@ -689,7 +719,7 @@ async def get_analytics_trends(
@app.get("/export/{company_name}", tags=["Export"]) @app.get("/export/{company_name}", tags=["Export"])
async def export_company_csv( async def export_company_csv(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_user), _: UserResponse = Depends(get_current_user),
): ):
"""Export analysis results for a company as a CSV file. """Export analysis results for a company as a CSV file.
@@ -741,7 +771,7 @@ async def export_company_csv(
@app.get("/export/{company_name}/pdf", tags=["Export"]) @app.get("/export/{company_name}/pdf", tags=["Export"])
async def export_company_pdf( async def export_company_pdf(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_user), _: UserResponse = Depends(get_current_user),
): ):
"""Export analysis results for a company as a formatted PDF report. """Export analysis results for a company as a formatted PDF report.
@@ -915,7 +945,7 @@ async def health_check():
tags=["Analysis"], tags=["Analysis"],
) )
async def analyze_company( async def analyze_company(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."), model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."),
_: UserResponse = Depends(get_current_user), _: UserResponse = Depends(get_current_user),
): ):
@@ -945,7 +975,7 @@ async def analyze_company(
) )
async def analyze_single_patent( async def analyze_single_patent(
patent_id: str, patent_id: str,
company_name: Annotated[str, Query(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", description="Company name for analysis context")], company_name: Annotated[str, Query(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", description="Company name for analysis context")],
_: UserResponse = Depends(get_current_user), _: UserResponse = Depends(get_current_user),
): ):
"""Analyze a single patent by its publication ID. """Analyze a single patent by its publication ID.
@@ -979,7 +1009,7 @@ async def analyze_single_patent(
async def list_analysis_results( async def list_analysis_results(
company_name: Annotated[ company_name: Annotated[
str | None, str | None,
_COMPANY_NAME_FILTER_QUERY, Query(description="Filter results by company name"),
] = None, ] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50, limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[ cursor: Annotated[
+9
View File
@@ -11,6 +11,7 @@ import { Batch } from './pages/Batch';
import { AnalyticsPage } from './pages/Analytics'; import { AnalyticsPage } from './pages/Analytics';
import { About } from './pages/About'; import { About } from './pages/About';
import { AdminUsers } from './pages/AdminUsers'; import { AdminUsers } from './pages/AdminUsers';
import { AdminRateLimits } from './pages/AdminRateLimits';
import { Compare } from './pages/Compare'; import { Compare } from './pages/Compare';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -56,6 +57,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin/rate-limits"
element={
<ProtectedRoute requireAdmin>
<AdminRateLimits />
</ProtectedRoute>
}
/>
</Route> </Route>
{/* Default redirect */} {/* Default redirect */}
+31
View File
@@ -201,6 +201,32 @@ export const analyticsApi = {
}, },
}; };
// Rate limit types
export interface RateLimitIpEntry {
ip: string;
total: number;
rejected: number;
}
export interface RateLimitEndpointStats {
endpoint: string;
limit: string;
total_requests: number;
rejected_requests: number;
by_ip: RateLimitIpEntry[];
}
export interface ThrottledBucket {
timestamp: string;
count: number;
}
export interface RateLimitStatsResponse {
rate_limits: RateLimitEndpointStats[];
throttled_24h: number;
throttled_over_time: ThrottledBucket[];
}
// Admin API // Admin API
export const adminApi = { export const adminApi = {
listUsers: async (limit = 100, offset = 0): Promise<User[]> => { listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
@@ -216,6 +242,11 @@ export const adminApi = {
deleteUser: async (userId: number): Promise<void> => { deleteUser: async (userId: number): Promise<void> => {
await api.delete(`/admin/users/${userId}`); await api.delete(`/admin/users/${userId}`);
}, },
getRateLimits: async (): Promise<RateLimitStatsResponse> => {
const response = await api.get<RateLimitStatsResponse>('/admin/rate-limits');
return response.data;
},
}; };
export default api; export default api;
+2 -1
View File
@@ -1,7 +1,7 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react'; import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon, ShieldAlert } from 'lucide-react';
export function Layout() { export function Layout() {
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
@@ -23,6 +23,7 @@ export function Layout() {
if (isAdmin) { if (isAdmin) {
navItems.push({ to: '/admin/users', icon: Users, label: 'Users' }); navItems.push({ to: '/admin/users', icon: Users, label: 'Users' });
navItems.push({ to: '/admin/rate-limits', icon: ShieldAlert, label: 'Rate Limits' });
} }
return ( return (
+240
View File
@@ -0,0 +1,240 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../api/client';
import type { RateLimitStatsResponse } from '../api/client';
import { ShieldAlert, Activity, AlertCircle, RefreshCw, Clock } from 'lucide-react';
const REFRESH_OPTIONS = [
{ label: '15s', value: 15_000 },
{ label: '30s', value: 30_000 },
{ label: '1m', value: 60_000 },
{ label: 'Off', value: 0 },
];
export function AdminRateLimits() {
const [refreshInterval, setRefreshInterval] = useState(30_000);
const { data, isLoading, isError, dataUpdatedAt } = useQuery<RateLimitStatsResponse>({
queryKey: ['admin-rate-limits'],
queryFn: () => adminApi.getRateLimits(),
refetchInterval: refreshInterval || false,
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
if (isError) {
return (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to load rate limit statistics.</span>
</div>
);
}
const maxThrottledCount = data?.throttled_over_time?.length
? Math.max(...data.throttled_over_time.map((b) => b.count))
: 0;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Rate Limiting Dashboard
</h2>
<p className="text-text-secondary">Monitor API rate limits and throttled requests.</p>
</div>
<div className="flex items-center gap-3">
{/* Last updated */}
{dataUpdatedAt > 0 && (
<span className="text-xs text-text-secondary flex items-center gap-1">
<Clock size={12} />
Updated {new Date(dataUpdatedAt).toLocaleTimeString()}
</span>
)}
{/* Refresh interval selector */}
<div className="flex items-center gap-1 bg-bg-card/60 border border-primary/15 rounded-xl p-1">
<RefreshCw size={14} className="text-text-secondary ml-2" />
{REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRefreshInterval(opt.value)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
refreshInterval === opt.value
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-card-hover'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<Activity size={18} className="text-primary" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total Requests
</span>
</div>
<div className="text-3xl font-bold text-text-primary">
{data?.rate_limits.reduce((sum, rl) => sum + rl.total_requests, 0) ?? 0}
</div>
</div>
<div className="bg-bg-card/60 border border-error/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert size={18} className="text-error" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Throttled (24h)
</span>
</div>
<div className="text-3xl font-bold text-error">
{data?.throttled_24h ?? 0}
</div>
</div>
<div className="bg-bg-card/60 border border-secondary/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert size={18} className="text-secondary" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rate-Limited Endpoints
</span>
</div>
<div className="text-3xl font-bold text-text-primary">
{data?.rate_limits.length ?? 0}
</div>
</div>
</div>
{/* Throttled over time chart (simple bar chart) */}
{data?.throttled_over_time && data.throttled_over_time.length > 0 && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Throttled Requests Over Time (Last 24h)
</h3>
<div className="flex items-end gap-1 h-32">
{data.throttled_over_time.map((bucket) => {
const height = maxThrottledCount > 0 ? (bucket.count / maxThrottledCount) * 100 : 0;
const hour = new Date(bucket.timestamp).getHours();
return (
<div key={bucket.timestamp} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-text-secondary">{bucket.count}</span>
<div
className="w-full bg-error/70 rounded-t-sm min-h-[2px] transition-all"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${bucket.timestamp}: ${bucket.count} throttled`}
/>
<span className="text-[10px] text-text-secondary">{hour}:00</span>
</div>
);
})}
</div>
</div>
)}
{/* Per-endpoint table */}
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-primary/10">
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Endpoint
</th>
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Limit
</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total Requests
</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rejected
</th>
</tr>
</thead>
<tbody className="divide-y divide-primary/10">
{data?.rate_limits.map((rl) => (
<tr key={rl.endpoint} className="hover:bg-bg-card-hover/50 transition-colors">
<td className="px-6 py-4 font-mono text-sm text-text-primary">{rl.endpoint}</td>
<td className="px-6 py-4">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{rl.limit}
</span>
</td>
<td className="px-6 py-4 text-right text-text-primary font-semibold">
{rl.total_requests}
</td>
<td className="px-6 py-4 text-right">
<span className={rl.rejected_requests > 0 ? 'text-error font-semibold' : 'text-text-secondary'}>
{rl.rejected_requests}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Per-IP breakdown */}
{data?.rate_limits.some((rl) => rl.by_ip.length > 0) && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-primary/10">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Per-IP Breakdown
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-primary/10">
<th className="text-left px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Endpoint
</th>
<th className="text-left px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
IP Address
</th>
<th className="text-right px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total
</th>
<th className="text-right px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rejected
</th>
</tr>
</thead>
<tbody className="divide-y divide-primary/10">
{data.rate_limits.flatMap((rl) =>
rl.by_ip.map((ipEntry) => (
<tr
key={`${rl.endpoint}-${ipEntry.ip}`}
className="hover:bg-bg-card-hover/50 transition-colors"
>
<td className="px-6 py-3 font-mono text-sm text-text-primary">{rl.endpoint}</td>
<td className="px-6 py-3 font-mono text-sm text-text-secondary">{ipEntry.ip}</td>
<td className="px-6 py-3 text-right text-text-primary">{ipEntry.total}</td>
<td className="px-6 py-3 text-right">
<span className={ipEntry.rejected > 0 ? 'text-error font-semibold' : 'text-text-secondary'}>
{ipEntry.rejected}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+5 -38
View File
@@ -43,18 +43,12 @@ class TestCompanyNameValidation:
# --- Too long --- # --- Too long ---
def test_over_128_chars_rejected(self, client, mock_analyzer): def test_over_100_chars_rejected(self, client, mock_analyzer):
"""A company name longer than 128 characters should be rejected.""" """A company name longer than 100 characters should be rejected."""
long_name = "A" * 129 long_name = "A" * 101
response = client.get(f"/analyze/{long_name}") response = client.get(f"/analyze/{long_name}")
assert response.status_code == 422 assert response.status_code == 422
def test_exactly_128_chars_accepted(self, client, mock_analyzer):
"""A company name of exactly 128 characters should be accepted."""
max_name = "A" * 128
response = client.get(f"/analyze/{max_name}")
assert response.status_code != 422
# --- Special characters --- # --- Special characters ---
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -101,7 +95,7 @@ class TestCompanyNameValidation:
"3M", "3M",
"21st Century Fox", "21st Century Fox",
"ab", # minimum length "ab", # minimum length
"A" * 128, # maximum length "A" * 100, # maximum length
], ],
) )
def test_valid_names_accepted(self, client, mock_analyzer, valid_name): def test_valid_names_accepted(self, client, mock_analyzer, valid_name):
@@ -124,7 +118,7 @@ class TestCompanyNameValidation:
"""Batch endpoint should reject company names that are too long.""" """Batch endpoint should reject company names that are too long."""
response = client.post( response = client.post(
"/analyze/batch", "/analyze/batch",
json={"companies": ["A" * 129]}, json={"companies": ["A" * 101]},
) )
assert response.status_code == 422 assert response.status_code == 422
@@ -161,30 +155,3 @@ class TestCompanyNameValidation:
json={"companies": ["-nvidia"]}, json={"companies": ["-nvidia"]},
) )
assert response.status_code == 422 assert response.status_code == 422
# --- GET /analyze/batch company_name filter validation ---
def test_batch_filter_special_chars_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects disallowed chars."""
response = client.get("/analyze/batch", params={"company_name": "nvidia!"})
assert response.status_code == 422
def test_batch_filter_too_short_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects names under 2 chars."""
response = client.get("/analyze/batch", params={"company_name": "X"})
assert response.status_code == 422
def test_batch_filter_too_long_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects names over 128 chars."""
response = client.get("/analyze/batch", params={"company_name": "A" * 129})
assert response.status_code == 422
def test_batch_filter_valid_name_accepted(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter accepts a valid name."""
response = client.get("/analyze/batch", params={"company_name": "nvidia"})
assert response.status_code != 422
def test_batch_filter_omitted_accepted(self, client, mock_analyzer):
"""GET /analyze/batch without company_name filter should work fine."""
response = client.get("/analyze/batch")
assert response.status_code != 422
+71 -2
View File
@@ -20,8 +20,10 @@ def client():
def reset_stats(): def reset_stats():
"""Reset rate limit stats between tests.""" """Reset rate limit stats between tests."""
api._rate_limit_stats.clear() api._rate_limit_stats.clear()
api._rejected_log.clear()
yield yield
api._rate_limit_stats.clear() api._rate_limit_stats.clear()
api._rejected_log.clear()
def _mock_admin(): def _mock_admin():
@@ -50,8 +52,7 @@ class TestRateLimitAdminEndpoint:
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_non_admin_rejected(self, client): def test_non_admin_rejected(self, client):
"""Non-admin users should get 403.""" """Non-admin users should get 401/403."""
# Without overriding the dependency, it should fail auth
response = client.get("/admin/rate-limits") response = client.get("/admin/rate-limits")
assert response.status_code in (401, 403) assert response.status_code in (401, 403)
@@ -77,6 +78,9 @@ class TestRateLimitAdminEndpoint:
for rl in data["rate_limits"]: for rl in data["rate_limits"]:
assert rl["total_requests"] == 0 assert rl["total_requests"] == 0
assert rl["rejected_requests"] == 0 assert rl["rejected_requests"] == 0
assert rl["by_ip"] == []
assert data["throttled_24h"] == 0
assert data["throttled_over_time"] == []
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -107,3 +111,68 @@ class TestRateLimitAdminEndpoint:
assert isinstance(rl["limit"], str) assert isinstance(rl["limit"], str)
finally: finally:
app.dependency_overrides.clear() 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()