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:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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.")]

View File

@@ -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.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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.")]

View File

@@ -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"])
# ---------------------------------------------------------------------------

View File

@@ -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(

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"] == []

View File

@@ -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"},
)

View File

@@ -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}}},
)

View File

@@ -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

View File

@@ -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", "")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"])