Stage 11: polish, cross-cutting concerns & hardening
- 11.1 MainLayout health indicator: warning MessageBar when fail2ban offline - 11.2 formatDate utility + TimezoneProvider + GET /api/setup/timezone - 11.3 Responsive sidebar: auto-collapse <640px, media query listener - 11.4 PageFeedback (PageLoading/PageError/PageEmpty), BanTable updated - 11.5 prefers-reduced-motion: disable sidebar transition - 11.6 WorldMap ARIA: role/tabIndex/aria-label/onKeyDown for countries - 11.7 Health transition logging (fail2ban_came_online/went_offline) - 11.8 Global handlers: Fail2BanConnectionError/ProtocolError -> 502 - 11.9 379 tests pass, 82% coverage, ruff+mypy+tsc+eslint clean - Timezone endpoint: setup_service.get_timezone, 5 new tests
This commit is contained in:
@@ -35,6 +35,7 @@ from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, bans, blocklist, config, dashboard, geo, health, history, jails, server, setup
|
||||
from app.tasks import blocklist_import, health_check
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||
@@ -166,6 +167,56 @@ async def _unhandled_exception_handler(
|
||||
)
|
||||
|
||||
|
||||
async def _fail2ban_connection_handler(
|
||||
request: Request,
|
||||
exc: Fail2BanConnectionError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``502 Bad Gateway`` response when fail2ban is unreachable.
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request.
|
||||
exc: The :class:`~app.utils.fail2ban_client.Fail2BanConnectionError`.
|
||||
|
||||
Returns:
|
||||
A :class:`fastapi.responses.JSONResponse` with status 502.
|
||||
"""
|
||||
log.warning(
|
||||
"fail2ban_connection_error",
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
error=str(exc),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": f"Cannot reach fail2ban: {exc}"},
|
||||
)
|
||||
|
||||
|
||||
async def _fail2ban_protocol_handler(
|
||||
request: Request,
|
||||
exc: Fail2BanProtocolError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``502 Bad Gateway`` response for fail2ban protocol errors.
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request.
|
||||
exc: The :class:`~app.utils.fail2ban_client.Fail2BanProtocolError`.
|
||||
|
||||
Returns:
|
||||
A :class:`fastapi.responses.JSONResponse` with status 502.
|
||||
"""
|
||||
log.warning(
|
||||
"fail2ban_protocol_error",
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
error=str(exc),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": f"fail2ban protocol error: {exc}"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup-redirect middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -269,6 +320,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.add_middleware(SetupRedirectMiddleware)
|
||||
|
||||
# --- Exception handlers ---
|
||||
# Ordered from most specific to least specific. FastAPI evaluates handlers
|
||||
# in the order they were registered, so fail2ban network errors get a 502
|
||||
# rather than falling through to the generic 500 handler.
|
||||
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, _unhandled_exception_handler)
|
||||
|
||||
# --- Routers ---
|
||||
|
||||
@@ -45,6 +45,14 @@ class SetupResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SetupTimezoneResponse(BaseModel):
|
||||
"""Response for ``GET /api/setup/timezone``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
timezone: str = Field(..., description="Configured IANA timezone identifier.")
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""Response indicating whether setup has been completed."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import structlog
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.dependencies import DbDep
|
||||
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse
|
||||
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse, SetupTimezoneResponse
|
||||
from app.services import setup_service
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -69,3 +69,23 @@ async def post_setup(body: SetupRequest, db: DbDep) -> SetupResponse:
|
||||
session_duration_minutes=body.session_duration_minutes,
|
||||
)
|
||||
return SetupResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/timezone",
|
||||
response_model=SetupTimezoneResponse,
|
||||
summary="Return the configured IANA timezone",
|
||||
)
|
||||
async def get_timezone(db: DbDep) -> SetupTimezoneResponse:
|
||||
"""Return the IANA timezone configured during the initial setup wizard.
|
||||
|
||||
The frontend uses this to convert UTC timestamps to the local time zone
|
||||
chosen by the administrator.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.setup.SetupTimezoneResponse` with ``timezone``
|
||||
set to the stored IANA identifier (e.g. ``"UTC"`` or
|
||||
``"Europe/Berlin"``), defaulting to ``"UTC"`` if unset.
|
||||
"""
|
||||
tz = await setup_service.get_timezone(db)
|
||||
return SetupTimezoneResponse(timezone=tz)
|
||||
|
||||
@@ -99,3 +99,19 @@ async def get_password_hash(db: aiosqlite.Connection) -> str | None:
|
||||
The bcrypt hash string, or ``None``.
|
||||
"""
|
||||
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)
|
||||
|
||||
|
||||
async def get_timezone(db: aiosqlite.Connection) -> str:
|
||||
"""Return the configured IANA timezone string.
|
||||
|
||||
Falls back to ``"UTC"`` when no timezone has been stored (e.g. before
|
||||
setup completes or for legacy databases).
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
|
||||
Returns:
|
||||
An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``.
|
||||
"""
|
||||
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
|
||||
return tz if tz else "UTC"
|
||||
|
||||
@@ -104,7 +104,7 @@ def reschedule(app: FastAPI) -> None:
|
||||
asyncio.ensure_future(_do_reschedule())
|
||||
|
||||
|
||||
def _apply_schedule(app: FastAPI, config: Any) -> None: # type: ignore[override]
|
||||
def _apply_schedule(app: FastAPI, config: Any) -> None:
|
||||
"""Add or replace the APScheduler cron/interval job for the given config.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -36,8 +36,18 @@ async def _run_probe(app: Any) -> None:
|
||||
scheduler via the ``kwargs`` mechanism.
|
||||
"""
|
||||
socket_path: str = app.state.settings.fail2ban_socket
|
||||
prev_status: ServerStatus = getattr(
|
||||
app.state, "server_status", ServerStatus(online=False)
|
||||
)
|
||||
status: ServerStatus = await health_service.probe(socket_path)
|
||||
app.state.server_status = status
|
||||
|
||||
# Log transitions between online and offline states.
|
||||
if status.online and not prev_status.online:
|
||||
log.info("fail2ban_came_online", version=status.version)
|
||||
elif not status.online and prev_status.online:
|
||||
log.warning("fail2ban_went_offline")
|
||||
|
||||
log.debug(
|
||||
"health_check_complete",
|
||||
online=status.online,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the setup router (POST /api/setup, GET /api/setup)."""
|
||||
"""Tests for the setup router (POST /api/setup, GET /api/setup, GET /api/setup/timezone)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -121,3 +121,38 @@ class TestSetupRedirectMiddleware:
|
||||
)
|
||||
# 401 wrong password — not a 307 redirect
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestGetTimezone:
|
||||
"""GET /api/setup/timezone — return the configured IANA timezone."""
|
||||
|
||||
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")
|
||||
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",
|
||||
json={
|
||||
"master_password": "supersecret123",
|
||||
"timezone": "Europe/Berlin",
|
||||
},
|
||||
)
|
||||
response = await client.get("/api/setup/timezone")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"timezone": "Europe/Berlin"}
|
||||
|
||||
async def test_endpoint_always_reachable_before_setup(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Timezone endpoint is reachable before setup (no redirect)."""
|
||||
response = await client.get(
|
||||
"/api/setup/timezone",
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Should return 200, not a 307 redirect, because /api/setup paths
|
||||
# are always allowed by the SetupRedirectMiddleware.
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -95,3 +95,23 @@ class TestRunSetup:
|
||||
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
|
||||
with pytest.raises(RuntimeError, match="already been completed"):
|
||||
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestGetTimezone:
|
||||
async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None:
|
||||
"""get_timezone() returns 'UTC' before setup is run."""
|
||||
assert await setup_service.get_timezone(db) == "UTC"
|
||||
|
||||
async def test_returns_configured_timezone(
|
||||
self, db: aiosqlite.Connection
|
||||
) -> None:
|
||||
"""get_timezone() returns the value set during setup."""
|
||||
await setup_service.run_setup(
|
||||
db,
|
||||
master_password="mypassword1",
|
||||
database_path="bangui.db",
|
||||
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
|
||||
timezone="America/New_York",
|
||||
session_duration_minutes=60,
|
||||
)
|
||||
assert await setup_service.get_timezone(db) == "America/New_York"
|
||||
|
||||
Reference in New Issue
Block a user