From 876af4695582331875b4052b69c2820527689902 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 24 Mar 2026 21:06:58 +0100 Subject: [PATCH] history archive router precedence + endpoint/source tests + history sync register test + task status update --- Docs/Tasks.md | 1 + backend/app/routers/history.py | 78 +++++++++++++++++++ backend/tests/test_routers/test_history.py | 26 +++++++ backend/tests/test_tasks/test_history_sync.py | 29 +++++++ 4 files changed, 134 insertions(+) create mode 100644 backend/tests/test_tasks/test_history_sync.py diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 570c4cf..4ee0a5b 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -9,6 +9,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. ## Open Issues 1. Ban history durability and fail2ban DB size management + - status: completed - description: BanGUI currently reads fail2ban history directly, but fail2ban's `dbpurgeage` may erase old history and can cause DB growth issues with long retention. Implement a BanGUI-native persistent archive and keep fail2ban DB short-lived. - acceptance criteria: - BanGUI can configure and fetch fail2ban `dbpurgeage` and `dbfile` from server API. diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 132d889..cb68a22 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -56,6 +56,10 @@ async def get_history( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), + source: str = Query( + default="fail2ban", + description="Data source: 'fail2ban' or 'archive'.", + ), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query( default=_DEFAULT_PAGE_SIZE, @@ -94,9 +98,47 @@ async def get_history( jail=jail, ip_filter=ip, origin=origin, + source=source, page=page, page_size=page_size, geo_enricher=_enricher, + db=request.app.state.db, + ) + + +@router.get( + "/archive", + response_model=HistoryListResponse, + summary="Return a paginated list of archived historical bans", +) +async def get_history_archive( + request: Request, + _auth: AuthDep, + range: TimeRange | None = Query( + default=None, + description="Optional time-range filter. Omit for all-time.", + ), + jail: str | None = Query(default=None, description="Restrict results to this jail name."), + ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."), + page: int = Query(default=1, ge=1, description="1-based page number."), + page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."), +) -> HistoryListResponse: + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + async def _enricher(addr: str) -> geo_service.GeoInfo | None: + return await geo_service.lookup(addr, http_session) + + return await history_service.list_history( + socket_path, + range_=range, + jail=jail, + ip_filter=ip, + source="archive", + page=page, + page_size=page_size, + geo_enricher=_enricher, + db=request.app.state.db, ) @@ -144,3 +186,39 @@ async def get_ip_history( raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.") return detail + + +@router.get( + "/archive", + response_model=HistoryListResponse, + summary="Return a paginated list of archived historical bans", +) +async def get_history_archive( + request: Request, + _auth: AuthDep, + range: TimeRange | None = Query( + default=None, + description="Optional time-range filter. Omit for all-time.", + ), + jail: str | None = Query(default=None, description="Restrict results to this jail name."), + ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."), + page: int = Query(default=1, ge=1, description="1-based page number."), + page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."), +) -> HistoryListResponse: + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + async def _enricher(addr: str) -> geo_service.GeoInfo | None: + return await geo_service.lookup(addr, http_session) + + return await history_service.list_history( + socket_path, + range_=range, + jail=jail, + ip_filter=ip, + source="archive", + page=page, + page_size=page_size, + geo_enricher=_enricher, + db=request.app.state.db, + ) diff --git a/backend/tests/test_routers/test_history.py b/backend/tests/test_routers/test_history.py index 314f9bb..9c6ffe9 100644 --- a/backend/tests/test_routers/test_history.py +++ b/backend/tests/test_routers/test_history.py @@ -225,6 +225,32 @@ class TestHistoryList: _args, kwargs = mock_fn.call_args assert kwargs.get("origin") == "blocklist" + async def test_forwards_source_filter(self, history_client: AsyncClient) -> None: + """The ``source`` query parameter is forwarded to the service.""" + mock_fn = AsyncMock(return_value=_make_history_list(n=0)) + with patch( + "app.routers.history.history_service.list_history", + new=mock_fn, + ): + await history_client.get("/api/history?source=archive") + + _args, kwargs = mock_fn.call_args + assert kwargs.get("source") == "archive" + + async def test_archive_route_forces_source_archive( + self, history_client: AsyncClient + ) -> None: + """GET /api/history/archive should call list_history with source='archive'.""" + mock_fn = AsyncMock(return_value=_make_history_list(n=0)) + with patch( + "app.routers.history.history_service.list_history", + new=mock_fn, + ): + await history_client.get("/api/history/archive") + + _args, kwargs = mock_fn.call_args + assert kwargs.get("source") == "archive" + async def test_empty_result(self, history_client: AsyncClient) -> None: """An empty history returns items=[] and total=0.""" with patch( diff --git a/backend/tests/test_tasks/test_history_sync.py b/backend/tests/test_tasks/test_history_sync.py new file mode 100644 index 0000000..c9e1c44 --- /dev/null +++ b/backend/tests/test_tasks/test_history_sync.py @@ -0,0 +1,29 @@ +"""Tests for history_sync task registration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from app.tasks import history_sync + + +class TestHistorySyncTask: + async def test_register_schedules_job(self) -> None: + fake_scheduler = MagicMock() + class FakeState: + pass + + class FakeSettings: + fail2ban_socket = "/tmp/fake.sock" + + app = type("FakeApp", (), {})() + app.state = FakeState() + app.state.scheduler = fake_scheduler + app.state.settings = FakeSettings() + + history_sync.register(app) + + fake_scheduler.add_job.assert_called_once() + called_args, called_kwargs = fake_scheduler.add_job.call_args + assert called_kwargs["id"] == history_sync.JOB_ID + assert called_kwargs["kwargs"]["app"] == app