feat: implement dashboard ban overview (Stage 5)

- Add ban_service reading fail2ban SQLite DB via read-only aiosqlite
- Add geo_service resolving IPs via ip-api.com with 10k in-memory cache
- Add GET /api/dashboard/bans and GET /api/dashboard/accesses endpoints
- Add TimeRange, DashboardBanItem, DashboardBanListResponse, AccessListItem,
  AccessListResponse models in models/ban.py
- Build BanTable component (Fluent UI DataGrid) with bans/accesses modes,
  pagination, loading/error/empty states, and ban-count badges
- Build useBans hook managing time-range and pagination state
- Update DashboardPage: status bar + time-range toolbar + tab switcher
- Add 37 new backend tests (ban service, geo service, dashboard router)
- All 141 tests pass; ruff/mypy --strict/tsc --noEmit clean
This commit is contained in:
2026-03-01 12:57:19 +01:00
parent 94661d7877
commit 9ac7f8d22d
15 changed files with 2346 additions and 29 deletions

View File

@@ -1,8 +1,9 @@
"""Tests for the dashboard router (GET /api/dashboard/status)."""
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans, GET /api/dashboard/accesses)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import aiosqlite
import pytest
@@ -11,6 +12,12 @@ 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.ban import (
AccessListItem,
AccessListResponse,
DashboardBanItem,
DashboardBanListResponse,
)
from app.models.server import ServerStatus
# ---------------------------------------------------------------------------
@@ -56,6 +63,8 @@ async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
total_bans=10,
total_failures=5,
)
# Provide a stub HTTP session so ban/access endpoints can access app.state.http_session.
app.state.http_session = MagicMock()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -94,6 +103,7 @@ async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: igno
app.state.db = db
app.state.server_status = ServerStatus(online=False)
app.state.http_session = MagicMock()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -192,3 +202,190 @@ class TestDashboardStatus:
assert response.status_code == 200
status = response.json()["status"]
assert status["online"] is False
# ---------------------------------------------------------------------------
# Dashboard bans endpoint
# ---------------------------------------------------------------------------
def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
"""Build a mock DashboardBanListResponse with *n* items."""
items = [
DashboardBanItem(
ip=f"1.2.3.{i}",
jail="sshd",
banned_at="2026-03-01T10:00:00+00:00",
service=None,
country_code="DE",
country_name="Germany",
asn="AS3320",
org="Telekom",
ban_count=1,
)
for i in range(n)
]
return DashboardBanListResponse(items=items, total=n, page=1, page_size=100)
def _make_access_list_response(n: int = 2) -> AccessListResponse:
"""Build a mock AccessListResponse with *n* items."""
items = [
AccessListItem(
ip=f"5.6.7.{i}",
jail="nginx",
timestamp="2026-03-01T10:00:00+00:00",
line=f"GET /admin HTTP/1.1 attempt {i}",
country_code="US",
country_name="United States",
asn="AS15169",
org="Google LLC",
)
for i in range(n)
]
return AccessListResponse(items=items, total=n, page=1, page_size=100)
class TestDashboardBans:
"""GET /api/dashboard/bans."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response()),
):
response = await dashboard_client.get("/api/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")
assert response.status_code == 401
async def test_response_contains_items_and_total(
self, dashboard_client: AsyncClient
) -> None:
"""Response body contains ``items`` list and ``total`` count."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(3)),
):
response = await dashboard_client.get("/api/dashboard/bans")
body = response.json()
assert "items" in body
assert "total" in body
assert body["total"] == 3
assert len(body["items"]) == 3
async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None:
"""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")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"
async def test_accepts_time_range_param(
self, dashboard_client: AsyncClient
) -> None:
"""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")
called_range = mock_list.call_args[0][1]
assert called_range == "7d"
async def test_empty_ban_list_returns_zero_total(
self, dashboard_client: AsyncClient
) -> None:
"""Returns ``total=0`` and empty ``items`` when no bans are in range."""
empty = DashboardBanListResponse(items=[], total=0, page=1, page_size=100)
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans")
body = response.json()
assert body["total"] == 0
assert body["items"] == []
async def test_item_shape_is_correct(self, dashboard_client: AsyncClient) -> None:
"""Each item in ``items`` has the expected fields."""
with patch(
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(1)),
):
response = await dashboard_client.get("/api/dashboard/bans")
item = response.json()["items"][0]
assert "ip" in item
assert "jail" in item
assert "banned_at" in item
assert "ban_count" in item
# ---------------------------------------------------------------------------
# Dashboard accesses endpoint
# ---------------------------------------------------------------------------
class TestDashboardAccesses:
"""GET /api/dashboard/accesses."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response()),
):
response = await dashboard_client.get("/api/dashboard/accesses")
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/accesses")
assert response.status_code == 401
async def test_response_contains_access_items(
self, dashboard_client: AsyncClient
) -> None:
"""Response body contains ``items`` with ``line`` fields."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response(2)),
):
response = await dashboard_client.get("/api/dashboard/accesses")
body = response.json()
assert body["total"] == 2
assert len(body["items"]) == 2
assert "line" in body["items"][0]
async def test_default_range_is_24h(
self, dashboard_client: AsyncClient
) -> None:
"""If no ``range`` param is provided the default ``24h`` preset is used."""
mock_list = AsyncMock(return_value=_make_access_list_response())
with patch(
"app.routers.dashboard.ban_service.list_accesses", new=mock_list
):
await dashboard_client.get("/api/dashboard/accesses")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"