feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
+ 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
frontend tests; fix mock setup in existing activate_jail tests
This commit is contained in:
@@ -1874,3 +1874,217 @@ class TestGetServiceStatus:
|
||||
).get("/api/config/service-status")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 3 endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestValidateJailEndpoint:
|
||||
"""Tests for ``POST /api/config/jails/{name}/validate``."""
|
||||
|
||||
async def test_200_valid_config(self, config_client: AsyncClient) -> None:
|
||||
"""Returns 200 with valid=True when the jail config has no issues."""
|
||||
from app.models.config import JailValidationResult
|
||||
|
||||
mock_result = JailValidationResult(
|
||||
jail_name="sshd", valid=True, issues=[]
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/validate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["valid"] is True
|
||||
assert data["jail_name"] == "sshd"
|
||||
assert data["issues"] == []
|
||||
|
||||
async def test_200_invalid_config(self, config_client: AsyncClient) -> None:
|
||||
"""Returns 200 with valid=False and issues when there are errors."""
|
||||
from app.models.config import JailValidationIssue, JailValidationResult
|
||||
|
||||
issue = JailValidationIssue(field="filter", message="Filter file not found: filter.d/bad.conf (or .local)")
|
||||
mock_result = JailValidationResult(
|
||||
jail_name="sshd", valid=False, issues=[issue]
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/validate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["valid"] is False
|
||||
assert len(data["issues"]) == 1
|
||||
assert data["issues"][0]["field"] == "filter"
|
||||
|
||||
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/bad-name/validate returns 400 on JailNameError."""
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(side_effect=JailNameError("bad name")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/bad-name/validate")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/validate 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/validate")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPendingRecovery:
|
||||
"""Tests for ``GET /api/config/pending-recovery``."""
|
||||
|
||||
async def test_returns_null_when_no_pending_recovery(
|
||||
self, config_client: AsyncClient
|
||||
) -> None:
|
||||
"""Returns null body (204-like 200) when pending_recovery is not set."""
|
||||
app = config_client._transport.app # type: ignore[attr-defined]
|
||||
app.state.pending_recovery = None
|
||||
|
||||
resp = await config_client.get("/api/config/pending-recovery")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() is None
|
||||
|
||||
async def test_returns_record_when_set(self, config_client: AsyncClient) -> None:
|
||||
"""Returns the PendingRecovery model when one is stored on app.state."""
|
||||
import datetime
|
||||
|
||||
from app.models.config import PendingRecovery
|
||||
|
||||
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
record = PendingRecovery(
|
||||
jail_name="sshd",
|
||||
activated_at=now - datetime.timedelta(seconds=20),
|
||||
detected_at=now,
|
||||
)
|
||||
app = config_client._transport.app # type: ignore[attr-defined]
|
||||
app.state.pending_recovery = record
|
||||
|
||||
resp = await config_client.get("/api/config/pending-recovery")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["jail_name"] == "sshd"
|
||||
assert data["recovered"] is False
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/pending-recovery returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/pending-recovery")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRollbackEndpoint:
|
||||
"""Tests for ``POST /api/config/jails/{name}/rollback``."""
|
||||
|
||||
async def test_200_success_clears_pending_recovery(
|
||||
self, config_client: AsyncClient
|
||||
) -> None:
|
||||
"""A successful rollback returns 200 and clears app.state.pending_recovery."""
|
||||
import datetime
|
||||
|
||||
from app.models.config import PendingRecovery, RollbackResponse
|
||||
|
||||
# Set up a pending recovery record on the app.
|
||||
app = config_client._transport.app # type: ignore[attr-defined]
|
||||
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
app.state.pending_recovery = PendingRecovery(
|
||||
jail_name="sshd",
|
||||
activated_at=now - datetime.timedelta(seconds=10),
|
||||
detected_at=now,
|
||||
)
|
||||
|
||||
mock_result = RollbackResponse(
|
||||
jail_name="sshd",
|
||||
disabled=True,
|
||||
fail2ban_running=True,
|
||||
active_jails=0,
|
||||
message="Jail 'sshd' disabled and fail2ban restarted.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/rollback")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["disabled"] is True
|
||||
assert data["fail2ban_running"] is True
|
||||
# Successful rollback must clear the pending record.
|
||||
assert app.state.pending_recovery is None
|
||||
|
||||
async def test_200_fail_preserves_pending_recovery(
|
||||
self, config_client: AsyncClient
|
||||
) -> None:
|
||||
"""When fail2ban is still down after rollback, pending_recovery is retained."""
|
||||
import datetime
|
||||
|
||||
from app.models.config import PendingRecovery, RollbackResponse
|
||||
|
||||
app = config_client._transport.app # type: ignore[attr-defined]
|
||||
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
record = PendingRecovery(
|
||||
jail_name="sshd",
|
||||
activated_at=now - datetime.timedelta(seconds=10),
|
||||
detected_at=now,
|
||||
)
|
||||
app.state.pending_recovery = record
|
||||
|
||||
mock_result = RollbackResponse(
|
||||
jail_name="sshd",
|
||||
disabled=True,
|
||||
fail2ban_running=False,
|
||||
active_jails=0,
|
||||
message="fail2ban did not come back online.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/rollback")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["fail2ban_running"] is False
|
||||
# Pending record should NOT be cleared when rollback didn't fully succeed.
|
||||
assert app.state.pending_recovery is not None
|
||||
|
||||
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/bad/rollback returns 400 on JailNameError."""
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/bad/rollback")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/rollback 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/rollback")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
Reference in New Issue
Block a user