Refactor: Split blocklist import flow into focused components

Extracted the monolithic import_source() function (776 lines) into focused,
testable components with clear single responsibilities:

- BlocklistDownloader: HTTP download with exponential backoff retry logic
  * Handles transient failures (429, 5xx errors, timeouts)
  * Configurable retry attempts and backoff strategy
  * 93% test coverage

- BlocklistParser: Parse and validate IP addresses
  * Extract valid IPv4/IPv6 addresses from text
  * Skip CIDRs and malformed entries gracefully
  * Separate parsing from validation concerns
  * 100% test coverage

- BanExecutor: Ban execution with error handling
  * Ban IPs via fail2ban socket
  * Stop on JailNotFoundError (jail doesn't exist)
  * Continue on JailOperationError (individual ban failures)
  * 100% test coverage

- BlocklistImportWorkflow: Thin orchestrator
  * Coordinates the download → parse → ban → log flow
  * Pre-warms geo cache with newly banned IPs
  * 96% test coverage

- blocklist_service.py: Maintains public API
  * Source CRUD (create, read, update, delete)
  * URL validation and preview functionality
  * Scheduling configuration and import triggers
  * 92% test coverage

Benefits:
* Each component is independently testable with mock dependencies
* Error handling is explicit and localized
* Components can evolve independently
* Logging is contextual and clear
* Retry and transient error handling are isolated

Testing:
* All 36 existing blocklist_service tests pass
* All 13 blocklist import task tests pass
* Added 17 comprehensive component unit tests
* Combined 96%+ coverage on new modules
* Zero type errors in new code

Documentation:
* Updated Refactoring.md with detailed architecture notes
* Added component architecture diagram to Architekture.md
* Documented ownership and responsibilities of each component

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:34:11 +02:00
parent 3bbf413c55
commit e08a16c7dd
8 changed files with 929 additions and 200 deletions

View File

@@ -0,0 +1,351 @@
"""Tests for blocklist refactored components.
Tests the individual components (downloader, parser, ban executor, workflow)
that were extracted from the monolithic blocklist_service.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from app.exceptions import JailNotFoundError, JailOperationError
from app.models.blocklist import BlocklistSource
from app.services.blocklist_ban_executor import BanExecutor
from app.services.blocklist_downloader import BlocklistDownloader
from app.services.blocklist_import_workflow import BlocklistImportWorkflow
from app.services.blocklist_parser import BlocklistParser, ParsedBlocklist
class TestBlocklistDownloader:
"""Test BlocklistDownloader component."""
@pytest.mark.asyncio
async def test_download_successful(self) -> None:
"""Test successful download."""
http_session = MagicMock(spec=aiohttp.ClientSession)
response = AsyncMock()
response.status = 200
response.text = AsyncMock(return_value="192.168.1.1\n10.0.0.1")
http_session.get = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=response)))
downloader = BlocklistDownloader(http_session)
status, text = await downloader.download(
"https://example.com/blocklist.txt",
aiohttp.ClientTimeout(total=30),
)
assert status == 200
assert text == "192.168.1.1\n10.0.0.1"
@pytest.mark.asyncio
async def test_download_retries_on_429(self) -> None:
"""Test retry logic on HTTP 429."""
http_session = MagicMock(spec=aiohttp.ClientSession)
response_429 = AsyncMock()
response_429.status = 429
response_429.text = AsyncMock(return_value="rate limited")
response_200 = AsyncMock()
response_200.status = 200
response_200.text = AsyncMock(return_value="192.168.1.1")
http_session.get = MagicMock(
side_effect=[
AsyncMock(__aenter__=AsyncMock(return_value=response_429)),
AsyncMock(__aenter__=AsyncMock(return_value=response_200)),
]
)
downloader = BlocklistDownloader(http_session, backoff_base=0.01)
status, text = await downloader.download(
"https://example.com/blocklist.txt",
aiohttp.ClientTimeout(total=30),
)
assert status == 200
assert text == "192.168.1.1"
assert http_session.get.call_count == 2
@pytest.mark.asyncio
async def test_download_fails_after_max_retries(self) -> None:
"""Test download fails after exhausting retries."""
http_session = MagicMock(spec=aiohttp.ClientSession)
response_error = AsyncMock()
response_error.status = 503
response_error.text = AsyncMock(return_value="service unavailable")
http_session.get = MagicMock(
side_effect=[
AsyncMock(__aenter__=AsyncMock(return_value=response_error)),
AsyncMock(__aenter__=AsyncMock(return_value=response_error)),
]
)
downloader = BlocklistDownloader(http_session, backoff_base=0.01)
status, text = await downloader.download(
"https://example.com/blocklist.txt",
aiohttp.ClientTimeout(total=30),
)
# After max retries exhausted, returns the last response
assert status == 503
assert http_session.get.call_count == 2
class TestBlocklistParser:
"""Test BlocklistParser component."""
def test_parse_valid_ips(self) -> None:
"""Test parsing content with valid IPs."""
content = "192.168.1.1\n10.0.0.1\n# Comment\n172.16.0.1"
result = BlocklistParser.parse(content)
assert result.valid_ips == ["192.168.1.1", "10.0.0.1", "172.16.0.1"]
assert result.skipped_entries == 0 # Comments are not counted as skipped
def test_parse_skips_cidrs(self) -> None:
"""Test that CIDR ranges are skipped."""
content = "192.168.1.0/24\n10.0.0.1\n172.16.0.0/16"
result = BlocklistParser.parse(content)
assert result.valid_ips == ["10.0.0.1"]
assert result.skipped_entries == 2
def test_parse_skips_malformed(self) -> None:
"""Test that malformed entries are skipped."""
content = "192.168.1.1\ninvalid\n10.0.0.1\nNOT_AN_IP"
result = BlocklistParser.parse(content)
assert result.valid_ips == ["192.168.1.1", "10.0.0.1"]
assert result.skipped_entries == 2
def test_parse_skips_empty_lines(self) -> None:
"""Test that empty lines are skipped."""
content = "192.168.1.1\n\n10.0.0.1\n\n"
result = BlocklistParser.parse(content)
assert result.valid_ips == ["192.168.1.1", "10.0.0.1"]
assert result.skipped_entries == 0
def test_parse_ipv6_addresses(self) -> None:
"""Test parsing IPv6 addresses."""
content = "2001:db8::1\nfe80::1\n192.168.1.1"
result = BlocklistParser.parse(content)
assert "2001:db8::1" in result.valid_ips
assert "fe80::1" in result.valid_ips
assert "192.168.1.1" in result.valid_ips
def test_parse_with_stats(self) -> None:
"""Test parse_with_stats returns samples and statistics."""
content = "\n".join(
["192.168.1.{}".format(i) for i in range(1, 30)]
+ ["# Comment"]
+ ["invalid_entry"]
)
entries, stats = BlocklistParser.parse_with_stats(content, sample_lines=10)
assert len(entries) <= 10
assert stats["total_lines"] == 31
assert stats["valid_count"] == 29
assert stats["skipped_count"] == 1 # Only invalid_entry is skipped (comment is ignored)
def test_parsed_blocklist_properties(self) -> None:
"""Test ParsedBlocklist properties."""
result = ParsedBlocklist(
valid_ips=["192.168.1.1", "10.0.0.1"],
skipped_entries=3,
)
assert result.valid_ips == ["192.168.1.1", "10.0.0.1"]
assert result.skipped_entries == 3
assert result.total_entries == 5
class TestBanExecutor:
"""Test BanExecutor component."""
@pytest.mark.asyncio
async def test_ban_ips_success(self) -> None:
"""Test successful banning of IPs."""
ban_ip = AsyncMock()
executor = BanExecutor(ban_ip)
ips = ["192.168.1.1", "10.0.0.1", "172.16.0.1"]
successful, failed, error = await executor.ban_ips(
"/var/run/fail2ban/fail2ban.sock",
"blocklist-import",
ips,
)
assert successful == 3
assert failed == 0
assert error is None
assert ban_ip.call_count == 3
@pytest.mark.asyncio
async def test_ban_ips_stops_on_jail_not_found(self) -> None:
"""Test that banning stops when jail is not found."""
ban_ip = AsyncMock()
ban_ip.side_effect = [
None, # First ban succeeds
JailNotFoundError("Jail not found"),
]
executor = BanExecutor(ban_ip)
ips = ["192.168.1.1", "10.0.0.1", "172.16.0.1"]
successful, failed, error = await executor.ban_ips(
"/var/run/fail2ban/fail2ban.sock",
"blocklist-import",
ips,
)
assert successful == 1
assert failed == 0
assert "Jail not found" in error
assert ban_ip.call_count == 2 # Stops after jail not found
@pytest.mark.asyncio
async def test_ban_ips_continues_on_operation_error(self) -> None:
"""Test that banning continues on individual operation errors."""
ban_ip = AsyncMock()
ban_ip.side_effect = [
None, # First ban succeeds
JailOperationError("Ban failed"),
None, # Third ban succeeds
]
executor = BanExecutor(ban_ip)
ips = ["192.168.1.1", "10.0.0.1", "172.16.0.1"]
successful, failed, error = await executor.ban_ips(
"/var/run/fail2ban/fail2ban.sock",
"blocklist-import",
ips,
)
assert successful == 2
assert failed == 1
assert error == "Ban failed"
assert ban_ip.call_count == 3 # Continues after operation error
@pytest.mark.asyncio
async def test_ban_ips_empty_list(self) -> None:
"""Test banning empty list of IPs."""
ban_ip = AsyncMock()
executor = BanExecutor(ban_ip)
successful, failed, error = await executor.ban_ips(
"/var/run/fail2ban/fail2ban.sock",
"blocklist-import",
[],
)
assert successful == 0
assert failed == 0
assert error is None
assert ban_ip.call_count == 0
class TestBlocklistImportWorkflow:
"""Test BlocklistImportWorkflow orchestrator."""
@pytest.mark.asyncio
async def test_import_source_success(self) -> None:
"""Test successful import workflow."""
http_session = MagicMock(spec=aiohttp.ClientSession)
response = AsyncMock()
response.status = 200
response.text = AsyncMock(return_value="192.168.1.1\n10.0.0.1\n# Comment")
http_session.get = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=response)))
ban_ip = AsyncMock()
log_result = AsyncMock()
workflow = BlocklistImportWorkflow(http_session, ban_ip, log_result)
source = BlocklistSource(
id=1,
name="Test Source",
url="https://example.com/blocklist.txt",
enabled=True,
created_at="2026-04-27T00:00:00Z",
updated_at="2026-04-27T00:00:00Z",
)
db = MagicMock()
result = await workflow.import_source(source, "/var/run/fail2ban/fail2ban.sock", db)
assert result.source_id == 1
assert result.ips_imported == 2
assert result.ips_skipped == 0
assert result.error is None
assert ban_ip.call_count == 2
@pytest.mark.asyncio
async def test_import_source_download_error(self) -> None:
"""Test import workflow with download error."""
http_session = MagicMock(spec=aiohttp.ClientSession)
http_session.get = MagicMock(
side_effect=aiohttp.ClientError("Connection failed")
)
ban_ip = AsyncMock()
log_result = AsyncMock()
workflow = BlocklistImportWorkflow(http_session, ban_ip, log_result)
source = BlocklistSource(
id=1,
name="Test Source",
url="https://example.com/blocklist.txt",
enabled=True,
created_at="2026-04-27T00:00:00Z",
updated_at="2026-04-27T00:00:00Z",
)
db = MagicMock()
result = await workflow.import_source(source, "/var/run/fail2ban/fail2ban.sock", db)
assert result.source_id == 1
assert result.ips_imported == 0
assert result.ips_skipped == 0
assert "Connection failed" in result.error or result.error is not None
assert log_result.await_count == 1
@pytest.mark.asyncio
async def test_import_source_http_non_200(self) -> None:
"""Test import workflow with non-200 HTTP status."""
http_session = MagicMock(spec=aiohttp.ClientSession)
response = AsyncMock()
response.status = 404
response.text = AsyncMock(return_value="Not Found")
http_session.get = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=response)))
ban_ip = AsyncMock()
log_result = AsyncMock()
workflow = BlocklistImportWorkflow(http_session, ban_ip, log_result)
source = BlocklistSource(
id=1,
name="Test Source",
url="https://example.com/blocklist.txt",
enabled=True,
created_at="2026-04-27T00:00:00Z",
updated_at="2026-04-27T00:00:00Z",
)
db = MagicMock()
result = await workflow.import_source(source, "/var/run/fail2ban/fail2ban.sock", db)
assert result.source_id == 1
assert result.ips_imported == 0
assert result.ips_skipped == 0
assert result.error == "HTTP 404"