"""Tests for the file_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 ( ActionConfig, FilterConfig, JailFileConfig, JailSectionConfig, ) from app.models.file_config import ( ConfFileContent, ConfFileEntry, ConfFilesResponse, JailConfigFile, JailConfigFileContent, JailConfigFilesResponse, ) from app.services.file_config_service import ( ConfigDirError, ConfigFileExistsError, ConfigFileNameError, ConfigFileNotFoundError, ConfigFileWriteError, ) # --------------------------------------------------------------------------- # 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 file_config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] """Provide an authenticated ``AsyncClient`` for file_config endpoint tests.""" settings = Settings( database_path=str(tmp_path / "file_config_test.db"), fail2ban_socket="/tmp/fake.sock", session_secret="test-file-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() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _jail_files_resp(files: list[JailConfigFile] | None = None) -> JailConfigFilesResponse: files = files or [JailConfigFile(name="sshd", filename="sshd.conf", enabled=True)] return JailConfigFilesResponse(files=files, total=len(files)) def _conf_files_resp(files: list[ConfFileEntry] | None = None) -> ConfFilesResponse: files = files or [ConfFileEntry(name="nginx", filename="nginx.conf")] return ConfFilesResponse(files=files, total=len(files)) def _conf_file_content(name: str = "nginx") -> ConfFileContent: return ConfFileContent( name=name, filename=f"{name}.conf", content=f"[Definition]\n# {name} filter\n", ) # --------------------------------------------------------------------------- # GET /api/config/jail-files # --------------------------------------------------------------------------- class TestListJailConfigFiles: async def test_200_returns_file_list( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.list_jail_config_files", AsyncMock(return_value=_jail_files_resp()), ): resp = await file_config_client.get("/api/config/jail-files") assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["files"][0]["filename"] == "sshd.conf" async def test_503_on_config_dir_error( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.list_jail_config_files", AsyncMock(side_effect=ConfigDirError("not found")), ): resp = await file_config_client.get("/api/config/jail-files") assert resp.status_code == 503 async def test_401_unauthenticated(self, file_config_client: AsyncClient) -> None: resp = await AsyncClient( transport=ASGITransport(app=file_config_client._transport.app), # type: ignore[attr-defined] base_url="http://test", ).get("/api/config/jail-files") assert resp.status_code == 401 # --------------------------------------------------------------------------- # GET /api/config/jail-files/{filename} # --------------------------------------------------------------------------- class TestGetJailConfigFile: async def test_200_returns_content( self, file_config_client: AsyncClient ) -> None: content = JailConfigFileContent( name="sshd", filename="sshd.conf", enabled=True, content="[sshd]\nenabled = true\n", ) with patch( "app.routers.file_config.file_config_service.get_jail_config_file", AsyncMock(return_value=content), ): resp = await file_config_client.get("/api/config/jail-files/sshd.conf") assert resp.status_code == 200 assert resp.json()["content"] == "[sshd]\nenabled = true\n" async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.get("/api/config/jail-files/missing.conf") assert resp.status_code == 404 async def test_400_invalid_filename( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNameError("bad name")), ): resp = await file_config_client.get("/api/config/jail-files/bad.txt") assert resp.status_code == 400 # --------------------------------------------------------------------------- # PUT /api/config/jail-files/{filename}/enabled # --------------------------------------------------------------------------- class TestSetJailConfigEnabled: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.set_jail_config_enabled", AsyncMock(return_value=None), ): resp = await file_config_client.put( "/api/config/jail-files/sshd.conf/enabled", json={"enabled": False}, ) assert resp.status_code == 204 async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.set_jail_config_enabled", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( "/api/config/jail-files/missing.conf/enabled", json={"enabled": True}, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # GET /api/config/filters/{name}/raw # --------------------------------------------------------------------------- class TestGetFilterFileRaw: """Tests for the renamed ``GET /api/config/filters/{name}/raw`` endpoint. The simple list (``GET /api/config/filters``) and the structured detail (``GET /api/config/filters/{name}``) are now served by the config router. This endpoint returns the raw file content only. """ async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_filter_file", AsyncMock(return_value=_conf_file_content("nginx")), ): resp = await file_config_client.get("/api/config/filters/nginx/raw") assert resp.status_code == 200 assert resp.json()["name"] == "nginx" async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get("/api/config/filters/missing/raw") assert resp.status_code == 404 # --------------------------------------------------------------------------- # PUT /api/config/filters/{name} # --------------------------------------------------------------------------- class TestUpdateFilterFile: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.write_filter_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( "/api/config/filters/nginx", json={"content": "[Definition]\nfailregex = test\n"}, ) assert resp.status_code == 204 async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.write_filter_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( "/api/config/filters/nginx", json={"content": "x"}, ) assert resp.status_code == 400 # --------------------------------------------------------------------------- # POST /api/config/filters # --------------------------------------------------------------------------- class TestCreateFilterFile: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_filter_file", AsyncMock(return_value="myfilter.conf"), ): resp = await file_config_client.post( "/api/config/filters", json={"name": "myfilter", "content": "[Definition]\n"}, ) assert resp.status_code == 201 assert resp.json()["filename"] == "myfilter.conf" async def test_409_conflict(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_filter_file", AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), ): resp = await file_config_client.post( "/api/config/filters", json={"name": "myfilter", "content": "[Definition]\n"}, ) assert resp.status_code == 409 async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_filter_file", AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( "/api/config/filters", json={"name": "../escape", "content": "[Definition]\n"}, ) assert resp.status_code == 400 # --------------------------------------------------------------------------- # GET /api/config/actions (smoke test — same logic as filters) # --------------------------------------------------------------------------- class TestListActionFiles: async def test_200_returns_files(self, file_config_client: AsyncClient) -> None: action_entry = ConfFileEntry(name="iptables", filename="iptables.conf") resp_data = ConfFilesResponse(files=[action_entry], total=1) with patch( "app.routers.file_config.file_config_service.list_action_files", AsyncMock(return_value=resp_data), ): resp = await file_config_client.get("/api/config/actions") assert resp.status_code == 200 assert resp.json()["files"][0]["filename"] == "iptables.conf" # --------------------------------------------------------------------------- # POST /api/config/actions # --------------------------------------------------------------------------- class TestCreateActionFile: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_action_file", AsyncMock(return_value="myaction.conf"), ): resp = await file_config_client.post( "/api/config/actions", json={"name": "myaction", "content": "[Definition]\n"}, ) assert resp.status_code == 201 assert resp.json()["filename"] == "myaction.conf" # --------------------------------------------------------------------------- # POST /api/config/jail-files # --------------------------------------------------------------------------- class TestCreateJailConfigFile: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_jail_config_file", AsyncMock(return_value="myjail.conf"), ): resp = await file_config_client.post( "/api/config/jail-files", json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, ) assert resp.status_code == 201 assert resp.json()["filename"] == "myjail.conf" async def test_409_conflict(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_jail_config_file", AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")), ): resp = await file_config_client.post( "/api/config/jail-files", json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, ) assert resp.status_code == 409 async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.create_jail_config_file", AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( "/api/config/jail-files", json={"name": "../escape", "content": "[Definition]\n"}, ) assert resp.status_code == 400 async def test_503_on_config_dir_error( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.create_jail_config_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.post( "/api/config/jail-files", json={"name": "anyjail", "content": "[anyjail]\nenabled = false\n"}, ) assert resp.status_code == 503 # --------------------------------------------------------------------------- # GET /api/config/filters/{name}/parsed # --------------------------------------------------------------------------- class TestGetParsedFilter: async def test_200_returns_parsed_config( self, file_config_client: AsyncClient ) -> None: cfg = FilterConfig(name="nginx", filename="nginx.conf") with patch( "app.routers.file_config.file_config_service.get_parsed_filter_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get("/api/config/filters/nginx/parsed") assert resp.status_code == 200 data = resp.json() assert data["name"] == "nginx" assert data["filename"] == "nginx.conf" async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( "/api/config/filters/missing/parsed" ) assert resp.status_code == 404 async def test_503_on_config_dir_error( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_filter_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get("/api/config/filters/nginx/parsed") assert resp.status_code == 503 # --------------------------------------------------------------------------- # PUT /api/config/filters/{name}/parsed # --------------------------------------------------------------------------- class TestUpdateParsedFilter: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_filter_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( "/api/config/filters/nginx/parsed", json={"failregex": ["^ "]}, ) assert resp.status_code == 204 async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( "/api/config/filters/missing/parsed", json={"failregex": []}, ) assert resp.status_code == 404 async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_filter_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( "/api/config/filters/nginx/parsed", json={"failregex": ["^ "]}, ) assert resp.status_code == 400 # --------------------------------------------------------------------------- # GET /api/config/actions/{name}/parsed # --------------------------------------------------------------------------- class TestGetParsedAction: async def test_200_returns_parsed_config( self, file_config_client: AsyncClient ) -> None: cfg = ActionConfig(name="iptables", filename="iptables.conf") with patch( "app.routers.file_config.file_config_service.get_parsed_action_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get( "/api/config/actions/iptables/parsed" ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "iptables" assert data["filename"] == "iptables.conf" async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( "/api/config/actions/missing/parsed" ) assert resp.status_code == 404 async def test_503_on_config_dir_error( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_action_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( "/api/config/actions/iptables/parsed" ) assert resp.status_code == 503 # --------------------------------------------------------------------------- # PUT /api/config/actions/{name}/parsed # --------------------------------------------------------------------------- class TestUpdateParsedAction: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_action_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( "/api/config/actions/iptables/parsed", json={"actionban": "iptables -I INPUT -s -j DROP"}, ) assert resp.status_code == 204 async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( "/api/config/actions/missing/parsed", json={"actionban": ""}, ) assert resp.status_code == 404 async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_action_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( "/api/config/actions/iptables/parsed", json={"actionban": "iptables -I INPUT -s -j DROP"}, ) assert resp.status_code == 400 # --------------------------------------------------------------------------- # GET /api/config/jail-files/{filename}/parsed # --------------------------------------------------------------------------- class TestGetParsedJailFile: async def test_200_returns_parsed_config( self, file_config_client: AsyncClient ) -> None: section = JailSectionConfig(enabled=True, port="ssh") cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section}) with patch( "app.routers.file_config.file_config_service.get_parsed_jail_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get( "/api/config/jail-files/sshd.conf/parsed" ) assert resp.status_code == 200 data = resp.json() assert data["filename"] == "sshd.conf" assert "sshd" in data["jails"] async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_jail_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.get( "/api/config/jail-files/missing.conf/parsed" ) assert resp.status_code == 404 async def test_503_on_config_dir_error( self, file_config_client: AsyncClient ) -> None: with patch( "app.routers.file_config.file_config_service.get_parsed_jail_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( "/api/config/jail-files/sshd.conf/parsed" ) assert resp.status_code == 503 # --------------------------------------------------------------------------- # PUT /api/config/jail-files/{filename}/parsed # --------------------------------------------------------------------------- class TestUpdateParsedJailFile: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_jail_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( "/api/config/jail-files/sshd.conf/parsed", json={"jails": {"sshd": {"enabled": False}}}, ) assert resp.status_code == 204 async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_jail_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( "/api/config/jail-files/missing.conf/parsed", json={"jails": {}}, ) assert resp.status_code == 404 async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.update_parsed_jail_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( "/api/config/jail-files/sshd.conf/parsed", json={"jails": {"sshd": {"enabled": True}}}, ) assert resp.status_code == 400