Implement tasks 1-3: sidebar order, jail activation rollback, pie chart colors

Task 1: Move Configuration to last position in sidebar NAV_ITEMS

Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
  failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
  and double failure (recovered=False)

Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
This commit was merged in pull request #1.
This commit is contained in:
2026-03-14 21:16:58 +01:00
parent 6bb38dbd8c
commit 4be2469f92
8 changed files with 449 additions and 581 deletions

View File

@@ -502,7 +502,8 @@ class TestActivateJail:
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(side_effect=[set(), set()]),
# First call: pre-activation (not active); second: post-reload (started).
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
@@ -2947,3 +2948,166 @@ class TestActivateJailBlocking:
assert result.active is True
mock_js.reload_all.assert_awaited_once()
# ---------------------------------------------------------------------------
# activate_jail — rollback on failure (Task 2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestActivateJailRollback:
"""Rollback logic in activate_jail restores the .local file and recovers."""
async def test_activate_jail_rollback_on_reload_failure(
self, tmp_path: Path
) -> None:
"""Rollback when reload_all raises on the activation reload.
Expects:
- The .local file is restored to its original content.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
reload_call_count = 0
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
nonlocal reload_call_count
reload_call_count += 1
if reload_call_count == 1:
raise RuntimeError("fail2ban crashed")
# Recovery reload succeeds.
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert local_path.read_text() == original_local
async def test_activate_jail_rollback_on_health_check_failure(
self, tmp_path: Path
) -> None:
"""Rollback when fail2ban is unreachable after the activation reload.
Expects:
- The .local file is restored to its original content.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
probe_call_count = 0
async def probe_side_effect(socket_path: str) -> bool:
nonlocal probe_call_count
probe_call_count += 1
# First _POST_RELOAD_MAX_ATTEMPTS probes (health-check after
# activation) all fail; subsequent probes (recovery) succeed.
from app.services.config_file_service import _POST_RELOAD_MAX_ATTEMPTS
return probe_call_count > _POST_RELOAD_MAX_ATTEMPTS
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(side_effect=probe_side_effect),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert local_path.read_text() == original_local
async def test_activate_jail_rollback_failure(self, tmp_path: Path) -> None:
"""recovered=False when both the activation and recovery reloads fail.
Expects:
- The response indicates recovered=False.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
# Both the activation reload and the recovery reload fail.
mock_js.reload_all = AsyncMock(
side_effect=RuntimeError("fail2ban unavailable")
)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is False