"""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"] == []