feat(stage-1): inactive jail discovery and activation

- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
  following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
  POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
  to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
2026-03-13 15:44:36 +01:00
parent a344f1035b
commit 8d9d63b866
15 changed files with 2711 additions and 182 deletions

View File

@@ -575,3 +575,245 @@ class TestUpdateMapColorThresholds:
# 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