fixed tests

This commit is contained in:
2026-05-15 20:41:05 +02:00
parent 96ce516ecf
commit 77df5d5d65
50 changed files with 1482 additions and 5089 deletions

View File

@@ -10,9 +10,13 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.exceptions import Fail2BanConnectionError
from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
from app.models.ban_domain import DomainActiveBanList
from app.models.geo import GeoDetail, GeoInfo
from app.models.jail import JailDetailResponse, JailListResponse
from app.models.jail_domain import (
DomainJailBannedIps,
DomainJailDetail,
DomainJailList,
)
from app.services import ban_service, jail_service
from app.services.jail_service import JailNotFoundError, JailOperationError
from app.utils import jail_socket
@@ -109,9 +113,9 @@ class TestListJails:
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert isinstance(result, JailListResponse)
assert isinstance(result, DomainJailList)
assert result.total == 1
assert result.jails[0].name == "sshd"
assert result.items[0].name == "sshd"
async def test_empty_jail_list(self, jail_service_state: JailServiceState) -> None:
"""list_jails returns empty response when no jails are active."""
@@ -120,7 +124,7 @@ class TestListJails:
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert result.total == 0
assert result.jails == []
assert result.items == []
async def test_jail_status_populated(self, jail_service_state: JailServiceState) -> None:
"""list_jails populates JailStatus with failed/banned counters."""
@@ -136,7 +140,7 @@ class TestListJails:
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET, jail_service_state)
jail = result.jails[0]
jail = result.items[0]
assert jail.status is not None
assert jail.status.currently_banned == 5
assert jail.status.total_banned == 50
@@ -155,7 +159,7 @@ class TestListJails:
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET, jail_service_state)
jail = result.jails[0]
jail = result.items[0]
assert jail.ban_time == 3600
assert jail.find_time == 300
assert jail.max_retry == 3
@@ -183,7 +187,7 @@ class TestListJails:
result = await jail_service.list_jails(_SOCKET, jail_service_state)
assert result.total == 2
names = {j.name for j in result.jails}
names = {j.name for j in result.items}
assert names == {"sshd", "nginx"}
async def test_connection_error_propagates(self, jail_service_state: JailServiceState) -> None:
@@ -223,7 +227,7 @@ class TestListJails:
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Verify the result uses the default values for backend and idle.
jail = result.jails[0]
jail = result.items[0]
assert jail.backend == "polling" # default
assert jail.idle is False # default
# Capability should now be cached as False.
@@ -249,7 +253,7 @@ class TestListJails:
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Verify real values are returned.
jail = result.jails[0]
jail = result.items[0]
assert jail.backend == "systemd" # real value
assert jail.idle is True # real value
# Capability should now be cached as True.
@@ -280,7 +284,7 @@ class TestListJails:
result = await jail_service.list_jails(_SOCKET, jail_service_state)
# Both jails should return default values (cached result is False).
for jail in result.jails:
for jail in result.items:
assert jail.backend == "polling"
assert jail.idle is False
@@ -329,11 +333,11 @@ class TestGetJail:
}
async def test_returns_jail_detail_response(self, jail_service_state: JailServiceState) -> None:
"""get_jail returns a JailDetailResponse."""
"""get_jail returns a DomainJailDetail."""
with _patch_client(self._full_responses()):
result = await jail_service.get_jail(_SOCKET, "sshd")
assert isinstance(result, JailDetailResponse)
assert isinstance(result, DomainJailDetail)
assert result.jail.name == "sshd"
async def test_log_paths_parsed(self, jail_service_state: JailServiceState) -> None:
@@ -453,9 +457,7 @@ class TestJailControls:
"reload|--all|[]|[['start', 'new'], ['start', 'nginx']]": (0, "OK"),
}
):
await jail_service.reload_all(
_SOCKET, include_jails=["new"], exclude_jails=["old"]
)
await jail_service.reload_all(_SOCKET, include_jails=["new"], exclude_jails=["old"])
async def test_reload_all_unknown_jail_raises_jail_not_found(self) -> None:
"""reload_all detects UnknownJailException and raises JailNotFoundError.
@@ -465,18 +467,19 @@ class TestJailControls:
test verifies that reload_all detects this and re-raises as
JailNotFoundError instead of the generic JailOperationError.
"""
with _patch_client(
{
"status": _make_global_status("sshd"),
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
1,
Exception("UnknownJailException('airsonic-auth')"),
),
}
), pytest.raises(jail_service.JailNotFoundError) as exc_info:
await jail_service.reload_all(
_SOCKET, include_jails=["airsonic-auth"]
)
with (
_patch_client(
{
"status": _make_global_status("sshd"),
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
1,
Exception("UnknownJailException('airsonic-auth')"),
),
}
),
pytest.raises(jail_service.JailNotFoundError) as exc_info,
):
await jail_service.reload_all(_SOCKET, include_jails=["airsonic-auth"])
assert exc_info.value.name == "airsonic-auth"
async def test_restart_sends_stop_command(self) -> None:
@@ -486,9 +489,7 @@ class TestJailControls:
async def test_restart_operation_error_raises(self) -> None:
"""restart() raises JailOperationError when fail2ban rejects the stop."""
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(
JailOperationError
):
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(JailOperationError):
await jail_service.restart(_SOCKET)
async def test_restart_connection_error_propagates(self) -> None:
@@ -496,9 +497,7 @@ class TestJailControls:
class _FailClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
)
self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET))
with (
patch("app.services.jail_service.Fail2BanClient", _FailClient),
@@ -638,7 +637,7 @@ class TestGetActiveBans:
with _patch_client(responses):
result = await ban_service.get_active_bans(_SOCKET)
assert isinstance(result, ActiveBanListResponse)
assert isinstance(result, DomainActiveBanList)
assert result.total == 1
assert result.bans[0].ip == "1.2.3.4"
assert result.bans[0].jail == "sshd"
@@ -724,17 +723,18 @@ class TestGetActiveBans:
),
}
mock_geo = {"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS1", org="ISP")}
mock_batch = AsyncMock(return_value=mock_geo)
mock_cache = AsyncMock()
mock_cache.lookup_batch = AsyncMock(return_value=mock_geo)
with _patch_client(responses):
mock_session = AsyncMock()
result = await ban_service.get_active_bans(
_SOCKET,
http_session=mock_session,
geo_batch_lookup=mock_batch,
geo_cache=mock_cache,
)
mock_batch.assert_awaited_once()
mock_cache.lookup_batch.assert_awaited_once()
assert result.total == 1
assert result.bans[0].country == "DE"
@@ -748,14 +748,17 @@ class TestGetActiveBans:
),
}
failing_batch = AsyncMock(side_effect=RuntimeError("geo down"))
import aiohttp
mock_cache = AsyncMock()
mock_cache.lookup_batch = AsyncMock(side_effect=aiohttp.ClientError("geo down"))
with _patch_client(responses):
mock_session = AsyncMock()
result = await ban_service.get_active_bans(
_SOCKET,
http_session=mock_session,
geo_batch_lookup=failing_batch,
geo_cache=mock_cache,
)
assert result.total == 1
@@ -777,9 +780,7 @@ class TestGetActiveBans:
return GeoInfo(country_code="JP", country_name="Japan", asn=None, org=None)
with _patch_client(responses):
result = await ban_service.get_active_bans(
_SOCKET, geo_enricher=_enricher
)
result = await ban_service.get_active_bans(_SOCKET, geo_enricher=_enricher)
assert result.total == 1
assert result.bans[0].country == "JP"
@@ -875,7 +876,7 @@ class TestLookupIp:
assert result.geo.org == "Acme"
async def test_http_session_uses_geo_service_lookup(self) -> None:
"""lookup_ip uses geo_service.lookup when http_session is provided."""
"""lookup_ip uses geo_enricher when provided."""
responses = {
"get|--all|banned|1.2.3.4": (0, []),
"status": _make_global_status("sshd"),
@@ -883,19 +884,16 @@ class TestLookupIp:
}
mock_geo = GeoInfo(country_code="JP", country_name="Japan", asn=None, org=None)
mock_session = AsyncMock()
mock_enricher = AsyncMock(return_value=mock_geo)
with _patch_client(responses), patch(
"app.services.jail_service.geo_service.lookup",
AsyncMock(return_value=mock_geo),
) as mock_lookup:
with _patch_client(responses):
result = await jail_service.lookup_ip(
_SOCKET,
"1.2.3.4",
http_session=mock_session,
geo_enricher=mock_enricher,
)
mock_lookup.assert_awaited_once_with("1.2.3.4", mock_session)
mock_enricher.assert_awaited_once_with("1.2.3.4")
assert isinstance(result.geo, GeoDetail)
assert result.geo.country_code == "JP"
assert result.geo.country_name == "Japan"
@@ -985,7 +983,7 @@ class TestGetJailBannedIps:
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert isinstance(result, JailBannedIpsResponse)
assert isinstance(result, DomainJailBannedIps)
async def test_total_reflects_all_entries(self) -> None:
"""total equals the number of parsed ban entries."""
@@ -996,12 +994,8 @@ class TestGetJailBannedIps:
async def test_page_1_returns_first_n_items(self) -> None:
"""page=1 with page_size=2 returns the first two entries."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=2
)
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", page=1, page_size=2)
assert len(result.items) == 2
assert result.items[0].ip == "1.2.3.4"
@@ -1010,12 +1004,8 @@ class TestGetJailBannedIps:
async def test_page_2_returns_remaining_items(self) -> None:
"""page=2 with page_size=2 returns the third entry."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=2, page_size=2
)
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", page=2, page_size=2)
assert len(result.items) == 1
assert result.items[0].ip == "9.10.11.12"
@@ -1023,9 +1013,7 @@ class TestGetJailBannedIps:
async def test_page_beyond_last_returns_empty_items(self) -> None:
"""Requesting a page past the end returns an empty items list."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=99, page_size=25
)
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", page=99, page_size=25)
assert result.items == []
assert result.total == 2
@@ -1033,9 +1021,7 @@ class TestGetJailBannedIps:
async def test_search_filter_narrows_results(self) -> None:
"""search parameter filters entries by IP substring."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="1.2.3"
)
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", search="1.2.3")
assert result.total == 1
assert result.items[0].ip == "1.2.3.4"
@@ -1044,18 +1030,14 @@ class TestGetJailBannedIps:
"""search filter is case-insensitive."""
entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"]
with _patch_client(_banned_ips_responses(entries=entries)):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="192.168"
)
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", search="192.168")
assert result.total == 1
async def test_search_no_match_returns_empty(self) -> None:
"""search that matches nothing returns empty items and total=0."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="999.999"
)
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", search="999.999")
assert result.total == 0
assert result.items == []
@@ -1080,9 +1062,7 @@ class TestGetJailBannedIps:
"get|sshd|banip|--with-time": (0, entries),
}
with _patch_client(responses):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=200
)
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd", page=1, page_size=200)
assert len(result.items) <= 100
@@ -1090,30 +1070,22 @@ class TestGetJailBannedIps:
"""Geo enrichment is requested only for IPs in the current page."""
from unittest.mock import MagicMock
from app.services import geo_service
http_session = MagicMock()
geo_enrichment_ips: list[list[str]] = []
async def _mock_lookup_batch(
ips: list[str], _session: Any, **_kw: Any
) -> dict[str, Any]:
geo_enrichment_ips.append(list(ips))
return {}
mock_cache = MagicMock()
mock_cache.lookup_batch = AsyncMock(
side_effect=lambda ips, _session, **_kw: (geo_enrichment_ips.append(list(ips)), {})[-1]
)
with (
_patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
),
patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch),
):
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
result = await jail_service.get_jail_banned_ips(
_SOCKET,
"sshd",
page=1,
page_size=2,
http_session=http_session,
geo_batch_lookup=geo_service.lookup_batch,
geo_cache=mock_cache,
)
# Only the 2-IP page slice should be passed to geo enrichment.
@@ -1123,6 +1095,7 @@ class TestGetJailBannedIps:
async def test_unknown_jail_raises_jail_not_found_error(self) -> None:
"""get_jail_banned_ips raises JailNotFoundError for unknown jail."""
# Simulate fail2ban returning an "unknown jail" error.
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
@@ -1142,9 +1115,7 @@ class TestGetJailBannedIps:
class _FailClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
)
self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET))
with (
patch("app.services.jail_service.Fail2BanClient", _FailClient),