"""Tests for health_service.probe().""" from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, patch import pytest from app.models.server import ServerStatus from app.services import health_service from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _SOCKET = "/fake/fail2ban.sock" def _make_send(responses: dict[str, Any]) -> AsyncMock: """Build an ``AsyncMock`` for ``Fail2BanClient.send`` keyed by command[0]. For the ``["status", jail_name]`` command the key is ``"status:"``. """ async def _side_effect(command: list[str]) -> Any: key = f"status:{command[1]}" if len(command) >= 2 and command[0] == "status" else command[0] if key not in responses: raise KeyError(f"Unexpected command key {key!r} in mock") return responses[key] mock = AsyncMock(side_effect=_side_effect) return mock # --------------------------------------------------------------------------- # Happy path # --------------------------------------------------------------------------- class TestProbeOnline: """Verify probe() correctly parses a healthy fail2ban response.""" async def test_online_flag_is_true(self) -> None: """status.online is True when ping succeeds.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 0), ("Jail list", "")]), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result: ServerStatus = await health_service.probe(_SOCKET) assert result.online is True async def test_version_parsed(self) -> None: """status.version contains the version string returned by fail2ban.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.1.0"), "status": (0, [("Number of jail", 0), ("Jail list", "")]), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.version == "1.1.0" async def test_active_jails_count(self) -> None: """status.active_jails reflects the jail count from the status command.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), "status:sshd": ( 0, [ ("Filter", [("Currently failed", 3), ("Total failed", 100)]), ("Actions", [("Currently banned", 1), ("Total banned", 50)]), ], ), "status:nginx": ( 0, [ ("Filter", [("Currently failed", 2), ("Total failed", 50)]), ("Actions", [("Currently banned", 0), ("Total banned", 10)]), ], ), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.active_jails == 2 async def test_total_bans_aggregated(self) -> None: """status.total_bans sums 'Currently banned' across all jails.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), "status:sshd": ( 0, [ ("Filter", [("Currently failed", 3), ("Total failed", 100)]), ("Actions", [("Currently banned", 4), ("Total banned", 50)]), ], ), "status:nginx": ( 0, [ ("Filter", [("Currently failed", 1), ("Total failed", 20)]), ("Actions", [("Currently banned", 2), ("Total banned", 15)]), ], ), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.total_bans == 6 # 4 + 2 async def test_total_failures_aggregated(self) -> None: """status.total_failures sums 'Currently failed' across all jails.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), "status:sshd": ( 0, [ ("Filter", [("Currently failed", 3), ("Total failed", 100)]), ("Actions", [("Currently banned", 1), ("Total banned", 50)]), ], ), "status:nginx": ( 0, [ ("Filter", [("Currently failed", 2), ("Total failed", 20)]), ("Actions", [("Currently banned", 0), ("Total banned", 10)]), ], ), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.total_failures == 5 # 3 + 2 async def test_empty_jail_list(self) -> None: """Probe succeeds with zero jails — no per-jail queries are made.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 0), ("Jail list", "")]), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.online is True assert result.active_jails == 0 assert result.total_bans == 0 assert result.total_failures == 0 # --------------------------------------------------------------------------- # Error handling # --------------------------------------------------------------------------- class TestProbeOffline: """Verify probe() returns online=False when the daemon is unreachable.""" async def test_connection_error_returns_offline(self) -> None: """Fail2BanConnectionError → online=False.""" with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = AsyncMock( side_effect=Fail2BanConnectionError("socket not found", _SOCKET) ) result = await health_service.probe(_SOCKET) assert result.online is False assert result.version is None async def test_protocol_error_returns_offline(self) -> None: """Fail2BanProtocolError → online=False.""" with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = AsyncMock( side_effect=Fail2BanProtocolError("bad pickle") ) result = await health_service.probe(_SOCKET) assert result.online is False async def test_bad_ping_response_returns_offline(self) -> None: """An unexpected ping response → online=False (defensive guard).""" send = _make_send({"ping": (0, "NOTPONG")}) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.online is False async def test_error_code_in_ping_returns_offline(self) -> None: """An error return code in the ping response → online=False.""" send = _make_send({"ping": (1, "ERROR")}) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.online is False async def test_per_jail_error_is_tolerated(self) -> None: """A parse error on an individual jail's status does not break the probe.""" send = _make_send( { "ping": (0, "pong"), "version": (0, "1.0.2"), "status": (0, [("Number of jail", 1), ("Jail list", "sshd")]), # Return garbage to trigger parse tolerance. "status:sshd": (0, "INVALID"), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) # The service should still be online even if per-jail parsing fails. assert result.online is True assert result.total_bans == 0 assert result.total_failures == 0 @pytest.mark.parametrize("version_return", [(1, "ERROR"), (0, None)]) async def test_version_failure_is_tolerated(self, version_return: tuple[int, Any]) -> None: """A failed or null version response does not prevent a successful probe.""" send = _make_send( { "ping": (0, "pong"), "version": version_return, "status": (0, [("Number of jail", 0), ("Jail list", "")]), } ) with patch("app.services.health_service.Fail2BanClient") as mock_client: mock_client.return_value.send = send result = await health_service.probe(_SOCKET) assert result.online is True