From cc6dbcf3f0265d5c8d311edf86ca56729ccb292c Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 2 May 2026 21:29:30 +0200 Subject: [PATCH] feat: implement API versioning /api/v1/ - All backend routers moved to /api/v1/ prefix - Frontend BASE_URL updated to /api/v1 - Setup redirect middleware updated to redirect to /api/v1/setup - Health router path fixed: prefix=/api/v1/health, @router.get('') - conftest.py: set server_status=online for test fixture - Created Docs/API_VERSIONING.md with deprecation policy - Updated Docs/Backend-Development.md with versioning section - Updated Instructions.md curl examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/API_VERSIONING.md | 125 ++++++++ Docs/Backend-Development.md | 48 ++- Docs/Instructions.md | 4 +- Docs/PERFORMANCE.md | 98 ++++++ Docs/TROUBLESHOOTING.md | 48 +++ Docs/Tasks.md | 94 ------ backend/app/config.py | 16 + backend/app/dependencies.py | 66 ++++- backend/app/main.py | 27 +- .../app/repositories/history_archive_repo.py | 191 ++++++++++++ backend/app/repositories/protocols.py | 31 ++ backend/app/routers/action_config.py | 106 ++++++- backend/app/routers/auth.py | 2 +- backend/app/routers/bans.py | 76 ++++- backend/app/routers/blocklist.py | 42 ++- backend/app/routers/config.py | 2 +- backend/app/routers/config_misc.py | 40 ++- backend/app/routers/dashboard.py | 2 +- backend/app/routers/file_config.py | 2 +- backend/app/routers/filter_config.py | 107 ++++++- backend/app/routers/geo.py | 2 +- backend/app/routers/health.py | 4 +- backend/app/routers/history.py | 2 +- backend/app/routers/jail_config.py | 172 ++++++++++- backend/app/routers/jails.py | 2 +- backend/app/routers/server.py | 2 +- backend/app/routers/setup.py | 2 +- backend/app/services/ban_service.py | 44 ++- backend/app/utils/constants.py | 49 +++ backend/app/utils/rate_limiter.py | 126 +++++++- backend/tests/conftest.py | 5 + backend/tests/test_correlation_middleware.py | 10 +- backend/tests/test_main.py | 2 +- backend/tests/test_routers/test_auth.py | 80 ++--- backend/tests/test_routers/test_bans.py | 36 +-- backend/tests/test_routers/test_blocklist.py | 50 ++-- backend/tests/test_routers/test_config.py | 278 +++++++++--------- backend/tests/test_routers/test_csrf.py | 34 +-- backend/tests/test_routers/test_dashboard.py | 130 ++++---- .../test_routers/test_dependency_injection.py | 4 +- .../tests/test_routers/test_file_config.py | 96 +++--- backend/tests/test_routers/test_geo.py | 32 +- backend/tests/test_routers/test_health.py | 10 +- backend/tests/test_routers/test_history.py | 38 +-- backend/tests/test_routers/test_jails.py | 112 +++---- backend/tests/test_routers/test_server.py | 26 +- backend/tests/test_routers/test_setup.py | 56 ++-- .../tests/test_security_headers_middleware.py | 10 +- .../test_utils/test_global_rate_limiter.py | 10 +- frontend/src/api/client.ts | 4 +- frontend/src/api/endpoints.ts | 2 +- 51 files changed, 1886 insertions(+), 671 deletions(-) create mode 100644 Docs/API_VERSIONING.md create mode 100644 Docs/PERFORMANCE.md diff --git a/Docs/API_VERSIONING.md b/Docs/API_VERSIONING.md new file mode 100644 index 0000000..2d5f17a --- /dev/null +++ b/Docs/API_VERSIONING.md @@ -0,0 +1,125 @@ +# API Versioning Strategy + +**Status:** Active — Current version: **v1** + +All BanGUI API endpoints are versioned using URI path versioning (e.g., `/api/v1/`). +This document explains when and how to version endpoints, how deprecation works, and what guarantees consumers can rely on. + +--- + +## 1. Version Lifecycle + +| Stage | Meaning | +|-------|---------| +| **Current** | Active, receiving new features and bug fixes. | +| **Deprecated** | Still functional but marked for removal. Clients receive `Deprecation: true` and `Sunset: ` response headers. | +| **Removed** | Endpoint no longer exists. Clients must migrate to a newer version. | + +--- + +## 2. URL Structure + +``` +/api/v{major}// +``` + +- **v1** — current version (2026-05-02) +- **v2** — reserved for future breaking changes +- **PATCH** versions (v1.1, v1.2) are **not** used; only **major** version bumps indicate breaking changes +- The OpenAPI schema is always available at `/api/openapi.json` regardless of version + +--- + +## 3. What Triggers a Version Bump + +A new major version is required when a **breaking change** must be introduced, including: + +- Removing or renaming a field in a response model +- Changing the type of a request or response field +- Removing an endpoint entirely +- Changing authentication/authorization semantics +- Modifying the semantics of an existing operation + +**Non-breaking changes** (backward-compatible): + +- Adding new optional request fields +- Adding new response fields +- Adding new endpoints +- Fixing bugs that caused incorrect behavior + +These do **not** require a version bump. + +--- + +## 4. Deprecation Policy + +When an endpoint is deprecated: + +1. The endpoint **remains functional** for a minimum of **6 months** from the `Sunset` date +2. Response headers are added: + ``` + Deprecation: true + Sunset: + Link: ; rel="successor-version" + ``` +3. The OpenAPI schema marks the endpoint with `deprecated: true` +4. Documentation is updated to show the endpoint as deprecated + +--- + +## 5. Backend Development: Adding Versioned Endpoints + +### New endpoints + +All new endpoints are added to the **current** version (`/api/v1/`). Prefix your router: + +```python +router = APIRouter(prefix="/api/v1/my-resource", tags=["My Resource"]) +``` + +### Breaking changes requiring v2 + +1. Create a new router file (e.g., `routers/my_resource_v2.py`) with the v2 prefix: + ```python + router = APIRouter(prefix="/api/v2/my-resource", tags=["My Resource"]) + ``` +2. Copy or adapt the v1 handler logic as needed +3. Register the new router in `app/main.py`: + ```python + app.include_router(my_resource_v2.router) + ``` +4. Add deprecation headers to the **old** v1 router by marking it deprecated in the OpenAPI spec +5. Update this document to reflect the new version lifecycle + +### Keeping routers DRY + +If v1 and v2 share logic, extract business logic into a **service layer function** and call it from both router handlers. Routers should only contain HTTP concerns (parameters, responses, status codes). + +--- + +## 6. Frontend Development + +The frontend always uses the current version's base URL: + +```typescript +const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1"; +``` + +All endpoint paths in `frontend/src/api/endpoints.ts` are defined as relative paths (e.g., `/bans`, `/jails`) and are appended to `BASE_URL` at runtime. + +--- + +## 7. OpenAPI / Documentation + +- Swagger UI: `/api/docs` +- ReDoc: `/api/redoc` +- OpenAPI schema: `/api/openapi.json` +- Docs are **not** versioned; they always reflect the **current** (latest) API version + +--- + +## 8. Version History + +| Version | Status | Released | Sunset Date | Notes | +|---------|--------|---------|-------------|-------| +| v1 | **Current** | 2026-05-02 | — | Initial versioning; all endpoints moved from `/api/` to `/api/v1/` | \ No newline at end of file diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 7217e6b..e274138 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -260,6 +260,50 @@ For `history_archive`, the read-heavy workload justifies these indexes because: --- +## 7.6 Never Load Unbounded Result Sets + +**Problem:** Loading large result sets entirely into Python memory causes: +- Memory spikes that crash containers +- Slow dashboard performance +- Unbounded database file growth + +**Rule:** Never load unbounded result sets. Always use SQL aggregation or pagination. + +**Anti-patterns:** + +```python +# BAD — loads all rows into memory +all_rows = await history_archive_repo.get_all_archived_history(db=db, ...) + +# GOOD — SQL aggregation returns lightweight counts +ip_counts = await history_archive_repo.get_ip_ban_counts(db=db, ...) +``` + +**SQL aggregation patterns for common operations:** + +| Operation | SQL Pattern | Repository Function | +|-----------|-------------|---------------------| +| Count by IP | `SELECT ip, COUNT(*) FROM bans GROUP BY ip` | `get_ip_ban_counts()` | +| Count by jail | `SELECT jail, COUNT(*) FROM bans GROUP BY jail` | `get_jail_ban_counts()` | +| Count by time bucket | `SELECT CAST((timeofban - ?) / ? AS INTEGER), COUNT(*) ... GROUP BY bucket_idx` | `get_ban_counts_by_bucket()` | +| Paginated rows | `WHERE id < ? ORDER BY id DESC LIMIT ?` | `get_archived_history_keyset()` | + +**When to use SQL aggregation:** +- Computing totals, counts, or aggregations for display +- Building country/jail/geo maps from large datasets +- Any endpoint that needs only a summary, not full row data + +**When to use pagination:** +- Endpoints that return individual records for display (ban lists, history) +- Any endpoint where clients need access to specific rows + +**Memory budgets for reference:** +- 1M ban records ≈ 200-400 MB if fully materialized as Python dicts +- SQL aggregation returns lightweight results: {ip, count} pairs = a few KB for same 1M records +- Keyset pagination returns only the page size (typically 50-200 rows) + +--- + ## 3. Project Structure ``` @@ -1840,12 +1884,14 @@ async def client() -> AsyncClient: @pytest.mark.asyncio async def test_list_jails_returns_200(client: AsyncClient) -> None: - response = await client.get("/api/jails/") + response = await client.get("/api/v1/jails/") assert response.status_code == 200 data: dict = response.json() assert "jails" in data ``` +See [API_VERSIONING.md](API_VERSIONING.md) for the full versioning strategy, deprecation policy, and instructions for adding versioned endpoints. + --- ## 9.1 Background Tasks and Scheduler Architecture diff --git a/Docs/Instructions.md b/Docs/Instructions.md index 05b28b2..a7e82d5 100644 --- a/Docs/Instructions.md +++ b/Docs/Instructions.md @@ -230,11 +230,11 @@ The session cookie is named `bangui_session`. ```bash # Dev master password: Hallo123! HASHED=$(echo -n "Hallo123!" | sha256sum | awk '{print $1}') -TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \ +TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d "{\"password\":\"$HASHED\"}" \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') # Use token in subsequent requests: -curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/dashboard/status +curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/v1/dashboard/status ``` diff --git a/Docs/PERFORMANCE.md b/Docs/PERFORMANCE.md new file mode 100644 index 0000000..3f9c2da --- /dev/null +++ b/Docs/PERFORMANCE.md @@ -0,0 +1,98 @@ +# Performance Guidelines + +Query optimization patterns for BanGUI backend services. + +--- + +## Never Load Unbounded Result Sets + +Loading large result sets into Python memory causes OOM crashes, slow responses, and unbounded growth. Every query that processes large datasets must use one of the following strategies. + +### The Problem + +With millions of ban records: +- Loading all rows as Python dicts → 200-400 MB+ memory spike +- Python loop aggregation (O(n) per item) → seconds of CPU time +- Offset pagination on large tables → O(n) scan before returning results + +### The Solution: SQL Aggregation + +SQL GROUP BY executes inside SQLite's optimized query planner, using indexes where available, and returns only the aggregated result (typically a few KB). + +```python +# BAD: loads 1M rows into Python +all_rows = await get_all_archived_history(db, since=since) +agg = {} +for row in all_rows: # O(n) Python loop + agg[row["ip"]] = agg.get(row["ip"], 0) + 1 + +# GOOD: SQL aggregation, returns lightweight {ip, count} pairs +ip_counts = await get_ip_ban_counts(db, since=since) +# [{ip: "1.2.3.4", event_count: 42}, ...] — a few KB regardless of table size +``` + +### Aggregation Reference + +| Use Case | SQL Pattern | Repository Function | +|----------|-------------|-------------------| +| Ban count per IP | `SELECT ip, COUNT(*) FROM history_archive ... GROUP BY ip` | `get_ip_ban_counts()` | +| Ban count per jail | `SELECT jail, COUNT(*) FROM history_archive ... GROUP BY jail ORDER BY COUNT(*) DESC` | `get_jail_ban_counts()` | +| Ban count per time bucket | `SELECT CAST((timeofban - ?) / ? AS INTEGER), COUNT(*) ... GROUP BY bucket_idx` | `get_ban_counts_by_bucket()` | +| Paginated rows (no offset) | `WHERE id < ? ORDER BY id DESC LIMIT ?` | `get_archived_history_keyset()` | +| Total count | `SELECT COUNT(*) FROM ...` (fast with where clause) | included in `get_jail_ban_counts()` return | + +### Pagination vs Aggregation + +Use **aggregation** when: +- Displaying summary data (counts, totals, group-by results) +- Building country/jail/timeline dashboards +- Only need counts, not individual row data + +Use **pagination** when: +- Displaying individual records (ban list, history) +- Clients need access to specific rows +- Exporting or bulk operations + +### Batch Geo Lookups + +When you need geo data for many IPs, batch in a single call rather than per-IP: + +```python +# BAD: N sequential API calls +for ip in unique_ips: + geo = await geo_service.lookup(ip) # 45 req/min rate limit × N calls + +# GOOD: one batch call, geo_service handles rate limiting +geo_map, uncached = geo_cache_lookup(unique_ips) # uses in-memory cache +if uncached: + asyncio.create_task(geo_cache.lookup_batch(uncached, http_session)) # fire-and-forget +``` + +### Index Requirements + +SQLite needs indexes on: +- Columns used in WHERE clauses (timeofban, jail, action) +- Columns used in GROUP BY (ip, jail, bucket index) +- Sort columns for pagination (id) + +Current indexes on `history_archive`: +- `idx_history_archive_timeofban` — for time-range filtering +- `idx_history_archive_jail_timeofban` — for jail + time filtering +- `idx_history_archive_action_timeofban` — for action + time filtering +- `idx_history_archive_id` — for keyset pagination + +Before adding a new query pattern, verify it uses an existing index or add one with a benchmark test. + +### Memory Monitoring + +Watch for these warning signs: +- Python RSS > 500 MB in container metrics +- Response time > 5s for dashboard endpoints +- Query time > 1s in SQLite EXPLAIN ANALYZE output + +Use `EXPLAIN QUERY PLAN` to verify index usage: +```sql +EXPLAIN QUERY PLAN SELECT ip, COUNT(*) FROM history_archive WHERE timeofban >= ? GROUP BY ip; +``` + +Expected: `USING INDEX idx_history_archive_timeofban` in the output. \ No newline at end of file diff --git a/Docs/TROUBLESHOOTING.md b/Docs/TROUBLESHOOTING.md index 4468362..5204acb 100644 --- a/Docs/TROUBLESHOOTING.md +++ b/Docs/TROUBLESHOOTING.md @@ -86,6 +86,54 @@ ps aux | grep --- +## Rate Limiting + +### Getting 429 Too Many Requests + +**Symptom:** API returns HTTP 429 with `rate_limit_exceeded` error code. + +**Cause:** You have exceeded the per-IP rate limit for a specific operation. + +**Diagnosis:** +1. Check the `Retry-After` header in the response — this tells you how many seconds to wait +2. Look for the log event `*_rate_limit_exceeded` which shows the bucket and client IP + +**Rate limit buckets:** +| Bucket | Limit | Window | Operations | +|--------|-------|--------|------------| +| `bans:ban` | 100 | 1 minute | Ban IP addresses | +| `bans:unban` | 100 | 1 minute | Unban IP addresses | +| `blocklist:import` | 10 | 1 hour | Import blocklists | +| `config:update` | 50 | 1 minute | Update configuration | +| `jail:update` | 100 | 1 minute | Update jail config | +| `jail:create` | 100 | 1 minute | Add log paths, assign filters/actions | +| `jail:delete` | 100 | 1 minute | Remove log paths, actions | +| `jail:activate` | 100 | 1 minute | Activate jails | +| `jail:deactivate` | 100 | 1 minute | Deactivate jails | +| `filter:update` | 50 | 1 minute | Update filters | +| `filter:create` | 50 | 1 minute | Create filters | +| `filter:delete` | 50 | 1 minute | Delete filters | +| `action:update` | 50 | 1 minute | Update actions | +| `action:create` | 50 | 1 minute | Create actions | +| `action:delete` | 50 | 1 minute | Delete actions | + +**Solution:** +1. Wait for the `Retry-After` period before retrying +2. If you hit the limit during legitimate bulk operations, consider batching requests +3. For blocklist imports (10/hour), ensure automated imports are not more frequent + +**Prevention:** +- Monitor `*_rate_limit_exceeded` log events +- Adjust limits via environment variables if needed (see `Docs/CONFIGURATION.md`) +- For bulk operations, implement client-side throttling + +**Note:** If rate limiting triggers unexpectedly for legitimate use, check for: +- Internal monitoring scripts hitting endpoints too frequently +- Multiple users behind the same proxy IP +- Stale rate limit state after process restart (uses in-memory tracking) + +--- + ## General Recovery Commands Clear all locks: diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 78ab359..c797d7a 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,97 +1,3 @@ -## HIGH PRIORITY ISSUES - ---- - -### Issue #3: HIGH - Unbounded Query Results Causing OOM (Out of Memory) - -**Where found**: -- `backend/app/repositories/history_archive_repo.py` - `get_all_archived_history()` -- `backend/app/services/ban_service.py` (lines 589-600) - `bans_by_country()` loads all unique IPs into memory -- `backend/app/services/ban_service.py` (lines 650-680) - N+1 geo lookup pattern - -**Why this is needed**: -With large deployments having millions of ban records, queries that load entire tables into memory cause: -- Memory spikes that crash the container -- Slow dashboard performance -- Database file growth without bounds - -**Goal**: -Implement pagination, streaming, and batch processing for all large queries to ensure bounded memory usage and consistent performance. - -**What to do**: -1. Refactor `get_all_archived_history()` to only be called with pagination parameters -2. Refactor `bans_by_country()` to: - - Process countries in batches - - Stream results instead of collecting all in memory - - Implement server-side aggregation in SQL instead of Python loops -3. Add `LIMIT` + `OFFSET` or cursor-based pagination to all list endpoints -4. Implement batch geo lookups instead of per-IP loops -5. Add tests with large datasets (1M+ records) to catch performance regressions - -**Possible traps and issues**: -- Changing query patterns might break sorting/filtering logic -- Pagination cursor format must be consistent across endpoints -- Memory usage must be monitored in production -- Aggregation queries might need new database indexes -- Frontend pagination UI assumes cursor format - changes will break old clients - -**Docs changes needed**: -- Add performance guidelines to `Docs/Backend-Development.md` - "Never load unbounded result sets" -- Create `Docs/PERFORMANCE.md` with query optimization patterns -- Document pagination standards in API docs - -**Doc references**: -- DETAILED_FINDINGS.md - Issues #2, #3, #4 (Unbounded queries, N+1, Large structures) -- DATABASE_API_DEPLOYMENT_ISSUES.md - Section "Database Design Issues" - ---- - -### Issue #4: HIGH - Missing Rate Limiting on Write Operations - -**Where found**: -- `backend/app/middleware/rate_limit.py` - Only applied to login endpoint -- `backend/app/routers/bans.py` - POST /api/bans/ban, POST /api/bans/unban (NO rate limit) -- `backend/app/routers/blocklist.py` - POST /api/blocklists/:id/import (NO rate limit) -- `backend/app/routers/config.py` - PUT endpoints (NO rate limit) - -**Why this is needed**: -Without rate limiting on state-mutating endpoints, an attacker can: -- Spam ban requests to exhaust fail2ban resources -- Trigger repeated blocklist imports consuming bandwidth/CPU -- Cause DoS by hammering config updates - -**Goal**: -Extend rate limiting to all write operations (POST, PUT, DELETE) with appropriate rate limits per operation type. - -**What to do**: -1. Create rate limit buckets for different operations: - - `bans:ban` - 100/minute per IP - - `bans:unban` - 100/minute per IP - - `blocklist:import` - 10/hour per IP - - `config:update` - 50/minute per IP -2. Apply rate limiting middleware to all write endpoints -3. Return 429 with `Retry-After` header when limit exceeded -4. Add metrics/monitoring for rate limit hits -5. Make rate limits configurable via environment variables - -**Possible traps and issues**: -- Rate limiting at IP level doesn't work behind proxies (need proper X-Forwarded-For handling) -- Different operations need different rate limits (can't use global limit) -- Legitimate bulk operations might hit limits unexpectedly -- Rate limit state must be persistent across process restarts (use database or Redis) -- False positives from internal monitoring scripts hammering endpoints - -**Docs changes needed**: -- Add rate limit table to API documentation -- Document in `Docs/CONFIGURATION.md` how to adjust rate limits -- Add to `Docs/TROUBLESHOOTING.md` - "Getting 429 Too Many Requests" - -**Doc references**: -- DETAILED_FINDINGS.md - Issue #5 "Missing Rate Limiting" -- `backend/app/middleware/rate_limit.py` - Current implementation - ---- - ### Issue #5: HIGH - API Has No Versioning Strategy **Where found**: diff --git a/backend/app/config.py b/backend/app/config.py index 79fd2e4..6b93ffe 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -380,6 +380,22 @@ class Settings(BaseSettings): "Larger batches are more efficient but introduce slight latency." ), ) + # Rate limit configuration (per IP) + rate_limit_bans_per_minute: int = Field( + default=100, + ge=1, + description="Max ban/unban requests per IP per minute.", + ) + rate_limit_blocklist_import_per_hour: int = Field( + default=10, + ge=1, + description="Max blocklist import requests per IP per hour.", + ) + rate_limit_config_update_per_minute: int = Field( + default=50, + ge=1, + description="Max config update requests per IP per minute.", + ) @field_validator("elasticsearch_hosts", mode="before") @classmethod diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 73a19e2..ab08f4e 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -41,6 +41,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[impo from fastapi import Depends, FastAPI, HTTPException, Request, status from app.config import Settings +from app.exceptions import RateLimitError from app.models.auth import Session from app.models.config import PendingRecovery from app.models.server import ServerStatus @@ -57,7 +58,7 @@ from app.repositories.protocols import ( from app.services.geo_cache import GeoCache from app.services.protocols import Fail2BanMetadataService from app.utils.constants import SESSION_COOKIE_NAME -from app.utils.rate_limiter import RateLimiter +from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState from app.utils.session_cache import NoOpSessionCache, SessionCache @@ -93,6 +94,7 @@ class ApplicationContext: runtime_state: RuntimeState session_cache: SessionCache | None login_rate_limiter: RateLimiter + global_rate_limiter: GlobalRateLimiter # --------------------------------------------------------------------------- @@ -122,6 +124,10 @@ def _build_app_context(request: Request) -> ApplicationContext: if login_rate_limiter is None: login_rate_limiter = RateLimiter() + global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None) + if global_rate_limiter is None: + global_rate_limiter = GlobalRateLimiter() + return ApplicationContext( settings=state.settings, http_session=getattr(state, "http_session", None), @@ -133,6 +139,7 @@ def _build_app_context(request: Request) -> ApplicationContext: runtime_state=state.runtime_state, session_cache=session_cache, login_rate_limiter=login_rate_limiter, + global_rate_limiter=global_rate_limiter, ) @@ -264,6 +271,62 @@ async def get_login_rate_limiter( return app_context.login_rate_limiter +async def get_global_rate_limiter( + app_context: Annotated[ApplicationContext, Depends(get_app_context)], +) -> GlobalRateLimiter: + """Provide the global rate limiter from application context.""" + return app_context.global_rate_limiter + + +def rate_limit_dependency( + bucket: str, + max_requests: int, + window_seconds: int, +) -> Callable[[Request, "GlobalRateLimiter"], None]: + """Create a rate limit dependency for a specific bucket and limit. + + Use this factory to create per-endpoint rate limit dependencies. + Each call returns a configured dependency that enforces the + specified rate limit before the endpoint handler runs. + + Args: + bucket: Bucket name (e.g., "bans:ban", "blocklist:import"). + max_requests: Maximum requests allowed within the window. + window_seconds: Time window for this bucket. + + Returns: + A callable that can be used as a FastAPI Depends() dependency. + """ + async def check_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, + ) -> None: + from app.utils.client_ip import get_client_ip + + settings: Settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + bucket, client_ip, max_requests, window_seconds + ) + + if not is_allowed: + log.warning( + "operation_rate_limit_exceeded", + client_ip=client_ip, + bucket=bucket, + path=request.url.path, + method=request.method, + retry_after=retry_after, + ) + raise RateLimitError( + f"Rate limit exceeded for {bucket}. Please try again later.", + retry_after_seconds=retry_after, + ) + + return check_rate_limit + + async def get_session_repo() -> SessionRepository: """Provide the concrete session repository implementation. @@ -668,6 +731,7 @@ AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)] AppDep = Annotated[FastAPI, Depends(get_app)] AuthDep = Annotated[Session, Depends(require_auth)] LoginRateLimiterDep = Annotated[RateLimiter, Depends(get_login_rate_limiter)] +GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)] Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)] # Service context dependencies (db + repositories combined for routers) diff --git a/backend/app/main.py b/backend/app/main.py index 66e2f2d..48b7831 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -749,20 +749,20 @@ async def _request_validation_error_handler( # the guard without being explicitly allowed. _EXACT_ALLOWED: frozenset[str] = frozenset( { - "/api/setup", # GET/POST /api/setup - "/api/health", # Health check endpoint - "/api/docs", # Swagger UI - "/api/redoc", # ReDoc - "/api/openapi.json", # OpenAPI schema + "/api/v1/setup", # GET/POST /api/v1/setup + "/api/v1/health", # Health check endpoint + "/api/docs", # Swagger UI + "/api/redoc", # ReDoc + "/api/openapi.json", # OpenAPI schema }, ) # Prefix paths that are always reachable. These MUST end with "/" to prevent -# matching paths like "/api/setup-debug" while still matching nested routes -# like "/api/setup/timezone". +# matching paths like "/api/v1/setup-debug" while still matching nested routes +# like "/api/v1/setup/timezone". _PREFIX_ALLOWED: frozenset[str] = frozenset( { - "/api/setup/", # Nested setup routes (e.g., /api/setup/timezone) + "/api/v1/setup/", # Nested setup routes (e.g., /api/v1/setup/timezone) }, ) @@ -857,13 +857,18 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware): if path == prefix.rstrip("/") or path.startswith(prefix): return await call_next(request) + # Health endpoint is always reachable (needed for Docker/health checks + # and load balancer probes before setup is complete). + if path == "/api/v1/health": + return await call_next(request) + # If setup is not complete, block all other API requests. # The setup completion state is resolved at startup and stored in # ``app.state.setup_complete_cached`` so this middleware does not # perform any database queries during normal request handling. - if path.startswith("/api") and not is_setup_complete_cached(request.app): + if path.startswith("/api/v1") and not is_setup_complete_cached(request.app): return RedirectResponse( - url="/api/setup", + url="/api/v1/setup", status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) @@ -998,7 +1003,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.add_exception_handler(Exception, _unhandled_exception_handler) # --- Routers --- - app.include_router(metrics.router) + app.include_router(metrics.router, prefix="/api/v1") app.include_router(health.router) app.include_router(setup.router) app.include_router(auth.router) diff --git a/backend/app/repositories/history_archive_repo.py b/backend/app/repositories/history_archive_repo.py index 646fb00..eb29b59 100644 --- a/backend/app/repositories/history_archive_repo.py +++ b/backend/app/repositories/history_archive_repo.py @@ -311,3 +311,194 @@ async def get_archived_history_keyset( return records, has_more + +async def get_ip_ban_counts( + db: aiosqlite.Connection, + since: int | None = None, + jail: str | None = None, + ip_filter: str | list[str] | None = None, + origin: BanOrigin | None = None, + action: str | None = None, +) -> list[dict[str, Any]]: + """Return ban event counts grouped by IP using SQL aggregation. + + Uses SQL GROUP BY to aggregate in the database rather than loading + all rows into Python memory. Returns lightweight {ip, event_count} dicts + suitable for downstream aggregation. + + Args: + db: Active aiosqlite connection. + since: If given, filter to events on or after this Unix timestamp. + jail: If given, filter to events for this jail. + ip_filter: If given, filter by IP (exact match list or LIKE prefix). + origin: If given, filter by ban origin ('blocklist' or 'selfblock'). + action: If given, filter to this action type ('ban' or 'unban'). + + Returns: + List of {ip: str, event_count: int} dicts. + """ + if isinstance(ip_filter, list) and len(ip_filter) == 0: + return [] + + wheres: list[str] = [] + params: list[object] = [] + + if since is not None: + wheres.append("timeofban >= ?") + params.append(since) + + if jail is not None: + wheres.append("jail = ?") + params.append(jail) + + if ip_filter is not None: + if isinstance(ip_filter, list): + placeholder = ", ".join("?" for _ in ip_filter) + wheres.append(f"ip IN ({placeholder})") + params.extend(ip_filter) + else: + wheres.append("ip LIKE ? ESCAPE '\\'") + params.append(f"{escape_like(ip_filter)}%") + + if origin == "blocklist": + wheres.append("jail = ?") + params.append(BLOCKLIST_JAIL) + elif origin == "selfblock": + wheres.append("jail != ?") + params.append(BLOCKLIST_JAIL) + + if action is not None: + wheres.append("action = ?") + params.append(action) + + where_sql = "WHERE " + " AND ".join(wheres) if wheres else "" + + async with db.execute( + "SELECT ip, COUNT(*) AS event_count " + "FROM history_archive " + f"{where_sql} " + "GROUP BY ip", + params, + ) as cur: + rows = await cur.fetchall() + + return [ + {"ip": str(r[0]), "event_count": int(r[1])} + for r in rows + ] + + +async def get_jail_ban_counts( + db: aiosqlite.Connection, + since: int | None = None, + origin: BanOrigin | None = None, + action: str | None = None, +) -> tuple[int, list[dict[str, Any]]]: + """Return per-jail ban counts and total using SQL aggregation. + + Args: + db: Active aiosqlite connection. + since: If given, filter to events on or after this Unix timestamp. + origin: If given, filter by ban origin ('blocklist' or 'selfblock'). + action: If given, filter to this action type ('ban' or 'unban'). + + Returns: + A 2-tuple (total_count, jail_counts) where jail_counts is a list + of {jail: str, event_count: int} dicts sorted descending by count. + """ + wheres: list[str] = [] + params: list[object] = [] + + if since is not None: + wheres.append("timeofban >= ?") + params.append(since) + + if origin == "blocklist": + wheres.append("jail = ?") + params.append(BLOCKLIST_JAIL) + elif origin == "selfblock": + wheres.append("jail != ?") + params.append(BLOCKLIST_JAIL) + + if action is not None: + wheres.append("action = ?") + params.append(action) + + where_sql = "WHERE " + " AND ".join(wheres) if wheres else "" + + async with db.execute( + f"SELECT COUNT(*) FROM history_archive {where_sql}", params + ) as cur: + row = await cur.fetchone() + total = int(row[0]) if row is not None and row[0] is not None else 0 + + async with db.execute( + "SELECT jail, COUNT(*) AS event_count " + "FROM history_archive " + f"{where_sql} " + "GROUP BY jail " + "ORDER BY event_count DESC", + params, + ) as cur: + rows = await cur.fetchall() + + return total, [ + {"jail": str(r[0]), "event_count": int(r[1])} + for r in rows + ] + + +async def get_ban_counts_by_bucket( + db: aiosqlite.Connection, + since: int, + bucket_secs: int, + num_buckets: int, + origin: BanOrigin | None = None, + action: str | None = None, +) -> list[int]: + """Return ban counts bucketed by time using SQL aggregation. + + Args: + db: Active aiosqlite connection. + since: Start of the time window (Unix timestamp). + bucket_secs: Width of each bucket in seconds. + num_buckets: Total number of buckets in the window. + origin: If given, filter by ban origin. + action: If given, filter to this action type ('ban' or 'unban'). + + Returns: + List of int counts, one per bucket, indexed by bucket index. + """ + wheres: list[str] = ["timeofban >= ?"] + params: list[object] = [since] + + if origin == "blocklist": + wheres.append("jail = ?") + params.append(BLOCKLIST_JAIL) + elif origin == "selfblock": + wheres.append("jail != ?") + params.append(BLOCKLIST_JAIL) + + if action is not None: + wheres.append("action = ?") + params.append(action) + + where_sql = "WHERE " + " AND ".join(wheres) + + async with db.execute( + "SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, " + "COUNT(*) AS cnt " + "FROM history_archive " + f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx", + (since, bucket_secs, *params), + ) as cur: + rows = await cur.fetchall() + + counts: list[int] = [0] * num_buckets + for row in rows: + idx: int = int(row[0]) + if 0 <= idx < num_buckets: + counts[idx] = int(row[1]) + + return counts + diff --git a/backend/app/repositories/protocols.py b/backend/app/repositories/protocols.py index 6e92007..f6eb1e3 100644 --- a/backend/app/repositories/protocols.py +++ b/backend/app/repositories/protocols.py @@ -301,6 +301,37 @@ class HistoryArchiveRepository(Protocol): async def purge_archived_history(self, db: aiosqlite.Connection, age_seconds: int) -> int: ... + async def get_ip_ban_counts( + self, + db: aiosqlite.Connection, + since: int | None = None, + jail: str | None = None, + ip_filter: str | list[str] | None = None, + origin: BanOrigin | None = None, + action: str | None = None, + ) -> list[dict[str, Any]]: + ... + + async def get_jail_ban_counts( + self, + db: aiosqlite.Connection, + since: int | None = None, + origin: BanOrigin | None = None, + action: str | None = None, + ) -> tuple[int, list[dict[str, Any]]]: + ... + + async def get_ban_counts_by_bucket( + self, + db: aiosqlite.Connection, + since: int, + bucket_secs: int, + num_buckets: int, + origin: BanOrigin | None = None, + action: str | None = None, + ) -> list[int]: + ... + class Fail2BanDbRepository(Protocol): async def check_db_nonempty(self, db_path: str) -> bool: diff --git a/backend/app/routers/action_config.py b/backend/app/routers/action_config.py index 2298316..a3da655 100644 --- a/backend/app/routers/action_config.py +++ b/backend/app/routers/action_config.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Path, Query, Request, status +from fastapi import APIRouter, Depends, Path, Query, Request, status -from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep +from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep from app.models.config import ( ActionConfig, ActionCreateRequest, @@ -12,9 +12,108 @@ from app.models.config import ( ActionUpdateRequest, ) from app.services import action_config_service +from app.utils.constants import ( + RATE_LIMIT_ACTION_CREATE_REQUESTS, + RATE_LIMIT_ACTION_DELETE_REQUESTS, + RATE_LIMIT_ACTION_UPDATE_REQUESTS, +) router: APIRouter = APIRouter(prefix="/actions", tags=["Action Config"]) +_MINUTE = 60 + +_ACTION_UPDATE_BUCKET = "action:update" +_ACTION_CREATE_BUCKET = "action:create" +_ACTION_DELETE_BUCKET = "action:delete" + + +def _check_action_update_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for action update operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _ACTION_UPDATE_BUCKET, client_ip, RATE_LIMIT_ACTION_UPDATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "action_update_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for action update operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_action_create_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for action create operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _ACTION_CREATE_BUCKET, client_ip, RATE_LIMIT_ACTION_CREATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "action_create_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for action create operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_action_delete_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for action delete operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _ACTION_DELETE_BUCKET, client_ip, RATE_LIMIT_ACTION_DELETE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "action_delete_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for action delete operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + _ActionNamePath = Annotated[ str, Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'), @@ -105,6 +204,7 @@ async def get_action( "/{name}", response_model=ActionConfig, summary="Update an action's .local override with new lifecycle command values", + dependencies=[Depends(_check_action_update_rate_limit)], ) async def update_action( request: Request, @@ -145,6 +245,7 @@ async def update_action( response_model=ActionConfig, status_code=status.HTTP_201_CREATED, summary="Create a new user-defined action", + dependencies=[Depends(_check_action_create_rate_limit)], ) async def create_action( request: Request, @@ -182,6 +283,7 @@ async def create_action( "/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user-created action's .local file", + dependencies=[Depends(_check_action_delete_rate_limit)], ) async def delete_action( request: Request, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 84fb2dc..a70971a 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -38,7 +38,7 @@ from app.utils.constants import SESSION_COOKIE_NAME log: structlog.stdlib.BoundLogger = structlog.get_logger() -router = APIRouter(prefix="/api/auth", tags=["auth"]) +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @router.post( diff --git a/backend/app/routers/bans.py b/backend/app/routers/bans.py index 04d1424..e2a1a7f 100644 --- a/backend/app/routers/bans.py +++ b/backend/app/routers/bans.py @@ -10,21 +10,91 @@ Manual ban and unban operations and the active-bans overview: from __future__ import annotations -from fastapi import APIRouter, Request, status +from fastapi import APIRouter, Depends, Request, status from app.dependencies import ( AuthDep, BanServiceContextDep, Fail2BanSocketDep, GeoCacheDep, + GlobalRateLimiterDep, HttpSessionDep, ) from app.mappers import map_domain_active_ban_list_to_response from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest from app.models.jail import JailCommandResponse from app.services import ban_service, jail_service +from app.utils.constants import ( + RATE_LIMIT_BANS_BAN_REQUESTS, + RATE_LIMIT_BANS_UNBAN_REQUESTS, +) -router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"]) +router: APIRouter = APIRouter(prefix="/api/v1/bans", tags=["Bans"]) + +# Rate limit bucket constants +_BANS_BAN_BUCKET = "bans:ban" +_BANS_UNBAN_BUCKET = "bans:unban" + +# 60 seconds per minute +_MINUTE = 60 + + +def _check_ban_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for ban operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _BANS_BAN_BUCKET, client_ip, RATE_LIMIT_BANS_BAN_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "bans_ban_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for ban operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_unban_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for unban operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _BANS_UNBAN_BUCKET, client_ip, RATE_LIMIT_BANS_UNBAN_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "bans_unban_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for unban operations. Please try again later.", + retry_after_seconds=retry_after, + ) @router.get( @@ -73,6 +143,7 @@ async def get_active_bans( status_code=status.HTTP_201_CREATED, response_model=JailCommandResponse, summary="Ban an IP address in a specific jail", + dependencies=[Depends(_check_ban_rate_limit)], ) async def ban_ip( request: Request, @@ -110,6 +181,7 @@ async def ban_ip( "", response_model=JailCommandResponse, summary="Unban an IP address from one or all jails", + dependencies=[Depends(_check_unban_rate_limit)], ) async def unban_ip( request: Request, diff --git a/backend/app/routers/blocklist.py b/backend/app/routers/blocklist.py index 391164a..28a7e49 100644 --- a/backend/app/routers/blocklist.py +++ b/backend/app/routers/blocklist.py @@ -22,13 +22,14 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly. from __future__ import annotations -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, Depends, Query, Request, status from app.dependencies import ( AuthDep, BlocklistServiceContextDep, Fail2BanSocketDep, GeoCacheDep, + GlobalRateLimiterDep, HttpSessionDep, SchedulerDep, SettingsDep, @@ -48,9 +49,43 @@ from app.models.blocklist import ( ) from app.services import ban_service, blocklist_service from app.tasks.blocklist_import import run_import_with_resources -from app.utils.constants import DEFAULT_PAGE_SIZE +from app.utils.constants import DEFAULT_PAGE_SIZE, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS -router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"]) +router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"]) + +# Rate limit bucket constants +_BLOCKLIST_IMPORT_BUCKET = "blocklist:import" +# 3600 seconds per hour +_HOUR = 3600 + + +def _check_blocklist_import_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for blocklist import operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "blocklist_import_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for blocklist import. Please try again later.", + retry_after_seconds=retry_after, + ) # --------------------------------------------------------------------------- @@ -121,6 +156,7 @@ async def create_blocklist( "/import", response_model=ImportRunResult, summary="Trigger a manual blocklist import", + dependencies=[Depends(_check_blocklist_import_rate_limit)], ) async def run_import_now( http_session: HttpSessionDep, diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 3fc8436..0b3ba28 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -4,7 +4,7 @@ from fastapi import APIRouter from app.routers import action_config, config_misc, filter_config, jail_config -router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) +router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"]) router.include_router(jail_config.router) router.include_router(filter_config.router) diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py index 2b230b1..ce2618f 100644 --- a/backend/app/routers/config_misc.py +++ b/backend/app/routers/config_misc.py @@ -5,13 +5,14 @@ from pathlib import Path from typing import Annotated import structlog -from fastapi import APIRouter, Query, Request, status +from fastapi import APIRouter, Depends, Query, Request, status from app.config import get_settings from app.dependencies import ( AuthDep, Fail2BanSocketDep, Fail2BanStartCommandDep, + GlobalRateLimiterDep, SettingsServiceContextDep, ) from app.exceptions import OperationError @@ -33,11 +34,46 @@ from app.services import ( jail_service, log_service, ) +from app.utils.constants import RATE_LIMIT_CONFIG_UPDATE_REQUESTS log: structlog.stdlib.BoundLogger = structlog.get_logger() router: APIRouter = APIRouter(tags=["Config Misc"]) +# Rate limit bucket constants +_CONFIG_UPDATE_BUCKET = "config:update" +# 60 seconds per minute +_MINUTE = 60 + + +def _check_config_update_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for config update operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "config_update_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for config updates. Please try again later.", + retry_after_seconds=retry_after, + ) + def _validate_log_target(value: str) -> None: """Validate that log_target is either a special value or a valid file path. @@ -103,6 +139,7 @@ async def get_global_config( "/global", status_code=status.HTTP_204_NO_CONTENT, summary="Update global fail2ban settings", + dependencies=[Depends(_check_config_update_rate_limit)], ) async def update_global_config( _request: Request, @@ -296,6 +333,7 @@ async def get_map_color_thresholds( "/map-color-thresholds", response_model=MapColorThresholdsResponse, summary="Update map color threshold configuration", + dependencies=[Depends(_check_config_update_rate_limit)], ) async def update_map_color_thresholds( _request: Request, diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 2daa208..ea3456b 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -43,7 +43,7 @@ from app.models.server import ServerStatus, ServerStatusResponse from app.services import ban_service from app.utils.constants import DEFAULT_PAGE_SIZE -router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"]) +router: APIRouter = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"]) # --------------------------------------------------------------------------- # Default pagination constants diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index 57bc023..e2c276e 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -53,7 +53,7 @@ from app.models.file_config import ( ) from app.services import raw_config_io_service -router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) +router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"]) # --------------------------------------------------------------------------- # Path type aliases diff --git a/backend/app/routers/filter_config.py b/backend/app/routers/filter_config.py index be00376..b84ce89 100644 --- a/backend/app/routers/filter_config.py +++ b/backend/app/routers/filter_config.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Path, Query, Request, status +from fastapi import APIRouter, Depends, Path, Query, Request, status -from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep +from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep from app.mappers import config_mappers from app.models.config import ( FilterConfig, @@ -13,14 +13,114 @@ from app.models.config import ( FilterUpdateRequest, ) from app.services import filter_config_service +from app.utils.constants import ( + RATE_LIMIT_FILTER_CREATE_REQUESTS, + RATE_LIMIT_FILTER_DELETE_REQUESTS, + RATE_LIMIT_FILTER_UPDATE_REQUESTS, +) router: APIRouter = APIRouter(prefix="/filters", tags=["Filter Config"]) +_MINUTE = 60 + +_FILTER_UPDATE_BUCKET = "filter:update" +_FILTER_CREATE_BUCKET = "filter:create" +_FILTER_DELETE_BUCKET = "filter:delete" + + +def _check_filter_update_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for filter update operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _FILTER_UPDATE_BUCKET, client_ip, RATE_LIMIT_FILTER_UPDATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "filter_update_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for filter update operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_filter_create_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for filter create operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _FILTER_CREATE_BUCKET, client_ip, RATE_LIMIT_FILTER_CREATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "filter_create_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for filter create operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_filter_delete_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for filter delete operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _FILTER_DELETE_BUCKET, client_ip, RATE_LIMIT_FILTER_DELETE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "filter_delete_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for filter delete operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + _FilterNamePath = Annotated[ str, Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'), ] + @router.get( "", response_model=FilterListResponse, @@ -107,6 +207,7 @@ _FilterNamePath = Annotated[ "/{name}", response_model=FilterConfig, summary="Update a filter's .local override with new regex/pattern values", + dependencies=[Depends(_check_filter_update_rate_limit)], ) async def update_filter( request: Request, @@ -158,6 +259,7 @@ async def update_filter( response_model=FilterConfig, status_code=status.HTTP_201_CREATED, summary="Create a new user-defined filter", + dependencies=[Depends(_check_filter_create_rate_limit)], ) async def create_filter( request: Request, @@ -206,6 +308,7 @@ async def create_filter( "/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user-created filter's .local file", + dependencies=[Depends(_check_filter_delete_rate_limit)], ) async def delete_filter( request: Request, diff --git a/backend/app/routers/geo.py b/backend/app/routers/geo.py index 1d0579a..9195e4c 100644 --- a/backend/app/routers/geo.py +++ b/backend/app/routers/geo.py @@ -21,7 +21,7 @@ from app.dependencies import ( from app.models.geo import GeoCacheStatsResponse, GeoReResolveResponse, IpLookupResponse from app.services import geo_service, jail_service -router: APIRouter = APIRouter(prefix="/api/geo", tags=["Geo"]) +router: APIRouter = APIRouter(prefix="/api/v1/geo", tags=["Geo"]) _IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")] diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 051252f..e63ab82 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -11,10 +11,10 @@ from fastapi.responses import JSONResponse from app.dependencies import ServerStatusDep -router: APIRouter = APIRouter(prefix="/api", tags=["Health"]) +router: APIRouter = APIRouter(prefix="/api/v1/health", tags=["Health"]) -@router.get("/health", summary="Application health check") +@router.get("", summary="Application health check") async def health_check(server_status: ServerStatusDep) -> JSONResponse: """Return application and fail2ban status. diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 83cfdcb..52ee30c 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -34,7 +34,7 @@ from app.models.history import HistoryListResponse, IpDetailResponse from app.services import history_service from app.utils.constants import DEFAULT_PAGE_SIZE -router: APIRouter = APIRouter(prefix="/api/history", tags=["History"]) +router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"]) @router.get( diff --git a/backend/app/routers/jail_config.py b/backend/app/routers/jail_config.py index 4322abe..d87e965 100644 --- a/backend/app/routers/jail_config.py +++ b/backend/app/routers/jail_config.py @@ -3,7 +3,7 @@ from __future__ import annotations import shlex from typing import Annotated -from fastapi import APIRouter, Path, Query, Request, status +from fastapi import APIRouter, Depends, Path, Query, Request, status from app.dependencies import ( AppDep, @@ -11,6 +11,7 @@ from app.dependencies import ( Fail2BanConfigDirDep, Fail2BanSocketDep, Fail2BanStartCommandDep, + GlobalRateLimiterDep, HealthProbeDep, PendingRecoveryDep, ) @@ -37,6 +38,13 @@ from app.services import ( jail_config_service, ) from app.utils.path_utils import validate_log_path +from app.utils.constants import ( + RATE_LIMIT_JAIL_ACTIVATE_REQUESTS, + RATE_LIMIT_JAIL_CREATE_REQUESTS, + RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS, + RATE_LIMIT_JAIL_DELETE_REQUESTS, + RATE_LIMIT_JAIL_UPDATE_REQUESTS, +) from app.utils.runtime_state import ( clear_activation_record, clear_pending_recovery, @@ -45,6 +53,160 @@ from app.utils.runtime_state import ( router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"]) +_MINUTE = 60 + +_JAIL_UPDATE_BUCKET = "jail:update" +_JAIL_CREATE_BUCKET = "jail:create" +_JAIL_DELETE_BUCKET = "jail:delete" +_JAIL_ACTIVATE_BUCKET = "jail:activate" +_JAIL_DEACTIVATE_BUCKET = "jail:deactivate" + + +def _check_jail_update_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for jail update operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _JAIL_UPDATE_BUCKET, client_ip, RATE_LIMIT_JAIL_UPDATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "jail_update_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for jail update operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_jail_create_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for jail create operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _JAIL_CREATE_BUCKET, client_ip, RATE_LIMIT_JAIL_CREATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "jail_create_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for jail create operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_jail_delete_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for jail delete operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _JAIL_DELETE_BUCKET, client_ip, RATE_LIMIT_JAIL_DELETE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "jail_delete_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for jail delete operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_jail_activate_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for jail activate operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _JAIL_ACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_ACTIVATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "jail_activate_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for jail activate operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + +def _check_jail_deactivate_rate_limit( + request: Request, + rate_limiter: GlobalRateLimiterDep, +) -> None: + """Check rate limit for jail deactivate operations.""" + from app.utils.client_ip import get_client_ip + + settings = request.app.state.settings + client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) + is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( + _JAIL_DEACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS, _MINUTE + ) + if not is_allowed: + from app.exceptions import RateLimitError + import structlog + + log = structlog.get_logger() + log.warning( + "jail_deactivate_rate_limit_exceeded", + client_ip=client_ip, + path=request.url.path, + retry_after=retry_after, + ) + raise RateLimitError( + "Rate limit exceeded for jail deactivate operations. Please try again later.", + retry_after_seconds=retry_after, + ) + + _NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')] @router.get( @@ -162,6 +324,7 @@ async def get_jail_config( "/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Update jail configuration", + dependencies=[Depends(_check_jail_update_rate_limit)], ) async def update_jail_config( request: Request, @@ -201,6 +364,7 @@ async def update_jail_config( "/{name}/logpath", status_code=status.HTTP_204_NO_CONTENT, summary="Add a log file path to an existing jail", + dependencies=[Depends(_check_jail_create_rate_limit)], ) async def add_log_path( request: Request, @@ -235,6 +399,7 @@ async def add_log_path( "/{name}/logpath", status_code=status.HTTP_204_NO_CONTENT, summary="Remove a monitored log path from a jail", + dependencies=[Depends(_check_jail_delete_rate_limit)], ) async def delete_log_path( request: Request, @@ -274,6 +439,7 @@ async def delete_log_path( "/{name}/activate", response_model=JailActivationResponse, summary="Activate an inactive jail", + dependencies=[Depends(_check_jail_activate_rate_limit)], ) async def activate_jail( app: AppDep, @@ -327,6 +493,7 @@ async def activate_jail( "/{name}/deactivate", response_model=JailActivationResponse, summary="Deactivate an active jail", + dependencies=[Depends(_check_jail_deactivate_rate_limit)], ) async def deactivate_jail( _auth: AuthDep, @@ -486,6 +653,7 @@ async def rollback_jail( "/{name}/filter", status_code=status.HTTP_204_NO_CONTENT, summary="Assign a filter to a jail", + dependencies=[Depends(_check_jail_create_rate_limit)], ) async def assign_filter_to_jail( request: Request, @@ -520,6 +688,7 @@ async def assign_filter_to_jail( "/{name}/action", status_code=status.HTTP_204_NO_CONTENT, summary="Add an action to a jail", + dependencies=[Depends(_check_jail_create_rate_limit)], ) async def assign_action_to_jail( request: Request, @@ -555,6 +724,7 @@ async def assign_action_to_jail( "/{name}/action/{action_name}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove an action from a jail", + dependencies=[Depends(_check_jail_delete_rate_limit)], ) async def remove_action_from_jail( request: Request, diff --git a/backend/app/routers/jails.py b/backend/app/routers/jails.py index ecf220b..ee87faa 100644 --- a/backend/app/routers/jails.py +++ b/backend/app/routers/jails.py @@ -44,7 +44,7 @@ from app.models.jail import ( ) from app.services import jail_service -router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"]) +router: APIRouter = APIRouter(prefix="/api/v1/jails", tags=["Jails"]) _NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")] diff --git a/backend/app/routers/server.py b/backend/app/routers/server.py index ffa1b05..4b31c6f 100644 --- a/backend/app/routers/server.py +++ b/backend/app/routers/server.py @@ -17,7 +17,7 @@ from app.mappers import server_mappers from app.models.server import ServerSettingsResponse, ServerSettingsUpdate from app.services import server_service -router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"]) +router: APIRouter = APIRouter(prefix="/api/v1/server", tags=["Server"]) # --------------------------------------------------------------------------- diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py index 6d37c24..11adc0c 100644 --- a/backend/app/routers/setup.py +++ b/backend/app/routers/setup.py @@ -19,7 +19,7 @@ from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_c log: structlog.stdlib.BoundLogger = structlog.get_logger() -router = APIRouter(prefix="/api/setup", tags=["setup"]) +router = APIRouter(prefix="/api/v1/setup", tags=["setup"]) @router.get( diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index aa928ca..796a292 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -572,19 +572,19 @@ async def bans_by_country( if app_db is None: raise ValueError("app_db must be provided when source is 'archive'") - all_rows = await history_archive_repo.get_all_archived_history( + # SQL aggregation — no row materialisation into Python memory. + ip_counts = await history_archive_repo.get_ip_ban_counts( db=app_db, since=since, origin=origin, action="ban", ) - total = len(all_rows) + # Total = sum of all event counts. + total = sum(int(row["event_count"]) for row in ip_counts) - agg_rows = {} - for row in all_rows: - ip = str(row["ip"]) - agg_rows[ip] = agg_rows.get(ip, 0) + 1 + # {ip: event_count} for downstream geo aggregation. + agg_rows = {row["ip"]: int(row["event_count"]) for row in ip_counts} unique_ips = list(agg_rows.keys()) else: @@ -653,7 +653,7 @@ async def bans_by_country( results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips)) geo_map = {ip: geo for ip, geo in results if geo is not None} - companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord] + companion_rows: list[dict[str, Any] | fail2ban_db_repo.BanRecord] if country_code is None: if source == "archive": companion_rows, _ = await history_archive_repo.get_archived_history( @@ -681,12 +681,15 @@ async def bans_by_country( if source == "archive": if matched_ips: - companion_rows = await history_archive_repo.get_all_archived_history( + # Use keyset pagination instead of loading all matched IPs at once. + companion_rows, _ = await history_archive_repo.get_archived_history( db=app_db, since=since, origin=origin, action="ban", ip_filter=matched_ips, + page=1, + page_size=_MAX_COMPANION_BANS, ) else: companion_rows = [] @@ -830,20 +833,16 @@ async def ban_trend( if app_db is None: raise ValueError("app_db must be provided when source is 'archive'") - all_rows = await history_archive_repo.get_all_archived_history( + # SQL aggregation — no row materialisation into Python memory. + counts = await history_archive_repo.get_ban_counts_by_bucket( db=app_db, since=since, + bucket_secs=bucket_secs, + num_buckets=num_buckets, origin=origin, action="ban", ) - counts: list[int] = [0] * num_buckets - for row in all_rows: - timeofban = int(row["timeofban"]) - bucket_index = int((timeofban - since) / bucket_secs) - if 0 <= bucket_index < num_buckets: - counts[bucket_index] += 1 - log.info( "ban_service_ban_trend", source=source, @@ -928,22 +927,17 @@ async def bans_by_jail( if app_db is None: raise ValueError("app_db must be provided when source is 'archive'") - all_rows = await history_archive_repo.get_all_archived_history( + # SQL aggregation — no row materialisation into Python memory. + total, jail_rows = await history_archive_repo.get_jail_ban_counts( db=app_db, since=since, origin=origin, action="ban", ) - jail_counter: dict[str, int] = {} - for row in all_rows: - jail_name = str(row["jail"]) - jail_counter[jail_name] = jail_counter.get(jail_name, 0) + 1 - - total = sum(jail_counter.values()) jail_counts = [ - DomainJailBanCount(jail=jail_name, count=count) - for jail_name, count in sorted(jail_counter.items(), key=lambda x: x[1], reverse=True) + DomainJailBanCount(jail=str(row["jail"]), count=int(row["event_count"])) + for row in jail_rows ] log.debug( diff --git a/backend/app/utils/constants.py b/backend/app/utils/constants.py index 889723d..d2a02b4 100644 --- a/backend/app/utils/constants.py +++ b/backend/app/utils/constants.py @@ -104,3 +104,52 @@ BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100 HEALTH_CHECK_INTERVAL_SECONDS: Final[int] = 30 """How often the background health-check task polls fail2ban.""" + +# --------------------------------------------------------------------------- +# Rate limits (per IP) +# --------------------------------------------------------------------------- + +RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100 +"""Max ban requests per IP per minute.""" + +RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100 +"""Max unban requests per IP per minute.""" + +RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10 +"""Max blocklist import requests per IP per hour.""" + +RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50 +"""Max config update requests per IP per minute.""" + +RATE_LIMIT_FILTER_UPDATE_REQUESTS: Final[int] = 50 +"""Max filter config update requests per IP per minute.""" + +RATE_LIMIT_FILTER_CREATE_REQUESTS: Final[int] = 50 +"""Max filter config create requests per IP per minute.""" + +RATE_LIMIT_FILTER_DELETE_REQUESTS: Final[int] = 50 +"""Max filter config delete requests per IP per minute.""" + +RATE_LIMIT_ACTION_UPDATE_REQUESTS: Final[int] = 50 +"""Max action config update requests per IP per minute.""" + +RATE_LIMIT_ACTION_CREATE_REQUESTS: Final[int] = 50 +"""Max action config create requests per IP per minute.""" + +RATE_LIMIT_ACTION_DELETE_REQUESTS: Final[int] = 50 +"""Max action config delete requests per IP per minute.""" + +RATE_LIMIT_JAIL_UPDATE_REQUESTS: Final[int] = 100 +"""Max jail config update requests per IP per minute.""" + +RATE_LIMIT_JAIL_CREATE_REQUESTS: Final[int] = 100 +"""Max jail config create requests per IP per minute.""" + +RATE_LIMIT_JAIL_DELETE_REQUESTS: Final[int] = 100 +"""Max jail config delete requests per IP per minute.""" + +RATE_LIMIT_JAIL_ACTIVATE_REQUESTS: Final[int] = 100 +"""Max jail activation requests per IP per minute.""" + +RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS: Final[int] = 100 +"""Max jail deactivation requests per IP per minute.""" diff --git a/backend/app/utils/rate_limiter.py b/backend/app/utils/rate_limiter.py index a1b2d59..24d4032 100644 --- a/backend/app/utils/rate_limiter.py +++ b/backend/app/utils/rate_limiter.py @@ -228,12 +228,16 @@ class GlobalRateLimiter: blocked until the oldest request expires. 4. A background cleanup task removes dormant IPs from memory periodically. - **Per-Endpoint Configuration:** + **Per-Bucket Configuration:** - Different endpoints can have different limits. For example: - - Login endpoint: 5 requests per 60 seconds - - Dashboard read: 100 requests per 60 seconds - - Config write: 20 requests per 60 seconds + Different endpoints can have different limits via named buckets: + - `bans:ban` — 100/minute per IP (ban operations) + - `bans:unban` — 100/minute per IP (unban operations) + - `blocklist:import` — 10/hour per IP (import operations) + - `config:update` — 50/minute per IP (config write operations) + + Each bucket tracks its own requests independently, so hitting the + blocklist:import limit does not affect the bans:ban limit. """ def __init__( @@ -250,6 +254,32 @@ class GlobalRateLimiter: self.max_requests: int = max_requests self.window_seconds: int = window_seconds self._requests: dict[str, deque[float]] = {} + self._buckets: dict[str, dict[str, deque[float]]] = {} + + def _get_bucket_deque( + self, + bucket: str, + ip_address: str, + max_requests: int, + window_seconds: int, + ) -> deque[float]: + """Get or create the deque for a specific bucket and IP. + + Args: + bucket: Bucket name (e.g., "bans:ban"). + ip_address: Client IP address. + max_requests: Maximum requests for this bucket (unused, for future). + window_seconds: Window in seconds (unused, for future). + + Returns: + The deque of timestamps for this bucket+IP. + """ + if bucket not in self._buckets: + self._buckets[bucket] = {} + bucket_dict = self._buckets[bucket] + if ip_address not in bucket_dict: + bucket_dict[ip_address] = deque() + return bucket_dict[ip_address] def check_allowed(self, ip_address: str) -> tuple[bool, float]: """Check if a request from *ip_address* is allowed. @@ -292,6 +322,53 @@ class GlobalRateLimiter: return False, retry_after + def check_allowed_for_bucket( + self, + bucket: str, + ip_address: str, + max_requests: int, + window_seconds: int, + ) -> tuple[bool, float]: + """Check if a request for a specific bucket is allowed. + + Each bucket has independent rate limiting. This allows different + endpoints to have different limits (e.g., blocklist import is more + restrictive than ban operations). + + Args: + bucket: Bucket name (e.g., "bans:ban", "blocklist:import"). + ip_address: The client IP address to rate-limit. + max_requests: Maximum requests allowed within the window. + window_seconds: Time window (seconds) for this bucket. + + Returns: + A tuple of (is_allowed, retry_after_seconds). If is_allowed is True, + retry_after_seconds is 0. If False, it's the estimated time to wait. + """ + now = time() + + requests = self._get_bucket_deque(bucket, ip_address, max_requests, window_seconds) + cutoff = now - window_seconds + + # Remove old requests outside the window + while requests and requests[0] < cutoff: + requests.popleft() + + # If under the limit, allow the request + if len(requests) < max_requests: + requests.append(now) + return True, 0.0 + + # Over the limit: calculate how long to wait + oldest_request = requests[0] + age = now - oldest_request + retry_after = window_seconds - age + + # Ensure retry_after is at least 1 second + retry_after = max(retry_after, 1.0) + + return False, retry_after + def cleanup_expired(self) -> None: """Remove all IPs with no recent requests (cleanup task). @@ -316,6 +393,21 @@ class GlobalRateLimiter: if ips_to_remove: log.debug("global_rate_limiter_cleanup", removed_ips=len(ips_to_remove)) + # Cleanup per-bucket dictionaries + for bucket, bucket_dict in list(self._buckets.items()): + bucket_ips_to_remove = [] + bucket_window = 60 # Use a reasonable window for bucket cleanup + bucket_cutoff = now - bucket_window + for ip_address, requests in bucket_dict.items(): + while requests and requests[0] < bucket_cutoff: + requests.popleft() + if not requests: + bucket_ips_to_remove.append(ip_address) + for ip_address in bucket_ips_to_remove: + del bucket_dict[ip_address] + if not bucket_dict: + del self._buckets[bucket] + def get_state(self) -> Mapping[str, int]: """Return a read-only view of current request counts per IP. @@ -334,6 +426,30 @@ class GlobalRateLimiter: result[ip_address] = count return result + def get_bucket_state(self, bucket: str) -> Mapping[str, int]: + """Return a read-only view of current request counts per IP for a bucket. + + For debugging and monitoring. + + Args: + bucket: Bucket name to get state for. + + Returns: + A mapping of IP addresses to their request counts in this bucket. + """ + if bucket not in self._buckets: + return {} + now = time() + result = {} + for ip_address, requests in self._buckets[bucket].items(): + # Count non-expired requests (use max window of 3600s for hourly buckets) + cutoff = now - 3600 + count = sum(1 for ts in requests if ts >= cutoff) + if count > 0: + result[ip_address] = count + return result + def reset(self) -> None: """Clear all tracked requests (for testing).""" self._requests.clear() + self._buckets.clear() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 50af991..121bbec 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -16,6 +16,7 @@ from httpx import ASGITransport, AsyncClient from app.config import Settings from app.db import init_db from app.main import create_app +from app.models.server import ServerStatus @pytest.fixture @@ -63,6 +64,10 @@ async def client(test_settings: Settings) -> AsyncClient: # type: ignore[misc] """ app = create_app(settings=test_settings) + # Ensure fail2ban is reported as online for tests (mock socket is not + # actually connected so we need to set the cached status manually). + app.state.server_status = ServerStatus(online=True) + # Bootstrap the database schema before making requests. ASGITransport # does not run the application lifespan, so we create the test SQLite file # directly rather than relying on startup logic. diff --git a/backend/tests/test_correlation_middleware.py b/backend/tests/test_correlation_middleware.py index 19f9369..b183ee9 100644 --- a/backend/tests/test_correlation_middleware.py +++ b/backend/tests/test_correlation_middleware.py @@ -27,7 +27,7 @@ def test_correlation_middleware_generates_uuid_when_header_absent() -> None: # Test with TestClient (synchronous) client = TestClient(app) - response = client.get("/api/health") + response = client.get("/api/v1/health") # Should have correlation ID header in response assert "X-Correlation-ID" in response.headers @@ -53,7 +53,7 @@ def test_correlation_middleware_preserves_header_from_request() -> None: client = TestClient(app) test_correlation_id = "550e8400-e29b-41d4-a716-446655440000" - response = client.get("/api/health", headers={"X-Correlation-ID": test_correlation_id}) + response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_correlation_id}) # Should return the same correlation ID in response assert response.headers["X-Correlation-ID"] == test_correlation_id @@ -76,7 +76,7 @@ def test_correlation_middleware_stores_in_request_state() -> None: # Make a request and verify correlation ID is available to handlers test_correlation_id = "550e8400-e29b-41d4-a716-446655440000" - response = client.get("/api/health", headers={"X-Correlation-ID": test_correlation_id}) + response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_correlation_id}) # The health endpoint should return 200, proving the correlation ID was processed assert response.status_code == 200 @@ -100,11 +100,11 @@ def test_correlation_id_in_response_headers() -> None: client = TestClient(app) # Test without providing header (should generate one) - response = client.get("/api/health") + response = client.get("/api/v1/health") assert "X-Correlation-ID" in response.headers # Test with providing header (should preserve it) test_id = "test-correlation-id-12345" - response = client.get("/api/health", headers={"X-Correlation-ID": test_id}) + response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_id}) assert response.headers["X-Correlation-ID"] == test_id diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 4105689..ca204ce 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -504,7 +504,7 @@ async def test_concurrent_requests_use_request_scoped_db_connections(tmp_path: P transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: app.state.setup_complete_cached = True - responses = await asyncio.gather(*(client.post("/api/auth/logout") for _ in range(5))) + responses = await asyncio.gather(*(client.post("/api/v1/auth/logout") for _ in range(5))) assert len(connections) == 5 assert len({id(connection) for connection in connections}) == 5 diff --git a/backend/tests/test_routers/test_auth.py b/backend/tests/test_routers/test_auth.py index bb48cd1..1ff5e80 100644 --- a/backend/tests/test_routers/test_auth.py +++ b/backend/tests/test_routers/test_auth.py @@ -26,7 +26,7 @@ _SETUP_PAYLOAD = { async def _do_setup(client: AsyncClient) -> None: """Run the setup wizard so auth endpoints are reachable.""" - resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 @@ -36,7 +36,7 @@ async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str: Note: The token is returned in the HttpOnly cookie, not in the JSON body. For testing Bearer token auth, we extract it from the cookie. """ - resp = await client.post("/api/auth/login", json={"password": password}) + resp = await client.post("/api/v1/auth/login", json={"password": password}) assert resp.status_code == 200 token = resp.cookies.get(SESSION_COOKIE_NAME) assert token is not None @@ -57,7 +57,7 @@ class TestLogin: """Login returns 200 and sets a session cookie for the correct password.""" await _do_setup(client) response = await client.post( - "/api/auth/login", json={"password": "Mysecretpass1!"} + "/api/v1/auth/login", json={"password": "Mysecretpass1!"} ) assert response.status_code == 200 body = response.json() @@ -69,7 +69,7 @@ class TestLogin: """Login sets the bangui_session HttpOnly cookie.""" await _do_setup(client) response = await client.post( - "/api/auth/login", json={"password": "Mysecretpass1!"} + "/api/v1/auth/login", json={"password": "Mysecretpass1!"} ) assert response.status_code == 200 assert SESSION_COOKIE_NAME in response.cookies @@ -85,7 +85,7 @@ class TestLogin: client._transport.app.state.settings.session_cookie_secure = True await _do_setup(client) response = await client.post( - "/api/auth/login", json={"password": "Mysecretpass1!"} + "/api/v1/auth/login", json={"password": "Mysecretpass1!"} ) assert response.status_code == 200 set_cookie = response.headers.get("set-cookie", "") @@ -97,14 +97,14 @@ class TestLogin: """Login returns 401 for an incorrect password.""" await _do_setup(client) response = await client.post( - "/api/auth/login", json={"password": "wrongpassword"} + "/api/v1/auth/login", json={"password": "wrongpassword"} ) assert response.status_code == 401 async def test_login_rejects_empty_password(self, client: AsyncClient) -> None: """Login returns 422 when password field is missing.""" await _do_setup(client) - response = await client.post("/api/auth/login", json={}) + response = await client.post("/api/v1/auth/login", json={}) assert response.status_code == 422 async def test_login_rate_limit_returns_429_after_5_attempts( @@ -117,13 +117,13 @@ class TestLogin: # First failed attempt is allowed response = await client.post( - "/api/auth/login", json={"password": "wrongpassword"} + "/api/v1/auth/login", json={"password": "wrongpassword"} ) assert response.status_code == 401 # Second attempt immediately after is blocked by 1s penalty response = await client.post( - "/api/auth/login", json={"password": "wrongpassword"} + "/api/v1/auth/login", json={"password": "wrongpassword"} ) assert response.status_code == 429 assert response.json()["detail"] == "Too many login attempts. Please try again later." @@ -142,11 +142,11 @@ class TestLogin: limiter.reset() # First attempt fails - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 401 # Second immediate attempt is rate-limited - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 429 assert "retry-after" in response.headers assert response.headers["retry-after"] == "60" @@ -160,12 +160,12 @@ class TestLogin: limiter.reset() # Make 1 failed attempt with default IP - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 401 # 2nd attempt is blocked response = await client.post( - "/api/auth/login", json={"password": "correct"} + "/api/v1/auth/login", json={"password": "correct"} ) assert response.status_code == 429 @@ -183,12 +183,12 @@ class TestLogin: limiter.reset() # Make 1 failed attempt (enough to trigger exponential backoff) - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 401 # 2nd attempt is blocked response = await client.post( - "/api/auth/login", json={"password": "wrong"} + "/api/v1/auth/login", json={"password": "wrong"} ) assert response.status_code == 429 @@ -197,7 +197,7 @@ class TestLogin: # Now a fresh login attempt should succeed (use correct password) response = await client.post( - "/api/auth/login", json={"password": "Mysecretpass1!"} + "/api/v1/auth/login", json={"password": "Mysecretpass1!"} ) assert response.status_code == 200 @@ -208,25 +208,25 @@ class TestLogin: limiter.reset() # 1st failure: 1 * 2^1 = 2s penalty - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 401 state = limiter.get_state() assert state["127.0.0.1"] == 1 # 2nd attempt blocked immediately by 2s penalty - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 429 # After 2.1s, the penalty expires and we can try again # (this will record a 2nd failure, creating a 1 * 2^2 = 4s penalty) await asyncio.sleep(2.1) - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 401 state = limiter.get_state() assert state["127.0.0.1"] == 2 # Now blocked by 4s penalty - response = await client.post("/api/auth/login", json={"password": "wrong"}) + response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) assert response.status_code == 429 @@ -242,7 +242,7 @@ class TestLogout: """Logout returns 200 with a confirmation message.""" await _do_setup(client) await _login(client) - response = await client.post("/api/auth/logout") + response = await client.post("/api/v1/auth/logout") assert response.status_code == 200 assert "message" in response.json() @@ -250,7 +250,7 @@ class TestLogout: """Logout clears the bangui_session cookie.""" await _do_setup(client) await _login(client) # sets cookie on client - response = await client.post("/api/auth/logout") + response = await client.post("/api/v1/auth/logout") assert response.status_code == 200 # Cookie should be set to empty / deleted in the Set-Cookie header. set_cookie = response.headers.get("set-cookie", "") @@ -259,7 +259,7 @@ class TestLogout: async def test_logout_is_idempotent(self, client: AsyncClient) -> None: """Logout succeeds even when called without a session token.""" await _do_setup(client) - response = await client.post("/api/auth/logout") + response = await client.post("/api/v1/auth/logout") assert response.status_code == 200 async def test_session_invalid_after_logout( @@ -269,7 +269,7 @@ class TestLogout: await _do_setup(client) token = await _login(client) - await client.post("/api/auth/logout") + await client.post("/api/v1/auth/logout") # Now try to use the invalidated token via Bearer header. The health # endpoint is unprotected so we validate against a hypothetical @@ -277,7 +277,7 @@ class TestLogout: # Here we just confirm the token is no longer in the DB by trying # to re-use it on logout (idempotent — still 200, not an error). response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 @@ -295,7 +295,7 @@ class TestRequireAuth: self, client: AsyncClient ) -> None: """Health endpoint is accessible without authentication.""" - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200 async def test_session_cache_is_disabled_by_default( @@ -317,11 +317,11 @@ class TestRequireAuth: with patch.object(session_repo, "get_session", side_effect=_tracking): resp1 = await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) resp2 = await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) @@ -346,7 +346,7 @@ class TestValidateSession: token = await _login(client) # Use Bearer token to authenticate response = await client.get( - "/api/auth/session", + "/api/v1/auth/session", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 @@ -357,7 +357,7 @@ class TestValidateSession: ) -> None: """Validate session returns 401 when no token is present.""" await _do_setup(client) - response = await client.get("/api/auth/session") + response = await client.get("/api/v1/auth/session") assert response.status_code == 401 async def test_validate_session_returns_401_with_invalid_token( @@ -366,7 +366,7 @@ class TestValidateSession: """Validate session returns 401 for an invalid or expired token.""" await _do_setup(client) response = await client.get( - "/api/auth/session", + "/api/v1/auth/session", headers={"Authorization": "Bearer invalidtoken"}, ) assert response.status_code == 401 @@ -379,7 +379,7 @@ class TestValidateSession: token = await _login(client) # httpx should automatically send the cookie, but use Bearer token as fallback response = await client.get( - "/api/auth/session", + "/api/v1/auth/session", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 @@ -392,11 +392,11 @@ class TestValidateSession: await _do_setup(client) token = await _login(client) await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) response = await client.get( - "/api/auth/session", + "/api/v1/auth/session", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 401 @@ -449,11 +449,11 @@ class TestRequireAuthSessionCache: with patch.object(session_repo, "get_session", side_effect=_tracking): resp1 = await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) resp2 = await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) @@ -475,7 +475,7 @@ class TestRequireAuthSessionCache: assert client._transport.app.state.session_cache.get(token) is None await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) @@ -491,17 +491,17 @@ class TestRequireAuthSessionCache: # Warm the cache. await client.get( - "/api/dashboard/status", + "/api/v1/dashboard/status", headers={"Authorization": f"Bearer {token}"}, ) assert client._transport.app.state.session_cache.get(token) is not None # Logout must evict the entry. await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) assert client._transport.app.state.session_cache.get(token) is None - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200 diff --git a/backend/tests/test_routers/test_bans.py b/backend/tests/test_routers/test_bans.py index e84d127..d09dfe4 100644 --- a/backend/tests/test_routers/test_bans.py +++ b/backend/tests/test_routers/test_bans.py @@ -49,9 +49,9 @@ async def bans_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - await ac.post("/api/setup", json=_SETUP_PAYLOAD) + await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -87,7 +87,7 @@ class TestGetActiveBans: "app.routers.bans.ban_service.get_active_bans", AsyncMock(return_value=mock_response), ): - resp = await bans_client.get("/api/bans/active") + resp = await bans_client.get("/api/v1/bans/active") assert resp.status_code == 200 data = resp.json() @@ -100,7 +100,7 @@ class TestGetActiveBans: resp = await AsyncClient( transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/bans/active") + ).get("/api/v1/bans/active") assert resp.status_code == 401 async def test_empty_when_no_bans(self, bans_client: AsyncClient) -> None: @@ -110,7 +110,7 @@ class TestGetActiveBans: "app.routers.bans.ban_service.get_active_bans", AsyncMock(return_value=mock_response), ): - resp = await bans_client.get("/api/bans/active") + resp = await bans_client.get("/api/v1/bans/active") assert resp.status_code == 200 assert resp.json()["total"] == 0 @@ -135,7 +135,7 @@ class TestGetActiveBans: "app.routers.bans.ban_service.get_active_bans", AsyncMock(return_value=mock_response), ): - resp = await bans_client.get("/api/bans/active") + resp = await bans_client.get("/api/v1/bans/active") ban = resp.json()["bans"][0] assert "ip" in ban @@ -160,7 +160,7 @@ class TestBanIp: AsyncMock(return_value=None), ): resp = await bans_client.post( - "/api/bans", + "/api/v1/bans", json={"ip": "1.2.3.4", "jail": "sshd"}, ) @@ -174,7 +174,7 @@ class TestBanIp: AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")), ): resp = await bans_client.post( - "/api/bans", + "/api/v1/bans", json={"ip": "bad", "jail": "sshd"}, ) @@ -189,7 +189,7 @@ class TestBanIp: AsyncMock(side_effect=JailNotFoundError("ghost")), ): resp = await bans_client.post( - "/api/bans", + "/api/v1/bans", json={"ip": "1.2.3.4", "jail": "ghost"}, ) @@ -200,7 +200,7 @@ class TestBanIp: resp = await AsyncClient( transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/bans", json={"ip": "1.2.3.4", "jail": "sshd"}) + ).post("/api/v1/bans", json={"ip": "1.2.3.4", "jail": "sshd"}) assert resp.status_code == 401 @@ -220,7 +220,7 @@ class TestUnbanIp: ): resp = await bans_client.request( "DELETE", - "/api/bans", + "/api/v1/bans", json={"ip": "1.2.3.4", "unban_all": True}, ) @@ -235,7 +235,7 @@ class TestUnbanIp: ): resp = await bans_client.request( "DELETE", - "/api/bans", + "/api/v1/bans", json={"ip": "1.2.3.4", "jail": "sshd"}, ) @@ -250,7 +250,7 @@ class TestUnbanIp: ): resp = await bans_client.request( "DELETE", - "/api/bans", + "/api/v1/bans", json={"ip": "bad", "unban_all": True}, ) @@ -266,7 +266,7 @@ class TestUnbanIp: ): resp = await bans_client.request( "DELETE", - "/api/bans", + "/api/v1/bans", json={"ip": "1.2.3.4", "jail": "ghost"}, ) @@ -287,7 +287,7 @@ class TestUnbanAll: "app.routers.bans.jail_service.unban_all_ips", AsyncMock(return_value=3), ): - resp = await bans_client.request("DELETE", "/api/bans/all") + resp = await bans_client.request("DELETE", "/api/v1/bans/all") assert resp.status_code == 200 data = resp.json() @@ -300,7 +300,7 @@ class TestUnbanAll: "app.routers.bans.jail_service.unban_all_ips", AsyncMock(return_value=0), ): - resp = await bans_client.request("DELETE", "/api/bans/all") + resp = await bans_client.request("DELETE", "/api/v1/bans/all") assert resp.status_code == 200 assert resp.json()["count"] == 0 @@ -318,7 +318,7 @@ class TestUnbanAll: ) ), ): - resp = await bans_client.request("DELETE", "/api/bans/all") + resp = await bans_client.request("DELETE", "/api/v1/bans/all") assert resp.status_code == 502 @@ -327,5 +327,5 @@ class TestUnbanAll: resp = await AsyncClient( transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).request("DELETE", "/api/bans/all") + ).request("DELETE", "/api/v1/bans/all") assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_blocklist.py b/backend/tests/test_routers/test_blocklist.py index 3008534..b6d35a3 100644 --- a/backend/tests/test_routers/test_blocklist.py +++ b/backend/tests/test_routers/test_blocklist.py @@ -129,11 +129,11 @@ async def bl_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 login_resp = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login_resp.status_code == 200 @@ -155,13 +155,13 @@ class TestListBlocklists: "app.routers.blocklist.blocklist_service.list_sources", new=AsyncMock(return_value=_make_source_list().sources), ): - resp = await bl_client.get("/api/blocklists") + resp = await bl_client.get("/api/v1/blocklists") assert resp.status_code == 200 async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None: """Unauthenticated request returns 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - resp = await client.get("/api/blocklists") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + resp = await client.get("/api/v1/blocklists") assert resp.status_code == 401 async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None: @@ -170,7 +170,7 @@ class TestListBlocklists: "app.routers.blocklist.blocklist_service.list_sources", new=AsyncMock(return_value=[_make_source()]), ): - resp = await bl_client.get("/api/blocklists") + resp = await bl_client.get("/api/v1/blocklists") body = resp.json() assert "sources" in body assert isinstance(body["sources"], list) @@ -191,7 +191,7 @@ class TestCreateBlocklist: new=AsyncMock(return_value=_make_source()), ): resp = await bl_client.post( - "/api/blocklists", + "/api/v1/blocklists", json={"name": "Test", "url": "https://test.test/", "enabled": True}, ) assert resp.status_code == 201 @@ -205,7 +205,7 @@ class TestCreateBlocklist: new=AsyncMock(return_value=_make_source(42)), ): resp = await bl_client.post( - "/api/blocklists", + "/api/v1/blocklists", json={"name": "Test", "url": "https://test.test/", "enabled": True}, ) assert resp.json()["id"] == 42 @@ -226,7 +226,7 @@ class TestUpdateBlocklist: new=AsyncMock(return_value=updated), ): resp = await bl_client.put( - "/api/blocklists/1", + "/api/v1/blocklists/1", json={"enabled": False}, ) assert resp.status_code == 200 @@ -238,7 +238,7 @@ class TestUpdateBlocklist: new=AsyncMock(return_value=None), ): resp = await bl_client.put( - "/api/blocklists/999", + "/api/v1/blocklists/999", json={"enabled": False}, ) assert resp.status_code == 404 @@ -256,7 +256,7 @@ class TestDeleteBlocklist: "app.routers.blocklist.blocklist_service.delete_source", new=AsyncMock(return_value=True), ): - resp = await bl_client.delete("/api/blocklists/1") + resp = await bl_client.delete("/api/v1/blocklists/1") assert resp.status_code == 204 async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None: @@ -265,7 +265,7 @@ class TestDeleteBlocklist: "app.routers.blocklist.blocklist_service.delete_source", new=AsyncMock(return_value=False), ): - resp = await bl_client.delete("/api/blocklists/999") + resp = await bl_client.delete("/api/v1/blocklists/999") assert resp.status_code == 404 @@ -284,7 +284,7 @@ class TestPreviewBlocklist: "app.routers.blocklist.blocklist_service.preview_source", new=AsyncMock(return_value=_make_preview()), ): - resp = await bl_client.get("/api/blocklists/1/preview") + resp = await bl_client.get("/api/v1/blocklists/1/preview") assert resp.status_code == 200 async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None: @@ -293,7 +293,7 @@ class TestPreviewBlocklist: "app.routers.blocklist.blocklist_service.get_source", new=AsyncMock(return_value=None), ): - resp = await bl_client.get("/api/blocklists/999/preview") + resp = await bl_client.get("/api/v1/blocklists/999/preview") assert resp.status_code == 404 async def test_preview_returns_502_on_download_error( @@ -307,7 +307,7 @@ class TestPreviewBlocklist: "app.routers.blocklist.blocklist_service.preview_source", new=AsyncMock(side_effect=ValueError("Connection refused")), ): - resp = await bl_client.get("/api/blocklists/1/preview") + resp = await bl_client.get("/api/v1/blocklists/1/preview") assert resp.status_code == 502 async def test_preview_response_shape(self, bl_client: AsyncClient) -> None: @@ -319,7 +319,7 @@ class TestPreviewBlocklist: "app.routers.blocklist.blocklist_service.preview_source", new=AsyncMock(return_value=_make_preview()), ): - resp = await bl_client.get("/api/blocklists/1/preview") + resp = await bl_client.get("/api/v1/blocklists/1/preview") body = resp.json() assert "entries" in body assert "valid_count" in body @@ -339,7 +339,7 @@ class TestRunImport: "app.routers.blocklist.blocklist_service.import_all", new=AsyncMock(return_value=_make_import_result()), ): - resp = await bl_client.post("/api/blocklists/import") + resp = await bl_client.post("/api/v1/blocklists/import") assert resp.status_code == 200 async def test_import_response_shape(self, bl_client: AsyncClient) -> None: @@ -348,7 +348,7 @@ class TestRunImport: "app.routers.blocklist.blocklist_service.import_all", new=AsyncMock(return_value=_make_import_result()), ): - resp = await bl_client.post("/api/blocklists/import") + resp = await bl_client.post("/api/v1/blocklists/import") body = resp.json() assert "total_imported" in body assert "total_skipped" in body @@ -368,7 +368,7 @@ class TestGetSchedule: "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime", new=AsyncMock(return_value=_make_schedule_info()), ): - resp = await bl_client.get("/api/blocklists/schedule") + resp = await bl_client.get("/api/v1/blocklists/schedule") assert resp.status_code == 200 async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None: @@ -377,7 +377,7 @@ class TestGetSchedule: "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime", new=AsyncMock(return_value=_make_schedule_info()), ): - resp = await bl_client.get("/api/blocklists/schedule") + resp = await bl_client.get("/api/v1/blocklists/schedule") body = resp.json() assert "config" in body assert "next_run_at" in body @@ -403,7 +403,7 @@ class TestGetSchedule: "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime", new=AsyncMock(return_value=info_with_errors), ): - resp = await bl_client.get("/api/blocklists/schedule") + resp = await bl_client.get("/api/v1/blocklists/schedule") body = resp.json() assert "last_run_errors" in body assert body["last_run_errors"] is True @@ -433,7 +433,7 @@ class TestUpdateSchedule: new=AsyncMock(return_value=new_info), ): resp = await bl_client.put( - "/api/blocklists/schedule", + "/api/v1/blocklists/schedule", json={ "frequency": "hourly", "interval_hours": 12, @@ -453,19 +453,19 @@ class TestUpdateSchedule: class TestImportLog: async def test_log_returns_200(self, bl_client: AsyncClient) -> None: """GET /api/blocklists/log returns 200.""" - resp = await bl_client.get("/api/blocklists/log") + resp = await bl_client.get("/api/v1/blocklists/log") assert resp.status_code == 200 async def test_log_response_shape(self, bl_client: AsyncClient) -> None: """Log response has items, total, page, page_size.""" - resp = await bl_client.get("/api/blocklists/log") + resp = await bl_client.get("/api/v1/blocklists/log") body = resp.json() for key in ("items", "total", "page", "page_size"): assert key in body async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None: """Log returns empty items list when no import runs have occurred.""" - resp = await bl_client.get("/api/blocklists/log") + resp = await bl_client.get("/api/v1/blocklists/log") body = resp.json() assert body["total"] == 0 assert body["items"] == [] diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 899a36c..9c45b4a 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -59,9 +59,9 @@ async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - await ac.post("/api/setup", json=_SETUP_PAYLOAD) + await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -105,7 +105,7 @@ class TestGetJailConfigs: "app.routers.jail_config.config_service.list_jail_configs", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/jails") + resp = await config_client.get("/api/v1/config/jails") assert resp.status_code == 200 data = resp.json() @@ -117,7 +117,7 @@ class TestGetJailConfigs: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/jails") + ).get("/api/v1/config/jails") assert resp.status_code == 401 async def test_502_on_connection_error(self, config_client: AsyncClient) -> None: @@ -128,7 +128,7 @@ class TestGetJailConfigs: "app.routers.jail_config.config_service.list_jail_configs", AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), ): - resp = await config_client.get("/api/config/jails") + resp = await config_client.get("/api/v1/config/jails") assert resp.status_code == 502 @@ -148,7 +148,7 @@ class TestGetJailConfig: "app.routers.jail_config.config_service.get_jail_config", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/jails/sshd") + resp = await config_client.get("/api/v1/config/jails/sshd") assert resp.status_code == 200 assert resp.json()["jail"]["name"] == "sshd" @@ -162,7 +162,7 @@ class TestGetJailConfig: "app.routers.jail_config.config_service.get_jail_config", AsyncMock(side_effect=JailNotFoundError("missing")), ): - resp = await config_client.get("/api/config/jails/missing") + resp = await config_client.get("/api/v1/config/jails/missing") assert resp.status_code == 404 @@ -171,7 +171,7 @@ class TestGetJailConfig: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/jails/sshd") + ).get("/api/v1/config/jails/sshd") assert resp.status_code == 401 @@ -190,7 +190,7 @@ class TestUpdateJailConfig: AsyncMock(return_value=None), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"ban_time": 3600}, ) @@ -205,7 +205,7 @@ class TestUpdateJailConfig: AsyncMock(side_effect=JailNotFoundError("missing")), ): resp = await config_client.put( - "/api/config/jails/missing", + "/api/v1/config/jails/missing", json={"ban_time": 3600}, ) @@ -220,7 +220,7 @@ class TestUpdateJailConfig: AsyncMock(side_effect=ConfigValidationError("bad regex")), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"fail_regex": ["[bad"]}, ) @@ -235,7 +235,7 @@ class TestUpdateJailConfig: AsyncMock(side_effect=ConfigOperationError("set failed")), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"ban_time": 3600}, ) @@ -248,7 +248,7 @@ class TestUpdateJailConfig: AsyncMock(return_value=None), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"dns_mode": "no"}, ) @@ -261,7 +261,7 @@ class TestUpdateJailConfig: AsyncMock(return_value=None), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"prefregex": r"^%(__prefix_line)s"}, ) @@ -274,7 +274,7 @@ class TestUpdateJailConfig: AsyncMock(return_value=None), ): resp = await config_client.put( - "/api/config/jails/sshd", + "/api/v1/config/jails/sshd", json={"date_pattern": "%Y-%m-%d %H:%M:%S"}, ) @@ -301,7 +301,7 @@ class TestGetGlobalConfig: "app.routers.config_misc.config_service.get_global_config", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/global") + resp = await config_client.get("/api/v1/config/global") assert resp.status_code == 200 data = resp.json() @@ -313,7 +313,7 @@ class TestGetGlobalConfig: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/global") + ).get("/api/v1/config/global") assert resp.status_code == 401 @@ -332,7 +332,7 @@ class TestUpdateGlobalConfig: AsyncMock(return_value=None), ): resp = await config_client.put( - "/api/config/global", + "/api/v1/config/global", json={"log_level": "DEBUG"}, ) @@ -347,7 +347,7 @@ class TestUpdateGlobalConfig: AsyncMock(side_effect=ConfigOperationError("set failed")), ): resp = await config_client.put( - "/api/config/global", + "/api/v1/config/global", json={"log_level": "INFO"}, ) @@ -368,7 +368,7 @@ class TestReloadFail2ban: "app.routers.config_misc.jail_service.reload_all", AsyncMock(return_value=None), ): - resp = await config_client.post("/api/config/reload") + resp = await config_client.post("/api/v1/config/reload") assert resp.status_code == 204 @@ -380,7 +380,7 @@ class TestReloadFail2ban: "app.routers.config_misc.jail_service.reload_all", AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")), ): - resp = await config_client.post("/api/config/reload") + resp = await config_client.post("/api/v1/config/reload") assert resp.status_code == 502 @@ -392,7 +392,7 @@ class TestReloadFail2ban: "app.routers.config_misc.jail_service.reload_all", AsyncMock(side_effect=JailOperationError("reload rejected")), ): - resp = await config_client.post("/api/config/reload") + resp = await config_client.post("/api/v1/config/reload") assert resp.status_code == 409 @@ -411,7 +411,7 @@ class TestRestartFail2ban: "app.routers.config_misc.jail_service.restart_daemon", AsyncMock(return_value=True), ): - resp = await config_client.post("/api/config/restart") + resp = await config_client.post("/api/v1/config/restart") assert resp.status_code == 204 @@ -421,7 +421,7 @@ class TestRestartFail2ban: "app.routers.config_misc.jail_service.restart_daemon", AsyncMock(return_value=False), ): - resp = await config_client.post("/api/config/restart") + resp = await config_client.post("/api/v1/config/restart") assert resp.status_code == 503 @@ -433,7 +433,7 @@ class TestRestartFail2ban: "app.routers.config_misc.jail_service.restart_daemon", AsyncMock(side_effect=JailOperationError("stop failed")), ): - resp = await config_client.post("/api/config/restart") + resp = await config_client.post("/api/v1/config/restart") assert resp.status_code == 409 @@ -445,7 +445,7 @@ class TestRestartFail2ban: "app.routers.config_misc.jail_service.restart_daemon", AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")), ): - resp = await config_client.post("/api/config/restart") + resp = await config_client.post("/api/v1/config/restart") assert resp.status_code == 502 @@ -456,7 +456,7 @@ class TestRestartFail2ban: "app.routers.config_misc.jail_service.restart_daemon", mock_restart, ): - resp = await config_client.post("/api/config/restart") + resp = await config_client.post("/api/v1/config/restart") assert resp.status_code == 204 mock_restart.assert_awaited_once() @@ -478,7 +478,7 @@ class TestRegexTest: return_value=mock_response, ): resp = await config_client.post( - "/api/config/regex-test", + "/api/v1/config/regex-test", json={ "log_line": "fail from 1.2.3.4", "fail_regex": r"(\d+\.\d+\.\d+\.\d+)", @@ -496,7 +496,7 @@ class TestRegexTest: return_value=mock_response, ): resp = await config_client.post( - "/api/config/regex-test", + "/api/v1/config/regex-test", json={"log_line": "ok line", "fail_regex": r"FAIL"}, ) @@ -509,7 +509,7 @@ class TestRegexTest: transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).post( - "/api/config/regex-test", + "/api/v1/config/regex-test", json={"log_line": "test", "fail_regex": "test"}, ) assert resp.status_code == 401 @@ -530,7 +530,7 @@ class TestAddLogPath: AsyncMock(return_value=None), ): resp = await config_client.post( - "/api/config/jails/sshd/logpath", + "/api/v1/config/jails/sshd/logpath", json={"log_path": "/var/log/specific.log", "tail": True}, ) @@ -545,7 +545,7 @@ class TestAddLogPath: AsyncMock(side_effect=JailNotFoundError("missing")), ): resp = await config_client.post( - "/api/config/jails/missing/logpath", + "/api/v1/config/jails/missing/logpath", json={"log_path": "/var/log/test.log"}, ) @@ -574,7 +574,7 @@ class TestPreviewLog: AsyncMock(return_value=mock_response), ): resp = await config_client.post( - "/api/config/preview-log", + "/api/v1/config/preview-log", json={"log_path": "/var/log/test.log", "fail_regex": "fail"}, ) @@ -594,7 +594,7 @@ class TestGetMapColorThresholds: async def test_200_returns_thresholds(self, config_client: AsyncClient) -> None: """GET /api/config/map-color-thresholds returns 200 with current values.""" - resp = await config_client.get("/api/config/map-color-thresholds") + resp = await config_client.get("/api/v1/config/map-color-thresholds") assert resp.status_code == 200 data = resp.json() @@ -623,7 +623,7 @@ class TestUpdateMapColorThresholds: "threshold_low": 30, } resp = await config_client.put( - "/api/config/map-color-thresholds", json=update_payload + "/api/v1/config/map-color-thresholds", json=update_payload ) assert resp.status_code == 200 @@ -633,7 +633,7 @@ class TestUpdateMapColorThresholds: assert data["threshold_low"] == 30 # Verify the values persist - get_resp = await config_client.get("/api/config/map-color-thresholds") + get_resp = await config_client.get("/api/v1/config/map-color-thresholds") assert get_resp.status_code == 200 get_data = get_resp.json() assert get_data["threshold_high"] == 200 @@ -648,7 +648,7 @@ class TestUpdateMapColorThresholds: "threshold_low": 20, } resp = await config_client.put( - "/api/config/map-color-thresholds", json=invalid_payload + "/api/v1/config/map-color-thresholds", json=invalid_payload ) assert resp.status_code == 400 @@ -664,7 +664,7 @@ class TestUpdateMapColorThresholds: "threshold_low": 0, } resp = await config_client.put( - "/api/config/map-color-thresholds", json=invalid_payload + "/api/v1/config/map-color-thresholds", json=invalid_payload ) # Pydantic validates ge=1 constraint before our service code runs @@ -701,7 +701,7 @@ class TestGetInactiveJails: "app.routers.jail_config.jail_config_service.list_inactive_jails", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/jails/inactive") + resp = await config_client.get("/api/v1/config/jails/inactive") assert resp.status_code == 200 data = resp.json() @@ -716,7 +716,7 @@ class TestGetInactiveJails: "app.routers.jail_config.jail_config_service.list_inactive_jails", AsyncMock(return_value=InactiveJailListResponse(items=[], total=0)), ): - resp = await config_client.get("/api/config/jails/inactive") + resp = await config_client.get("/api/v1/config/jails/inactive") assert resp.status_code == 200 assert resp.json()["total"] == 0 @@ -727,7 +727,7 @@ class TestGetInactiveJails: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/jails/inactive") + ).get("/api/v1/config/jails/inactive") assert resp.status_code == 401 @@ -753,7 +753,7 @@ class TestActivateJail: AsyncMock(return_value=mock_response), ): resp = await config_client.post( - "/api/config/jails/apache-auth/activate", json={} + "/api/v1/config/jails/apache-auth/activate", json={} ) assert resp.status_code == 200 @@ -773,7 +773,7 @@ class TestActivateJail: AsyncMock(return_value=mock_response), ) as mock_activate: resp = await config_client.post( - "/api/config/jails/apache-auth/activate", + "/api/v1/config/jails/apache-auth/activate", json={"bantime": "1h", "maxretry": 3}, ) @@ -792,7 +792,7 @@ class TestActivateJail: AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( - "/api/config/jails/missing/activate", json={} + "/api/v1/config/jails/missing/activate", json={} ) assert resp.status_code == 404 @@ -806,7 +806,7 @@ class TestActivateJail: AsyncMock(side_effect=JailAlreadyActiveError("sshd")), ): resp = await config_client.post( - "/api/config/jails/sshd/activate", json={} + "/api/v1/config/jails/sshd/activate", json={} ) assert resp.status_code == 409 @@ -823,7 +823,7 @@ class TestActivateJail: AsyncMock(side_effect=Fail2BanConnectionError("No socket", "/tmp/fake.sock")), ): resp = await config_client.post( - "/api/config/jails/sshd/activate", json={} + "/api/v1/config/jails/sshd/activate", json={} ) assert resp.status_code == 502 @@ -838,7 +838,7 @@ class TestActivateJail: AsyncMock(side_effect=JailNameError("bad name")), ): resp = await config_client.post( - "/api/config/jails/bad-name/activate", json={} + "/api/v1/config/jails/bad-name/activate", json={} ) assert resp.status_code == 400 @@ -848,7 +848,7 @@ class TestActivateJail: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/activate", json={}) + ).post("/api/v1/config/jails/sshd/activate", json={}) assert resp.status_code == 401 async def test_200_with_active_false_on_missing_logpath(self, config_client: AsyncClient) -> None: @@ -867,7 +867,7 @@ class TestActivateJail: AsyncMock(return_value=blocked_response), ): resp = await config_client.post( - "/api/config/jails/airsonic-auth/activate", json={} + "/api/v1/config/jails/airsonic-auth/activate", json={} ) assert resp.status_code == 200 @@ -899,7 +899,7 @@ class TestDeactivateJail: "app.routers.jail_config.jail_config_service.deactivate_jail", AsyncMock(return_value=mock_response), ): - resp = await config_client.post("/api/config/jails/sshd/deactivate") + resp = await config_client.post("/api/v1/config/jails/sshd/deactivate") assert resp.status_code == 200 data = resp.json() @@ -915,7 +915,7 @@ class TestDeactivateJail: AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( - "/api/config/jails/missing/deactivate" + "/api/v1/config/jails/missing/deactivate" ) assert resp.status_code == 404 @@ -929,7 +929,7 @@ class TestDeactivateJail: AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")), ): resp = await config_client.post( - "/api/config/jails/apache-auth/deactivate" + "/api/v1/config/jails/apache-auth/deactivate" ) assert resp.status_code == 409 @@ -943,7 +943,7 @@ class TestDeactivateJail: AsyncMock(side_effect=JailNameError("bad")), ): resp = await config_client.post( - "/api/config/jails/sshd/deactivate" + "/api/v1/config/jails/sshd/deactivate" ) assert resp.status_code == 400 @@ -953,7 +953,7 @@ class TestDeactivateJail: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/deactivate") + ).post("/api/v1/config/jails/sshd/deactivate") assert resp.status_code == 401 async def test_deactivate_triggers_health_probe(self, config_client: AsyncClient) -> None: @@ -975,7 +975,7 @@ class TestDeactivateJail: AsyncMock(), ) as mock_probe, ): - resp = await config_client.post("/api/config/jails/sshd/deactivate") + resp = await config_client.post("/api/v1/config/jails/sshd/deactivate") assert resp.status_code == 200 mock_probe.assert_awaited_once() @@ -1021,7 +1021,7 @@ class TestListFilters: "app.routers.filter_config.filter_config_service.list_filters", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/filters") + resp = await config_client.get("/api/v1/config/filters") assert resp.status_code == 200 data = resp.json() @@ -1037,7 +1037,7 @@ class TestListFilters: "app.routers.filter_config.filter_config_service.list_filters", AsyncMock(return_value=FilterListResponse(filters=[], total=0)), ): - resp = await config_client.get("/api/config/filters") + resp = await config_client.get("/api/v1/config/filters") assert resp.status_code == 200 assert resp.json()["total"] == 0 @@ -1060,7 +1060,7 @@ class TestListFilters: "app.routers.filter_config.filter_config_service.list_filters", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/filters") + resp = await config_client.get("/api/v1/config/filters") data = resp.json() assert data["filters"][0]["name"] == "sshd" # active first @@ -1071,7 +1071,7 @@ class TestListFilters: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/filters") + ).get("/api/v1/config/filters") assert resp.status_code == 401 @@ -1089,7 +1089,7 @@ class TestGetFilter: "app.routers.filter_config.filter_config_service.get_filter", AsyncMock(return_value=_make_filter_config("sshd")), ): - resp = await config_client.get("/api/config/filters/sshd") + resp = await config_client.get("/api/v1/config/filters/sshd") assert resp.status_code == 200 data = resp.json() @@ -1105,7 +1105,7 @@ class TestGetFilter: "app.routers.filter_config.filter_config_service.get_filter", AsyncMock(side_effect=FilterNotFoundError("missing")), ): - resp = await config_client.get("/api/config/filters/missing") + resp = await config_client.get("/api/v1/config/filters/missing") assert resp.status_code == 404 @@ -1114,7 +1114,7 @@ class TestGetFilter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/filters/sshd") + ).get("/api/v1/config/filters/sshd") assert resp.status_code == 401 @@ -1133,7 +1133,7 @@ class TestUpdateFilter: AsyncMock(return_value=_make_filter_config("sshd")), ): resp = await config_client.put( - "/api/config/filters/sshd", + "/api/v1/config/filters/sshd", json={"failregex": [r"^fail from "]}, ) @@ -1149,7 +1149,7 @@ class TestUpdateFilter: AsyncMock(side_effect=FilterNotFoundError("missing")), ): resp = await config_client.put( - "/api/config/filters/missing", + "/api/v1/config/filters/missing", json={}, ) @@ -1164,7 +1164,7 @@ class TestUpdateFilter: AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), ): resp = await config_client.put( - "/api/config/filters/sshd", + "/api/v1/config/filters/sshd", json={"failregex": ["[bad"]}, ) @@ -1179,7 +1179,7 @@ class TestUpdateFilter: AsyncMock(side_effect=FilterNameError("bad")), ): resp = await config_client.put( - "/api/config/filters/bad", + "/api/v1/config/filters/bad", json={}, ) @@ -1192,7 +1192,7 @@ class TestUpdateFilter: AsyncMock(return_value=_make_filter_config("sshd")), ) as mock_update: resp = await config_client.put( - "/api/config/filters/sshd?reload=true", + "/api/v1/config/filters/sshd?reload=true", json={}, ) @@ -1204,7 +1204,7 @@ class TestUpdateFilter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).put("/api/config/filters/sshd", json={}) + ).put("/api/v1/config/filters/sshd", json={}) assert resp.status_code == 401 @@ -1223,7 +1223,7 @@ class TestCreateFilter: AsyncMock(return_value=_make_filter_config("my-custom")), ): resp = await config_client.post( - "/api/config/filters", + "/api/v1/config/filters", json={"name": "my-custom", "failregex": [r"^fail from "]}, ) @@ -1239,7 +1239,7 @@ class TestCreateFilter: AsyncMock(side_effect=FilterAlreadyExistsError("sshd")), ): resp = await config_client.post( - "/api/config/filters", + "/api/v1/config/filters", json={"name": "sshd"}, ) @@ -1254,7 +1254,7 @@ class TestCreateFilter: AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), ): resp = await config_client.post( - "/api/config/filters", + "/api/v1/config/filters", json={"name": "test", "failregex": ["[bad"]}, ) @@ -1269,7 +1269,7 @@ class TestCreateFilter: AsyncMock(side_effect=FilterNameError("bad")), ): resp = await config_client.post( - "/api/config/filters", + "/api/v1/config/filters", json={"name": "bad"}, ) @@ -1280,7 +1280,7 @@ class TestCreateFilter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/filters", json={"name": "test"}) + ).post("/api/v1/config/filters", json={"name": "test"}) assert resp.status_code == 401 @@ -1298,7 +1298,7 @@ class TestDeleteFilter: "app.routers.filter_config.filter_config_service.delete_filter", AsyncMock(return_value=None), ): - resp = await config_client.delete("/api/config/filters/my-custom") + resp = await config_client.delete("/api/v1/config/filters/my-custom") assert resp.status_code == 204 @@ -1310,7 +1310,7 @@ class TestDeleteFilter: "app.routers.filter_config.filter_config_service.delete_filter", AsyncMock(side_effect=FilterNotFoundError("missing")), ): - resp = await config_client.delete("/api/config/filters/missing") + resp = await config_client.delete("/api/v1/config/filters/missing") assert resp.status_code == 404 @@ -1322,7 +1322,7 @@ class TestDeleteFilter: "app.routers.filter_config.filter_config_service.delete_filter", AsyncMock(side_effect=FilterReadonlyError("sshd")), ): - resp = await config_client.delete("/api/config/filters/sshd") + resp = await config_client.delete("/api/v1/config/filters/sshd") assert resp.status_code == 409 @@ -1334,7 +1334,7 @@ class TestDeleteFilter: "app.routers.filter_config.filter_config_service.delete_filter", AsyncMock(side_effect=FilterNameError("bad")), ): - resp = await config_client.delete("/api/config/filters/bad") + resp = await config_client.delete("/api/v1/config/filters/bad") assert resp.status_code == 400 @@ -1343,7 +1343,7 @@ class TestDeleteFilter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).delete("/api/config/filters/sshd") + ).delete("/api/v1/config/filters/sshd") assert resp.status_code == 401 @@ -1362,7 +1362,7 @@ class TestAssignFilterToJail: AsyncMock(return_value=None), ): resp = await config_client.post( - "/api/config/jails/sshd/filter", + "/api/v1/config/jails/sshd/filter", json={"filter_name": "myfilter"}, ) @@ -1377,7 +1377,7 @@ class TestAssignFilterToJail: AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( - "/api/config/jails/missing/filter", + "/api/v1/config/jails/missing/filter", json={"filter_name": "sshd"}, ) @@ -1392,7 +1392,7 @@ class TestAssignFilterToJail: AsyncMock(side_effect=FilterNotFoundError("missing-filter")), ): resp = await config_client.post( - "/api/config/jails/sshd/filter", + "/api/v1/config/jails/sshd/filter", json={"filter_name": "missing-filter"}, ) @@ -1407,7 +1407,7 @@ class TestAssignFilterToJail: AsyncMock(side_effect=JailNameError("bad")), ): resp = await config_client.post( - "/api/config/jails/sshd/filter", + "/api/v1/config/jails/sshd/filter", json={"filter_name": "valid"}, ) @@ -1422,7 +1422,7 @@ class TestAssignFilterToJail: AsyncMock(side_effect=FilterNameError("bad")), ): resp = await config_client.post( - "/api/config/jails/sshd/filter", + "/api/v1/config/jails/sshd/filter", json={"filter_name": "../evil"}, ) @@ -1435,7 +1435,7 @@ class TestAssignFilterToJail: AsyncMock(return_value=None), ) as mock_assign: resp = await config_client.post( - "/api/config/jails/sshd/filter?reload=true", + "/api/v1/config/jails/sshd/filter?reload=true", json={"filter_name": "sshd"}, ) @@ -1447,7 +1447,7 @@ class TestAssignFilterToJail: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"}) + ).post("/api/v1/config/jails/sshd/filter", json={"filter_name": "sshd"}) assert resp.status_code == 401 @@ -1472,7 +1472,7 @@ class TestListActionsRouter: "app.routers.action_config.action_config_service.list_actions", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/actions") + resp = await config_client.get("/api/v1/config/actions") assert resp.status_code == 200 data = resp.json() @@ -1490,7 +1490,7 @@ class TestListActionsRouter: "app.routers.action_config.action_config_service.list_actions", AsyncMock(return_value=mock_response), ): - resp = await config_client.get("/api/config/actions") + resp = await config_client.get("/api/v1/config/actions") data = resp.json() assert data["actions"][0]["name"] == "zzz" # active comes first @@ -1499,7 +1499,7 @@ class TestListActionsRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/actions") + ).get("/api/v1/config/actions") assert resp.status_code == 401 @@ -1518,7 +1518,7 @@ class TestGetActionRouter: "app.routers.action_config.action_config_service.get_action", AsyncMock(return_value=mock_action), ): - resp = await config_client.get("/api/config/actions/iptables") + resp = await config_client.get("/api/v1/config/actions/iptables") assert resp.status_code == 200 assert resp.json()["name"] == "iptables" @@ -1530,7 +1530,7 @@ class TestGetActionRouter: "app.routers.action_config.action_config_service.get_action", AsyncMock(side_effect=ActionNotFoundError("missing")), ): - resp = await config_client.get("/api/config/actions/missing") + resp = await config_client.get("/api/v1/config/actions/missing") assert resp.status_code == 404 @@ -1538,7 +1538,7 @@ class TestGetActionRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/actions/iptables") + ).get("/api/v1/config/actions/iptables") assert resp.status_code == 401 @@ -1558,7 +1558,7 @@ class TestUpdateActionRouter: AsyncMock(return_value=updated), ): resp = await config_client.put( - "/api/config/actions/iptables", + "/api/v1/config/actions/iptables", json={"actionban": "echo ban"}, ) @@ -1573,7 +1573,7 @@ class TestUpdateActionRouter: AsyncMock(side_effect=ActionNotFoundError("missing")), ): resp = await config_client.put( - "/api/config/actions/missing", json={} + "/api/v1/config/actions/missing", json={} ) assert resp.status_code == 404 @@ -1586,7 +1586,7 @@ class TestUpdateActionRouter: AsyncMock(side_effect=ActionNameError()), ): resp = await config_client.put( - "/api/config/actions/badname", json={} + "/api/v1/config/actions/badname", json={} ) assert resp.status_code == 400 @@ -1595,7 +1595,7 @@ class TestUpdateActionRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).put("/api/config/actions/iptables", json={}) + ).put("/api/v1/config/actions/iptables", json={}) assert resp.status_code == 401 @@ -1615,7 +1615,7 @@ class TestCreateActionRouter: AsyncMock(return_value=created), ): resp = await config_client.post( - "/api/config/actions", + "/api/v1/config/actions", json={"name": "custom", "actionban": "echo ban"}, ) @@ -1630,7 +1630,7 @@ class TestCreateActionRouter: AsyncMock(side_effect=ActionAlreadyExistsError("iptables")), ): resp = await config_client.post( - "/api/config/actions", + "/api/v1/config/actions", json={"name": "iptables"}, ) @@ -1644,7 +1644,7 @@ class TestCreateActionRouter: AsyncMock(side_effect=ActionNameError()), ): resp = await config_client.post( - "/api/config/actions", + "/api/v1/config/actions", json={"name": "badname"}, ) @@ -1654,7 +1654,7 @@ class TestCreateActionRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/actions", json={"name": "x"}) + ).post("/api/v1/config/actions", json={"name": "x"}) assert resp.status_code == 401 @@ -1665,7 +1665,7 @@ class TestDeleteActionRouter: "app.routers.action_config.action_config_service.delete_action", AsyncMock(return_value=None), ): - resp = await config_client.delete("/api/config/actions/custom") + resp = await config_client.delete("/api/v1/config/actions/custom") assert resp.status_code == 204 @@ -1676,7 +1676,7 @@ class TestDeleteActionRouter: "app.routers.action_config.action_config_service.delete_action", AsyncMock(side_effect=ActionNotFoundError("missing")), ): - resp = await config_client.delete("/api/config/actions/missing") + resp = await config_client.delete("/api/v1/config/actions/missing") assert resp.status_code == 404 @@ -1687,7 +1687,7 @@ class TestDeleteActionRouter: "app.routers.action_config.action_config_service.delete_action", AsyncMock(side_effect=ActionReadonlyError("iptables")), ): - resp = await config_client.delete("/api/config/actions/iptables") + resp = await config_client.delete("/api/v1/config/actions/iptables") assert resp.status_code == 409 @@ -1698,7 +1698,7 @@ class TestDeleteActionRouter: "app.routers.action_config.action_config_service.delete_action", AsyncMock(side_effect=ActionNameError()), ): - resp = await config_client.delete("/api/config/actions/badname") + resp = await config_client.delete("/api/v1/config/actions/badname") assert resp.status_code == 400 @@ -1706,7 +1706,7 @@ class TestDeleteActionRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).delete("/api/config/actions/iptables") + ).delete("/api/v1/config/actions/iptables") assert resp.status_code == 401 @@ -1718,7 +1718,7 @@ class TestAssignActionToJailRouter: AsyncMock(return_value=None), ): resp = await config_client.post( - "/api/config/jails/sshd/action", + "/api/v1/config/jails/sshd/action", json={"action_name": "iptables"}, ) @@ -1732,7 +1732,7 @@ class TestAssignActionToJailRouter: AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( - "/api/config/jails/missing/action", + "/api/v1/config/jails/missing/action", json={"action_name": "iptables"}, ) @@ -1746,7 +1746,7 @@ class TestAssignActionToJailRouter: AsyncMock(side_effect=ActionNotFoundError("missing")), ): resp = await config_client.post( - "/api/config/jails/sshd/action", + "/api/v1/config/jails/sshd/action", json={"action_name": "missing"}, ) @@ -1760,7 +1760,7 @@ class TestAssignActionToJailRouter: AsyncMock(side_effect=JailNameError()), ): resp = await config_client.post( - "/api/config/jails/badjailname/action", + "/api/v1/config/jails/badjailname/action", json={"action_name": "iptables"}, ) @@ -1774,7 +1774,7 @@ class TestAssignActionToJailRouter: AsyncMock(side_effect=ActionNameError()), ): resp = await config_client.post( - "/api/config/jails/sshd/action", + "/api/v1/config/jails/sshd/action", json={"action_name": "badaction"}, ) @@ -1786,7 +1786,7 @@ class TestAssignActionToJailRouter: AsyncMock(return_value=None), ) as mock_assign: resp = await config_client.post( - "/api/config/jails/sshd/action?reload=true", + "/api/v1/config/jails/sshd/action?reload=true", json={"action_name": "iptables"}, ) @@ -1797,7 +1797,7 @@ class TestAssignActionToJailRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/action", json={"action_name": "iptables"}) + ).post("/api/v1/config/jails/sshd/action", json={"action_name": "iptables"}) assert resp.status_code == 401 @@ -1809,7 +1809,7 @@ class TestRemoveActionFromJailRouter: AsyncMock(return_value=None), ): resp = await config_client.delete( - "/api/config/jails/sshd/action/iptables" + "/api/v1/config/jails/sshd/action/iptables" ) assert resp.status_code == 204 @@ -1822,7 +1822,7 @@ class TestRemoveActionFromJailRouter: AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.delete( - "/api/config/jails/missing/action/iptables" + "/api/v1/config/jails/missing/action/iptables" ) assert resp.status_code == 404 @@ -1835,7 +1835,7 @@ class TestRemoveActionFromJailRouter: AsyncMock(side_effect=JailNameError()), ): resp = await config_client.delete( - "/api/config/jails/badjailname/action/iptables" + "/api/v1/config/jails/badjailname/action/iptables" ) assert resp.status_code == 400 @@ -1848,7 +1848,7 @@ class TestRemoveActionFromJailRouter: AsyncMock(side_effect=ActionNameError()), ): resp = await config_client.delete( - "/api/config/jails/sshd/action/badactionname" + "/api/v1/config/jails/sshd/action/badactionname" ) assert resp.status_code == 400 @@ -1859,7 +1859,7 @@ class TestRemoveActionFromJailRouter: AsyncMock(return_value=None), ) as mock_rm: resp = await config_client.delete( - "/api/config/jails/sshd/action/iptables?reload=true" + "/api/v1/config/jails/sshd/action/iptables?reload=true" ) assert resp.status_code == 204 @@ -1869,7 +1869,7 @@ class TestRemoveActionFromJailRouter: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).delete("/api/config/jails/sshd/action/iptables") + ).delete("/api/v1/config/jails/sshd/action/iptables") assert resp.status_code == 401 @@ -1896,7 +1896,7 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(return_value=self._mock_log_response()), ): - resp = await config_client.get("/api/config/fail2ban-log") + resp = await config_client.get("/api/v1/config/fail2ban-log") assert resp.status_code == 200 data = resp.json() @@ -1911,7 +1911,7 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(return_value=self._mock_log_response()), ) as mock_fn: - resp = await config_client.get("/api/config/fail2ban-log?lines=500") + resp = await config_client.get("/api/v1/config/fail2ban-log?lines=500") assert resp.status_code == 200 _socket, lines_arg, _filter = mock_fn.call_args.args @@ -1923,7 +1923,7 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(return_value=self._mock_log_response()), ) as mock_fn: - resp = await config_client.get("/api/config/fail2ban-log?filter=ERROR") + resp = await config_client.get("/api/v1/config/fail2ban-log?filter=ERROR") assert resp.status_code == 200 _socket, _lines, filter_arg = mock_fn.call_args.args @@ -1937,7 +1937,7 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(side_effect=ConfigOperationError("fail2ban is logging to 'STDOUT'")), ): - resp = await config_client.get("/api/config/fail2ban-log") + resp = await config_client.get("/api/v1/config/fail2ban-log") assert resp.status_code == 400 @@ -1949,7 +1949,7 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(side_effect=ConfigOperationError("outside the allowed directory")), ): - resp = await config_client.get("/api/config/fail2ban-log") + resp = await config_client.get("/api/v1/config/fail2ban-log") assert resp.status_code == 400 @@ -1961,13 +1961,13 @@ class TestGetFail2BanLog: "app.routers.config_misc.log_service.read_fail2ban_log", AsyncMock(side_effect=Fail2BanConnectionError("socket error", "/tmp/f.sock")), ): - resp = await config_client.get("/api/config/fail2ban-log") + resp = await config_client.get("/api/v1/config/fail2ban-log") assert resp.status_code == 502 async def test_422_for_lines_exceeding_max(self, config_client: AsyncClient) -> None: """GET /api/config/fail2ban-log returns 422 for lines > 2000.""" - resp = await config_client.get("/api/config/fail2ban-log?lines=9999") + resp = await config_client.get("/api/v1/config/fail2ban-log?lines=9999") assert resp.status_code == 422 async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: @@ -1975,7 +1975,7 @@ class TestGetFail2BanLog: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/fail2ban-log") + ).get("/api/v1/config/fail2ban-log") assert resp.status_code == 401 @@ -2004,7 +2004,7 @@ class TestGetServiceStatus: "app.routers.config_misc.health_service.get_service_status", AsyncMock(return_value=self._mock_status(online=True)), ): - resp = await config_client.get("/api/config/service-status") + resp = await config_client.get("/api/v1/config/service-status") assert resp.status_code == 200 data = resp.json() @@ -2019,7 +2019,7 @@ class TestGetServiceStatus: "app.routers.config_misc.health_service.get_service_status", AsyncMock(return_value=self._mock_status(online=False)), ): - resp = await config_client.get("/api/config/service-status") + resp = await config_client.get("/api/v1/config/service-status") assert resp.status_code == 200 data = resp.json() @@ -2032,7 +2032,7 @@ class TestGetServiceStatus: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/service-status") + ).get("/api/v1/config/service-status") assert resp.status_code == 401 @@ -2056,7 +2056,7 @@ class TestValidateJailEndpoint: "app.routers.jail_config.jail_config_service.validate_jail_config", AsyncMock(return_value=mock_result), ): - resp = await config_client.post("/api/config/jails/sshd/validate") + resp = await config_client.post("/api/v1/config/jails/sshd/validate") assert resp.status_code == 200 data = resp.json() @@ -2076,7 +2076,7 @@ class TestValidateJailEndpoint: "app.routers.jail_config.jail_config_service.validate_jail_config", AsyncMock(return_value=mock_result), ): - resp = await config_client.post("/api/config/jails/sshd/validate") + resp = await config_client.post("/api/v1/config/jails/sshd/validate") assert resp.status_code == 200 data = resp.json() @@ -2092,7 +2092,7 @@ class TestValidateJailEndpoint: "app.routers.jail_config.jail_config_service.validate_jail_config", AsyncMock(side_effect=JailNameError("bad name")), ): - resp = await config_client.post("/api/config/jails/bad-name/validate") + resp = await config_client.post("/api/v1/config/jails/bad-name/validate") assert resp.status_code == 400 @@ -2101,7 +2101,7 @@ class TestValidateJailEndpoint: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/validate") + ).post("/api/v1/config/jails/sshd/validate") assert resp.status_code == 401 @@ -2116,7 +2116,7 @@ class TestPendingRecovery: app = config_client._transport.app # type: ignore[attr-defined] app.state.pending_recovery = None - resp = await config_client.get("/api/config/jails/pending-recovery") + resp = await config_client.get("/api/v1/config/jails/pending-recovery") assert resp.status_code == 200 assert resp.json() is None @@ -2136,7 +2136,7 @@ class TestPendingRecovery: app = config_client._transport.app # type: ignore[attr-defined] app.state.pending_recovery = record - resp = await config_client.get("/api/config/jails/pending-recovery") + resp = await config_client.get("/api/v1/config/jails/pending-recovery") assert resp.status_code == 200 data = resp.json() @@ -2148,7 +2148,7 @@ class TestPendingRecovery: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/jails/pending-recovery") + ).get("/api/v1/config/jails/pending-recovery") assert resp.status_code == 401 @@ -2184,7 +2184,7 @@ class TestRollbackEndpoint: "app.routers.jail_config.jail_config_service._rollback_jail", AsyncMock(return_value=mock_result), ): - resp = await config_client.post("/api/config/jails/sshd/rollback") + resp = await config_client.post("/api/v1/config/jails/sshd/rollback") assert resp.status_code == 200 data = resp.json() @@ -2221,7 +2221,7 @@ class TestRollbackEndpoint: "app.routers.jail_config.jail_config_service._rollback_jail", AsyncMock(return_value=mock_result), ): - resp = await config_client.post("/api/config/jails/sshd/rollback") + resp = await config_client.post("/api/v1/config/jails/sshd/rollback") assert resp.status_code == 200 data = resp.json() @@ -2237,7 +2237,7 @@ class TestRollbackEndpoint: "app.routers.jail_config.jail_config_service.rollback_jail", AsyncMock(side_effect=JailNameError("bad")), ): - resp = await config_client.post("/api/config/jails/bad/rollback") + resp = await config_client.post("/api/v1/config/jails/bad/rollback") assert resp.status_code == 400 @@ -2246,6 +2246,6 @@ class TestRollbackEndpoint: resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/config/jails/sshd/rollback") + ).post("/api/v1/config/jails/sshd/rollback") assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_csrf.py b/backend/tests/test_routers/test_csrf.py index fb7375a..bec0fba 100644 --- a/backend/tests/test_routers/test_csrf.py +++ b/backend/tests/test_routers/test_csrf.py @@ -26,14 +26,14 @@ _SETUP_PAYLOAD = { async def _do_setup(client: AsyncClient) -> None: """Run the setup wizard so auth endpoints are reachable.""" - resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str: """Helper: perform login and return the session token.""" resp = await client.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": password}, headers={"X-BanGUI-Request": "1"}, ) @@ -58,7 +58,7 @@ class TestCsrfProtection: # POST with correct CSRF header should succeed (endpoint may fail for other reasons) response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", cookies={SESSION_COOKIE_NAME: token}, headers={"X-BanGUI-Request": "1"}, ) @@ -74,7 +74,7 @@ class TestCsrfProtection: # POST without CSRF header should be rejected response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", cookies={SESSION_COOKIE_NAME: token}, headers={}, # Explicitly omit X-BanGUI-Request ) @@ -92,7 +92,7 @@ class TestCsrfProtection: # POST with wrong CSRF header value should be rejected response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", cookies={SESSION_COOKIE_NAME: token}, headers={"X-BanGUI-Request": "invalid"}, ) @@ -107,7 +107,7 @@ class TestCsrfProtection: # POST with Bearer token but no CSRF header should succeed response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) # Expect 200 (logout succeeds) not 403 (CSRF check should be skipped) @@ -122,7 +122,7 @@ class TestCsrfProtection: # GET without CSRF header should succeed (safe method) response = await client.get( - "/api/auth/session", + "/api/v1/auth/session", cookies={SESSION_COOKIE_NAME: token}, headers={}, # Explicitly omit X-BanGUI-Request ) @@ -138,7 +138,7 @@ class TestCsrfProtection: # OPTIONS without CSRF header should succeed (safe method) response = await client.options( - "/api/auth/session", + "/api/v1/auth/session", cookies={SESSION_COOKIE_NAME: token}, headers={}, ) @@ -154,7 +154,7 @@ class TestCsrfProtection: # HEAD without CSRF header should succeed (safe method) response = await client.head( - "/api/auth/session", + "/api/v1/auth/session", cookies={SESSION_COOKIE_NAME: token}, headers={}, ) @@ -172,7 +172,7 @@ class TestCsrfProtection: # The endpoint may fail for other reasons (no ban to delete), but not 403 CSRF response = await client.request( "DELETE", - "/api/bans", + "/api/v1/bans", content='{"ip": "192.0.2.1", "jail": "sshd"}', cookies={SESSION_COOKIE_NAME: token}, headers={"X-BanGUI-Request": "1"}, @@ -190,7 +190,7 @@ class TestCsrfProtection: # DELETE without CSRF header should be rejected response = await client.request( "DELETE", - "/api/bans", + "/api/v1/bans", content='{"ip": "192.0.2.1", "jail": "sshd"}', cookies={SESSION_COOKIE_NAME: token}, headers={}, @@ -206,7 +206,7 @@ class TestCsrfProtection: # PUT with correct CSRF header should not be rejected by CSRF middleware response = await client.put( - "/api/blocklists/schedule", + "/api/v1/blocklists/schedule", json={"enabled": False}, cookies={SESSION_COOKIE_NAME: token}, headers={"X-BanGUI-Request": "1"}, @@ -223,7 +223,7 @@ class TestCsrfProtection: # PUT without CSRF header should be rejected response = await client.put( - "/api/blocklists/schedule", + "/api/v1/blocklists/schedule", json={"enabled": False}, cookies={SESSION_COOKIE_NAME: token}, headers={}, @@ -240,7 +240,7 @@ class TestCsrfProtection: # PATCH with correct CSRF header should not be rejected by CSRF middleware # (endpoint may not exist, but CSRF check should pass) response = await client.patch( - "/api/auth/logout", + "/api/v1/auth/logout", cookies={SESSION_COOKIE_NAME: token}, headers={"X-BanGUI-Request": "1"}, ) @@ -256,7 +256,7 @@ class TestCsrfProtection: # PATCH without CSRF header should be rejected response = await client.patch( - "/api/auth/logout", + "/api/v1/auth/logout", cookies={SESSION_COOKIE_NAME: token}, headers={}, ) @@ -271,7 +271,7 @@ class TestCsrfProtection: # POST without any authentication should bypass CSRF check # (the endpoint itself will reject it with 401, not 403) response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={}, ) # Should be 401 (auth required) not 403 (CSRF failed) @@ -289,7 +289,7 @@ class TestCsrfProtection: # POST with Bearer token via Authorization header and no CSRF header # should NOT be rejected by CSRF middleware response = await client.post( - "/api/auth/logout", + "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) # Should succeed (200) not fail with 403 diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index 80e74ab..6a86216 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -69,12 +69,12 @@ async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: # Complete setup so the middleware doesn't redirect. - resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 # Login to get a session cookie. login_resp = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login_resp.status_code == 200 @@ -107,11 +107,11 @@ async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: igno transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 login_resp = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login_resp.status_code == 200 @@ -133,7 +133,7 @@ class TestDashboardStatus: self, dashboard_client: AsyncClient ) -> None: """Authenticated request returns HTTP 200.""" - response = await dashboard_client.get("/api/dashboard/status") + response = await dashboard_client.get("/api/v1/dashboard/status") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( @@ -141,15 +141,15 @@ class TestDashboardStatus: ) -> None: """Unauthenticated request returns HTTP 401.""" # Complete setup so the middleware allows the request through. - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/dashboard/status") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/dashboard/status") assert response.status_code == 401 async def test_response_shape_when_online( self, dashboard_client: AsyncClient ) -> None: """Response contains the expected ``status`` object shape.""" - response = await dashboard_client.get("/api/dashboard/status") + response = await dashboard_client.get("/api/v1/dashboard/status") body = response.json() assert "status" in body @@ -165,7 +165,7 @@ class TestDashboardStatus: self, dashboard_client: AsyncClient ) -> None: """Endpoint returns the exact values from ``app.state.server_status``.""" - response = await dashboard_client.get("/api/dashboard/status") + response = await dashboard_client.get("/api/v1/dashboard/status") body = response.json() status = body["status"] @@ -179,7 +179,7 @@ class TestDashboardStatus: self, offline_dashboard_client: AsyncClient ) -> None: """Endpoint returns online=False when the cache holds an offline snapshot.""" - response = await offline_dashboard_client.get("/api/dashboard/status") + response = await offline_dashboard_client.get("/api/v1/dashboard/status") assert response.status_code == 200 body = response.json() status = body["status"] @@ -195,13 +195,13 @@ class TestDashboardStatus: ) -> None: """Endpoint returns online=False as a safe default if the cache is absent.""" # Setup + login so the endpoint is reachable. - await client.post("/api/setup", json=_SETUP_PAYLOAD) + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) await client.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) # server_status is not set on app.state in the shared `client` fixture. - response = await client.get("/api/dashboard/status") + response = await client.get("/api/v1/dashboard/status") assert response.status_code == 200 status = response.json()["status"] assert status["online"] is False @@ -243,15 +243,15 @@ class TestDashboardBans: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=_make_ban_list_response()), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/dashboard/bans") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/dashboard/bans") assert response.status_code == 401 async def test_response_contains_items_and_total( @@ -262,7 +262,7 @@ class TestDashboardBans: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=_make_ban_list_response(3)), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") body = response.json() assert "items" in body @@ -274,7 +274,7 @@ class TestDashboardBans: """If no ``range`` param is provided the default ``24h`` preset is used.""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans") + await dashboard_client.get("/api/v1/dashboard/bans") called_range = mock_list.call_args[0][1] assert called_range == "24h" @@ -285,7 +285,7 @@ class TestDashboardBans: """The ``range`` query parameter is forwarded to ban_service.""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans?range=7d") + await dashboard_client.get("/api/v1/dashboard/bans?range=7d") called_range = mock_list.call_args[0][1] assert called_range == "7d" @@ -296,7 +296,7 @@ class TestDashboardBans: """The ``source`` query parameter is forwarded to ban_service.""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans?source=archive") + await dashboard_client.get("/api/v1/dashboard/bans?source=archive") called_source = mock_list.call_args[1]["source"] assert called_source == "archive" @@ -310,7 +310,7 @@ class TestDashboardBans: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=empty), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") body = response.json() assert body["total"] == 0 @@ -322,7 +322,7 @@ class TestDashboardBans: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=_make_ban_list_response(1)), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") item = response.json()["items"][0] assert "ip" in item @@ -386,15 +386,15 @@ class TestBansByCountry: "app.routers.dashboard.ban_service.bans_by_country", new=AsyncMock(return_value=_make_bans_by_country_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-country") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-country") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/dashboard/bans/by-country") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/dashboard/bans/by-country") assert response.status_code == 401 async def test_response_shape(self, dashboard_client: AsyncClient) -> None: @@ -403,7 +403,7 @@ class TestBansByCountry: "app.routers.dashboard.ban_service.bans_by_country", new=AsyncMock(return_value=_make_bans_by_country_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-country") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-country") body = response.json() assert "countries" in body @@ -423,7 +423,7 @@ class TestBansByCountry: with patch( "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn ): - await dashboard_client.get("/api/dashboard/bans/by-country?range=7d") + await dashboard_client.get("/api/v1/dashboard/bans/by-country?range=7d") called_range = mock_fn.call_args[0][1] assert called_range == "7d" @@ -433,7 +433,7 @@ class TestBansByCountry: ) -> None: """An invalid source value returns HTTP 422.""" response = await dashboard_client.get( - "/api/dashboard/bans/by-country?source=invalid" + "/api/v1/dashboard/bans/by-country?source=invalid" ) assert response.status_code == 422 @@ -453,7 +453,7 @@ class TestBansByCountry: "app.routers.dashboard.ban_service.bans_by_country", new=AsyncMock(return_value=empty), ): - response = await dashboard_client.get("/api/dashboard/bans/by-country") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-country") body = response.json() assert body["total"] == 0 @@ -477,7 +477,7 @@ class TestDashboardBansOriginField: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=_make_ban_list_response(1)), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") item = response.json()["items"][0] assert "origin" in item @@ -491,7 +491,7 @@ class TestDashboardBansOriginField: "app.routers.dashboard.ban_service.list_bans", new=AsyncMock(return_value=_make_ban_list_response(1)), ): - response = await dashboard_client.get("/api/dashboard/bans") + response = await dashboard_client.get("/api/v1/dashboard/bans") item = response.json()["items"][0] assert item["jail"] == "sshd" @@ -505,7 +505,7 @@ class TestDashboardBansOriginField: "app.routers.dashboard.ban_service.bans_by_country", new=AsyncMock(return_value=_make_bans_by_country_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-country") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-country") bans = response.json()["bans"] assert all("origin" in ban for ban in bans) @@ -518,7 +518,7 @@ class TestDashboardBansOriginField: """The ``source`` query parameter is forwarded to bans_by_country.""" mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/by-country?source=archive") + await dashboard_client.get("/api/v1/dashboard/bans/by-country?source=archive") assert mock_fn.call_args[1]["source"] == "archive" @@ -529,7 +529,7 @@ class TestDashboardBansOriginField: mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn): await dashboard_client.get( - "/api/dashboard/bans/by-country?country_code=DE" + "/api/v1/dashboard/bans/by-country?country_code=DE" ) _, kwargs = mock_fn.call_args @@ -543,7 +543,7 @@ class TestDashboardBansOriginField: "app.routers.dashboard.ban_service.bans_by_country", new=AsyncMock(return_value=_make_bans_by_country_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-country") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-country") bans = response.json()["bans"] blocklist_ban = next(b for b in bans if b["jail"] == "blocklist-import") @@ -564,7 +564,7 @@ class TestOriginFilterParam: """``?origin=blocklist`` is passed to ``ban_service.list_bans``.""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans?origin=blocklist") + await dashboard_client.get("/api/v1/dashboard/bans?origin=blocklist") _, kwargs = mock_list.call_args assert kwargs.get("origin") == "blocklist" @@ -575,7 +575,7 @@ class TestOriginFilterParam: """``?origin=selfblock`` is passed to ``ban_service.list_bans``.""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans?origin=selfblock") + await dashboard_client.get("/api/v1/dashboard/bans?origin=selfblock") _, kwargs = mock_list.call_args assert kwargs.get("origin") == "selfblock" @@ -586,7 +586,7 @@ class TestOriginFilterParam: """Omitting ``origin`` passes ``None`` to the service (no filtering).""" mock_list = AsyncMock(return_value=_make_ban_list_response()) with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): - await dashboard_client.get("/api/dashboard/bans") + await dashboard_client.get("/api/v1/dashboard/bans") _, kwargs = mock_list.call_args assert kwargs.get("origin") is None @@ -595,7 +595,7 @@ class TestOriginFilterParam: self, dashboard_client: AsyncClient ) -> None: """An invalid ``origin`` value returns HTTP 422 Unprocessable Entity.""" - response = await dashboard_client.get("/api/dashboard/bans?origin=invalid") + response = await dashboard_client.get("/api/v1/dashboard/bans?origin=invalid") assert response.status_code == 422 async def test_by_country_origin_blocklist_forwarded( @@ -607,7 +607,7 @@ class TestOriginFilterParam: "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn ): await dashboard_client.get( - "/api/dashboard/bans/by-country?origin=blocklist" + "/api/v1/dashboard/bans/by-country?origin=blocklist" ) _, kwargs = mock_fn.call_args @@ -621,7 +621,7 @@ class TestOriginFilterParam: with patch( "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn ): - await dashboard_client.get("/api/dashboard/bans/by-country") + await dashboard_client.get("/api/v1/dashboard/bans/by-country") _, kwargs = mock_fn.call_args assert kwargs.get("origin") is None @@ -655,15 +655,15 @@ class TestBanTrend: "app.routers.dashboard.ban_service.ban_trend", new=AsyncMock(return_value=_make_ban_trend_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/trend") + response = await dashboard_client.get("/api/v1/dashboard/bans/trend") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/dashboard/bans/trend") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/dashboard/bans/trend") assert response.status_code == 401 async def test_response_shape(self, dashboard_client: AsyncClient) -> None: @@ -672,7 +672,7 @@ class TestBanTrend: "app.routers.dashboard.ban_service.ban_trend", new=AsyncMock(return_value=_make_ban_trend_response(24)), ): - response = await dashboard_client.get("/api/dashboard/bans/trend") + response = await dashboard_client.get("/api/v1/dashboard/bans/trend") body = response.json() assert "buckets" in body @@ -688,7 +688,7 @@ class TestBanTrend: "app.routers.dashboard.ban_service.ban_trend", new=AsyncMock(return_value=_make_ban_trend_response(3)), ): - response = await dashboard_client.get("/api/dashboard/bans/trend") + response = await dashboard_client.get("/api/v1/dashboard/bans/trend") for bucket in response.json()["buckets"]: assert "timestamp" in bucket @@ -699,7 +699,7 @@ class TestBanTrend: """Omitting ``range`` defaults to ``24h``.""" mock_fn = AsyncMock(return_value=_make_ban_trend_response()) with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/trend") + await dashboard_client.get("/api/v1/dashboard/bans/trend") called_range = mock_fn.call_args[0][1] assert called_range == "24h" @@ -708,7 +708,7 @@ class TestBanTrend: """The ``range`` query parameter is forwarded to the service.""" mock_fn = AsyncMock(return_value=_make_ban_trend_response(28)) with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/trend?range=7d") + await dashboard_client.get("/api/v1/dashboard/bans/trend?range=7d") called_range = mock_fn.call_args[0][1] assert called_range == "7d" @@ -718,7 +718,7 @@ class TestBanTrend: mock_fn = AsyncMock(return_value=_make_ban_trend_response()) with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): await dashboard_client.get( - "/api/dashboard/bans/trend?origin=blocklist" + "/api/v1/dashboard/bans/trend?origin=blocklist" ) _, kwargs = mock_fn.call_args @@ -730,7 +730,7 @@ class TestBanTrend: """Omitting ``origin`` passes ``None`` to the service.""" mock_fn = AsyncMock(return_value=_make_ban_trend_response()) with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/trend") + await dashboard_client.get("/api/v1/dashboard/bans/trend") _, kwargs = mock_fn.call_args assert kwargs.get("origin") is None @@ -740,7 +740,7 @@ class TestBanTrend: ) -> None: """An invalid ``range`` value returns HTTP 422.""" response = await dashboard_client.get( - "/api/dashboard/bans/trend?range=invalid" + "/api/v1/dashboard/bans/trend?range=invalid" ) assert response.status_code == 422 @@ -749,7 +749,7 @@ class TestBanTrend: ) -> None: """An invalid source value returns HTTP 422.""" response = await dashboard_client.get( - "/api/dashboard/bans/trend?source=invalid" + "/api/v1/dashboard/bans/trend?source=invalid" ) assert response.status_code == 422 @@ -762,7 +762,7 @@ class TestBanTrend: "app.routers.dashboard.ban_service.ban_trend", new=AsyncMock(return_value=empty), ): - response = await dashboard_client.get("/api/dashboard/bans/trend") + response = await dashboard_client.get("/api/v1/dashboard/bans/trend") body = response.json() assert body["buckets"] == [] @@ -799,15 +799,15 @@ class TestBansByJail: "app.routers.dashboard.ban_service.bans_by_jail", new=AsyncMock(return_value=_make_bans_by_jail_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-jail") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/dashboard/bans/by-jail") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/dashboard/bans/by-jail") assert response.status_code == 401 async def test_response_shape(self, dashboard_client: AsyncClient) -> None: @@ -816,7 +816,7 @@ class TestBansByJail: "app.routers.dashboard.ban_service.bans_by_jail", new=AsyncMock(return_value=_make_bans_by_jail_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-jail") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail") body = response.json() assert "jails" in body @@ -831,7 +831,7 @@ class TestBansByJail: "app.routers.dashboard.ban_service.bans_by_jail", new=AsyncMock(return_value=_make_bans_by_jail_response()), ): - response = await dashboard_client.get("/api/dashboard/bans/by-jail") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail") for entry in response.json()["jails"]: assert "jail" in entry @@ -843,7 +843,7 @@ class TestBansByJail: """Omitting ``range`` defaults to ``"24h"``.""" mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/by-jail") + await dashboard_client.get("/api/v1/dashboard/bans/by-jail") called_range = mock_fn.call_args[0][1] assert called_range == "24h" @@ -852,7 +852,7 @@ class TestBansByJail: """The ``range`` query parameter is forwarded to the service.""" mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/by-jail?range=7d") + await dashboard_client.get("/api/v1/dashboard/bans/by-jail?range=7d") called_range = mock_fn.call_args[0][1] assert called_range == "7d" @@ -862,7 +862,7 @@ class TestBansByJail: mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): await dashboard_client.get( - "/api/dashboard/bans/by-jail?origin=blocklist" + "/api/v1/dashboard/bans/by-jail?origin=blocklist" ) _, kwargs = mock_fn.call_args @@ -874,7 +874,7 @@ class TestBansByJail: """Omitting ``origin`` passes ``None`` to the service.""" mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): - await dashboard_client.get("/api/dashboard/bans/by-jail") + await dashboard_client.get("/api/v1/dashboard/bans/by-jail") _, kwargs = mock_fn.call_args assert kwargs.get("origin") is None @@ -884,7 +884,7 @@ class TestBansByJail: ) -> None: """An invalid ``range`` value returns HTTP 422.""" response = await dashboard_client.get( - "/api/dashboard/bans/by-jail?range=invalid" + "/api/v1/dashboard/bans/by-jail?range=invalid" ) assert response.status_code == 422 @@ -893,7 +893,7 @@ class TestBansByJail: ) -> None: """An invalid source value returns HTTP 422.""" response = await dashboard_client.get( - "/api/dashboard/bans/by-jail?source=invalid" + "/api/v1/dashboard/bans/by-jail?source=invalid" ) assert response.status_code == 422 @@ -906,7 +906,7 @@ class TestBansByJail: "app.routers.dashboard.ban_service.bans_by_jail", new=AsyncMock(return_value=empty), ): - response = await dashboard_client.get("/api/dashboard/bans/by-jail") + response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail") body = response.json() assert body["jails"] == [] diff --git a/backend/tests/test_routers/test_dependency_injection.py b/backend/tests/test_routers/test_dependency_injection.py index b110d42..f75f967 100644 --- a/backend/tests/test_routers/test_dependency_injection.py +++ b/backend/tests/test_routers/test_dependency_injection.py @@ -146,7 +146,7 @@ async def test_auth_login_uses_injected_auth_service(tmp_path: Path) -> None: base_url="http://test", ) as client: response = await client.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": "ignored"}, ) @@ -185,7 +185,7 @@ async def test_jail_list_uses_injected_jail_service_and_auth(tmp_path: Path) -> base_url="http://test", ) as client: response = await client.get( - "/api/jails", + "/api/v1/jails", headers={"Cookie": f"{SESSION_COOKIE_NAME}=fake-token"}, ) diff --git a/backend/tests/test_routers/test_file_config.py b/backend/tests/test_routers/test_file_config.py index 610c143..dab4407 100644 --- a/backend/tests/test_routers/test_file_config.py +++ b/backend/tests/test_routers/test_file_config.py @@ -68,9 +68,9 @@ async def file_config_client(tmp_path: Path) -> AsyncClient: # type: ignore[mis transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - await ac.post("/api/setup", json=_SETUP_PAYLOAD) + await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -115,7 +115,7 @@ class TestListJailConfigFiles: "app.routers.file_config.raw_config_io_service.list_jail_config_files", AsyncMock(return_value=_jail_files_resp()), ): - resp = await file_config_client.get("/api/config/jail-files") + resp = await file_config_client.get("/api/v1/config/jail-files") assert resp.status_code == 200 data = resp.json() @@ -129,7 +129,7 @@ class TestListJailConfigFiles: "app.routers.file_config.raw_config_io_service.list_jail_config_files", AsyncMock(side_effect=ConfigDirError("not found")), ): - resp = await file_config_client.get("/api/config/jail-files") + resp = await file_config_client.get("/api/v1/config/jail-files") assert resp.status_code == 503 @@ -137,7 +137,7 @@ class TestListJailConfigFiles: resp = await AsyncClient( transport=ASGITransport(app=file_config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/config/jail-files") + ).get("/api/v1/config/jail-files") assert resp.status_code == 401 @@ -160,7 +160,7 @@ class TestGetJailConfigFile: "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(return_value=content), ): - resp = await file_config_client.get("/api/config/jail-files/sshd.conf") + resp = await file_config_client.get("/api/v1/config/jail-files/sshd.conf") assert resp.status_code == 200 assert resp.json()["content"] == "[sshd]\nenabled = true\n" @@ -170,7 +170,7 @@ class TestGetJailConfigFile: "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): - resp = await file_config_client.get("/api/config/jail-files/missing.conf") + resp = await file_config_client.get("/api/v1/config/jail-files/missing.conf") assert resp.status_code == 404 @@ -181,7 +181,7 @@ class TestGetJailConfigFile: "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNameError("bad name")), ): - resp = await file_config_client.get("/api/config/jail-files/bad.txt") + resp = await file_config_client.get("/api/v1/config/jail-files/bad.txt") assert resp.status_code == 400 @@ -198,7 +198,7 @@ class TestSetJailConfigEnabled: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/jail-files/sshd.conf/enabled", + "/api/v1/config/jail-files/sshd.conf/enabled", json={"enabled": False}, ) @@ -210,7 +210,7 @@ class TestSetJailConfigEnabled: AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( - "/api/config/jail-files/missing.conf/enabled", + "/api/v1/config/jail-files/missing.conf/enabled", json={"enabled": True}, ) @@ -235,7 +235,7 @@ class TestGetFilterFileRaw: "app.routers.file_config.raw_config_io_service.get_filter_file", AsyncMock(return_value=_conf_file_content("nginx")), ): - resp = await file_config_client.get("/api/config/filters/nginx/raw") + resp = await file_config_client.get("/api/v1/config/filters/nginx/raw") assert resp.status_code == 200 assert resp.json()["name"] == "nginx" @@ -245,7 +245,7 @@ class TestGetFilterFileRaw: "app.routers.file_config.raw_config_io_service.get_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): - resp = await file_config_client.get("/api/config/filters/missing/raw") + resp = await file_config_client.get("/api/v1/config/filters/missing/raw") assert resp.status_code == 404 @@ -262,7 +262,7 @@ class TestUpdateFilterFile: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/filters/nginx/raw", + "/api/v1/config/filters/nginx/raw", json={"content": "[Definition]\nfailregex = test\n"}, ) @@ -274,7 +274,7 @@ class TestUpdateFilterFile: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/filters/nginx/raw", + "/api/v1/config/filters/nginx/raw", json={"content": "x"}, ) @@ -293,7 +293,7 @@ class TestCreateFilterFile: AsyncMock(return_value="myfilter.conf"), ): resp = await file_config_client.post( - "/api/config/filters/raw", + "/api/v1/config/filters/raw", json={"name": "myfilter", "content": "[Definition]\n"}, ) @@ -306,7 +306,7 @@ class TestCreateFilterFile: AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), ): resp = await file_config_client.post( - "/api/config/filters/raw", + "/api/v1/config/filters/raw", json={"name": "myfilter", "content": "[Definition]\n"}, ) @@ -318,7 +318,7 @@ class TestCreateFilterFile: AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( - "/api/config/filters/raw", + "/api/v1/config/filters/raw", json={"name": "../escape", "content": "[Definition]\n"}, ) @@ -345,7 +345,7 @@ class TestListActionFiles: "app.routers.config.action_config_service.list_actions", AsyncMock(return_value=resp_data), ): - resp = await file_config_client.get("/api/config/actions") + resp = await file_config_client.get("/api/v1/config/actions") assert resp.status_code == 200 assert resp.json()["actions"][0]["name"] == "iptables" @@ -369,7 +369,7 @@ class TestCreateActionFile: AsyncMock(return_value=created), ): resp = await file_config_client.post( - "/api/config/actions", + "/api/v1/config/actions", json={"name": "myaction", "actionban": "echo ban "}, ) @@ -390,7 +390,7 @@ class TestGetActionFileRaw: "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(return_value=_conf_file_content("iptables")), ): - resp = await file_config_client.get("/api/config/actions/iptables/raw") + resp = await file_config_client.get("/api/v1/config/actions/iptables/raw") assert resp.status_code == 200 assert resp.json()["name"] == "iptables" @@ -400,7 +400,7 @@ class TestGetActionFileRaw: "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): - resp = await file_config_client.get("/api/config/actions/missing/raw") + resp = await file_config_client.get("/api/v1/config/actions/missing/raw") assert resp.status_code == 404 @@ -411,7 +411,7 @@ class TestGetActionFileRaw: "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): - resp = await file_config_client.get("/api/config/actions/iptables/raw") + resp = await file_config_client.get("/api/v1/config/actions/iptables/raw") assert resp.status_code == 503 @@ -430,7 +430,7 @@ class TestUpdateActionFileRaw: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/actions/iptables/raw", + "/api/v1/config/actions/iptables/raw", json={"content": "[Definition]\nactionban = iptables -I INPUT -s -j DROP\n"}, ) @@ -442,7 +442,7 @@ class TestUpdateActionFileRaw: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/actions/iptables/raw", + "/api/v1/config/actions/iptables/raw", json={"content": "x"}, ) @@ -454,7 +454,7 @@ class TestUpdateActionFileRaw: AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( - "/api/config/actions/missing/raw", + "/api/v1/config/actions/missing/raw", json={"content": "x"}, ) @@ -466,7 +466,7 @@ class TestUpdateActionFileRaw: AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.put( - "/api/config/actions/escape/raw", + "/api/v1/config/actions/escape/raw", json={"content": "x"}, ) @@ -485,7 +485,7 @@ class TestCreateJailConfigFile: AsyncMock(return_value="myjail.conf"), ): resp = await file_config_client.post( - "/api/config/jail-files", + "/api/v1/config/jail-files", json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, ) @@ -498,7 +498,7 @@ class TestCreateJailConfigFile: AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")), ): resp = await file_config_client.post( - "/api/config/jail-files", + "/api/v1/config/jail-files", json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, ) @@ -510,7 +510,7 @@ class TestCreateJailConfigFile: AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( - "/api/config/jail-files", + "/api/v1/config/jail-files", json={"name": "../escape", "content": "[Definition]\n"}, ) @@ -524,7 +524,7 @@ class TestCreateJailConfigFile: AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.post( - "/api/config/jail-files", + "/api/v1/config/jail-files", json={"name": "anyjail", "content": "[anyjail]\nenabled = false\n"}, ) @@ -545,7 +545,7 @@ class TestGetParsedFilter: "app.routers.file_config.raw_config_io_service.get_parsed_filter_file", AsyncMock(return_value=cfg), ): - resp = await file_config_client.get("/api/config/filters/nginx/parsed") + resp = await file_config_client.get("/api/v1/config/filters/nginx/parsed") assert resp.status_code == 200 data = resp.json() @@ -558,7 +558,7 @@ class TestGetParsedFilter: AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( - "/api/config/filters/missing/parsed" + "/api/v1/config/filters/missing/parsed" ) assert resp.status_code == 404 @@ -570,7 +570,7 @@ class TestGetParsedFilter: "app.routers.file_config.raw_config_io_service.get_parsed_filter_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): - resp = await file_config_client.get("/api/config/filters/nginx/parsed") + resp = await file_config_client.get("/api/v1/config/filters/nginx/parsed") assert resp.status_code == 503 @@ -587,7 +587,7 @@ class TestUpdateParsedFilter: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/filters/nginx/parsed", + "/api/v1/config/filters/nginx/parsed", json={"failregex": ["^ "]}, ) @@ -599,7 +599,7 @@ class TestUpdateParsedFilter: AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( - "/api/config/filters/missing/parsed", + "/api/v1/config/filters/missing/parsed", json={"failregex": []}, ) @@ -611,7 +611,7 @@ class TestUpdateParsedFilter: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/filters/nginx/parsed", + "/api/v1/config/filters/nginx/parsed", json={"failregex": ["^ "]}, ) @@ -633,7 +633,7 @@ class TestGetParsedAction: AsyncMock(return_value=cfg), ): resp = await file_config_client.get( - "/api/config/actions/iptables/parsed" + "/api/v1/config/actions/iptables/parsed" ) assert resp.status_code == 200 @@ -647,7 +647,7 @@ class TestGetParsedAction: AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( - "/api/config/actions/missing/parsed" + "/api/v1/config/actions/missing/parsed" ) assert resp.status_code == 404 @@ -660,7 +660,7 @@ class TestGetParsedAction: AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( - "/api/config/actions/iptables/parsed" + "/api/v1/config/actions/iptables/parsed" ) assert resp.status_code == 503 @@ -678,7 +678,7 @@ class TestUpdateParsedAction: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/actions/iptables/parsed", + "/api/v1/config/actions/iptables/parsed", json={"actionban": "iptables -I INPUT -s -j DROP"}, ) @@ -690,7 +690,7 @@ class TestUpdateParsedAction: AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( - "/api/config/actions/missing/parsed", + "/api/v1/config/actions/missing/parsed", json={"actionban": ""}, ) @@ -702,7 +702,7 @@ class TestUpdateParsedAction: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/actions/iptables/parsed", + "/api/v1/config/actions/iptables/parsed", json={"actionban": "iptables -I INPUT -s -j DROP"}, ) @@ -725,7 +725,7 @@ class TestGetParsedJailFile: AsyncMock(return_value=cfg), ): resp = await file_config_client.get( - "/api/config/jail-files/sshd.conf/parsed" + "/api/v1/config/jail-files/sshd.conf/parsed" ) assert resp.status_code == 200 @@ -739,7 +739,7 @@ class TestGetParsedJailFile: AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.get( - "/api/config/jail-files/missing.conf/parsed" + "/api/v1/config/jail-files/missing.conf/parsed" ) assert resp.status_code == 404 @@ -752,7 +752,7 @@ class TestGetParsedJailFile: AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( - "/api/config/jail-files/sshd.conf/parsed" + "/api/v1/config/jail-files/sshd.conf/parsed" ) assert resp.status_code == 503 @@ -770,7 +770,7 @@ class TestUpdateParsedJailFile: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/jail-files/sshd.conf/parsed", + "/api/v1/config/jail-files/sshd.conf/parsed", json={"jails": {"sshd": {"enabled": False}}}, ) @@ -782,7 +782,7 @@ class TestUpdateParsedJailFile: AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( - "/api/config/jail-files/missing.conf/parsed", + "/api/v1/config/jail-files/missing.conf/parsed", json={"jails": {}}, ) @@ -794,7 +794,7 @@ class TestUpdateParsedJailFile: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/jail-files/sshd.conf/parsed", + "/api/v1/config/jail-files/sshd.conf/parsed", json={"jails": {"sshd": {"enabled": True}}}, ) diff --git a/backend/tests/test_routers/test_geo.py b/backend/tests/test_routers/test_geo.py index c718a4e..20c5f92 100644 --- a/backend/tests/test_routers/test_geo.py +++ b/backend/tests/test_routers/test_geo.py @@ -54,9 +54,9 @@ async def geo_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] async with AsyncClient(transport=transport, base_url="http://test") as ac: setup_payload = _SETUP_PAYLOAD.copy() setup_payload["database_path"] = settings.database_path - await ac.post("/api/setup", json=setup_payload) + await ac.post("/api/v1/setup", json=setup_payload) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -85,7 +85,7 @@ class TestGeoLookup: "app.routers.geo.jail_service.lookup_ip", AsyncMock(return_value=result), ): - resp = await geo_client.get("/api/geo/lookup/1.2.3.4") + resp = await geo_client.get("/api/v1/geo/lookup/1.2.3.4") assert resp.status_code == 200 data = resp.json() @@ -107,7 +107,7 @@ class TestGeoLookup: "app.routers.geo.jail_service.lookup_ip", AsyncMock(return_value=result), ): - resp = await geo_client.get("/api/geo/lookup/8.8.8.8") + resp = await geo_client.get("/api/v1/geo/lookup/8.8.8.8") assert resp.status_code == 200 assert resp.json()["currently_banned_in"] == [] @@ -123,7 +123,7 @@ class TestGeoLookup: "app.routers.geo.jail_service.lookup_ip", AsyncMock(return_value=result), ): - resp = await geo_client.get("/api/geo/lookup/1.2.3.4") + resp = await geo_client.get("/api/v1/geo/lookup/1.2.3.4") assert resp.status_code == 200 assert resp.json()["geo"] is None @@ -134,7 +134,7 @@ class TestGeoLookup: "app.routers.geo.jail_service.lookup_ip", AsyncMock(side_effect=ValueError("Invalid IP address: 'bad_ip'")), ): - resp = await geo_client.get("/api/geo/lookup/bad_ip") + resp = await geo_client.get("/api/v1/geo/lookup/bad_ip") assert resp.status_code == 400 assert "detail" in resp.json() @@ -145,7 +145,7 @@ class TestGeoLookup: resp = await AsyncClient( transport=ASGITransport(app=app), base_url="http://test", - ).get("/api/geo/lookup/1.2.3.4") + ).get("/api/v1/geo/lookup/1.2.3.4") assert resp.status_code == 401 async def test_ipv6_address(self, geo_client: AsyncClient) -> None: @@ -159,7 +159,7 @@ class TestGeoLookup: "app.routers.geo.jail_service.lookup_ip", AsyncMock(return_value=result), ): - resp = await geo_client.get("/api/geo/lookup/2001:db8::1") + resp = await geo_client.get("/api/v1/geo/lookup/2001:db8::1") assert resp.status_code == 200 assert resp.json()["ip"] == "2001:db8::1" @@ -179,7 +179,7 @@ class TestReResolve: "app.routers.geo.geo_service.re_resolve_all", AsyncMock(return_value={"resolved": 0, "total": 0}), ): - resp = await geo_client.post("/api/geo/re-resolve") + resp = await geo_client.post("/api/v1/geo/re-resolve") assert resp.status_code == 200 data = resp.json() @@ -188,7 +188,7 @@ class TestReResolve: async def test_empty_when_no_unresolved_ips(self, geo_client: AsyncClient) -> None: """Returns resolved=0, total=0 when geo_cache has no NULL country_code rows.""" - resp = await geo_client.post("/api/geo/re-resolve") + resp = await geo_client.post("/api/v1/geo/re-resolve") assert resp.status_code == 200 assert resp.json() == {"resolved": 0, "total": 0} @@ -209,7 +209,7 @@ class TestReResolve: "lookup_batch", new_callable=lambda: AsyncMock(return_value=geo_result), ): - resp = await geo_client.post("/api/geo/re-resolve") + resp = await geo_client.post("/api/v1/geo/re-resolve") assert resp.status_code == 200 data = resp.json() @@ -222,7 +222,7 @@ class TestReResolve: resp = await AsyncClient( transport=ASGITransport(app=app), base_url="http://test", - ).post("/api/geo/re-resolve") + ).post("/api/v1/geo/re-resolve") assert resp.status_code == 401 @@ -246,7 +246,7 @@ class TestGeoStats: "app.routers.geo.geo_service.cache_stats", AsyncMock(return_value=stats), ): - resp = await geo_client.get("/api/geo/stats") + resp = await geo_client.get("/api/v1/geo/stats") assert resp.status_code == 200 data = resp.json() @@ -257,7 +257,7 @@ class TestGeoStats: async def test_stats_empty_cache(self, geo_client: AsyncClient) -> None: """GET /api/geo/stats returns all zeros on a fresh database.""" - resp = await geo_client.get("/api/geo/stats") + resp = await geo_client.get("/api/v1/geo/stats") assert resp.status_code == 200 data = resp.json() @@ -274,7 +274,7 @@ class TestGeoStats: await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("8.8.8.8",)) await db.commit() - resp = await geo_client.get("/api/geo/stats") + resp = await geo_client.get("/api/v1/geo/stats") assert resp.status_code == 200 assert resp.json()["unresolved"] >= 2 @@ -285,5 +285,5 @@ class TestGeoStats: resp = await AsyncClient( transport=ASGITransport(app=app), base_url="http://test", - ).get("/api/geo/stats") + ).get("/api/v1/geo/stats") assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_health.py b/backend/tests/test_routers/test_health.py index f842bc3..b716977 100644 --- a/backend/tests/test_routers/test_health.py +++ b/backend/tests/test_routers/test_health.py @@ -10,7 +10,7 @@ from app.models.server import ServerStatus async def test_health_check_returns_200_when_online(client: AsyncClient) -> None: """``GET /api/health`` must return HTTP 200 when fail2ban is online.""" client._transport.app.state.server_status = ServerStatus(online=True) - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200 @@ -18,7 +18,7 @@ async def test_health_check_returns_200_when_online(client: AsyncClient) -> None async def test_health_check_returns_503_when_offline(client: AsyncClient) -> None: """``GET /api/health`` must return HTTP 503 when fail2ban is offline.""" client._transport.app.state.server_status = ServerStatus(online=False) - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 503 @@ -26,7 +26,7 @@ async def test_health_check_returns_503_when_offline(client: AsyncClient) -> Non async def test_health_check_returns_ok_status_when_online(client: AsyncClient) -> None: """``GET /api/health`` must contain ``status: ok`` when fail2ban is online.""" client._transport.app.state.server_status = ServerStatus(online=True) - response = await client.get("/api/health") + response = await client.get("/api/v1/health") data: dict[str, str] = response.json() assert data["status"] == "ok" assert data["fail2ban"] == "online" @@ -36,7 +36,7 @@ async def test_health_check_returns_ok_status_when_online(client: AsyncClient) - async def test_health_check_returns_unavailable_when_offline(client: AsyncClient) -> None: """``GET /api/health`` must contain ``status: unavailable`` when fail2ban is offline.""" client._transport.app.state.server_status = ServerStatus(online=False) - response = await client.get("/api/health") + response = await client.get("/api/v1/health") data: dict[str, str] = response.json() assert data["status"] == "unavailable" assert data["fail2ban"] == "offline" @@ -45,6 +45,6 @@ async def test_health_check_returns_unavailable_when_offline(client: AsyncClient @pytest.mark.asyncio async def test_health_check_content_type_is_json(client: AsyncClient) -> None: """``GET /api/health`` must set the ``Content-Type`` header to JSON.""" - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert "application/json" in response.headers.get("content-type", "") diff --git a/backend/tests/test_routers/test_history.py b/backend/tests/test_routers/test_history.py index f12bf8e..e6eb80d 100644 --- a/backend/tests/test_routers/test_history.py +++ b/backend/tests/test_routers/test_history.py @@ -114,11 +114,11 @@ async def history_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 login_resp = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login_resp.status_code == 200 @@ -144,15 +144,15 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=AsyncMock(return_value=_make_history_list()), ): - response = await history_client.get("/api/history") + response = await history_client.get("/api/v1/history") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/history") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/history") assert response.status_code == 401 async def test_response_shape(self, history_client: AsyncClient) -> None: @@ -162,7 +162,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=AsyncMock(return_value=mock_response), ): - response = await history_client.get("/api/history") + response = await history_client.get("/api/v1/history") body = response.json() assert "items" in body @@ -192,7 +192,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history?jail=nginx") + await history_client.get("/api/v1/history?jail=nginx") _args, kwargs = mock_fn.call_args assert kwargs.get("jail") == "nginx" @@ -204,7 +204,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history?ip=192.168") + await history_client.get("/api/v1/history?ip=192.168") _args, kwargs = mock_fn.call_args assert kwargs.get("ip_filter") == "192.168" @@ -216,7 +216,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history?range=7d") + await history_client.get("/api/v1/history?range=7d") _args, kwargs = mock_fn.call_args assert kwargs.get("range_") == "7d" @@ -228,7 +228,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history?origin=blocklist") + await history_client.get("/api/v1/history?origin=blocklist") _args, kwargs = mock_fn.call_args assert kwargs.get("origin") == "blocklist" @@ -240,7 +240,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history?source=archive") + await history_client.get("/api/v1/history?source=archive") _args, kwargs = mock_fn.call_args assert kwargs.get("source") == "archive" @@ -254,7 +254,7 @@ class TestHistoryList: "app.routers.history.history_service.list_history", new=mock_fn, ): - await history_client.get("/api/history/archive") + await history_client.get("/api/v1/history/archive") _args, kwargs = mock_fn.call_args assert kwargs.get("source") == "archive" @@ -272,7 +272,7 @@ class TestHistoryList: ) ), ): - response = await history_client.get("/api/history") + response = await history_client.get("/api/v1/history") body = response.json() assert body["items"] == [] @@ -295,15 +295,15 @@ class TestIpHistory: "app.routers.history.history_service.get_ip_detail", new=AsyncMock(return_value=_make_ip_detail("1.2.3.4")), ): - response = await history_client.get("/api/history/1.2.3.4") + response = await history_client.get("/api/v1/history/1.2.3.4") assert response.status_code == 200 async def test_returns_401_when_unauthenticated( self, client: AsyncClient ) -> None: """Unauthenticated request returns HTTP 401.""" - await client.post("/api/setup", json=_SETUP_PAYLOAD) - response = await client.get("/api/history/1.2.3.4") + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/v1/history/1.2.3.4") assert response.status_code == 401 async def test_returns_404_for_unknown_ip( @@ -314,7 +314,7 @@ class TestIpHistory: "app.routers.history.history_service.get_ip_detail", new=AsyncMock(return_value=None), ): - response = await history_client.get("/api/history/9.9.9.9") + response = await history_client.get("/api/v1/history/9.9.9.9") assert response.status_code == 404 async def test_response_shape(self, history_client: AsyncClient) -> None: @@ -324,7 +324,7 @@ class TestIpHistory: "app.routers.history.history_service.get_ip_detail", new=AsyncMock(return_value=mock_detail), ): - response = await history_client.get("/api/history/1.2.3.4") + response = await history_client.get("/api/v1/history/1.2.3.4") body = response.json() assert body["ip"] == "1.2.3.4" @@ -376,7 +376,7 @@ class TestIpHistory: "app.routers.history.history_service.get_ip_detail", new=AsyncMock(return_value=mock_detail), ): - response = await history_client.get("/api/history/10.0.0.1") + response = await history_client.get("/api/v1/history/10.0.0.1") assert response.status_code == 200 body = response.json() diff --git a/backend/tests/test_routers/test_jails.py b/backend/tests/test_routers/test_jails.py index 67b9733..ced0264 100644 --- a/backend/tests/test_routers/test_jails.py +++ b/backend/tests/test_routers/test_jails.py @@ -49,9 +49,9 @@ async def jails_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - await ac.post("/api/setup", json=_SETUP_PAYLOAD) + await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -129,7 +129,7 @@ class TestGetJails: "app.routers.jails.jail_service.list_jails", AsyncMock(return_value=mock_response), ): - resp = await jails_client.get("/api/jails") + resp = await jails_client.get("/api/v1/jails") assert resp.status_code == 200 data = resp.json() @@ -141,7 +141,7 @@ class TestGetJails: resp = await AsyncClient( transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/jails") + ).get("/api/v1/jails") assert resp.status_code == 401 async def test_response_shape(self, jails_client: AsyncClient) -> None: @@ -151,7 +151,7 @@ class TestGetJails: "app.routers.jails.jail_service.list_jails", AsyncMock(return_value=mock_response), ): - resp = await jails_client.get("/api/jails") + resp = await jails_client.get("/api/v1/jails") jail = resp.json()["items"][0] assert "name" in jail @@ -176,7 +176,7 @@ class TestGetJailDetail: "app.routers.jails.jail_service.get_jail", AsyncMock(return_value=_detail()), ): - resp = await jails_client.get("/api/jails/sshd") + resp = await jails_client.get("/api/v1/jails/sshd") assert resp.status_code == 200 data = resp.json() @@ -193,7 +193,7 @@ class TestGetJailDetail: "app.routers.jails.jail_service.get_jail", AsyncMock(side_effect=JailNotFoundError("ghost")), ): - resp = await jails_client.get("/api/jails/ghost") + resp = await jails_client.get("/api/v1/jails/ghost") assert resp.status_code == 404 @@ -212,7 +212,7 @@ class TestStartJail: "app.routers.jails.jail_service.start_jail", AsyncMock(return_value=None), ): - resp = await jails_client.post("/api/jails/sshd/start") + resp = await jails_client.post("/api/v1/jails/sshd/start") assert resp.status_code == 200 assert resp.json()["jail"] == "sshd" @@ -225,7 +225,7 @@ class TestStartJail: "app.routers.jails.jail_service.start_jail", AsyncMock(side_effect=JailNotFoundError("ghost")), ): - resp = await jails_client.post("/api/jails/ghost/start") + resp = await jails_client.post("/api/v1/jails/ghost/start") assert resp.status_code == 404 @@ -237,7 +237,7 @@ class TestStartJail: "app.routers.jails.jail_service.start_jail", AsyncMock(side_effect=JailOperationError("already running")), ): - resp = await jails_client.post("/api/jails/sshd/start") + resp = await jails_client.post("/api/v1/jails/sshd/start") assert resp.status_code == 409 @@ -256,7 +256,7 @@ class TestStopJail: "app.routers.jails.jail_service.stop_jail", AsyncMock(return_value=None), ): - resp = await jails_client.post("/api/jails/sshd/stop") + resp = await jails_client.post("/api/v1/jails/sshd/stop") assert resp.status_code == 200 @@ -270,7 +270,7 @@ class TestStopJail: "app.routers.jails.jail_service.stop_jail", AsyncMock(return_value=None), ): - resp = await jails_client.post("/api/jails/sshd/stop") + resp = await jails_client.post("/api/v1/jails/sshd/stop") assert resp.status_code == 200 @@ -290,7 +290,7 @@ class TestToggleIdle: AsyncMock(return_value=None), ): resp = await jails_client.post( - "/api/jails/sshd/idle", + "/api/v1/jails/sshd/idle", content="true", headers={"Content-Type": "application/json"}, ) @@ -304,7 +304,7 @@ class TestToggleIdle: AsyncMock(return_value=None), ): resp = await jails_client.post( - "/api/jails/sshd/idle", + "/api/v1/jails/sshd/idle", content="false", headers={"Content-Type": "application/json"}, ) @@ -326,7 +326,7 @@ class TestReloadJail: "app.routers.jails.jail_service.reload_jail", AsyncMock(return_value=None), ): - resp = await jails_client.post("/api/jails/sshd/reload") + resp = await jails_client.post("/api/v1/jails/sshd/reload") assert resp.status_code == 200 assert resp.json()["jail"] == "sshd" @@ -346,7 +346,7 @@ class TestReloadAll: "app.routers.jails.jail_service.reload_all", AsyncMock(return_value=None), ): - resp = await jails_client.post("/api/jails/reload-all") + resp = await jails_client.post("/api/v1/jails/reload-all") assert resp.status_code == 200 assert resp.json()["jail"] == "*" @@ -366,7 +366,7 @@ class TestIgnoreIpEndpoints: "app.routers.jails.jail_service.get_ignore_list", AsyncMock(return_value=["127.0.0.1"]), ): - resp = await jails_client.get("/api/jails/sshd/ignoreip") + resp = await jails_client.get("/api/v1/jails/sshd/ignoreip") assert resp.status_code == 200 assert resp.json() == {"items": ["127.0.0.1"], "total": 1} @@ -378,7 +378,7 @@ class TestIgnoreIpEndpoints: AsyncMock(return_value=None), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "192.168.1.0/24"}, ) @@ -391,7 +391,7 @@ class TestIgnoreIpEndpoints: AsyncMock(side_effect=ValueError("Invalid IP address or network: 'bad'")), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "bad"}, ) @@ -405,7 +405,7 @@ class TestIgnoreIpEndpoints: ): resp = await jails_client.request( "DELETE", - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "127.0.0.1"}, ) @@ -419,7 +419,7 @@ class TestIgnoreIpEndpoints: "app.routers.jails.jail_service.get_ignore_list", AsyncMock(side_effect=JailNotFoundError("ghost")), ): - resp = await jails_client.get("/api/jails/ghost/ignoreip") + resp = await jails_client.get("/api/v1/jails/ghost/ignoreip") assert resp.status_code == 404 @@ -431,7 +431,7 @@ class TestIgnoreIpEndpoints: "app.routers.jails.jail_service.get_ignore_list", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.get("/api/jails/sshd/ignoreip") + resp = await jails_client.get("/api/v1/jails/sshd/ignoreip") assert resp.status_code == 502 @@ -444,7 +444,7 @@ class TestIgnoreIpEndpoints: AsyncMock(side_effect=JailNotFoundError("ghost")), ): resp = await jails_client.post( - "/api/jails/ghost/ignoreip", + "/api/v1/jails/ghost/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -459,7 +459,7 @@ class TestIgnoreIpEndpoints: AsyncMock(side_effect=JailOperationError("fail2ban rejected")), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -474,7 +474,7 @@ class TestIgnoreIpEndpoints: AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -490,7 +490,7 @@ class TestIgnoreIpEndpoints: ): resp = await jails_client.request( "DELETE", - "/api/jails/ghost/ignoreip", + "/api/v1/jails/ghost/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -506,7 +506,7 @@ class TestIgnoreIpEndpoints: ): resp = await jails_client.request( "DELETE", - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -522,7 +522,7 @@ class TestIgnoreIpEndpoints: ): resp = await jails_client.request( "DELETE", - "/api/jails/sshd/ignoreip", + "/api/v1/jails/sshd/ignoreip", json={"ip": "1.2.3.4"}, ) @@ -544,7 +544,7 @@ class TestToggleIgnoreSelf: AsyncMock(return_value=None), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreself", + "/api/v1/jails/sshd/ignoreself", content="true", headers={"Content-Type": "application/json"}, ) @@ -559,7 +559,7 @@ class TestToggleIgnoreSelf: AsyncMock(return_value=None), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreself", + "/api/v1/jails/sshd/ignoreself", content="false", headers={"Content-Type": "application/json"}, ) @@ -576,7 +576,7 @@ class TestToggleIgnoreSelf: AsyncMock(side_effect=JailNotFoundError("ghost")), ): resp = await jails_client.post( - "/api/jails/ghost/ignoreself", + "/api/v1/jails/ghost/ignoreself", content="true", headers={"Content-Type": "application/json"}, ) @@ -592,7 +592,7 @@ class TestToggleIgnoreSelf: AsyncMock(side_effect=JailOperationError("fail2ban rejected")), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreself", + "/api/v1/jails/sshd/ignoreself", content="true", headers={"Content-Type": "application/json"}, ) @@ -608,7 +608,7 @@ class TestToggleIgnoreSelf: AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): resp = await jails_client.post( - "/api/jails/sshd/ignoreself", + "/api/v1/jails/sshd/ignoreself", content="true", headers={"Content-Type": "application/json"}, ) @@ -632,7 +632,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.list_jails", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.get("/api/jails") + resp = await jails_client.get("/api/v1/jails") assert resp.status_code == 502 @@ -644,7 +644,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.get_jail", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.get("/api/jails/sshd") + resp = await jails_client.get("/api/v1/jails/sshd") assert resp.status_code == 502 @@ -656,7 +656,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.reload_all", AsyncMock(side_effect=JailOperationError("reload failed")), ): - resp = await jails_client.post("/api/jails/reload-all") + resp = await jails_client.post("/api/v1/jails/reload-all") assert resp.status_code == 409 @@ -668,7 +668,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.reload_all", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.post("/api/jails/reload-all") + resp = await jails_client.post("/api/v1/jails/reload-all") assert resp.status_code == 502 @@ -680,7 +680,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.start_jail", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.post("/api/jails/sshd/start") + resp = await jails_client.post("/api/v1/jails/sshd/start") assert resp.status_code == 502 @@ -692,7 +692,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.stop_jail", AsyncMock(side_effect=JailOperationError("stop failed")), ): - resp = await jails_client.post("/api/jails/sshd/stop") + resp = await jails_client.post("/api/v1/jails/sshd/stop") assert resp.status_code == 409 @@ -704,7 +704,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.stop_jail", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.post("/api/jails/sshd/stop") + resp = await jails_client.post("/api/v1/jails/sshd/stop") assert resp.status_code == 502 @@ -717,7 +717,7 @@ class TestFail2BanConnectionErrors: AsyncMock(side_effect=JailNotFoundError("ghost")), ): resp = await jails_client.post( - "/api/jails/ghost/idle", + "/api/v1/jails/ghost/idle", content="true", headers={"Content-Type": "application/json"}, ) @@ -733,7 +733,7 @@ class TestFail2BanConnectionErrors: AsyncMock(side_effect=JailOperationError("idle failed")), ): resp = await jails_client.post( - "/api/jails/sshd/idle", + "/api/v1/jails/sshd/idle", content="true", headers={"Content-Type": "application/json"}, ) @@ -749,7 +749,7 @@ class TestFail2BanConnectionErrors: AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): resp = await jails_client.post( - "/api/jails/sshd/idle", + "/api/v1/jails/sshd/idle", content="true", headers={"Content-Type": "application/json"}, ) @@ -764,7 +764,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.reload_jail", AsyncMock(side_effect=JailNotFoundError("ghost")), ): - resp = await jails_client.post("/api/jails/ghost/reload") + resp = await jails_client.post("/api/v1/jails/ghost/reload") assert resp.status_code == 404 @@ -776,7 +776,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.reload_jail", AsyncMock(side_effect=JailOperationError("reload failed")), ): - resp = await jails_client.post("/api/jails/sshd/reload") + resp = await jails_client.post("/api/v1/jails/sshd/reload") assert resp.status_code == 409 @@ -788,7 +788,7 @@ class TestFail2BanConnectionErrors: "app.routers.jails.jail_service.reload_jail", AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), ): - resp = await jails_client.post("/api/jails/sshd/reload") + resp = await jails_client.post("/api/v1/jails/sshd/reload") assert resp.status_code == 502 @@ -834,7 +834,7 @@ class TestGetJailBannedIps: "app.routers.jails.jail_service.get_jail_banned_ips", AsyncMock(return_value=self._mock_response()), ): - resp = await jails_client.get("/api/jails/sshd/banned") + resp = await jails_client.get("/api/v1/jails/sshd/banned") assert resp.status_code == 200 data = resp.json() @@ -848,7 +848,7 @@ class TestGetJailBannedIps: """GET /api/jails/sshd/banned?search=1.2.3 passes search to service.""" mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1)) with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): - resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3") + resp = await jails_client.get("/api/v1/jails/sshd/banned?search=1.2.3") assert resp.status_code == 200 _args, call_kwargs = mock_fn.call_args @@ -860,7 +860,7 @@ class TestGetJailBannedIps: return_value=self._mock_response(page=2, page_size=10, total=0, items=[]) ) with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): - resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10") + resp = await jails_client.get("/api/v1/jails/sshd/banned?page=2&page_size=10") assert resp.status_code == 200 _args, call_kwargs = mock_fn.call_args @@ -869,17 +869,17 @@ class TestGetJailBannedIps: async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None: """GET /api/jails/sshd/banned?page=0 returns 400.""" - resp = await jails_client.get("/api/jails/sshd/banned?page=0") + resp = await jails_client.get("/api/v1/jails/sshd/banned?page=0") assert resp.status_code == 400 async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None: """GET /api/jails/sshd/banned?page_size=200 returns 400.""" - resp = await jails_client.get("/api/jails/sshd/banned?page_size=200") + resp = await jails_client.get("/api/v1/jails/sshd/banned?page_size=200") assert resp.status_code == 400 async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None: """GET /api/jails/sshd/banned?page_size=0 returns 400.""" - resp = await jails_client.get("/api/jails/sshd/banned?page_size=0") + resp = await jails_client.get("/api/v1/jails/sshd/banned?page_size=0") assert resp.status_code == 400 async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: @@ -890,7 +890,7 @@ class TestGetJailBannedIps: "app.routers.jails.jail_service.get_jail_banned_ips", AsyncMock(side_effect=JailNotFoundError("ghost")), ): - resp = await jails_client.get("/api/jails/ghost/banned") + resp = await jails_client.get("/api/v1/jails/ghost/banned") assert resp.status_code == 404 @@ -904,7 +904,7 @@ class TestGetJailBannedIps: side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock") ), ): - resp = await jails_client.get("/api/jails/sshd/banned") + resp = await jails_client.get("/api/v1/jails/sshd/banned") assert resp.status_code == 502 @@ -916,7 +916,7 @@ class TestGetJailBannedIps: "app.routers.jails.jail_service.get_jail_banned_ips", AsyncMock(return_value=self._mock_response()), ): - resp = await jails_client.get("/api/jails/sshd/banned") + resp = await jails_client.get("/api/v1/jails/sshd/banned") item = resp.json()["items"][0] assert "ip" in item @@ -931,6 +931,6 @@ class TestGetJailBannedIps: resp = await AsyncClient( transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/jails/sshd/banned") + ).get("/api/v1/jails/sshd/banned") assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_server.py b/backend/tests/test_routers/test_server.py index a575850..cdd29f8 100644 --- a/backend/tests/test_routers/test_server.py +++ b/backend/tests/test_routers/test_server.py @@ -48,9 +48,9 @@ async def server_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: - await ac.post("/api/setup", json=_SETUP_PAYLOAD) + await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD) login = await ac.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 @@ -88,7 +88,7 @@ class TestGetServerSettings: "app.routers.server.server_service.get_settings", AsyncMock(return_value=mock_response), ): - resp = await server_client.get("/api/server/settings") + resp = await server_client.get("/api/v1/server/settings") assert resp.status_code == 200 data = resp.json() @@ -101,7 +101,7 @@ class TestGetServerSettings: resp = await AsyncClient( transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).get("/api/server/settings") + ).get("/api/v1/server/settings") assert resp.status_code == 401 async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: @@ -112,7 +112,7 @@ class TestGetServerSettings: "app.routers.server.server_service.get_settings", AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), ): - resp = await server_client.get("/api/server/settings") + resp = await server_client.get("/api/v1/server/settings") assert resp.status_code == 502 @@ -132,7 +132,7 @@ class TestUpdateServerSettings: AsyncMock(return_value=None), ): resp = await server_client.put( - "/api/server/settings", + "/api/v1/server/settings", json={"log_level": "DEBUG"}, ) @@ -147,7 +147,7 @@ class TestUpdateServerSettings: AsyncMock(side_effect=ServerOperationError("set failed")), ): resp = await server_client.put( - "/api/server/settings", + "/api/v1/server/settings", json={"log_level": "DEBUG"}, ) @@ -158,7 +158,7 @@ class TestUpdateServerSettings: resp = await AsyncClient( transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).put("/api/server/settings", json={"log_level": "DEBUG"}) + ).put("/api/v1/server/settings", json={"log_level": "DEBUG"}) assert resp.status_code == 401 async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: @@ -170,7 +170,7 @@ class TestUpdateServerSettings: AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), ): resp = await server_client.put( - "/api/server/settings", + "/api/v1/server/settings", json={"log_level": "INFO"}, ) @@ -191,7 +191,7 @@ class TestFlushLogs: "app.routers.server.server_service.flush_logs", AsyncMock(return_value="OK"), ): - resp = await server_client.post("/api/server/flush-logs") + resp = await server_client.post("/api/v1/server/flush-logs") assert resp.status_code == 200 assert resp.json()["message"] == "OK" @@ -204,7 +204,7 @@ class TestFlushLogs: "app.routers.server.server_service.flush_logs", AsyncMock(side_effect=ServerOperationError("flushlogs failed")), ): - resp = await server_client.post("/api/server/flush-logs") + resp = await server_client.post("/api/v1/server/flush-logs") assert resp.status_code == 400 @@ -213,7 +213,7 @@ class TestFlushLogs: resp = await AsyncClient( transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] base_url="http://test", - ).post("/api/server/flush-logs") + ).post("/api/v1/server/flush-logs") assert resp.status_code == 401 async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: @@ -224,6 +224,6 @@ class TestFlushLogs: "app.routers.server.server_service.flush_logs", AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), ): - resp = await server_client.post("/api/server/flush-logs") + resp = await server_client.post("/api/v1/server/flush-logs") assert resp.status_code == 502 diff --git a/backend/tests/test_routers/test_setup.py b/backend/tests/test_routers/test_setup.py index d336d71..1ccd16b 100644 --- a/backend/tests/test_routers/test_setup.py +++ b/backend/tests/test_routers/test_setup.py @@ -69,14 +69,14 @@ class TestGetSetupStatus: async def test_returns_not_completed_on_fresh_db(self, client: AsyncClient) -> None: """Status endpoint reports setup not done on a fresh database.""" - response = await client.get("/api/setup") + response = await client.get("/api/v1/setup") assert response.status_code == 200 assert response.json() == {"completed": False} async def test_returns_completed_after_setup(self, client: AsyncClient) -> None: """Status endpoint reports setup done after POST /api/setup.""" await client.post( - "/api/setup", + "/api/v1/setup", json={ "master_password": "Supersecret1!", "database_path": "bangui.db", @@ -85,7 +85,7 @@ class TestGetSetupStatus: "session_duration_minutes": 60, }, ) - response = await client.get("/api/setup") + response = await client.get("/api/v1/setup") assert response.status_code == 200 assert response.json() == {"completed": True} @@ -96,7 +96,7 @@ class TestPostSetup: async def test_accepts_valid_payload(self, client: AsyncClient) -> None: """Setup endpoint returns 201 for a valid first-run payload.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={ "master_password": "Supersecret1!", "database_path": "bangui.db", @@ -112,7 +112,7 @@ class TestPostSetup: async def test_rejects_short_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords shorter than 8 characters.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "short"}, ) assert response.status_code == 422 @@ -120,7 +120,7 @@ class TestPostSetup: async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords missing an uppercase character.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "lowercase1!"}, ) assert response.status_code == 422 @@ -132,7 +132,7 @@ class TestPostSetup: async def test_rejects_missing_number_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords missing a numeric character.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "NoNumbers!"}, ) assert response.status_code == 422 @@ -146,7 +146,7 @@ class TestPostSetup: ) -> None: """Setup endpoint rejects passwords missing a required special character.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "NoSpecial1"}, ) assert response.status_code == 422 @@ -164,10 +164,10 @@ class TestPostSetup: "timezone": "UTC", "session_duration_minutes": 60, } - first = await client.post("/api/setup", json=payload) + first = await client.post("/api/v1/setup", json=payload) assert first.status_code == 201 - second = await client.post("/api/setup", json=payload) + second = await client.post("/api/v1/setup", json=payload) assert second.status_code == 409 async def test_accepts_defaults_for_optional_fields( @@ -175,7 +175,7 @@ class TestPostSetup: ) -> None: """Setup endpoint uses defaults when optional fields are omitted.""" response = await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "Supersecret1!"}, ) assert response.status_code == 201 @@ -195,7 +195,7 @@ class TestPostSetupRuntimeState: "session_duration_minutes": 90, } - response = await client.post("/api/setup", json=payload) + response = await client.post("/api/v1/setup", json=payload) assert response.status_code == 201 assert app.state.runtime_settings is not None assert app.state.runtime_settings.database_path == payload["database_path"] @@ -213,30 +213,30 @@ class TestSetupRedirectMiddleware: ) -> None: """Non-setup API requests redirect to /api/setup on a fresh instance.""" response = await client.get( - "/api/auth/login", + "/api/v1/auth/login", follow_redirects=False, ) # Middleware issues 307 redirect to /api/setup assert response.status_code == 307 - assert response.headers["location"] == "/api/setup" + assert response.headers["location"] == "/api/v1/setup" async def test_health_always_reachable_before_setup( self, client: AsyncClient ) -> None: """Health endpoint is always reachable even before setup.""" - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200 async def test_no_redirect_after_setup(self, client: AsyncClient) -> None: """Protected endpoints are reachable (no redirect) after setup.""" await client.post( - "/api/setup", + "/api/v1/setup", json={"master_password": "Supersecret1!"}, ) # /api/auth/login should now be reachable (returns 405 GET not allowed, # not a setup redirect) response = await client.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": "wrong"}, follow_redirects=False, ) @@ -249,20 +249,20 @@ class TestGetTimezone: async def test_returns_utc_before_setup(self, client: AsyncClient) -> None: """Timezone endpoint returns 'UTC' on a fresh database (no setup yet).""" - response = await client.get("/api/setup/timezone") + response = await client.get("/api/v1/setup/timezone") assert response.status_code == 200 assert response.json() == {"timezone": "UTC"} async def test_returns_configured_timezone(self, client: AsyncClient) -> None: """Timezone endpoint returns the value set during setup.""" await client.post( - "/api/setup", + "/api/v1/setup", json={ "master_password": "Supersecret1!", "timezone": "Europe/Berlin", }, ) - response = await client.get("/api/setup/timezone") + response = await client.get("/api/v1/setup/timezone") assert response.status_code == 200 assert response.json() == {"timezone": "Europe/Berlin"} @@ -271,7 +271,7 @@ class TestGetTimezone: ) -> None: """Timezone endpoint is reachable before setup (no redirect).""" response = await client.get( - "/api/setup/timezone", + "/api/v1/setup/timezone", follow_redirects=False, ) # Should return 200, not a 307 redirect, because /api/setup paths @@ -296,7 +296,7 @@ class TestSetupCompleteCaching: app, client = app_and_client assert isinstance(app, FastAPI) - resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 assert app.state.setup_complete_cached is True @@ -315,8 +315,8 @@ class TestSetupCompleteCaching: assert isinstance(app, FastAPI) # Do setup and warm the cache. - await client.post("/api/setup", json=_SETUP_PAYLOAD) - await client.post("/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}) + await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) + await client.post("/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}) assert app.state.setup_complete_cached is True call_count = 0 @@ -328,7 +328,7 @@ class TestSetupCompleteCaching: with patch("app.services.setup_service.is_setup_complete", side_effect=_counting): await client.post( - "/api/auth/login", + "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) @@ -510,10 +510,10 @@ class TestSetupRedirectMiddlewareDbNone: async with AsyncClient( transport=transport, base_url="http://test" ) as ac: - response = await ac.get("/api/auth/login", follow_redirects=False) + response = await ac.get("/api/v1/auth/login", follow_redirects=False) assert response.status_code == 307 - assert response.headers["location"] == "/api/setup" + assert response.headers["location"] == "/api/v1/setup" async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None: """Health endpoint is always reachable even when db is not initialised.""" @@ -531,7 +531,7 @@ class TestSetupRedirectMiddlewareDbNone: async with AsyncClient( transport=transport, base_url="http://test" ) as ac: - response = await ac.get("/api/health") + response = await ac.get("/api/v1/health") assert response.status_code == 200 diff --git a/backend/tests/test_security_headers_middleware.py b/backend/tests/test_security_headers_middleware.py index 38fb227..fe64af4 100644 --- a/backend/tests/test_security_headers_middleware.py +++ b/backend/tests/test_security_headers_middleware.py @@ -20,7 +20,7 @@ def test_security_headers_middleware_adds_csp_header() -> None: app = create_app(settings=settings) client = TestClient(app) - response = client.get("/api/health") + response = client.get("/api/v1/health") assert "Content-Security-Policy" in response.headers assert response.headers["Content-Security-Policy"] == "default-src 'self'" @@ -40,7 +40,7 @@ def test_security_headers_middleware_adds_x_frame_options() -> None: app = create_app(settings=settings) client = TestClient(app) - response = client.get("/api/health") + response = client.get("/api/v1/health") assert "X-Frame-Options" in response.headers assert response.headers["X-Frame-Options"] == "DENY" @@ -60,7 +60,7 @@ def test_security_headers_middleware_adds_x_content_type_options() -> None: app = create_app(settings=settings) client = TestClient(app) - response = client.get("/api/health") + response = client.get("/api/v1/health") assert "X-Content-Type-Options" in response.headers assert response.headers["X-Content-Type-Options"] == "nosniff" @@ -80,7 +80,7 @@ def test_security_headers_middleware_adds_x_xss_protection() -> None: app = create_app(settings=settings) client = TestClient(app) - response = client.get("/api/health") + response = client.get("/api/v1/health") assert "X-XSS-Protection" in response.headers assert response.headers["X-XSS-Protection"] == "1; mode=block" @@ -102,7 +102,7 @@ def test_security_headers_on_all_response_types() -> None: client = TestClient(app) # Test on successful response - response = client.get("/api/health") + response = client.get("/api/v1/health") assert response.status_code == 200 assert "Content-Security-Policy" in response.headers assert "X-Frame-Options" in response.headers diff --git a/backend/tests/test_utils/test_global_rate_limiter.py b/backend/tests/test_utils/test_global_rate_limiter.py index fb68378..d7d875d 100644 --- a/backend/tests/test_utils/test_global_rate_limiter.py +++ b/backend/tests/test_utils/test_global_rate_limiter.py @@ -17,7 +17,7 @@ _SETUP_PAYLOAD = { async def _do_setup(client: AsyncClient) -> None: """Run the setup wizard so auth endpoints are reachable.""" - resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 @@ -146,11 +146,11 @@ class TestRateLimitMiddleware: try: # First 3 requests should succeed for i in range(3): - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200, f"Request {i+1} failed" # Fourth request should be rate limited - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 429 assert response.json()["code"] == "rate_limit_exceeded" assert "Retry-After" in response.headers @@ -169,11 +169,11 @@ class TestRateLimitMiddleware: try: # First request succeeds - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 200 # Second request is rate limited - response = await client.get("/api/health") + response = await client.get("/api/v1/health") assert response.status_code == 429 assert "Retry-After" in response.headers retry_after = int(response.headers["Retry-After"]) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4e0ba98..1ca5efd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -17,8 +17,8 @@ import { ErrorResponse } from "../types/response"; import { ENDPOINTS } from "./endpoints"; -/** Base URL for all API calls. Falls back to `/api` in production. */ -const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api"; +/** Base URL for all API calls. Falls back to `/api/v1` in production. */ +const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1"; /** Standard header name for correlation IDs (matches backend convention) */ const CORRELATION_ID_HEADER: string = "X-Correlation-ID"; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index fdf01f7..cf69263 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -107,7 +107,7 @@ export const ENDPOINTS = { configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`, configActionParsed: (name: string): string => - `/config/actions/${encodeURIComponent(name)}/parsed`, + `/config/actions/${encodeURIComponent(name)}/parsed", // fail2ban log viewer (Task 2) configFail2BanLog: "/config/fail2ban-log",