Add missing jails router tests to achieve 100% line coverage

All error-handling branches in app/routers/jails.py were previously
untested: every Fail2BanConnectionError (502) path, several
JailNotFoundError (404) and JailOperationError (409) paths, and the
toggle_ignore_self endpoint which had zero coverage.

Added 26 new test cases across three new test classes
(TestIgnoreIpEndpoints extended, TestToggleIgnoreSelf,
TestFail2BanConnectionErrors) covering every remaining branch.

- app/routers/jails.py: 61% → 100% line coverage
- Overall backend coverage: 83% → 85%
- Total test count: 497 → 523 (all pass)
- ruff check and mypy --strict clean
This commit is contained in:
2026-03-11 19:27:43 +01:00
parent 2f602e45f7
commit 029c094e18
2 changed files with 424 additions and 0 deletions

View File

@@ -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 |
| 120121 | `GET /api/jails` | `Fail2BanConnectionError` → 502 |
| 157158 | `GET /api/jails/{name}` | `Fail2BanConnectionError` → 502 |
| 195198 | `POST /api/jails/reload-all` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 |
| 234235 | `POST /api/jails/{name}/start` | `Fail2BanConnectionError` → 502 |
| 270273 | `POST /api/jails/{name}/stop` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 |
| 314319 | `POST /api/jails/{name}/idle` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 351356 | `POST /api/jails/{name}/reload` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 399402 | `GET /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `Fail2BanConnectionError` → 502 |
| 449454 | `POST /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 491496 | `DELETE /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 529542 | `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_<unit>_<scenario>_<expected>`.
- 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).

View File

@@ -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