history archive router precedence + endpoint/source tests + history sync register test + task status update
This commit is contained in:
@@ -9,6 +9,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
## Open Issues
|
## Open Issues
|
||||||
|
|
||||||
1. Ban history durability and fail2ban DB size management
|
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.
|
- 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:
|
- acceptance criteria:
|
||||||
- BanGUI can configure and fetch fail2ban `dbpurgeage` and `dbfile` from server API.
|
- BanGUI can configure and fetch fail2ban `dbpurgeage` and `dbfile` from server API.
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ async def get_history(
|
|||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
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: int = Query(default=1, ge=1, description="1-based page number."),
|
||||||
page_size: int = Query(
|
page_size: int = Query(
|
||||||
default=_DEFAULT_PAGE_SIZE,
|
default=_DEFAULT_PAGE_SIZE,
|
||||||
@@ -94,9 +98,47 @@ async def get_history(
|
|||||||
jail=jail,
|
jail=jail,
|
||||||
ip_filter=ip,
|
ip_filter=ip,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
|
source=source,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
geo_enricher=_enricher,
|
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}.")
|
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
|
||||||
|
|
||||||
return detail
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -225,6 +225,32 @@ class TestHistoryList:
|
|||||||
_args, kwargs = mock_fn.call_args
|
_args, kwargs = mock_fn.call_args
|
||||||
assert kwargs.get("origin") == "blocklist"
|
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:
|
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
||||||
"""An empty history returns items=[] and total=0."""
|
"""An empty history returns items=[] and total=0."""
|
||||||
with patch(
|
with patch(
|
||||||
|
|||||||
29
backend/tests/test_tasks/test_history_sync.py
Normal file
29
backend/tests/test_tasks/test_history_sync.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user