refactor: Make service dependencies explicit and injectable
Remove hidden cross-service coupling by making dependencies explicit through dependency injection while maintaining backward compatibility via lazy imports. Key changes: - history_service and ban_service: Removed direct module-level imports of fail2ban_metadata_service, added optional service parameters to functions - Added get_fail2ban_metadata_service() provider to dependencies.py - Updated history router to inject Fail2BanMetadataService dependency - history_service functions now use lazy imports in fallback paths for backward compatibility when service is not explicitly injected - All test patches updated to use internal _get_fail2ban_db_path() helper - jail_config_service and jail_service already follow best practices This pattern prevents circular imports, makes services testable via explicit mocking, and documents service dependencies clearly. Fixes: Instructions.md #2 - Hidden cross-service coupling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -111,6 +111,16 @@ async def f2b_db_path(tmp_path: Path) -> str:
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_fail2ban_metadata_service(f2b_db_path: str) -> object:
|
||||
"""Return a mock Fail2BanMetadataService for tests."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_service = AsyncMock()
|
||||
mock_service.get_db_path = AsyncMock(return_value=f2b_db_path)
|
||||
return mock_service
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_history tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -124,7 +134,7 @@ class TestListHistory:
|
||||
) -> None:
|
||||
"""No filter returns every record in the database."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history("fake_socket")
|
||||
@@ -136,7 +146,7 @@ class TestListHistory:
|
||||
) -> None:
|
||||
"""The ``range_`` filter excludes bans older than the window."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
# "24h" window should include only the two recent bans
|
||||
@@ -148,7 +158,7 @@ class TestListHistory:
|
||||
async def test_jail_filter(self, f2b_db_path: str) -> None:
|
||||
"""Jail filter restricts results to bans from that jail."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history("fake_socket", jail="nginx")
|
||||
@@ -158,7 +168,7 @@ class TestListHistory:
|
||||
async def test_ip_prefix_filter(self, f2b_db_path: str) -> None:
|
||||
"""IP prefix filter restricts results to matching IPs."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -171,7 +181,7 @@ class TestListHistory:
|
||||
async def test_combined_filters(self, f2b_db_path: str) -> None:
|
||||
"""Jail + IP prefix filters applied together narrow the result set."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -183,7 +193,7 @@ class TestListHistory:
|
||||
async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
|
||||
"""Origin filter should include only selfblock entries."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -196,7 +206,7 @@ class TestListHistory:
|
||||
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
|
||||
"""Filtering by a non-existent IP returns an empty result set."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -210,7 +220,7 @@ class TestListHistory:
|
||||
) -> None:
|
||||
"""``failures`` field is parsed from the JSON ``data`` column."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -224,7 +234,7 @@ class TestListHistory:
|
||||
) -> None:
|
||||
"""``matches`` list is parsed from the JSON ``data`` column."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -238,7 +248,7 @@ class TestListHistory:
|
||||
async def test_http_session_geo_lookup_is_used(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""A provided HTTP session is used for geo enrichment by the service."""
|
||||
"""A provided geo_enricher is used by the service."""
|
||||
fake_session = AsyncMock()
|
||||
|
||||
mock_geo = AsyncMock()
|
||||
@@ -247,20 +257,20 @@ class TestListHistory:
|
||||
mock_geo.asn = "AS15169"
|
||||
mock_geo.org = "Google"
|
||||
|
||||
mock_enricher = AsyncMock(return_value=mock_geo)
|
||||
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
), patch(
|
||||
"app.services.history_service.geo_service.lookup",
|
||||
new=AsyncMock(return_value=mock_geo),
|
||||
) as mock_lookup:
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket",
|
||||
ip_filter="1.2.3.4",
|
||||
http_session=fake_session,
|
||||
geo_enricher=mock_enricher,
|
||||
)
|
||||
|
||||
assert mock_lookup.call_args.args == ("1.2.3.4", fake_session)
|
||||
assert mock_enricher.call_args.args == ("1.2.3.4",)
|
||||
assert result.items[0].country_code == "US"
|
||||
assert result.items[0].country_name == "United States"
|
||||
assert result.items[0].asn == "AS15169"
|
||||
@@ -271,7 +281,7 @@ class TestListHistory:
|
||||
) -> None:
|
||||
"""Records with ``data=NULL`` produce failures=0 and matches=[]."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -285,7 +295,7 @@ class TestListHistory:
|
||||
async def test_pagination(self, f2b_db_path: str) -> None:
|
||||
"""Pagination returns the correct slice."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -309,7 +319,7 @@ class TestListHistory:
|
||||
await db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
@@ -335,7 +345,7 @@ class TestGetIpDetail:
|
||||
) -> None:
|
||||
"""Returns ``None`` when the IP has no records in the database."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "99.99.99.99")
|
||||
@@ -346,7 +356,7 @@ class TestGetIpDetail:
|
||||
) -> None:
|
||||
"""Returns an IpDetailResponse with correct totals for a known IP."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
@@ -361,7 +371,7 @@ class TestGetIpDetail:
|
||||
) -> None:
|
||||
"""Timeline events are ordered newest-first."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
@@ -374,7 +384,7 @@ class TestGetIpDetail:
|
||||
async def test_last_ban_at_is_most_recent(self, f2b_db_path: str) -> None:
|
||||
"""``last_ban_at`` matches the banned_at of the first timeline event."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
@@ -397,7 +407,7 @@ class TestGetIpDetail:
|
||||
fake_enricher = AsyncMock(return_value=mock_geo)
|
||||
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail(
|
||||
@@ -431,31 +441,32 @@ class TestSyncFromFail2BanDb:
|
||||
)
|
||||
]
|
||||
|
||||
mock_archive_repo = AsyncMock()
|
||||
mock_archive_repo.get_max_timeofban = AsyncMock(return_value=1000)
|
||||
mock_archive_repo.archive_ban_event = AsyncMock()
|
||||
|
||||
mock_metadata_service = AsyncMock()
|
||||
mock_metadata_service.get_db_path = AsyncMock(return_value="/tmp/fake.sqlite3")
|
||||
|
||||
with patch(
|
||||
"app.services.history_service._get_last_archive_ts",
|
||||
new=AsyncMock(return_value=1000),
|
||||
), patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value="/tmp/fake.sqlite3"),
|
||||
), patch(
|
||||
"app.services.history_service.fail2ban_db_repo.get_history_page",
|
||||
new=AsyncMock(return_value=(fake_rows, 1)),
|
||||
) as mock_page, patch(
|
||||
"app.services.history_service.archive_ban_event",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as archive_mock:
|
||||
) as mock_page:
|
||||
count = await history_service.sync_from_fail2ban_db(
|
||||
fake_db, "/tmp/fake.sock"
|
||||
fake_db, "/tmp/fake.sock",
|
||||
history_archive_repo=mock_archive_repo,
|
||||
fail2ban_metadata_service=mock_metadata_service,
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
mock_metadata_service.get_db_path.assert_awaited_once_with("/tmp/fake.sock")
|
||||
mock_page.assert_awaited_once_with(
|
||||
db_path="/tmp/fake.sqlite3",
|
||||
since=1001,
|
||||
page=1,
|
||||
page_size=500,
|
||||
)
|
||||
archive_mock.assert_awaited_once_with(
|
||||
mock_archive_repo.archive_ban_event.assert_awaited_once_with(
|
||||
db=fake_db,
|
||||
jail="sshd",
|
||||
ip="1.2.3.4",
|
||||
|
||||
Reference in New Issue
Block a user