"""Tests for history_service.list_history() and history_service.get_ip_detail().""" from __future__ import annotations import json import time from pathlib import Path from typing import Any from unittest.mock import AsyncMock, patch import aiosqlite import pytest from app.services import history_service # --------------------------------------------------------------------------- # Time helpers # --------------------------------------------------------------------------- _NOW: int = int(time.time()) _ONE_HOUR_AGO: int = _NOW - 3600 _TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600 _THIRTY_DAYS_AGO: int = _NOW - 30 * 24 * 3600 # --------------------------------------------------------------------------- # DB fixture helpers # --------------------------------------------------------------------------- async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None: """Create a minimal fail2ban SQLite database with the given ban rows.""" async with aiosqlite.connect(path) as db: await db.execute( "CREATE TABLE jails (" "name TEXT NOT NULL UNIQUE, " "enabled INTEGER NOT NULL DEFAULT 1" ")" ) await db.execute( "CREATE TABLE bans (" "jail TEXT NOT NULL, " "ip TEXT, " "timeofban INTEGER NOT NULL, " "bantime INTEGER NOT NULL, " "bancount INTEGER NOT NULL DEFAULT 1, " "data JSON" ")" ) for row in rows: await db.execute( "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) " "VALUES (?, ?, ?, ?, ?, ?)", ( row["jail"], row["ip"], row["timeofban"], row.get("bantime", 3600), row.get("bancount", 1), json.dumps(row["data"]) if "data" in row else None, ), ) await db.commit() @pytest.fixture async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc] """Return the path to a test fail2ban SQLite database.""" path = str(tmp_path / "fail2ban_test.sqlite3") await _create_f2b_db( path, [ { "jail": "sshd", "ip": "1.2.3.4", "timeofban": _ONE_HOUR_AGO, "bantime": 3600, "bancount": 3, "data": { "matches": ["Mar 1 sshd[1]: Failed password for root"], "failures": 5, }, }, { "jail": "nginx", "ip": "5.6.7.8", "timeofban": _ONE_HOUR_AGO, "bantime": 7200, "bancount": 1, "data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3}, }, { "jail": "sshd", "ip": "1.2.3.4", "timeofban": _TWO_DAYS_AGO, "bantime": 3600, "bancount": 2, "data": {"failures": 5}, }, { "jail": "sshd", "ip": "9.0.0.1", "timeofban": _THIRTY_DAYS_AGO, "bantime": 3600, "bancount": 1, "data": None, }, ], ) return path # --------------------------------------------------------------------------- # list_history tests # --------------------------------------------------------------------------- class TestListHistory: """history_service.list_history().""" async def test_returns_all_bans_with_no_filter( self, f2b_db_path: str ) -> None: """No filter returns every record in the database.""" with patch( "app.services.history_service._get_fail2ban_db_path", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history("fake_socket") assert result.total == 4 assert len(result.items) == 4 async def test_time_range_filter_excludes_old_bans( self, f2b_db_path: str ) -> None: """The ``range_`` filter excludes bans older than the window.""" with patch( "app.services.history_service._get_fail2ban_db_path", new=AsyncMock(return_value=f2b_db_path), ): # "24h" window should include only the two recent bans result = await history_service.list_history( "fake_socket", range_="24h" ) assert result.total == 2 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history("fake_socket", jail="nginx") assert result.total == 1 assert result.items[0].jail == "nginx" 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", ip_filter="1.2.3" ) assert result.total == 2 for item in result.items: assert item.ip.startswith("1.2.3") 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", jail="sshd", ip_filter="1.2.3.4" ) # 2 sshd bans for 1.2.3.4 assert result.total == 2 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", ip_filter="99.99.99.99" ) assert result.total == 0 assert result.items == [] async def test_failures_extracted_from_data( self, f2b_db_path: str ) -> None: """``failures`` field is parsed from the JSON ``data`` column.""" with patch( "app.services.history_service._get_fail2ban_db_path", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", ip_filter="5.6.7.8" ) assert result.total == 1 assert result.items[0].failures == 3 async def test_matches_extracted_from_data( self, f2b_db_path: str ) -> None: """``matches`` list is parsed from the JSON ``data`` column.""" with patch( "app.services.history_service._get_fail2ban_db_path", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", ip_filter="1.2.3.4", range_="24h" ) # Most recent record for 1.2.3.4 has a matches list recent = result.items[0] assert len(recent.matches) == 1 assert "Failed password" in recent.matches[0] async def test_null_data_column_handled_gracefully( self, f2b_db_path: str ) -> None: """Records with ``data=NULL`` produce failures=0 and matches=[].""" with patch( "app.services.history_service._get_fail2ban_db_path", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", ip_filter="9.0.0.1" ) assert result.total == 1 item = result.items[0] assert item.failures == 0 assert item.matches == [] 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.list_history( "fake_socket", page=1, page_size=2 ) assert result.total == 4 assert len(result.items) == 2 assert result.page == 1 assert result.page_size == 2 # --------------------------------------------------------------------------- # get_ip_detail tests # --------------------------------------------------------------------------- class TestGetIpDetail: """history_service.get_ip_detail().""" async def test_returns_none_for_unknown_ip( self, f2b_db_path: str ) -> None: """Returns ``None`` when the IP has no records in the database.""" with patch( "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") assert result is None async def test_returns_ip_detail_for_known_ip( self, f2b_db_path: str ) -> None: """Returns an IpDetailResponse with correct totals for a known IP.""" with patch( "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") assert result is not None assert result.ip == "1.2.3.4" assert result.total_bans == 2 assert result.total_failures == 10 # 5 + 5 async def test_timeline_ordered_newest_first( self, f2b_db_path: str ) -> None: """Timeline events are ordered newest-first.""" with patch( "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") assert result is not None assert len(result.timeline) == 2 # First event should be the most recent assert result.timeline[0].banned_at > result.timeline[1].banned_at 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", new=AsyncMock(return_value=f2b_db_path), ): result = await history_service.get_ip_detail("fake_socket", "1.2.3.4") assert result is not None assert result.last_ban_at == result.timeline[0].banned_at async def test_geo_enrichment_applied_when_provided( self, f2b_db_path: str ) -> None: """Geolocation is applied when a geo_enricher is provided.""" from app.services.geo_service import GeoInfo mock_geo = GeoInfo( country_code="US", country_name="United States", asn="AS15169", org="Google", ) fake_enricher = AsyncMock(return_value=mock_geo) with patch( "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", geo_enricher=fake_enricher ) assert result is not None assert result.country_code == "US" assert result.country_name == "United States" assert result.asn == "AS15169" assert result.org == "Google"