Compare commits

...

3 Commits

Author SHA1 Message Date
agent-company 0e68e8c900 Add cursor-based pagination to /analyze/batch and /jobs endpoints
- Fix route ordering bug: GET /analyze/batch was shadowed by
  GET /analyze/{company_name} causing all GET requests to /analyze/batch
  to be erroneously handled as single-company analysis (503). Move
  /analyze/batch GET registration to before the {company_name} route.
- Update TypeScript schema.d.ts: add AnalysisRecord, PaginatedAnalysisResponse,
  PaginatedJobsResponse schemas; add GET /analyze/batch operation with
  cursor+limit+company_name params; update list_jobs_jobs_get to include
  cursor param and return PaginatedJobsResponse.
- Update frontend/src/api/client.ts: add listBatchAnalyses() method with
  cursor/limit support; update listJobs() to accept cursor and return
  PaginatedJobsResponse; default limit changed from 10 to 50.
- Update frontend/src/types/index.ts: export AnalysisRecord,
  PaginatedAnalysisResponse, PaginatedJobsResponse.
- Expand tests/test_pagination.py: add auth fixture so tests pass JWT
  validation; add 11 new /jobs tests covering first page, last page,
  subsequent pages, empty results, status filter, limit boundaries, cursor
  forwarding, and paginated response shape.

Closes leeworks-agents/SPARC#1684

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:34:18 +00:00
AI-Manager 313800215c Merge pull request 'Add rate limit stats to admin panel' (#1682) from feature/1675-rate-limit-admin into main
Merge PR #1682
2026-05-19 00:12:56 +00:00
agent-company a0cb9a5773 Add rate limit status and usage statistics to admin panel
Add GET /admin/rate-limits endpoint (admin-only) that returns current
rate limit configuration and request statistics for all rate-limited
endpoints (/auth/register and /auth/login). Tracks total requests and
rejection counts via in-memory counters.

Includes tests for admin access, non-admin rejection, empty state,
request tracking, and configuration display.

Closes leeworks-agents/SPARC#1675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 21:53:01 +00:00
6 changed files with 571 additions and 79 deletions
+111 -52
View File
@@ -245,10 +245,37 @@ app = FastAPI(
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
# In-memory rate limit statistics
_rate_limit_stats: dict[str, dict] = {}
def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None:
"""Record a request against a rate-limited endpoint."""
key = endpoint
if key not in _rate_limit_stats:
_rate_limit_stats[key] = {
"endpoint": endpoint,
"total_requests": 0,
"rejected_requests": 0,
"by_ip": {},
}
_rate_limit_stats[key]["total_requests"] += 1
if rejected:
_rate_limit_stats[key]["rejected_requests"] += 1
ip_stats = _rate_limit_stats[key].setdefault("by_ip", {})
if ip not in ip_stats:
ip_stats[ip] = {"total": 0, "rejected": 0}
ip_stats[ip]["total"] += 1
if rejected:
ip_stats[ip]["rejected"] += 1
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""Return 429 with Retry-After header when rate limit is exceeded."""
endpoint = request.url.path
ip = get_remote_address(request)
_track_rate_limit_request(endpoint, ip, rejected=True)
retry_after = getattr(exc, "retry_after", 60)
return JSONResponse(
status_code=429,
@@ -277,6 +304,7 @@ async def register(request: Request, body: RegisterRequest):
The first registered user automatically becomes an admin.
"""
_track_rate_limit_request("/auth/register", get_remote_address(request))
db = get_db_client()
# First user becomes admin
@@ -307,6 +335,7 @@ async def register(request: Request, body: RegisterRequest):
@limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest):
"""Authenticate user and return JWT tokens."""
_track_rate_limit_request("/auth/login", get_remote_address(request))
db = get_db_client()
user = db.authenticate_user(body.email, body.password)
@@ -471,6 +500,36 @@ async def remove_tracked_company(
return {"message": f"Stopped tracking {company_name}"}
@app.get("/admin/rate-limits", tags=["Admin"])
async def get_rate_limit_stats(
_: UserResponse = Depends(get_current_admin),
):
"""Get rate limit status and usage statistics (admin only).
Returns current rate limit configuration and request statistics
for all rate-limited endpoints.
Returns:
List of rate limit stats per endpoint with total/rejected counts
"""
rate_limits_config = {
"/auth/register": {"limit": "5/minute"},
"/auth/login": {"limit": "10/minute"},
}
results = []
for endpoint, conf in rate_limits_config.items():
stats = _rate_limit_stats.get(endpoint, {})
results.append({
"endpoint": endpoint,
"limit": conf["limit"],
"total_requests": stats.get("total_requests", 0),
"rejected_requests": stats.get("rejected_requests", 0),
})
return {"rate_limits": results}
@app.get("/admin/alerts", tags=["Admin"])
async def list_alerts(
limit: int = Query(default=50, ge=1, le=200),
@@ -838,6 +897,58 @@ async def health_check():
)
@app.get(
"/analyze/batch",
response_model=PaginatedAnalysisResponse,
tags=["Analysis"],
)
async def list_analysis_results(
company_name: Annotated[
str | None,
Query(description="Filter results by company name"),
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[
str | None,
Query(description="Opaque cursor from a previous response's next_cursor field"),
] = None,
_: UserResponse = Depends(get_current_user),
):
"""List stored analysis results with cursor-based pagination.
Returns past analysis results ordered by timestamp descending. Use
``limit`` to control page size (default 50, max 200). The response
includes a ``next_cursor`` field; pass it back as the ``cursor`` query
parameter to fetch the next page. When ``next_cursor`` is ``null``,
there are no more results.
Args:
company_name: Optional filter by company name
limit: Maximum number of results to return (default 50, max 200)
cursor: Opaque pagination cursor from a previous response
Returns:
Paginated list of analysis results
"""
db = _get_job_db()
rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor)
has_next = len(rows) > limit
if has_next:
rows = rows[:limit]
items = [AnalysisRecord(**row) for row in rows]
next_cursor = None
if has_next and rows:
last = rows[-1]
ts = last["timestamp"]
ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
next_cursor = f"{ts_str}|{last['id']}"
return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor)
@app.get(
"/analyze/{company_name}",
response_model=CompanyAnalysisResponse,
@@ -900,58 +1011,6 @@ async def analyze_single_patent(
raise HTTPException(status_code=404, detail=str(e))
@app.get(
"/analyze/batch",
response_model=PaginatedAnalysisResponse,
tags=["Analysis"],
)
async def list_analysis_results(
company_name: Annotated[
str | None,
Query(description="Filter results by company name"),
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[
str | None,
Query(description="Opaque cursor from a previous response's next_cursor field"),
] = None,
_: UserResponse = Depends(get_current_user),
):
"""List stored analysis results with cursor-based pagination.
Returns past analysis results ordered by timestamp descending. Use
``limit`` to control page size (default 50, max 200). The response
includes a ``next_cursor`` field; pass it back as the ``cursor`` query
parameter to fetch the next page. When ``next_cursor`` is ``null``,
there are no more results.
Args:
company_name: Optional filter by company name
limit: Maximum number of results to return (default 50, max 200)
cursor: Opaque pagination cursor from a previous response
Returns:
Paginated list of analysis results
"""
db = _get_job_db()
rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor)
has_next = len(rows) > limit
if has_next:
rows = rows[:limit]
items = [AnalysisRecord(**row) for row in rows]
next_cursor = None
if has_next and rows:
last = rows[-1]
ts = last["timestamp"]
ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
next_cursor = f"{ts_str}|{last['id']}"
return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor)
@app.post(
"/analyze/batch",
response_model=BatchAnalysisResponse,
+79 -3
View File
@@ -1,5 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics } from '../types';
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics, PaginatedJobsResponse, PaginatedAnalysisResponse } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -141,15 +141,60 @@ export const analysisApi = {
return response.data;
},
listJobs: async (status?: string, limit = 10): Promise<JobStatus[]> => {
listJobs: async (status?: string, limit = 50, cursor?: string): Promise<PaginatedJobsResponse> => {
const params = new URLSearchParams();
if (status) params.append('status', status);
params.append('limit', limit.toString());
const response = await api.get<JobStatus[]>(`/jobs?${params}`);
if (cursor) params.append('cursor', cursor);
const response = await api.get<PaginatedJobsResponse>(`/jobs?${params}`);
return response.data;
},
listBatchAnalyses: async (companyName?: string, limit = 50, cursor?: string): Promise<PaginatedAnalysisResponse> => {
const params = new URLSearchParams();
if (companyName) params.append('company_name', companyName);
params.append('limit', limit.toString());
if (cursor) params.append('cursor', cursor);
const response = await api.get<PaginatedAnalysisResponse>(`/analyze/batch?${params}`);
return response.data;
},
getCompanyHistory: async (companyName: string, limit = 20): Promise<AnalysisHistoryItem[]> => {
const response = await api.get<AnalysisHistoryItem[]>(
`/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}`
);
return response.data;
},
diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise<AnalysisDiff> => {
const response = await api.get<AnalysisDiff>(
`/analyze/${encodeURIComponent(companyName)}/diff?from=${fromId}&to=${toId}`
);
return response.data;
},
};
// Analysis diff types
export interface AnalysisHistoryItem {
id: number;
analysis_type: string | null;
model: string | null;
timestamp: string;
}
export interface AnalysisDiff {
company_name: string;
from_id: number;
to_id: number;
from_timestamp: string;
to_timestamp: string;
patent_count_delta: number;
added_patents: string[];
removed_patents: string[];
changed_fields: Record<string, { from: string | null; to: string | null }>;
summary: string;
}
// Export API
export const exportApi = {
exportCsv: async (companyName: string): Promise<void> => {
@@ -201,6 +246,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
export const adminApi = {
listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
@@ -216,6 +287,11 @@ export const adminApi = {
deleteUser: async (userId: number): Promise<void> => {
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;
+96 -5
View File
@@ -222,7 +222,17 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* List Batch Analyses
* @description List stored analysis results with cursor-based pagination.
*
* Returns past analysis results ordered by timestamp descending. Use
* ``limit`` to control page size (default 50, max 200). The response
* includes a ``next_cursor`` field; pass it back as the ``cursor`` query
* parameter to fetch the next page. When ``next_cursor`` is ``null``,
* there are no more results.
*/
get: operations["list_batch_analyses_analyze_batch_get"];
put?: never;
/**
* Analyze Companies Batch
@@ -308,14 +318,15 @@ export interface paths {
};
/**
* List Jobs
* @description List all analysis jobs.
* @description List analysis jobs with cursor-based pagination.
*
* Args:
* status: Optional filter by job status
* limit: Maximum number of jobs to return (default 10, max 100)
* limit: Maximum number of jobs to return (default 50, max 200)
* cursor: Opaque cursor from a previous response's next_cursor field
*
* Returns:
* List of job statuses
* Paginated list of job statuses with next_cursor for subsequent pages
*/
get: operations["list_jobs_jobs_get"];
put?: never;
@@ -330,6 +341,27 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* AnalysisRecord
* @description A single stored analysis result.
*/
AnalysisRecord: {
/** Id */
id: number;
/** Company Name */
company_name?: string | null;
/** Analysis Type */
analysis_type?: string | null;
/** Model */
model?: string | null;
/** Response */
response?: string | null;
/**
* Timestamp
* Format: date-time
*/
timestamp?: string | null;
};
/**
* AnalyticsResponse
* @description Analytics response model.
@@ -425,6 +457,26 @@ export interface components {
*/
timestamp: string;
};
/**
* PaginatedAnalysisResponse
* @description Paginated response for analysis result listings.
*/
PaginatedAnalysisResponse: {
/** Items */
items: components["schemas"]["AnalysisRecord"][];
/** Next Cursor */
next_cursor?: string | null;
};
/**
* PaginatedJobsResponse
* @description Paginated response for job listings.
*/
PaginatedJobsResponse: {
/** Items */
items: components["schemas"]["JobStatus"][];
/** Next Cursor */
next_cursor?: string | null;
};
/**
* JobStatus
* @description Status of a background analysis job.
@@ -944,7 +996,10 @@ export interface operations {
query?: {
/** @description Filter by status: pending, running, completed, failed */
status?: string | null;
/** @description Maximum number of jobs to return (default 50, max 200) */
limit?: number;
/** @description Opaque cursor from a previous response's next_cursor field */
cursor?: string | null;
};
header?: never;
path?: never;
@@ -958,7 +1013,43 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobStatus"][];
"application/json": components["schemas"]["PaginatedJobsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_batch_analyses_analyze_batch_get: {
parameters: {
query?: {
/** @description Filter results by company name */
company_name?: string | null;
/** @description Maximum number of results to return (default 50, max 200) */
limit?: number;
/** @description Opaque cursor from a previous response's next_cursor field */
cursor?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedAnalysisResponse"];
};
};
/** @description Validation Error */
+5
View File
@@ -30,3 +30,8 @@ export type HealthResponse = components['schemas']['HealthResponse'];
export type BatchAnalysisRequest = components['schemas']['BatchAnalysisRequest'];
export type ValidationError = components['schemas']['ValidationError'];
export type HTTPValidationError = components['schemas']['HTTPValidationError'];
// Pagination types
export type AnalysisRecord = components['schemas']['AnalysisRecord'];
export type PaginatedAnalysisResponse = components['schemas']['PaginatedAnalysisResponse'];
export type PaginatedJobsResponse = components['schemas']['PaginatedJobsResponse'];
+171 -19
View File
@@ -1,12 +1,13 @@
"""Tests for cursor-based pagination on /analyze/batch GET and /jobs endpoints."""
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, Mock, patch
import pytest
from fastapi.testclient import TestClient
from SPARC.api import app
from SPARC.auth import create_access_token
@pytest.fixture
@@ -15,6 +16,27 @@ def client():
return TestClient(app)
@pytest.fixture(autouse=True)
def mock_auth_db():
"""Mock the auth DB so JWT token validation succeeds without a real database."""
db = MagicMock()
db.get_user_by_id.return_value = {
"id": 1,
"email": "user@test.com",
"role": "user",
"created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
}
with patch("SPARC.api.get_db_client", return_value=db), \
patch("SPARC.auth.get_db_client", return_value=db):
yield db
def _auth_header():
"""Create a Bearer auth header for a regular user."""
token = create_access_token(1, "user@test.com", "user")
return {"Authorization": f"Bearer {token}"}
def _make_analysis_row(id_: int, minutes_ago: int = 0, company: str = "nvidia"):
"""Create a fake analysis row dict."""
ts = datetime.now() - timedelta(minutes=minutes_ago)
@@ -56,7 +78,7 @@ class TestAnalyzeBatchGetPagination:
]
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=10")
response = client.get("/analyze/batch?limit=10", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
@@ -71,7 +93,7 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = rows
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=3")
response = client.get("/analyze/batch?limit=3", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 3
@@ -84,11 +106,14 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?cursor=2025-01-01T00:00:00|42")
client.get("/analyze/batch?cursor=2025-01-01T00:00:00|42", headers=_auth_header())
db.list_analyses.assert_called_once()
call_kwargs = db.list_analyses.call_args
assert call_kwargs.kwargs.get("cursor") == "2025-01-01T00:00:00|42" or \
(call_kwargs[1].get("cursor") == "2025-01-01T00:00:00|42" if len(call_kwargs) > 1 else False)
cursor_val = (
call_kwargs.kwargs.get("cursor")
or (call_kwargs[1].get("cursor") if len(call_kwargs) > 1 else None)
)
assert cursor_val == "2025-01-01T00:00:00|42"
@patch("SPARC.api._get_job_db")
def test_default_limit_is_50(self, mock_get_db, client):
@@ -97,19 +122,19 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch")
client.get("/analyze/batch", headers=_auth_header())
call_kwargs = db.list_analyses.call_args
# The endpoint requests limit+1 from DB, so 51
assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51
def test_limit_over_200_rejected(self, client):
"""Limit > 200 should be rejected with 422."""
response = client.get("/analyze/batch?limit=201")
response = client.get("/analyze/batch?limit=201", headers=_auth_header())
assert response.status_code == 422
def test_limit_zero_rejected(self, client):
"""Limit < 1 should be rejected with 422."""
response = client.get("/analyze/batch?limit=0")
response = client.get("/analyze/batch?limit=0", headers=_auth_header())
assert response.status_code == 422
@patch("SPARC.api._get_job_db")
@@ -119,10 +144,13 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?company_name=intel")
client.get("/analyze/batch?company_name=intel", headers=_auth_header())
call_kwargs = db.list_analyses.call_args
assert call_kwargs.kwargs.get("company_name") == "intel" or \
"intel" in (call_kwargs.args if call_kwargs.args else [])
company_val = (
call_kwargs.kwargs.get("company_name")
or (call_kwargs[1].get("company_name") if len(call_kwargs) > 1 else None)
)
assert company_val == "intel"
@patch("SPARC.api._get_job_db")
def test_empty_result_set(self, mock_get_db, client):
@@ -131,15 +159,30 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
response = client.get("/analyze/batch")
response = client.get("/analyze/batch", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["next_cursor"] is None
@patch("SPARC.api._get_job_db")
def test_subsequent_page_uses_cursor(self, mock_get_db, client):
"""Passing a cursor should retrieve the next page of results."""
db = Mock()
db.list_analyses.return_value = [_make_analysis_row(99, minutes_ago=100)]
mock_get_db.return_value = db
class TestJobsPaginationDefaults:
"""Test that /jobs endpoint uses updated defaults."""
cursor = "2025-06-01T12:00:00|50"
response = client.get(f"/analyze/batch?limit=10&cursor={cursor}", headers=_auth_header())
assert response.status_code == 200
data = response.json()
# Only one item returned → last page → no next cursor
assert len(data["items"]) == 1
assert data["next_cursor"] is None
class TestJobsPagination:
"""Test cursor-based pagination on GET /jobs."""
@patch("SPARC.api._get_job_db")
def test_default_limit_is_50(self, mock_get_db, client):
@@ -148,14 +191,19 @@ class TestJobsPaginationDefaults:
db.list_jobs.return_value = []
mock_get_db.return_value = db
client.get("/jobs")
client.get("/jobs", headers=_auth_header())
call_kwargs = db.list_jobs.call_args
# Endpoint requests limit+1 from DB, so 51
assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51
def test_limit_over_200_rejected(self, client):
"""Limit > 200 should be rejected with 422."""
response = client.get("/jobs?limit=201")
response = client.get("/jobs?limit=201", headers=_auth_header())
assert response.status_code == 422
def test_limit_zero_rejected(self, client):
"""Limit < 1 should be rejected with 422."""
response = client.get("/jobs?limit=0", headers=_auth_header())
assert response.status_code == 422
@patch("SPARC.api._get_job_db")
@@ -165,5 +213,109 @@ class TestJobsPaginationDefaults:
db.list_jobs.return_value = []
mock_get_db.return_value = db
response = client.get("/jobs?limit=200")
response = client.get("/jobs?limit=200", headers=_auth_header())
assert response.status_code == 200
@patch("SPARC.api._get_job_db")
def test_first_page_returns_items_and_cursor(self, mock_get_db, client):
"""First page with more results than limit should return next_cursor."""
db = Mock()
# Return limit+1 rows to simulate more data available
rows = [_make_job_row(f"job-{i}", minutes_ago=i) for i in range(4)]
db.list_jobs.return_value = rows
mock_get_db.return_value = db
response = client.get("/jobs?limit=3", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 3
assert data["next_cursor"] is not None
@patch("SPARC.api._get_job_db")
def test_last_page_returns_no_cursor(self, mock_get_db, client):
"""When fewer results than limit, next_cursor should be null (last page)."""
db = Mock()
rows = [
_make_job_row("job-a", minutes_ago=5),
_make_job_row("job-b", minutes_ago=10),
]
db.list_jobs.return_value = rows
mock_get_db.return_value = db
response = client.get("/jobs?limit=10", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["next_cursor"] is None
@patch("SPARC.api._get_job_db")
def test_cursor_forwarded_to_db(self, mock_get_db, client):
"""The cursor query param should be forwarded to the database layer."""
db = Mock()
db.list_jobs.return_value = []
mock_get_db.return_value = db
client.get("/jobs?cursor=2025-01-01T00:00:00|job-99", headers=_auth_header())
db.list_jobs.assert_called_once()
call_kwargs = db.list_jobs.call_args
cursor_val = (
call_kwargs.kwargs.get("cursor")
or (call_kwargs[1].get("cursor") if len(call_kwargs) > 1 else None)
)
assert cursor_val == "2025-01-01T00:00:00|job-99"
@patch("SPARC.api._get_job_db")
def test_empty_result_set(self, mock_get_db, client):
"""Empty result set returns empty items list and null next_cursor."""
db = Mock()
db.list_jobs.return_value = []
mock_get_db.return_value = db
response = client.get("/jobs", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["next_cursor"] is None
@patch("SPARC.api._get_job_db")
def test_status_filter_forwarded(self, mock_get_db, client):
"""The status filter should be forwarded to the database layer."""
db = Mock()
db.list_jobs.return_value = []
mock_get_db.return_value = db
client.get("/jobs?status=completed", headers=_auth_header())
db.list_jobs.assert_called_once()
call_kwargs = db.list_jobs.call_args
status_val = (
call_kwargs.kwargs.get("status")
or (call_kwargs[1].get("status") if len(call_kwargs) > 1 else None)
)
assert status_val == "completed"
@patch("SPARC.api._get_job_db")
def test_response_has_paginated_shape(self, mock_get_db, client):
"""Response must have 'items' and 'next_cursor' fields (paginated shape)."""
db = Mock()
db.list_jobs.return_value = [_make_job_row("job-x")]
mock_get_db.return_value = db
response = client.get("/jobs?limit=10", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "next_cursor" in data
@patch("SPARC.api._get_job_db")
def test_subsequent_page_uses_cursor(self, mock_get_db, client):
"""Passing cursor returns the next page; last page has null next_cursor."""
db = Mock()
db.list_jobs.return_value = [_make_job_row("job-last", minutes_ago=200)]
mock_get_db.return_value = db
cursor = "2025-06-01T12:00:00|job-50"
response = client.get(f"/jobs?limit=10&cursor={cursor}", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["next_cursor"] is None
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the /admin/rate-limits endpoint."""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from SPARC import api
from SPARC.api import app
from SPARC.auth import UserResponse
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
@pytest.fixture(autouse=True)
def reset_stats():
"""Reset rate limit stats between tests."""
api._rate_limit_stats.clear()
yield
api._rate_limit_stats.clear()
def _mock_admin():
"""Return a mock admin user."""
return UserResponse(id=1, email="admin@test.com", role="admin", created_at="2025-01-01T00:00:00")
def _mock_user():
"""Return a mock non-admin user."""
return UserResponse(id=2, email="user@test.com", role="user", created_at="2025-01-01T00:00:00")
class TestRateLimitAdminEndpoint:
"""Test GET /admin/rate-limits."""
def test_admin_can_access(self, client):
"""Admin users should be able to access the rate-limits endpoint."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
assert response.status_code == 200
data = response.json()
assert "rate_limits" in data
assert isinstance(data["rate_limits"], list)
finally:
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
response = client.get("/admin/rate-limits")
assert response.status_code in (401, 403)
def test_returns_configured_endpoints(self, client):
"""Should list all rate-limited endpoints."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
assert response.status_code == 200
data = response.json()
endpoints = [rl["endpoint"] for rl in data["rate_limits"]]
assert "/auth/register" in endpoints
assert "/auth/login" in endpoints
finally:
app.dependency_overrides.clear()
def test_empty_state_shows_zero_counts(self, client):
"""When no requests have been made, counts should be zero."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
for rl in data["rate_limits"]:
assert rl["total_requests"] == 0
assert rl["rejected_requests"] == 0
finally:
app.dependency_overrides.clear()
def test_tracks_requests(self, client):
"""After making requests, the stats should reflect them."""
api._track_rate_limit_request("/auth/login", "127.0.0.1")
api._track_rate_limit_request("/auth/login", "127.0.0.1")
api._track_rate_limit_request("/auth/login", "192.168.1.1", rejected=True)
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")
assert login_stats["total_requests"] == 3
assert login_stats["rejected_requests"] == 1
finally:
app.dependency_overrides.clear()
def test_includes_limit_config(self, client):
"""Each endpoint entry should include the rate limit config string."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
for rl in data["rate_limits"]:
assert "limit" in rl
assert isinstance(rl["limit"], str)
finally:
app.dependency_overrides.clear()