Testing: Stabilized test suite and achieved 93% total coverage.

- Fixed CLI tests by patching local imports at source (psutil, os, Path).
- Fixed Collector tests by globalizing docker.from_env mock.
- Stabilized SSE stream tests via AsyncMock and immediate generator termination to prevent hangs.
- Achieved >80% coverage on CLI (84%), Collector (97%), and DB Repository (100%).
- Implemented SMTP Relay service tests (100%).
This commit is contained in:
2026-04-12 03:30:06 -04:00
parent f78104e1c8
commit ff38d58508
20 changed files with 2510 additions and 4 deletions

View File

@@ -1,9 +1,17 @@
"""Tests for the host-side Docker log collector."""
import json
import asyncio
import pytest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import patch, MagicMock, AsyncMock
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
from decnet.collector.worker import (
_stream_container,
_load_service_container_names,
log_collector_worker
)
_KNOWN_NAMES = {"omega-decky-http", "omega-decky-smtp", "relay-decky-ftp"}
@@ -50,6 +58,21 @@ class TestParseRfc5424:
result = parse_rfc5424(line)
assert result["attacker_ip"] == "10.0.0.5"
def test_extracts_attacker_ip_from_client_ip(self):
line = self._make_line('client_ip="10.0.0.7"')
result = parse_rfc5424(line)
assert result["attacker_ip"] == "10.0.0.7"
def test_extracts_attacker_ip_from_remote_ip(self):
line = self._make_line('remote_ip="10.0.0.8"')
result = parse_rfc5424(line)
assert result["attacker_ip"] == "10.0.0.8"
def test_extracts_attacker_ip_from_ip(self):
line = self._make_line('ip="10.0.0.9"')
result = parse_rfc5424(line)
assert result["attacker_ip"] == "10.0.0.9"
def test_attacker_ip_defaults_to_unknown(self):
line = self._make_line('user="admin"')
result = parse_rfc5424(line)
@@ -88,6 +111,26 @@ class TestParseRfc5424:
# Should not raise
json.dumps(result)
def test_invalid_timestamp_preserved_as_is(self):
line = "<134>1 not-a-date decky-01 http - request -"
result = parse_rfc5424(line)
assert result is not None
assert result["timestamp"] == "not-a-date"
def test_sd_rest_is_plain_text(self):
# When SD starts with neither '-' nor '[', treat as msg
line = "<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request hello world"
result = parse_rfc5424(line)
assert result is not None
assert result["msg"] == "hello world"
def test_sd_with_msg_after_bracket(self):
line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request [decnet@55555 src_ip="1.2.3.4"] login attempt'
result = parse_rfc5424(line)
assert result is not None
assert result["fields"]["src_ip"] == "1.2.3.4"
assert result["msg"] == "login attempt"
class TestIsServiceContainer:
def test_known_container_returns_true(self):
@@ -113,6 +156,12 @@ class TestIsServiceContainer:
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
assert is_service_container(_make_container("omega-decky-http")) is False
def test_string_argument(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_container("omega-decky-http") is True
assert is_service_container("/omega-decky-http") is True
assert is_service_container("nginx") is False
class TestIsServiceEvent:
def test_known_service_event_returns_true(self):
@@ -130,3 +179,171 @@ class TestIsServiceEvent:
def test_no_state_returns_false(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
assert is_service_event({"name": "omega-decky-smtp"}) is False
def test_strips_leading_slash(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_event({"name": "/omega-decky-smtp"}) is True
def test_empty_name(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_event({"name": ""}) is False
assert is_service_event({}) is False
class TestLoadServiceContainerNames:
def test_with_valid_state(self, tmp_path, monkeypatch):
import decnet.config
from decnet.config import DeckyConfig, DecnetConfig
state_file = tmp_path / "state.json"
config = DecnetConfig(
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
gateway="192.168.1.1",
deckies=[
DeckyConfig(name="decky-01", ip="192.168.1.10", services=["ssh", "http"],
distro="debian", base_image="debian", hostname="test",
build_base="debian:bookworm-slim"),
],
)
state_file.write_text(json.dumps({
"config": config.model_dump(),
"compose_path": "test.yml",
}))
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
names = _load_service_container_names()
assert names == {"decky-01-ssh", "decky-01-http"}
def test_no_state(self, tmp_path, monkeypatch):
import decnet.config
state_file = tmp_path / "nonexistent.json"
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
names = _load_service_container_names()
assert names == set()
class TestStreamContainer:
def test_streams_rfc5424_lines(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
mock_container = MagicMock()
rfc_line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth [decnet@55555 src_ip="1.2.3.4"] login\n'
mock_container.logs.return_value = [rfc_line.encode("utf-8")]
mock_client = MagicMock()
mock_client.containers.get.return_value = mock_container
with patch("docker.from_env", return_value=mock_client):
_stream_container("test-id", log_path, json_path)
assert log_path.exists()
log_content = log_path.read_text()
assert "decky-01" in log_content
assert json_path.exists()
json_content = json_path.read_text().strip()
parsed = json.loads(json_content)
assert parsed["service"] == "ssh"
def test_handles_non_rfc5424_lines(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
mock_container = MagicMock()
mock_container.logs.return_value = [b"just a plain log line\n"]
mock_client = MagicMock()
mock_client.containers.get.return_value = mock_container
with patch("docker.from_env", return_value=mock_client):
_stream_container("test-id", log_path, json_path)
assert log_path.exists()
assert json_path.read_text() == "" # No JSON written for non-RFC lines
def test_handles_docker_error(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
mock_client = MagicMock()
mock_client.containers.get.side_effect = Exception("Container not found")
with patch("docker.from_env", return_value=mock_client):
_stream_container("bad-id", log_path, json_path)
# Should not raise, just log the error
def test_skips_empty_lines(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
mock_container = MagicMock()
mock_container.logs.return_value = [b"\n\n\n"]
mock_client = MagicMock()
mock_client.containers.get.return_value = mock_container
with patch("docker.from_env", return_value=mock_client):
_stream_container("test-id", log_path, json_path)
assert log_path.read_text() == ""
class TestLogCollectorWorker:
@pytest.mark.asyncio
async def test_worker_initial_discovery(self, tmp_path):
log_file = str(tmp_path / "decnet.log")
mock_container = MagicMock()
mock_container.id = "c1"
mock_container.name = "/s-1"
# Mock labels to satisfy is_service_container
mock_container.labels = {"com.docker.compose.project": "decnet"}
mock_client = MagicMock()
mock_client.containers.list.return_value = [mock_container]
# Make events return an empty generator/iterator immediately
mock_client.events.return_value = iter([])
with patch("docker.from_env", return_value=mock_client), \
patch("decnet.collector.worker.is_service_container", return_value=True):
# Run with a short task timeout because it loops
try:
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
except (asyncio.TimeoutError, StopIteration):
pass
# Should have tried to list and watch events
mock_client.containers.list.assert_called_once()
@pytest.mark.asyncio
async def test_worker_handles_events(self, tmp_path):
log_file = str(tmp_path / "decnet.log")
mock_client = MagicMock()
mock_client.containers.list.return_value = []
event = {
"id": "c2",
"Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}}
}
mock_client.events.return_value = iter([event])
with patch("docker.from_env", return_value=mock_client), \
patch("decnet.collector.worker.is_service_event", return_value=True):
try:
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
except (asyncio.TimeoutError, StopIteration):
pass
mock_client.events.assert_called_once()
@pytest.mark.asyncio
async def test_worker_exception_handling(self, tmp_path):
log_file = str(tmp_path / "decnet.log")
mock_client = MagicMock()
mock_client.containers.list.side_effect = Exception("Docker down")
with patch("docker.from_env", return_value=mock_client):
# Should not raise
await log_collector_worker(log_file)