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).
473 lines
17 KiB
Python
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"] == []
|