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>
This commit is contained in:
125
Docs/API_VERSIONING.md
Normal file
125
Docs/API_VERSIONING.md
Normal file
@@ -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: <date>` response headers. |
|
||||
| **Removed** | Endpoint no longer exists. Clients must migrate to a newer version. |
|
||||
|
||||
---
|
||||
|
||||
## 2. URL Structure
|
||||
|
||||
```
|
||||
/api/v{major}/<resource>/<path>
|
||||
```
|
||||
|
||||
- **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: <RFC-5322 date>
|
||||
Link: <https://bangui.example.com/api/v2/...>; 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/` |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
98
Docs/PERFORMANCE.md
Normal file
98
Docs/PERFORMANCE.md
Normal file
@@ -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.
|
||||
@@ -86,6 +86,54 @@ ps aux | grep <pid>
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 <ip>"},
|
||||
)
|
||||
|
||||
@@ -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 <ip> -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": ["^<HOST> "]},
|
||||
)
|
||||
|
||||
@@ -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": ["^<HOST> "]},
|
||||
)
|
||||
|
||||
@@ -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 <ip> -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 <ip> -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}}},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user