Add ban management features and update documentation

- Implement ban model, service, and router endpoints in backend
- Add ban table component and dashboard integration in frontend
- Update ban-related types and API endpoints
- Add comprehensive tests for ban service and dashboard router
- Update documentation (Features, Tasks, Architecture, Web-Design)
- Clean up old fail2ban configuration files
- Update Makefile with new commands
This commit is contained in:
2026-03-06 20:33:42 +01:00
parent 06738dbfa5
commit cbad4ea706
20 changed files with 58 additions and 760 deletions

View File

@@ -1,4 +1,4 @@
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans, GET /api/dashboard/accesses)."""
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans)."""
from __future__ import annotations
@@ -13,8 +13,6 @@ 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,
)
@@ -228,24 +226,6 @@ def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
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."""
@@ -334,62 +314,6 @@ class TestDashboardBans:
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"
# ---------------------------------------------------------------------------
# Bans by country endpoint
# ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
"""Tests for ban_service.list_bans() and ban_service.list_accesses()."""
"""Tests for ban_service.list_bans()."""
from __future__ import annotations
@@ -299,61 +299,3 @@ class TestListBansPagination:
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
assert result.total == 3 # All three bans are within 7d.
# ---------------------------------------------------------------------------
# list_accesses
# ---------------------------------------------------------------------------
class TestListAccesses:
"""Verify ban_service.list_accesses()."""
async def test_expands_matches_into_rows(self, f2b_db_path: str) -> None:
"""Each element in ``data.matches`` becomes a separate row."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
# Two bans in last 24h: sshd (1 match) + nginx (1 match) = 2 rows.
assert result.total == 2
assert len(result.items) == 2
async def test_access_item_has_line_field(self, f2b_db_path: str) -> None:
"""Each access item contains the raw matched log line."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
for item in result.items:
assert item.line
async def test_ban_with_no_matches_produces_no_access_rows(
self, f2b_db_path: str
) -> None:
"""Bans with empty matches list do not contribute rows."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "7d")
# Third ban (9.10.11.12) has no matches, so only 2 rows total.
assert result.total == 2
async def test_empty_db_returns_zero_accesses(
self, empty_f2b_db_path: str
) -> None:
"""Returns empty result when no bans exist."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
assert result.total == 0
assert result.items == []