diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ddc03f8..4eb0b22 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -681,3 +681,46 @@ Run the full quality-assurance pipeline after the filter-bar changes: - Production build succeeds. --- + +## Stage 8 — Jails Router Test Coverage + +### Task 8.1 — Bring jails router to 100 % line coverage + +**Status:** `done` + +`app/routers/jails.py` currently sits at **61 %** line coverage (54 of 138 lines uncovered). The missing lines are exclusively error-handling paths — the 502 `Fail2BanConnectionError` branch across every endpoint, several 404/409 branches in the jail-control and ignore-list endpoints, and the `toggle_ignore_self` endpoint which has no tests at all. These are critical banning-related paths that the Instructions require to be fully covered. + +**Missing coverage (uncovered lines):** + +| Lines | Endpoint | Missing path | +|---|---|---| +| 69 | `_bad_gateway` helper | One-time body — hit by first 502 test | +| 120–121 | `GET /api/jails` | `Fail2BanConnectionError` → 502 | +| 157–158 | `GET /api/jails/{name}` | `Fail2BanConnectionError` → 502 | +| 195–198 | `POST /api/jails/reload-all` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 | +| 234–235 | `POST /api/jails/{name}/start` | `Fail2BanConnectionError` → 502 | +| 270–273 | `POST /api/jails/{name}/stop` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 | +| 314–319 | `POST /api/jails/{name}/idle` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 | +| 351–356 | `POST /api/jails/{name}/reload` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 | +| 399–402 | `GET /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `Fail2BanConnectionError` → 502 | +| 449–454 | `POST /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 | +| 491–496 | `DELETE /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 | +| 529–542 | `POST /api/jails/{name}/ignoreself` | All paths (entirely untested) | + +**Implementation:** + +- Add new test classes / test methods to `backend/tests/test_routers/test_jails.py`. +- Follow the naming pattern: `test___`. +- Each 502 test mocks the service function to raise `Fail2BanConnectionError`. +- Each 404 test mocks the service to raise `JailNotFoundError`. +- Each 409 test mocks the service to raise `JailOperationError`. +- Wrap `toggle_ignore_self` tests in a `TestToggleIgnoreSelf` class covering: 200 (on), 200 (off), 404, 409, 502. +- No changes to production code required — this is a pure test addition. + +**Acceptance criteria:** + +- `app/routers/jails.py` reaches **100 %** line coverage. +- All new tests use `AsyncMock` and follow existing test patterns. +- `ruff check` and `mypy --strict` pass (tests are type-clean). +- Total test suite still passes (`497 + N` tests passing). + diff --git a/backend/tests/test_routers/test_jails.py b/backend/tests/test_routers/test_jails.py index 2841334..94b47b4 100644 --- a/backend/tests/test_routers/test_jails.py +++ b/backend/tests/test_routers/test_jails.py @@ -407,3 +407,384 @@ class TestIgnoreIpEndpoints: ) assert resp.status_code == 200 + + async def test_get_ignore_list_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.get_ignore_list", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.get("/api/jails/ghost/ignoreip") + + assert resp.status_code == 404 + + async def test_get_ignore_list_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_ignore_list", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails/sshd/ignoreip") + + assert resp.status_code == 502 + + async def test_add_ignore_ip_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 404 + + async def test_add_ignore_ip_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 409 + + async def test_add_ignore_ip_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 502 + + async def test_delete_ignore_ip_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/ghost/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 404 + + async def test_delete_ignore_ip_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/sshd/ignoreip returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 409 + + async def test_delete_ignore_ip_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/ignoreself +# --------------------------------------------------------------------------- + + +class TestToggleIgnoreSelf: + """Tests for ``POST /api/jails/{name}/ignoreself``.""" + + async def test_200_enables_ignore_self(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself with ``true`` returns 200.""" + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + assert "enabled" in resp.json()["message"] + + async def test_200_disables_ignore_self(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself with ``false`` returns 200.""" + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="false", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + assert "disabled" in resp.json()["message"] + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/ignoreself returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 404 + + async def test_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 409 + + async def test_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# 502 error paths — Fail2BanConnectionError across remaining endpoints +# --------------------------------------------------------------------------- + + +class TestFail2BanConnectionErrors: + """Tests that every endpoint returns 502 when fail2ban is unreachable.""" + + async def test_get_jails_502(self, jails_client: AsyncClient) -> None: + """GET /api/jails returns 502 when fail2ban socket is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.list_jails", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails") + + assert resp.status_code == 502 + + async def test_get_jail_502(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails/sshd") + + assert resp.status_code == 502 + + async def test_reload_all_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/reload-all returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.reload_all", + AsyncMock(side_effect=JailOperationError("reload failed")), + ): + resp = await jails_client.post("/api/jails/reload-all") + + assert resp.status_code == 409 + + async def test_reload_all_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/reload-all returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.reload_all", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/reload-all") + + assert resp.status_code == 502 + + async def test_start_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/start returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.start_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/start") + + assert resp.status_code == 502 + + async def test_stop_jail_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(side_effect=JailOperationError("stop failed")), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 409 + + async def test_stop_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 502 + + async def test_toggle_idle_404(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/idle returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 404 + + async def test_toggle_idle_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=JailOperationError("idle failed")), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 409 + + async def test_toggle_idle_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 502 + + async def test_reload_jail_404(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/reload returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post("/api/jails/ghost/reload") + + assert resp.status_code == 404 + + async def test_reload_jail_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/reload returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=JailOperationError("reload failed")), + ): + resp = await jails_client.post("/api/jails/sshd/reload") + + assert resp.status_code == 409 + + async def test_reload_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/reload returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/reload") + + assert resp.status_code == 502