"""Tests for the config router 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.config import ( GlobalConfigResponse, JailConfig, JailConfigListResponse, JailConfigResponse, RegexTestResponse, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- _SETUP_PAYLOAD = { "master_password": "testpassword1", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, } @pytest.fixture async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] """Provide an authenticated ``AsyncClient`` for config endpoint tests.""" settings = Settings( database_path=str(tmp_path / "config_test.db"), fail2ban_socket="/tmp/fake.sock", session_secret="test-config-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() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: await ac.post("/api/setup", json=_SETUP_PAYLOAD) login = await ac.post( "/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) assert login.status_code == 200 yield ac await db.close() def _make_jail_config(name: str = "sshd") -> JailConfig: return JailConfig( name=name, ban_time=600, max_retry=5, find_time=600, fail_regex=["regex1"], ignore_regex=[], log_paths=["/var/log/auth.log"], date_pattern=None, log_encoding="UTF-8", backend="polling", use_dns="warn", prefregex="", actions=["iptables"], ) # --------------------------------------------------------------------------- # GET /api/config/jails # --------------------------------------------------------------------------- class TestGetJailConfigs: """Tests for ``GET /api/config/jails``.""" async def test_200_returns_jail_list(self, config_client: AsyncClient) -> None: """GET /api/config/jails returns 200 with JailConfigListResponse.""" mock_response = JailConfigListResponse( jails=[_make_jail_config("sshd")], total=1 ) with patch( "app.routers.config.config_service.list_jail_configs", AsyncMock(return_value=mock_response), ): resp = await config_client.get("/api/config/jails") assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["jails"][0]["name"] == "sshd" async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/jails returns 401 without a valid session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).get("/api/config/jails") assert resp.status_code == 401 async def test_502_on_connection_error(self, config_client: AsyncClient) -> None: """GET /api/config/jails returns 502 when fail2ban is unreachable.""" from app.utils.fail2ban_client import Fail2BanConnectionError with patch( "app.routers.config.config_service.list_jail_configs", AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), ): resp = await config_client.get("/api/config/jails") assert resp.status_code == 502 # --------------------------------------------------------------------------- # GET /api/config/jails/{name} # --------------------------------------------------------------------------- class TestGetJailConfig: """Tests for ``GET /api/config/jails/{name}``.""" async def test_200_returns_jail_config(self, config_client: AsyncClient) -> None: """GET /api/config/jails/sshd returns 200 with JailConfigResponse.""" mock_response = JailConfigResponse(jail=_make_jail_config("sshd")) with patch( "app.routers.config.config_service.get_jail_config", AsyncMock(return_value=mock_response), ): resp = await config_client.get("/api/config/jails/sshd") assert resp.status_code == 200 assert resp.json()["jail"]["name"] == "sshd" assert resp.json()["jail"]["ban_time"] == 600 async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: """GET /api/config/jails/missing returns 404.""" from app.services.config_service import JailNotFoundError with patch( "app.routers.config.config_service.get_jail_config", AsyncMock(side_effect=JailNotFoundError("missing")), ): resp = await config_client.get("/api/config/jails/missing") assert resp.status_code == 404 async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/jails/sshd returns 401 without session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).get("/api/config/jails/sshd") assert resp.status_code == 401 # --------------------------------------------------------------------------- # PUT /api/config/jails/{name} # --------------------------------------------------------------------------- class TestUpdateJailConfig: """Tests for ``PUT /api/config/jails/{name}``.""" async def test_204_on_success(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd returns 204 on success.""" with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(return_value=None), ): resp = await config_client.put( "/api/config/jails/sshd", json={"ban_time": 3600}, ) assert resp.status_code == 204 async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/missing returns 404.""" from app.services.config_service import JailNotFoundError with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(side_effect=JailNotFoundError("missing")), ): resp = await config_client.put( "/api/config/jails/missing", json={"ban_time": 3600}, ) assert resp.status_code == 404 async def test_422_on_invalid_regex(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd returns 422 for invalid regex pattern.""" from app.services.config_service import ConfigValidationError with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(side_effect=ConfigValidationError("bad regex")), ): resp = await config_client.put( "/api/config/jails/sshd", json={"fail_regex": ["[bad"]}, ) assert resp.status_code == 422 async def test_400_on_config_operation_error(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd returns 400 when set command fails.""" from app.services.config_service import ConfigOperationError with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(side_effect=ConfigOperationError("set failed")), ): resp = await config_client.put( "/api/config/jails/sshd", json={"ban_time": 3600}, ) assert resp.status_code == 400 async def test_204_with_dns_mode(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd accepts dns_mode field.""" with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(return_value=None), ): resp = await config_client.put( "/api/config/jails/sshd", json={"dns_mode": "no"}, ) assert resp.status_code == 204 async def test_204_with_prefregex(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd accepts prefregex field.""" with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(return_value=None), ): resp = await config_client.put( "/api/config/jails/sshd", json={"prefregex": r"^%(__prefix_line)s"}, ) assert resp.status_code == 204 async def test_204_with_date_pattern(self, config_client: AsyncClient) -> None: """PUT /api/config/jails/sshd accepts date_pattern field.""" with patch( "app.routers.config.config_service.update_jail_config", AsyncMock(return_value=None), ): resp = await config_client.put( "/api/config/jails/sshd", json={"date_pattern": "%Y-%m-%d %H:%M:%S"}, ) assert resp.status_code == 204 # --------------------------------------------------------------------------- # GET /api/config/global # --------------------------------------------------------------------------- class TestGetGlobalConfig: """Tests for ``GET /api/config/global``.""" async def test_200_returns_global_config(self, config_client: AsyncClient) -> None: """GET /api/config/global returns 200 with GlobalConfigResponse.""" mock_response = GlobalConfigResponse( log_level="WARNING", log_target="/var/log/fail2ban.log", db_purge_age=86400, db_max_matches=10, ) with patch( "app.routers.config.config_service.get_global_config", AsyncMock(return_value=mock_response), ): resp = await config_client.get("/api/config/global") assert resp.status_code == 200 data = resp.json() assert data["log_level"] == "WARNING" assert data["db_purge_age"] == 86400 async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/global returns 401 without session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).get("/api/config/global") assert resp.status_code == 401 # --------------------------------------------------------------------------- # PUT /api/config/global # --------------------------------------------------------------------------- class TestUpdateGlobalConfig: """Tests for ``PUT /api/config/global``.""" async def test_204_on_success(self, config_client: AsyncClient) -> None: """PUT /api/config/global returns 204 on success.""" with patch( "app.routers.config.config_service.update_global_config", AsyncMock(return_value=None), ): resp = await config_client.put( "/api/config/global", json={"log_level": "DEBUG"}, ) assert resp.status_code == 204 async def test_400_on_operation_error(self, config_client: AsyncClient) -> None: """PUT /api/config/global returns 400 when set command fails.""" from app.services.config_service import ConfigOperationError with patch( "app.routers.config.config_service.update_global_config", AsyncMock(side_effect=ConfigOperationError("set failed")), ): resp = await config_client.put( "/api/config/global", json={"log_level": "INFO"}, ) assert resp.status_code == 400 # --------------------------------------------------------------------------- # POST /api/config/reload # --------------------------------------------------------------------------- class TestReloadFail2ban: """Tests for ``POST /api/config/reload``.""" async def test_204_on_success(self, config_client: AsyncClient) -> None: """POST /api/config/reload returns 204 on success.""" with patch( "app.routers.config.jail_service.reload_all", AsyncMock(return_value=None), ): resp = await config_client.post("/api/config/reload") assert resp.status_code == 204 # --------------------------------------------------------------------------- # POST /api/config/regex-test # --------------------------------------------------------------------------- class TestRegexTest: """Tests for ``POST /api/config/regex-test``.""" async def test_200_matched(self, config_client: AsyncClient) -> None: """POST /api/config/regex-test returns matched=true for a valid match.""" mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None) with patch( "app.routers.config.config_service.test_regex", return_value=mock_response, ): resp = await config_client.post( "/api/config/regex-test", json={ "log_line": "fail from 1.2.3.4", "fail_regex": r"(\d+\.\d+\.\d+\.\d+)", }, ) assert resp.status_code == 200 assert resp.json()["matched"] is True async def test_200_not_matched(self, config_client: AsyncClient) -> None: """POST /api/config/regex-test returns matched=false for no match.""" mock_response = RegexTestResponse(matched=False, groups=[], error=None) with patch( "app.routers.config.config_service.test_regex", return_value=mock_response, ): resp = await config_client.post( "/api/config/regex-test", json={"log_line": "ok line", "fail_regex": r"FAIL"}, ) assert resp.status_code == 200 assert resp.json()["matched"] is False async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """POST /api/config/regex-test returns 401 without session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).post( "/api/config/regex-test", json={"log_line": "test", "fail_regex": "test"}, ) assert resp.status_code == 401 # --------------------------------------------------------------------------- # POST /api/config/jails/{name}/logpath # --------------------------------------------------------------------------- class TestAddLogPath: """Tests for ``POST /api/config/jails/{name}/logpath``.""" async def test_204_on_success(self, config_client: AsyncClient) -> None: """POST /api/config/jails/sshd/logpath returns 204 on success.""" with patch( "app.routers.config.config_service.add_log_path", AsyncMock(return_value=None), ): resp = await config_client.post( "/api/config/jails/sshd/logpath", json={"log_path": "/var/log/specific.log", "tail": True}, ) assert resp.status_code == 204 async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: """POST /api/config/jails/missing/logpath returns 404.""" from app.services.config_service import JailNotFoundError with patch( "app.routers.config.config_service.add_log_path", AsyncMock(side_effect=JailNotFoundError("missing")), ): resp = await config_client.post( "/api/config/jails/missing/logpath", json={"log_path": "/var/log/test.log"}, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # POST /api/config/preview-log # --------------------------------------------------------------------------- class TestPreviewLog: """Tests for ``POST /api/config/preview-log``.""" async def test_200_returns_preview(self, config_client: AsyncClient) -> None: """POST /api/config/preview-log returns 200 with LogPreviewResponse.""" from app.models.config import LogPreviewLine, LogPreviewResponse mock_response = LogPreviewResponse( lines=[LogPreviewLine(line="fail line", matched=True, groups=[])], total_lines=1, matched_count=1, ) with patch( "app.routers.config.config_service.preview_log", AsyncMock(return_value=mock_response), ): resp = await config_client.post( "/api/config/preview-log", json={"log_path": "/var/log/test.log", "fail_regex": "fail"}, ) assert resp.status_code == 200 data = resp.json() assert data["total_lines"] == 1 assert data["matched_count"] == 1 # --------------------------------------------------------------------------- # GET /api/config/map-color-thresholds # --------------------------------------------------------------------------- class TestGetMapColorThresholds: """Tests for ``GET /api/config/map-color-thresholds``.""" async def test_200_returns_thresholds(self, config_client: AsyncClient) -> None: """GET /api/config/map-color-thresholds returns 200 with current values.""" resp = await config_client.get("/api/config/map-color-thresholds") assert resp.status_code == 200 data = resp.json() assert "threshold_high" in data assert "threshold_medium" in data assert "threshold_low" in data # Should return defaults after setup assert data["threshold_high"] == 100 assert data["threshold_medium"] == 50 assert data["threshold_low"] == 20 # --------------------------------------------------------------------------- # PUT /api/config/map-color-thresholds # --------------------------------------------------------------------------- class TestUpdateMapColorThresholds: """Tests for ``PUT /api/config/map-color-thresholds``.""" async def test_200_updates_thresholds(self, config_client: AsyncClient) -> None: """PUT /api/config/map-color-thresholds returns 200 and updates settings.""" update_payload = { "threshold_high": 200, "threshold_medium": 80, "threshold_low": 30, } resp = await config_client.put( "/api/config/map-color-thresholds", json=update_payload ) assert resp.status_code == 200 data = resp.json() assert data["threshold_high"] == 200 assert data["threshold_medium"] == 80 assert data["threshold_low"] == 30 # Verify the values persist get_resp = await config_client.get("/api/config/map-color-thresholds") assert get_resp.status_code == 200 get_data = get_resp.json() assert get_data["threshold_high"] == 200 assert get_data["threshold_medium"] == 80 assert get_data["threshold_low"] == 30 async def test_400_for_invalid_order(self, config_client: AsyncClient) -> None: """PUT /api/config/map-color-thresholds returns 400 if thresholds are misordered.""" invalid_payload = { "threshold_high": 50, "threshold_medium": 50, "threshold_low": 20, } resp = await config_client.put( "/api/config/map-color-thresholds", json=invalid_payload ) assert resp.status_code == 400 assert "high > medium > low" in resp.json()["detail"] async def test_400_for_non_positive_values( self, config_client: AsyncClient ) -> None: """PUT /api/config/map-color-thresholds returns 422 for non-positive values (Pydantic validation).""" invalid_payload = { "threshold_high": 100, "threshold_medium": 50, "threshold_low": 0, } resp = await config_client.put( "/api/config/map-color-thresholds", json=invalid_payload ) # Pydantic validates ge=1 constraint before our service code runs assert resp.status_code == 422 # --------------------------------------------------------------------------- # GET /api/config/jails/inactive # --------------------------------------------------------------------------- class TestGetInactiveJails: """Tests for ``GET /api/config/jails/inactive``.""" async def test_200_returns_inactive_list(self, config_client: AsyncClient) -> None: """GET /api/config/jails/inactive returns 200 with InactiveJailListResponse.""" from app.models.config import InactiveJail, InactiveJailListResponse mock_jail = InactiveJail( name="apache-auth", filter="apache-auth", actions=[], port="http,https", logpath=["/var/log/apache2/error.log"], bantime="10m", findtime="5m", maxretry=5, source_file="/etc/fail2ban/jail.conf", enabled=False, ) mock_response = InactiveJailListResponse(jails=[mock_jail], total=1) with patch( "app.routers.config.config_file_service.list_inactive_jails", AsyncMock(return_value=mock_response), ): resp = await config_client.get("/api/config/jails/inactive") assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["jails"][0]["name"] == "apache-auth" async def test_200_empty_list(self, config_client: AsyncClient) -> None: """GET /api/config/jails/inactive returns 200 with empty list.""" from app.models.config import InactiveJailListResponse with patch( "app.routers.config.config_file_service.list_inactive_jails", AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)), ): resp = await config_client.get("/api/config/jails/inactive") assert resp.status_code == 200 assert resp.json()["total"] == 0 assert resp.json()["jails"] == [] async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/jails/inactive returns 401 without a valid session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).get("/api/config/jails/inactive") assert resp.status_code == 401 # --------------------------------------------------------------------------- # POST /api/config/jails/{name}/activate # --------------------------------------------------------------------------- class TestActivateJail: """Tests for ``POST /api/config/jails/{name}/activate``.""" async def test_200_activates_jail(self, config_client: AsyncClient) -> None: """POST /api/config/jails/apache-auth/activate returns 200.""" from app.models.config import JailActivationResponse mock_response = JailActivationResponse( name="apache-auth", active=True, message="Jail 'apache-auth' activated successfully.", ) with patch( "app.routers.config.config_file_service.activate_jail", AsyncMock(return_value=mock_response), ): resp = await config_client.post( "/api/config/jails/apache-auth/activate", json={} ) assert resp.status_code == 200 data = resp.json() assert data["active"] is True assert data["name"] == "apache-auth" async def test_200_with_overrides(self, config_client: AsyncClient) -> None: """POST .../activate accepts override fields.""" from app.models.config import JailActivationResponse mock_response = JailActivationResponse( name="apache-auth", active=True, message="Activated." ) with patch( "app.routers.config.config_file_service.activate_jail", AsyncMock(return_value=mock_response), ) as mock_activate: resp = await config_client.post( "/api/config/jails/apache-auth/activate", json={"bantime": "1h", "maxretry": 3}, ) assert resp.status_code == 200 # Verify the override values were passed to the service called_req = mock_activate.call_args.args[3] assert called_req.bantime == "1h" assert called_req.maxretry == 3 async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: """POST /api/config/jails/missing/activate returns 404.""" from app.services.config_file_service import JailNotFoundInConfigError with patch( "app.routers.config.config_file_service.activate_jail", AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( "/api/config/jails/missing/activate", json={} ) assert resp.status_code == 404 async def test_409_when_already_active(self, config_client: AsyncClient) -> None: """POST /api/config/jails/sshd/activate returns 409 if already active.""" from app.services.config_file_service import JailAlreadyActiveError with patch( "app.routers.config.config_file_service.activate_jail", AsyncMock(side_effect=JailAlreadyActiveError("sshd")), ): resp = await config_client.post( "/api/config/jails/sshd/activate", json={} ) assert resp.status_code == 409 async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: """POST /api/config/jails/ with bad name returns 400.""" from app.services.config_file_service import JailNameError with patch( "app.routers.config.config_file_service.activate_jail", AsyncMock(side_effect=JailNameError("bad name")), ): resp = await config_client.post( "/api/config/jails/bad-name/activate", json={} ) assert resp.status_code == 400 async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """POST /api/config/jails/sshd/activate returns 401 without session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).post("/api/config/jails/sshd/activate", json={}) assert resp.status_code == 401 # --------------------------------------------------------------------------- # POST /api/config/jails/{name}/deactivate # --------------------------------------------------------------------------- class TestDeactivateJail: """Tests for ``POST /api/config/jails/{name}/deactivate``.""" async def test_200_deactivates_jail(self, config_client: AsyncClient) -> None: """POST /api/config/jails/sshd/deactivate returns 200.""" from app.models.config import JailActivationResponse mock_response = JailActivationResponse( name="sshd", active=False, message="Jail 'sshd' deactivated successfully.", ) with patch( "app.routers.config.config_file_service.deactivate_jail", AsyncMock(return_value=mock_response), ): resp = await config_client.post("/api/config/jails/sshd/deactivate") assert resp.status_code == 200 data = resp.json() assert data["active"] is False assert data["name"] == "sshd" async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: """POST /api/config/jails/missing/deactivate returns 404.""" from app.services.config_file_service import JailNotFoundInConfigError with patch( "app.routers.config.config_file_service.deactivate_jail", AsyncMock(side_effect=JailNotFoundInConfigError("missing")), ): resp = await config_client.post( "/api/config/jails/missing/deactivate" ) assert resp.status_code == 404 async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None: """POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive.""" from app.services.config_file_service import JailAlreadyInactiveError with patch( "app.routers.config.config_file_service.deactivate_jail", AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")), ): resp = await config_client.post( "/api/config/jails/apache-auth/deactivate" ) assert resp.status_code == 409 async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: """POST /api/config/jails/.../deactivate with bad name returns 400.""" from app.services.config_file_service import JailNameError with patch( "app.routers.config.config_file_service.deactivate_jail", AsyncMock(side_effect=JailNameError("bad")), ): resp = await config_client.post( "/api/config/jails/sshd/deactivate" ) assert resp.status_code == 400 async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """POST /api/config/jails/sshd/deactivate returns 401 without session.""" resp = await AsyncClient( transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).post("/api/config/jails/sshd/deactivate") assert resp.status_code == 401