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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user