Files
BanGUI/backend/tests/test_routers/test_blocklist.py
Lukas 207be94c42 Show blocklist import error badge in navigation
When the most recent scheduled import completed with errors, surface the
failure in the persistent app shell:
- A warning MessageBar appears at top of main content area
- An amber badge is rendered on the Blocklists sidebar nav item

Backend: add last_run_errors: bool | None to ScheduleInfo model and
populate it in get_schedule_info() from the latest import_log row.

Frontend: extend ScheduleInfo type, add useBlocklistStatus polling hook,
wire both indicators into MainLayout.

Tests: 3 new service tests + 1 new router test (433 total, all pass).
2026-03-07 21:00:00 +01:00

473 lines
17 KiB
Python

"""Tests for the blocklist router (9 endpoints)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import aiosqlite
import pytest
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.blocklist import (
BlocklistListResponse,
BlocklistSource,
ImportLogListResponse,
ImportRunResult,
ImportSourceResult,
PreviewResponse,
ScheduleConfig,
ScheduleFrequency,
ScheduleInfo,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SETUP_PAYLOAD = {
"master_password": "testpassword1",
"database_path": "bangui.db",
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
"timezone": "UTC",
"session_duration_minutes": 60,
}
def _make_source(source_id: int = 1) -> BlocklistSource:
return BlocklistSource(
id=source_id,
name="Test Source",
url="https://test.test/ips.txt",
enabled=True,
created_at="2026-01-01T00:00:00Z",
updated_at="2026-01-01T00:00:00Z",
)
def _make_source_list() -> BlocklistListResponse:
return BlocklistListResponse(sources=[_make_source(1), _make_source(2)])
def _make_schedule_info() -> ScheduleInfo:
return ScheduleInfo(
config=ScheduleConfig(
frequency=ScheduleFrequency.daily,
interval_hours=24,
hour=3,
minute=0,
day_of_week=0,
),
next_run_at="2026-02-01T03:00:00+00:00",
last_run_at=None,
)
def _make_import_result() -> ImportRunResult:
return ImportRunResult(
results=[
ImportSourceResult(
source_id=1,
source_url="https://test.test/ips.txt",
ips_imported=5,
ips_skipped=1,
error=None,
)
],
total_imported=5,
total_skipped=1,
errors_count=0,
)
def _make_log_response() -> ImportLogListResponse:
return ImportLogListResponse(
items=[], total=0, page=1, page_size=50, total_pages=1
)
def _make_preview() -> PreviewResponse:
return PreviewResponse(
entries=["1.2.3.4", "5.6.7.8"],
total_lines=10,
valid_count=8,
skipped_count=2,
)
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
async def bl_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
"""Provide an authenticated AsyncClient for blocklist endpoint tests."""
settings = Settings(
database_path=str(tmp_path / "bl_router_test.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-bl-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
db.row_factory = aiosqlite.Row
await init_db(db)
app.state.db = db
app.state.http_session = MagicMock()
# Provide a minimal scheduler stub so the router can call .get_job().
scheduler_stub = MagicMock()
scheduler_stub.get_job = MagicMock(return_value=None)
app.state.scheduler = scheduler_stub
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
login_resp = await ac.post(
"/api/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login_resp.status_code == 200
yield ac
await db.close()
# ---------------------------------------------------------------------------
# GET /api/blocklists
# ---------------------------------------------------------------------------
class TestListBlocklists:
async def test_authenticated_returns_200(self, bl_client: AsyncClient) -> None:
"""Authenticated request to list sources returns HTTP 200."""
with patch(
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=_make_source_list().sources),
):
resp = await bl_client.get("/api/blocklists")
assert resp.status_code == 200
async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None:
"""Unauthenticated request returns 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.get("/api/blocklists")
assert resp.status_code == 401
async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None:
"""Response body has a 'sources' array."""
with patch(
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=[_make_source()]),
):
resp = await bl_client.get("/api/blocklists")
body = resp.json()
assert "sources" in body
assert isinstance(body["sources"], list)
# ---------------------------------------------------------------------------
# POST /api/blocklists
# ---------------------------------------------------------------------------
class TestCreateBlocklist:
async def test_create_returns_201(self, bl_client: AsyncClient) -> None:
"""POST /api/blocklists creates a source and returns HTTP 201."""
with patch(
"app.routers.blocklist.blocklist_service.create_source",
new=AsyncMock(return_value=_make_source()),
):
resp = await bl_client.post(
"/api/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.status_code == 201
async def test_create_source_id_in_response(self, bl_client: AsyncClient) -> None:
"""Created source response includes the id field."""
with patch(
"app.routers.blocklist.blocklist_service.create_source",
new=AsyncMock(return_value=_make_source(42)),
):
resp = await bl_client.post(
"/api/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.json()["id"] == 42
# ---------------------------------------------------------------------------
# PUT /api/blocklists/{id}
# ---------------------------------------------------------------------------
class TestUpdateBlocklist:
async def test_update_returns_200(self, bl_client: AsyncClient) -> None:
"""PUT /api/blocklists/1 returns 200 for a found source."""
updated = _make_source()
updated.enabled = False
with patch(
"app.routers.blocklist.blocklist_service.update_source",
new=AsyncMock(return_value=updated),
):
resp = await bl_client.put(
"/api/blocklists/1",
json={"enabled": False},
)
assert resp.status_code == 200
async def test_update_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
"""PUT /api/blocklists/999 returns 404 when source does not exist."""
with patch(
"app.routers.blocklist.blocklist_service.update_source",
new=AsyncMock(return_value=None),
):
resp = await bl_client.put(
"/api/blocklists/999",
json={"enabled": False},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/blocklists/{id}
# ---------------------------------------------------------------------------
class TestDeleteBlocklist:
async def test_delete_returns_204(self, bl_client: AsyncClient) -> None:
"""DELETE /api/blocklists/1 returns 204 for a found source."""
with patch(
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=True),
):
resp = await bl_client.delete("/api/blocklists/1")
assert resp.status_code == 204
async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
"""DELETE /api/blocklists/999 returns 404 when source does not exist."""
with patch(
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=False),
):
resp = await bl_client.delete("/api/blocklists/999")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# GET /api/blocklists/{id}/preview
# ---------------------------------------------------------------------------
class TestPreviewBlocklist:
async def test_preview_returns_200(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/1/preview returns 200 for existing source."""
with patch(
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=_make_source()),
), patch(
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
assert resp.status_code == 200
async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/999/preview returns 404 when source not found."""
with patch(
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=None),
):
resp = await bl_client.get("/api/blocklists/999/preview")
assert resp.status_code == 404
async def test_preview_returns_502_on_download_error(
self, bl_client: AsyncClient
) -> None:
"""GET /api/blocklists/1/preview returns 502 when URL is unreachable."""
with patch(
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=_make_source()),
), patch(
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(side_effect=ValueError("Connection refused")),
):
resp = await bl_client.get("/api/blocklists/1/preview")
assert resp.status_code == 502
async def test_preview_response_shape(self, bl_client: AsyncClient) -> None:
"""Preview response has entries, valid_count, skipped_count, total_lines."""
with patch(
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=_make_source()),
), patch(
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
body = resp.json()
assert "entries" in body
assert "valid_count" in body
assert "skipped_count" in body
assert "total_lines" in body
# ---------------------------------------------------------------------------
# POST /api/blocklists/import
# ---------------------------------------------------------------------------
class TestRunImport:
async def test_import_returns_200(self, bl_client: AsyncClient) -> None:
"""POST /api/blocklists/import returns 200 with aggregated results."""
with patch(
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
assert resp.status_code == 200
async def test_import_response_shape(self, bl_client: AsyncClient) -> None:
"""Import response has results, total_imported, total_skipped, errors_count."""
with patch(
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
body = resp.json()
assert "total_imported" in body
assert "total_skipped" in body
assert "errors_count" in body
assert "results" in body
# ---------------------------------------------------------------------------
# GET /api/blocklists/schedule
# ---------------------------------------------------------------------------
class TestGetSchedule:
async def test_schedule_returns_200(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/schedule returns 200."""
with patch(
"app.routers.blocklist.blocklist_service.get_schedule_info",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
assert resp.status_code == 200
async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None:
"""Schedule response includes the config sub-object."""
with patch(
"app.routers.blocklist.blocklist_service.get_schedule_info",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
body = resp.json()
assert "config" in body
assert "next_run_at" in body
assert "last_run_at" in body
async def test_schedule_response_includes_last_run_errors(
self, bl_client: AsyncClient
) -> None:
"""GET /api/blocklists/schedule includes last_run_errors field."""
info_with_errors = ScheduleInfo(
config=ScheduleConfig(
frequency=ScheduleFrequency.daily,
interval_hours=24,
hour=3,
minute=0,
day_of_week=0,
),
next_run_at=None,
last_run_at="2026-03-01T03:00:00+00:00",
last_run_errors=True,
)
with patch(
"app.routers.blocklist.blocklist_service.get_schedule_info",
new=AsyncMock(return_value=info_with_errors),
):
resp = await bl_client.get("/api/blocklists/schedule")
body = resp.json()
assert "last_run_errors" in body
assert body["last_run_errors"] is True
# ---------------------------------------------------------------------------
# PUT /api/blocklists/schedule
# ---------------------------------------------------------------------------
class TestUpdateSchedule:
async def test_update_schedule_returns_200(self, bl_client: AsyncClient) -> None:
"""PUT /api/blocklists/schedule persists new config and returns 200."""
new_info = ScheduleInfo(
config=ScheduleConfig(
frequency=ScheduleFrequency.hourly,
interval_hours=12,
hour=0,
minute=0,
day_of_week=0,
),
next_run_at=None,
last_run_at=None,
)
with patch(
"app.routers.blocklist.blocklist_service.set_schedule",
new=AsyncMock(),
), patch(
"app.routers.blocklist.blocklist_service.get_schedule_info",
new=AsyncMock(return_value=new_info),
), patch(
"app.routers.blocklist.blocklist_import_task.reschedule",
):
resp = await bl_client.put(
"/api/blocklists/schedule",
json={
"frequency": "hourly",
"interval_hours": 12,
"hour": 0,
"minute": 0,
"day_of_week": 0,
},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# GET /api/blocklists/log
# ---------------------------------------------------------------------------
class TestImportLog:
async def test_log_returns_200(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/log returns 200."""
resp = await bl_client.get("/api/blocklists/log")
assert resp.status_code == 200
async def test_log_response_shape(self, bl_client: AsyncClient) -> None:
"""Log response has items, total, page, page_size, total_pages."""
resp = await bl_client.get("/api/blocklists/log")
body = resp.json()
for key in ("items", "total", "page", "page_size", "total_pages"):
assert key in body
async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None:
"""Log returns empty items list when no import runs have occurred."""
resp = await bl_client.get("/api/blocklists/log")
body = resp.json()
assert body["total"] == 0
assert body["items"] == []