merge: resolve conflicts between testing and main
Some checks failed
PR Gate / Lint (ruff) (pull_request) Failing after 11s
PR Gate / Test (pytest) (3.11) (pull_request) Failing after 10s
PR Gate / Test (pytest) (3.12) (pull_request) Failing after 10s
PR Gate / SAST (bandit) (pull_request) Successful in 12s
PR Gate / Dependency audit (pip-audit) (pull_request) Failing after 13s

This commit is contained in:
2026-04-12 04:09:17 -04:00
270 changed files with 24132 additions and 2309 deletions

0
tests/api/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,61 @@
import json
import pytest
from hypothesis import given, strategies as st, settings
import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
async def test_change_password(client: httpx.AsyncClient) -> None:
# First login to get token
login_resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
token = login_resp.json()["access_token"]
# Try changing password with wrong old password
resp1 = await client.post(
"/api/v1/auth/change-password",
json={"old_password": "wrong", "new_password": "new_secure_password"},
headers={"Authorization": f"Bearer {token}"}
)
assert resp1.status_code == 401
# Change password successfully
resp2 = await client.post(
"/api/v1/auth/change-password",
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": "new_secure_password"},
headers={"Authorization": f"Bearer {token}"}
)
assert resp2.status_code == 200
# Verify old password no longer works
resp3 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
assert resp3.status_code == 401
# Verify new password works and must_change_password is False
resp4 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": "new_secure_password"})
assert resp4.status_code == 200
assert resp4.json()["must_change_password"] is False
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
old_password=st.text(min_size=0, max_size=2048),
new_password=st.text(min_size=0, max_size=2048)
)
async def test_fuzz_change_password(client: httpx.AsyncClient, old_password: str, new_password: str) -> None:
"""Fuzz the change-password endpoint with random strings."""
# Get valid token first
_login_resp: httpx.Response = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
_token: str = _login_resp.json()["access_token"]
_payload: dict[str, str] = {"old_password": old_password, "new_password": new_password}
try:
_response: httpx.Response = await client.post(
"/api/v1/auth/change-password",
json=_payload,
headers={"Authorization": f"Bearer {_token}"}
)
assert _response.status_code in (200, 401, 422)
except (UnicodeEncodeError, json.JSONDecodeError):
pass

View File

@@ -0,0 +1,50 @@
import json
import pytest
from hypothesis import given, strategies as st, settings
import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
async def test_login_success(client: httpx.AsyncClient) -> None:
response = await client.post(
"/api/v1/auth/login",
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "must_change_password" in data
assert data["must_change_password"] is True
@pytest.mark.anyio
async def test_login_failure(client: httpx.AsyncClient) -> None:
response = await client.post(
"/api/v1/auth/login",
json={"username": DECNET_ADMIN_USER, "password": "wrongpassword"}
)
assert response.status_code == 401
response = await client.post(
"/api/v1/auth/login",
json={"username": "nonexistent", "password": "wrongpassword"}
)
assert response.status_code == 401
@pytest.mark.anyio
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
username=st.text(min_size=0, max_size=2048),
password=st.text(min_size=0, max_size=2048)
)
async def test_fuzz_login(client: httpx.AsyncClient, username: str, password: str) -> None:
"""Fuzz the login endpoint with random strings (including non-ASCII)."""
_payload: dict[str, str] = {"username": username, "password": password}
try:
_response: httpx.Response = await client.post("/api/v1/auth/login", json=_payload)
assert _response.status_code in (200, 401, 422)
except (UnicodeEncodeError, json.JSONDecodeError):
pass

View File

View File

@@ -0,0 +1,42 @@
import pytest
import httpx
from hypothesis import given, settings, strategies as st
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
async def test_add_and_get_bounty(client: httpx.AsyncClient, auth_token: str):
# We can't directly call add_bounty from API yet (it's internal to ingester)
# But we can test the endpoint returns 200 even if empty.
resp = await client.get("/api/v1/bounty", headers={"Authorization": f"Bearer {auth_token}"})
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "data" in data
assert isinstance(data["data"], list)
@pytest.mark.anyio
async def test_bounty_pagination(client: httpx.AsyncClient, auth_token: str):
resp = await client.get("/api/v1/bounty?limit=1&offset=0", headers={"Authorization": f"Bearer {auth_token}"})
assert resp.status_code == 200
assert resp.json()["limit"] == 1
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
limit=st.integers(min_value=-2000, max_value=5000),
offset=st.integers(min_value=-2000, max_value=5000),
bounty_type=st.one_of(st.none(), st.text(max_size=256)),
search=st.one_of(st.none(), st.text(max_size=2048)),
)
async def test_fuzz_bounty_query(client: httpx.AsyncClient, auth_token: str, limit: int, offset: int, bounty_type, search) -> None:
params = {"limit": limit, "offset": offset}
if bounty_type is not None:
params["bounty_type"] = bounty_type
if search is not None:
params["search"] = search
try:
resp = await client.get("/api/v1/bounty", params=params, headers={"Authorization": f"Bearer {auth_token}"})
assert resp.status_code in (200, 422)
except (UnicodeEncodeError,):
pass

132
tests/api/conftest.py Normal file
View File

@@ -0,0 +1,132 @@
import os
import json
import uuid as _uuid
import pytest
from typing import Any, AsyncGenerator
from pathlib import Path
from sqlmodel import SQLModel
import httpx
from hypothesis import HealthCheck
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import os as _os
# Must be set before any decnet import touches decnet.env
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
from decnet.web.api import app
from decnet.web.dependencies import repo
from decnet.web.db.models import User
from decnet.web.auth import get_password_hash
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
import decnet.config
@pytest.fixture(scope="function", autouse=True)
async def setup_db(monkeypatch) -> AsyncGenerator[None, None]:
# StaticPool holds one connection forever — :memory: stays alive for the whole test
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Patch BOTH — session_factory is what all queries actually use
monkeypatch.setattr(repo, "engine", engine)
monkeypatch.setattr(repo, "session_factory", session_factory)
# Create schema
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
# Seed admin user
async with session_factory() as session:
if not (await session.execute(select(User).where(User.username == DECNET_ADMIN_USER))).scalar_one_or_none():
session.add(User(
uuid=str(_uuid.uuid4()),
username=DECNET_ADMIN_USER,
password_hash=get_password_hash(DECNET_ADMIN_PASSWORD),
role="admin",
must_change_password=True,
))
await session.commit()
yield
await engine.dispose()
@pytest.fixture
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_token(client: httpx.AsyncClient) -> str:
resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
return resp.json()["access_token"]
@pytest.fixture(autouse=True)
def patch_state_file(monkeypatch, tmp_path) -> Path:
state_file = tmp_path / "decnet-state.json"
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
return state_file
@pytest.fixture
def mock_state_file(patch_state_file: Path):
_test_state = {
"config": {
"mode": "unihost",
"interface": "eth0",
"subnet": "192.168.1.0/24",
"gateway": "192.168.1.1",
"deckies": [
{
"name": "test-decky-1",
"ip": "192.168.1.10",
"services": ["ssh"],
"distro": "debian",
"base_image": "debian",
"hostname": "test-host-1",
"service_config": {"ssh": {"banner": "SSH-2.0-OpenSSH_8.9"}},
"archetype": "deaddeck",
"nmap_os": "linux",
"build_base": "debian:bookworm-slim",
"mutate_interval": 30,
"last_mutated": 0.0
},
{
"name": "test-decky-2",
"ip": "192.168.1.11",
"services": ["http"],
"distro": "ubuntu",
"base_image": "ubuntu",
"hostname": "test-host-2",
"service_config": {},
"archetype": None,
"nmap_os": "linux",
"build_base": "debian:bookworm-slim",
"mutate_interval": 30,
"last_mutated": 0.0
}
],
"log_target": None,
"log_file": "test.log",
"ipvlan": False,
"mutate_interval": 30
},
"compose_path": "test-compose.yml"
}
patch_state_file.write_text(json.dumps(_test_state))
yield _test_state
# Share fuzz settings across API tests
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI
_FUZZ_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
_FUZZ_SETTINGS: dict[str, Any] = {
"max_examples": _FUZZ_EXAMPLES,
"deadline": None,
"suppress_health_check": [HealthCheck.function_scoped_fixture],
}

View File

View File

@@ -0,0 +1,25 @@
import pytest
import httpx
from hypothesis import given, settings, strategies as st
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient, auth_token: str):
_response = await client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {auth_token}"})
assert _response.status_code == 200
_data = _response.json()
assert len(_data) == 2
assert _data[0]["name"] == "test-decky-1"
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(token=st.text(min_size=0, max_size=4096))
async def test_fuzz_deckies_auth(client: httpx.AsyncClient, token: str) -> None:
"""Fuzz the Authorization header on the deckies endpoint."""
try:
resp = await client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code in (200, 401, 422)
except (UnicodeEncodeError,):
pass

View File

@@ -0,0 +1,41 @@
"""
Tests for the mutate decky API endpoint.
"""
import pytest
import httpx
from unittest.mock import patch
class TestMutateDecky:
@pytest.mark.asyncio
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
resp = await client.post("/api/v1/deckies/decky-01/mutate")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str):
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=True):
resp = await client.post(
"/api/v1/deckies/decky-01/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 200
assert "Successfully mutated" in resp.json()["message"]
@pytest.mark.asyncio
async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str):
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=False):
resp = await client.post(
"/api/v1/deckies/decky-01/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_invalid_decky_name_returns_422(self, client: httpx.AsyncClient, auth_token: str):
resp = await client.post(
"/api/v1/deckies/INVALID NAME!!/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 422

View File

@@ -0,0 +1,89 @@
"""
Tests for the mutate interval API endpoint.
"""
import pytest
import httpx
from unittest.mock import patch
from pathlib import Path
from decnet.config import DeckyConfig, DecnetConfig
def _decky(name: str = "decky-01") -> DeckyConfig:
return DeckyConfig(
name=name, ip="192.168.1.10", services=["ssh"],
distro="debian", base_image="debian", hostname="test-host",
build_base="debian:bookworm-slim", nmap_os="linux",
mutate_interval=30,
)
def _config() -> DecnetConfig:
return DecnetConfig(
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
gateway="192.168.1.1", deckies=[_decky()],
)
class TestMutateInterval:
@pytest.mark.asyncio
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
resp = await client.put(
"/api/v1/deckies/decky-01/mutate-interval",
json={"mutate_interval": 60},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str):
with patch("decnet.web.router.fleet.api_mutate_interval.load_state", return_value=None):
resp = await client.put(
"/api/v1/deckies/decky-01/mutate-interval",
headers={"Authorization": f"Bearer {auth_token}"},
json={"mutate_interval": 60},
)
assert resp.status_code == 500
@pytest.mark.asyncio
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
config = _config()
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
return_value=(config, Path("test.yml"))):
resp = await client.put(
"/api/v1/deckies/nonexistent/mutate-interval",
headers={"Authorization": f"Bearer {auth_token}"},
json={"mutate_interval": 60},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
config = _config()
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
return_value=(config, Path("test.yml"))):
with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save:
resp = await client.put(
"/api/v1/deckies/decky-01/mutate-interval",
headers={"Authorization": f"Bearer {auth_token}"},
json={"mutate_interval": 120},
)
assert resp.status_code == 200
assert resp.json()["message"] == "Mutation interval updated"
mock_save.assert_called_once()
# Verify the interval was actually updated on the decky config
assert config.deckies[0].mutate_interval == 120
@pytest.mark.asyncio
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
config = _config()
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
return_value=(config, Path("test.yml"))):
with patch("decnet.web.router.fleet.api_mutate_interval.save_state"):
resp = await client.put(
"/api/v1/deckies/decky-01/mutate-interval",
headers={"Authorization": f"Bearer {auth_token}"},
json={"mutate_interval": None},
)
assert resp.status_code == 200
assert config.deckies[0].mutate_interval is None

View File

View File

@@ -0,0 +1,43 @@
import pytest
import httpx
from typing import Any, Optional
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings
@pytest.mark.anyio
async def test_get_logs_unauthorized(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/logs")
assert response.status_code == 401
@pytest.mark.anyio
async def test_get_logs_success(client: httpx.AsyncClient, auth_token: str) -> None:
response = await client.get(
"/api/v1/logs",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert "data" in data
assert data["total"] >= 0
assert isinstance(data["data"], list)
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
limit=st.integers(min_value=-2000, max_value=5000),
offset=st.integers(min_value=-2000, max_value=5000),
search=st.one_of(st.none(), st.text(max_size=2048))
)
async def test_fuzz_get_logs(client: httpx.AsyncClient, auth_token: str, limit: int, offset: int, search: Optional[str]) -> None:
_params: dict[str, Any] = {"limit": limit, "offset": offset}
if search is not None:
_params["search"] = search
_response: httpx.Response = await client.get(
"/api/v1/logs",
params=_params,
headers={"Authorization": f"Bearer {auth_token}"}
)
assert _response.status_code in (200, 422)

View File

@@ -0,0 +1,116 @@
"""
Histogram bucketing tests using freezegun.
freeze_time controls Python's datetime.now() so we can compute
explicit bucket timestamps deterministically, then pass them to
add_log and verify SQLite groups them into the right buckets.
"""
import pytest
from datetime import datetime, timedelta
from freezegun import freeze_time
from hypothesis import given, settings, strategies as st
from decnet.web.db.sqlite.repository import SQLiteRepository
from ..conftest import _FUZZ_SETTINGS
@pytest.fixture
def repo(tmp_path):
return SQLiteRepository(db_path=str(tmp_path / "histogram_test.db"))
def _log(decky="d", service="ssh", ip="1.2.3.4", timestamp=None):
return {
"decky": decky,
"service": service,
"event_type": "connect",
"attacker_ip": ip,
"raw_line": "test",
"fields": "{}",
"msg": "",
**({"timestamp": timestamp} if timestamp else {}),
}
@pytest.mark.anyio
async def test_histogram_empty_db(repo):
result = await repo.get_log_histogram()
assert result == []
@pytest.mark.anyio
@freeze_time("2026-04-09 12:00:00")
async def test_histogram_single_bucket(repo):
now = datetime.now()
ts = now.strftime("%Y-%m-%d %H:%M:%S")
for _ in range(5):
await repo.add_log(_log(timestamp=ts))
result = await repo.get_log_histogram(interval_minutes=15)
assert len(result) == 1
assert result[0]["count"] == 5
@pytest.mark.anyio
@freeze_time("2026-04-09 12:00:00")
async def test_histogram_two_buckets(repo):
now = datetime.now()
bucket_a = now.strftime("%Y-%m-%d %H:%M:%S")
bucket_b = (now + timedelta(minutes=20)).strftime("%Y-%m-%d %H:%M:%S")
for _ in range(3):
await repo.add_log(_log(timestamp=bucket_a))
for _ in range(7):
await repo.add_log(_log(timestamp=bucket_b))
result = await repo.get_log_histogram(interval_minutes=15)
assert len(result) == 2
counts = {r["count"] for r in result}
assert counts == {3, 7}
@pytest.mark.anyio
@freeze_time("2026-04-09 12:00:00")
async def test_histogram_respects_start_end_filter(repo):
now = datetime.now()
inside = now.strftime("%Y-%m-%d %H:%M:%S")
outside = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
await repo.add_log(_log(timestamp=inside))
await repo.add_log(_log(timestamp=outside))
start = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
end = (now + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
result = await repo.get_log_histogram(start_time=start, end_time=end, interval_minutes=15)
total = sum(r["count"] for r in result)
assert total == 1
@pytest.mark.anyio
@freeze_time("2026-04-09 12:00:00")
async def test_histogram_search_filter(repo):
now = datetime.now()
ts = now.strftime("%Y-%m-%d %H:%M:%S")
await repo.add_log(_log(decky="ssh-decky", service="ssh", timestamp=ts))
await repo.add_log(_log(decky="ftp-decky", service="ftp", timestamp=ts))
result = await repo.get_log_histogram(search="service:ssh", interval_minutes=15)
total = sum(r["count"] for r in result)
assert total == 1
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
search=st.one_of(st.none(), st.text(max_size=512)),
interval_minutes=st.integers(min_value=1, max_value=10000),
)
async def test_fuzz_histogram(repo, search: str | None, interval_minutes: int) -> None:
"""Fuzz histogram params — must never raise uncaught exceptions."""
try:
await repo.get_log_histogram(search=search, interval_minutes=interval_minutes)
except Exception as exc:
pytest.fail(f"get_log_histogram raised unexpectedly: {exc}")

View File

View File

@@ -0,0 +1,47 @@
import pytest
import httpx
from ..conftest import _FUZZ_SETTINGS
from hypothesis import given, strategies as st, settings
@pytest.mark.anyio
async def test_get_stats_unauthorized(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/stats")
assert response.status_code == 401
@pytest.mark.anyio
async def test_get_stats_success(client: httpx.AsyncClient, auth_token: str) -> None:
response = await client.get(
"/api/v1/stats",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert "total_logs" in data
assert "unique_attackers" in data
assert "active_deckies" in data
@pytest.mark.anyio
async def test_stats_includes_deployed_count(mock_state_file, client: httpx.AsyncClient, auth_token: str):
_response = await client.get("/api/v1/stats", headers={"Authorization": f"Bearer {auth_token}"})
assert _response.status_code == 200
_data = _response.json()
assert "deployed_deckies" in _data
assert _data["deployed_deckies"] == 2
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
token=st.text(min_size=0, max_size=4096)
)
async def test_fuzz_auth_header(client: httpx.AsyncClient, token: str) -> None:
"""Fuzz the Authorization header with full unicode noise."""
try:
_response: httpx.Response = await client.get(
"/api/v1/stats",
headers={"Authorization": f"Bearer {token}"}
)
assert _response.status_code in (401, 422)
except (UnicodeEncodeError, httpx.InvalidURL, httpx.CookieConflict):
# Expected client-side rejection of invalid header characters
pass

View File

@@ -0,0 +1 @@
# Stream test package

View File

@@ -0,0 +1,52 @@
"""
Tests for the SSE stream endpoint (decnet/web/router/stream/api_stream_events.py).
"""
import pytest
import httpx
from unittest.mock import AsyncMock, patch
# ── Stream endpoint tests ─────────────────────────────────────────────────────
class TestStreamEvents:
@pytest.mark.asyncio
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
resp = await client.get("/api/v1/stream")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_stream_sends_initial_stats(self, client: httpx.AsyncClient, auth_token: str):
# We force the generator to exit immediately by making the first awaitable raise
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
# This will hit the 'except Exception' or just exit the generator
resp = await client.get(
"/api/v1/stream",
headers={"Authorization": f"Bearer {auth_token}"},
params={"lastEventId": "0"},
)
# It might return a 200 with an empty/error stream or a 500 depending on how SSE-starlette handles generator failure
# But the important thing is that it FINISHES.
assert resp.status_code in (200, 500)
@pytest.mark.asyncio
async def test_stream_with_query_token(self, client: httpx.AsyncClient, auth_token: str):
# Apply the same crash-fix to avoid hanging
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
resp = await client.get(
"/api/v1/stream",
params={"token": auth_token, "lastEventId": "0"},
)
assert resp.status_code in (200, 500)
@pytest.mark.asyncio
async def test_stream_invalid_token_401(self, client: httpx.AsyncClient):
resp = await client.get(
"/api/v1/stream",
params={"token": "bad-token", "lastEventId": "0"},
)
assert resp.status_code == 401

View File

@@ -0,0 +1,200 @@
"""
Direct async tests for SQLiteRepository.
These exercise the DB layer without going through the HTTP stack,
covering DEBT-006 (zero test coverage on the database layer).
"""
import json
import pytest
from hypothesis import given, settings, strategies as st
from decnet.web.db.sqlite.repository import SQLiteRepository
from .conftest import _FUZZ_SETTINGS
@pytest.fixture
def repo(tmp_path):
return SQLiteRepository(db_path=str(tmp_path / "test.db"))
@pytest.mark.anyio
async def test_add_and_get_log(repo):
await repo.add_log({
"decky": "decky-01",
"service": "ssh",
"event_type": "connect",
"attacker_ip": "10.0.0.1",
"raw_line": "SSH connect from 10.0.0.1",
"fields": json.dumps({"port": 22}),
"msg": "new connection",
})
logs = await repo.get_logs(limit=10, offset=0)
assert len(logs) == 1
assert logs[0]["decky"] == "decky-01"
assert logs[0]["service"] == "ssh"
assert logs[0]["attacker_ip"] == "10.0.0.1"
@pytest.mark.anyio
async def test_get_total_logs(repo):
for i in range(5):
await repo.add_log({
"decky": f"decky-0{i}",
"service": "ssh",
"event_type": "connect",
"attacker_ip": f"10.0.0.{i}",
"raw_line": "test",
"fields": "{}",
"msg": "",
})
total = await repo.get_total_logs()
assert total == 5
@pytest.mark.anyio
async def test_search_filter_by_decky(repo):
await repo.add_log({"decky": "target", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
await repo.add_log({"decky": "other", "service": "ftp", "event_type": "login",
"attacker_ip": "2.2.2.2", "raw_line": "y", "fields": "{}", "msg": ""})
logs = await repo.get_logs(search="decky:target")
assert len(logs) == 1
assert logs[0]["decky"] == "target"
@pytest.mark.anyio
async def test_search_filter_by_service(repo):
await repo.add_log({"decky": "d1", "service": "rdp", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
await repo.add_log({"decky": "d2", "service": "smtp", "event_type": "connect",
"attacker_ip": "1.1.1.2", "raw_line": "y", "fields": "{}", "msg": ""})
logs = await repo.get_logs(search="service:rdp")
assert len(logs) == 1
assert logs[0]["service"] == "rdp"
@pytest.mark.anyio
async def test_search_filter_by_json_field(repo):
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "x",
"fields": json.dumps({"username": "root"}), "msg": ""})
await repo.add_log({"decky": "d2", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.2", "raw_line": "y",
"fields": json.dumps({"username": "admin"}), "msg": ""})
logs = await repo.get_logs(search="username:root")
assert len(logs) == 1
assert json.loads(logs[0]["fields"])["username"] == "root"
@pytest.mark.anyio
async def test_get_logs_after_id(repo):
for i in range(4):
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": f"line {i}",
"fields": "{}", "msg": ""})
max_id = await repo.get_max_log_id()
assert max_id == 4
# Add one more after we captured max_id
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "line 4", "fields": "{}", "msg": ""})
new_logs = await repo.get_logs_after_id(last_id=max_id)
assert len(new_logs) == 1
@pytest.mark.anyio
async def test_full_text_search(repo):
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "supersecretstring",
"fields": "{}", "msg": ""})
await repo.add_log({"decky": "d2", "service": "ftp", "event_type": "login",
"attacker_ip": "2.2.2.2", "raw_line": "nothing special",
"fields": "{}", "msg": ""})
logs = await repo.get_logs(search="supersecretstring")
assert len(logs) == 1
@pytest.mark.anyio
async def test_pagination(repo):
for i in range(10):
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": f"line {i}",
"fields": "{}", "msg": ""})
page1 = await repo.get_logs(limit=4, offset=0)
page2 = await repo.get_logs(limit=4, offset=4)
page3 = await repo.get_logs(limit=4, offset=8)
assert len(page1) == 4
assert len(page2) == 4
assert len(page3) == 2
# No duplicates across pages
ids1 = {r["id"] for r in page1}
ids2 = {r["id"] for r in page2}
assert ids1.isdisjoint(ids2)
@pytest.mark.anyio
async def test_add_and_get_bounty(repo):
await repo.add_bounty({
"decky": "decky-01",
"service": "ssh",
"attacker_ip": "10.0.0.1",
"bounty_type": "credentials",
"payload": {"username": "root", "password": "toor"},
})
bounties = await repo.get_bounties(limit=10, offset=0)
assert len(bounties) == 1
assert bounties[0]["decky"] == "decky-01"
assert bounties[0]["bounty_type"] == "credentials"
@pytest.mark.anyio
async def test_user_lifecycle(repo):
import uuid
uid = str(uuid.uuid4())
await repo.create_user({
"uuid": uid,
"username": "testuser",
"password_hash": "hashed_pw",
"role": "viewer",
"must_change_password": True,
})
user = await repo.get_user_by_username("testuser")
assert user is not None
assert user["role"] == "viewer"
assert user["must_change_password"] == 1
await repo.update_user_password(uid, "new_hashed_pw", must_change_password=False)
updated = await repo.get_user_by_uuid(uid)
assert updated["password_hash"] == "new_hashed_pw"
assert updated["must_change_password"] == 0
@pytest.mark.fuzz
@pytest.mark.anyio
@settings(**_FUZZ_SETTINGS)
@given(
raw_line=st.text(max_size=2048),
fields=st.text(max_size=2048),
attacker_ip=st.text(max_size=128),
)
async def test_fuzz_add_log(repo, raw_line: str, fields: str, attacker_ip: str) -> None:
"""Fuzz add_log with arbitrary strings — must never raise uncaught exceptions."""
try:
await repo.add_log({
"decky": "fuzz-decky",
"service": "ssh",
"event_type": "connect",
"attacker_ip": attacker_ip,
"raw_line": raw_line,
"fields": fields,
"msg": "",
})
except Exception as exc:
pytest.fail(f"add_log raised unexpectedly: {exc}")

View File

@@ -0,0 +1,26 @@
"""
Schemathesis contract tests.
Generates requests from the OpenAPI spec and verifies that no input causes a 5xx.
Currently scoped to `not_a_server_error` only — full response-schema conformance
(including undocumented 401 responses) is blocked by DEBT-020 (missing error
response declarations across all protected endpoints). Once DEBT-020 is resolved,
replace the checks list with the default (remove the argument) for full compliance.
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
"""
import pytest
import schemathesis
from hypothesis import settings
from schemathesis.checks import not_a_server_error
from decnet.web.api import app
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
@pytest.mark.fuzz
@schemathesis.pytest.parametrize(api=schema)
@settings(max_examples=5, deadline=None)
def test_schema_compliance(case):
case.call_and_validate(checks=[not_a_server_error])

11
tests/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Shared pytest configuration.
Env vars required by decnet.env must be set here, at module level, before
any test file imports decnet.* — pytest loads conftest.py first.
"""
import os
os.environ.setdefault("DECNET_JWT_SECRET", "test-jwt-secret-not-for-production-use")
# Expose OpenAPI schema so schemathesis can load it during tests
os.environ.setdefault("DECNET_DEVELOPER", "true")

0
tests/live/__init__.py Normal file
View File

160
tests/live/conftest.py Normal file
View File

@@ -0,0 +1,160 @@
"""
Shared fixtures for live subprocess service tests.
Each fixture starts the real server.py in a subprocess, captures its stdout
(RFC 5424 syslog lines) via a background reader thread, polls the port for
readiness, yields (port, log_drain_fn), then tears down.
"""
import os
import queue
import re
import socket
import subprocess
import sys
import threading
import time
from collections.abc import Generator
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).parent.parent.parent
_TEMPLATES = _REPO_ROOT / "templates"
# Prefer the project venv's Python (has Flask, Twisted, etc.) over system Python
_VENV_PYTHON = _REPO_ROOT / ".venv" / "bin" / "python"
_PYTHON = str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable
# RFC 5424: <PRI>1 TIMESTAMP HOSTNAME APP-NAME - MSGID [SD] MSG?
# Use search (not match) so lines prefixed by Twisted timestamps are handled.
_RFC5424_RE = re.compile(r"<\d+>1 \S+ \S+ \S+ - \S+ ")
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_for_port(port: int, timeout: float = 8.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
socket.create_connection(("127.0.0.1", port), timeout=0.1).close()
return True
except OSError:
time.sleep(0.05)
return False
def _drain(q: queue.Queue, timeout: float = 2.0) -> list[str]:
"""Drain all lines from the log queue within *timeout* seconds."""
lines: list[str] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
lines.append(q.get(timeout=max(0.01, deadline - time.monotonic())))
except queue.Empty:
break
return lines
def assert_rfc5424(
lines: list[str],
*,
service: str | None = None,
event_type: str | None = None,
**fields: str,
) -> str:
"""
Assert that at least one line in *lines* is a valid RFC 5424 log entry
matching the given criteria. Returns the first matching line.
"""
for line in lines:
if not _RFC5424_RE.search(line):
continue
if service and f" {service} " not in line:
continue
if event_type and event_type not in line:
continue
if all(f'{k}="{v}"' in line or f"{k}={v}" in line for k, v in fields.items()):
return line
criteria = {"service": service, "event_type": event_type, **fields}
raise AssertionError(
f"No RFC 5424 line matching {criteria!r} found among {len(lines)} lines:\n"
+ "\n".join(f" {line!r}" for line in lines[:20])
)
class _ServiceProcess:
"""Manages a live service subprocess and its stdout log queue."""
def __init__(self, service: str, port: int):
template_dir = _TEMPLATES / service
env = {
**os.environ,
"NODE_NAME": "test-node",
"PORT": str(port),
"PYTHONPATH": str(template_dir),
"LOG_TARGET": "",
}
self._proc = subprocess.Popen(
[_PYTHON, str(template_dir / "server.py")],
cwd=str(template_dir),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
text=True,
)
self._q: queue.Queue = queue.Queue()
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
def _read_loop(self) -> None:
assert self._proc.stdout is not None
for line in self._proc.stdout:
self._q.put(line.rstrip("\n"))
def drain(self, timeout: float = 2.0) -> list[str]:
return _drain(self._q, timeout)
def stop(self) -> None:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait()
@pytest.fixture
def live_service() -> Generator:
"""
Factory fixture: call live_service(service_name) to start a server.
Usage::
def test_foo(live_service):
port, drain = live_service("redis")
# connect to 127.0.0.1:port ...
lines = drain()
assert_rfc5424(lines, service="redis", event_type="auth")
"""
started: list[_ServiceProcess] = []
def _start(service: str) -> tuple[int, callable]:
port = _free_port()
svc = _ServiceProcess(service, port)
started.append(svc)
if not _wait_for_port(port):
svc.stop()
pytest.fail(f"Service '{service}' did not bind to port {port} within 8s")
# Flush startup noise before the test begins
svc.drain(timeout=0.3)
return port, svc.drain
yield _start
for svc in started:
svc.stop()

View File

@@ -0,0 +1,39 @@
import ftplib
import pytest
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestFTPLive:
def test_banner_received(self, live_service):
port, drain = live_service("ftp")
ftp = ftplib.FTP()
ftp.connect("127.0.0.1", port, timeout=5)
welcome = ftp.getwelcome()
ftp.close()
assert "220" in welcome or "vsFTPd" in welcome or len(welcome) > 0
def test_login_logged(self, live_service):
port, drain = live_service("ftp")
ftp = ftplib.FTP()
ftp.connect("127.0.0.1", port, timeout=5)
try:
ftp.login("admin", "hunter2")
except ftplib.all_errors:
pass
finally:
ftp.close()
lines = drain()
assert_rfc5424(lines, service="ftp")
def test_connect_logged(self, live_service):
port, drain = live_service("ftp")
ftp = ftplib.FTP()
ftp.connect("127.0.0.1", port, timeout=5)
ftp.close()
lines = drain()
# At least one RFC 5424 line from the ftp service
rfc_lines = [line for line in lines if "<" in line and ">1 " in line and "ftp" in line]
assert rfc_lines, "No ftp RFC 5424 lines found. stdout:\n" + "\n".join(lines[:15])

View File

@@ -0,0 +1,41 @@
import pytest
import requests
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestHTTPLive:
def test_get_request_logged(self, live_service):
port, drain = live_service("http")
resp = requests.get(f"http://127.0.0.1:{port}/admin", timeout=5)
assert resp.status_code == 403
lines = drain()
assert_rfc5424(lines, service="http", event_type="request")
def test_server_header_set(self, live_service):
port, drain = live_service("http")
resp = requests.get(f"http://127.0.0.1:{port}/", timeout=5)
assert "Server" in resp.headers
assert resp.headers["Server"] != ""
def test_post_body_logged(self, live_service):
port, drain = live_service("http")
requests.post(
f"http://127.0.0.1:{port}/login",
data={"username": "admin", "password": "secret"},
timeout=5,
)
lines = drain()
# body field present in log line
assert any("body=" in line for line in lines if "request" in line), (
"Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10])
)
def test_method_and_path_in_log(self, live_service):
port, drain = live_service("http")
requests.get(f"http://127.0.0.1:{port}/secret/file.txt", timeout=5)
lines = drain()
matched = assert_rfc5424(lines, service="http", event_type="request")
assert "GET" in matched or 'method="GET"' in matched
assert "/secret/file.txt" in matched or 'path="/secret/file.txt"' in matched

View File

@@ -0,0 +1,80 @@
import imaplib
import pytest
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestIMAPLive:
def test_banner_received(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
welcome = imap.welcome.decode()
imap.logout()
assert "OK" in welcome
def test_connect_logged(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
imap.logout()
lines = drain()
assert_rfc5424(lines, service="imap", event_type="connect")
def test_login_logged(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
try:
imap.login("admin", "wrongpass")
except imaplib.IMAP4.error:
pass
lines = drain()
try:
imap.logout()
except Exception:
pass
lines += drain()
assert_rfc5424(lines, service="imap", event_type="auth")
def test_auth_success_logged(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
imap.login("admin", "admin123") # valid cred from IMAP_USERS default
lines = drain()
imap.logout()
lines += drain()
matched = assert_rfc5424(lines, service="imap", event_type="auth")
assert "success" in matched, f"Expected auth success in log. Got:\n{matched!r}"
def test_auth_fail_logged(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
try:
imap.login("hacker", "crackedpassword")
except imaplib.IMAP4.error:
pass # expected
lines = drain()
try:
imap.logout()
except Exception:
pass
lines += drain()
matched = assert_rfc5424(lines, service="imap", event_type="auth")
assert "failed" in matched, f"Expected auth failure in log. Got:\n{matched!r}"
def test_select_inbox_after_login(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
imap.login("admin", "admin123")
status, data = imap.select("INBOX")
imap.logout()
assert status == "OK", f"SELECT INBOX failed: {data}"
def test_capability_command(self, live_service):
port, drain = live_service("imap")
imap = imaplib.IMAP4("127.0.0.1", port)
status, caps = imap.capability()
imap.logout()
assert status == "OK"
cap_str = b" ".join(caps).decode()
assert "IMAP4rev1" in cap_str

View File

@@ -0,0 +1,70 @@
import pytest
import pymongo
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestMongoDBLive:
def test_connect_succeeds(self, live_service):
port, drain = live_service("mongodb")
client = pymongo.MongoClient(
f"mongodb://127.0.0.1:{port}/",
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
)
# ismaster is handled — should not raise
client.admin.command("ismaster")
client.close()
def test_connect_logged(self, live_service):
port, drain = live_service("mongodb")
client = pymongo.MongoClient(
f"mongodb://127.0.0.1:{port}/",
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
)
try:
client.admin.command("ismaster")
except Exception:
pass
finally:
client.close()
lines = drain()
assert_rfc5424(lines, service="mongodb", event_type="connect")
def test_message_logged(self, live_service):
port, drain = live_service("mongodb")
client = pymongo.MongoClient(
f"mongodb://127.0.0.1:{port}/",
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
)
try:
client.admin.command("ismaster")
except Exception:
pass
finally:
client.close()
lines = drain()
assert_rfc5424(lines, service="mongodb", event_type="message")
def test_list_databases(self, live_service):
port, drain = live_service("mongodb")
client = pymongo.MongoClient(
f"mongodb://127.0.0.1:{port}/",
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
)
try:
# list_database_names triggers OP_MSG
client.list_database_names()
except Exception:
pass
finally:
client.close()
lines = drain()
# At least one message was exchanged
assert any("mongodb" in line for line in lines), (
"Expected at least one mongodb log line"
)

View File

@@ -0,0 +1,63 @@
import time
import pytest
import paho.mqtt.client as mqtt
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestMQTTLive:
def test_connect_accepted(self, live_service):
port, drain = live_service("mqtt")
connected = []
client = mqtt.Client(client_id="test-scanner")
client.on_connect = lambda c, u, f, rc: connected.append(rc)
client.connect("127.0.0.1", port, keepalive=5)
client.loop_start()
deadline = time.monotonic() + 5
while not connected and time.monotonic() < deadline:
time.sleep(0.05)
client.loop_stop()
client.disconnect()
assert connected and connected[0] == 0, f"Expected CONNACK rc=0, got {connected}"
def test_connect_logged(self, live_service):
port, drain = live_service("mqtt")
client = mqtt.Client(client_id="hax0r")
client.connect("127.0.0.1", port, keepalive=5)
client.loop_start()
time.sleep(0.3)
client.loop_stop()
client.disconnect()
lines = drain()
assert_rfc5424(lines, service="mqtt", event_type="auth")
def test_client_id_in_log(self, live_service):
port, drain = live_service("mqtt")
client = mqtt.Client(client_id="evil-scanner-9000")
client.connect("127.0.0.1", port, keepalive=5)
client.loop_start()
time.sleep(0.3)
client.loop_stop()
client.disconnect()
lines = drain()
matched = assert_rfc5424(lines, service="mqtt", event_type="auth")
assert "evil-scanner-9000" in matched, (
f"Expected client_id in log line. Got:\n{matched!r}"
)
def test_subscribe_logged(self, live_service):
port, drain = live_service("mqtt")
subscribed = []
client = mqtt.Client(client_id="sub-test")
client.on_subscribe = lambda c, u, mid, qos: subscribed.append(mid)
client.connect("127.0.0.1", port, keepalive=5)
client.loop_start()
time.sleep(0.2)
client.subscribe("plant/#")
time.sleep(0.3)
client.loop_stop()
client.disconnect()
lines = drain()
assert_rfc5424(lines, service="mqtt", event_type="subscribe")

View File

@@ -0,0 +1,65 @@
import pytest
import pymysql
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestMySQLLive:
def test_handshake_received(self, live_service):
port, drain = live_service("mysql")
# Honeypot sends MySQL greeting then denies auth — OperationalError expected
try:
pymysql.connect(
host="127.0.0.1",
port=port,
user="root",
password="password",
connect_timeout=5,
)
except pymysql.err.OperationalError:
pass # expected: Access denied
def test_auth_logged(self, live_service):
port, drain = live_service("mysql")
try:
pymysql.connect(
host="127.0.0.1",
port=port,
user="admin",
password="hunter2",
connect_timeout=5,
)
except pymysql.err.OperationalError:
pass
lines = drain()
assert_rfc5424(lines, service="mysql", event_type="auth")
def test_username_in_log(self, live_service):
port, drain = live_service("mysql")
try:
pymysql.connect(
host="127.0.0.1",
port=port,
user="dbhacker",
password="letmein",
connect_timeout=5,
)
except pymysql.err.OperationalError:
pass
lines = drain()
matched = assert_rfc5424(lines, service="mysql", event_type="auth")
assert "dbhacker" in matched, (
f"Expected username in log line. Got:\n{matched!r}"
)
def test_connect_logged(self, live_service):
port, drain = live_service("mysql")
try:
pymysql.connect(
host="127.0.0.1", port=port, user="x", password="y", connect_timeout=5
)
except pymysql.err.OperationalError:
pass
lines = drain()
assert_rfc5424(lines, service="mysql", event_type="connect")

View File

@@ -0,0 +1,58 @@
import poplib
import pytest
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestPOP3Live:
def test_banner_received(self, live_service):
port, drain = live_service("pop3")
pop = poplib.POP3("127.0.0.1", port)
welcome = pop.getwelcome().decode()
pop.quit()
assert "+OK" in welcome
def test_connect_logged(self, live_service):
port, drain = live_service("pop3")
pop = poplib.POP3("127.0.0.1", port)
pop.quit()
lines = drain()
assert_rfc5424(lines, service="pop3", event_type="connect")
def test_user_command_logged(self, live_service):
port, drain = live_service("pop3")
pop = poplib.POP3("127.0.0.1", port)
pop.user("admin")
pop.quit()
lines = drain()
assert_rfc5424(lines, service="pop3", event_type="command")
def test_auth_success_logged(self, live_service):
port, drain = live_service("pop3")
pop = poplib.POP3("127.0.0.1", port)
pop.user("admin")
pop.pass_("admin123") # valid cred from IMAP_USERS default
lines = drain()
pop.quit()
lines += drain()
matched = assert_rfc5424(lines, service="pop3", event_type="auth")
assert "success" in matched, f"Expected auth success in log. Got:\n{matched!r}"
def test_auth_fail_logged(self, live_service):
port, drain = live_service("pop3")
pop = poplib.POP3("127.0.0.1", port)
pop.user("root")
try:
pop.pass_("wrongpassword")
except poplib.error_proto:
pass # expected: -ERR Authentication failed
lines = drain()
try:
pop.quit()
except Exception:
pass
lines += drain()
matched = assert_rfc5424(lines, service="pop3", event_type="auth")
assert "failed" in matched, f"Expected auth failure in log. Got:\n{matched!r}"

View File

@@ -0,0 +1,75 @@
import pytest
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestPostgresLive:
def test_handshake_received(self, live_service):
port, drain = live_service("postgres")
import psycopg2
try:
psycopg2.connect(
host="127.0.0.1",
port=port,
user="admin",
password="password",
dbname="production",
connect_timeout=5,
)
except psycopg2.OperationalError:
pass # expected: honeypot rejects auth
def test_startup_logged(self, live_service):
port, drain = live_service("postgres")
import psycopg2
try:
psycopg2.connect(
host="127.0.0.1",
port=port,
user="postgres",
password="secret",
dbname="postgres",
connect_timeout=5,
)
except psycopg2.OperationalError:
pass
lines = drain()
assert_rfc5424(lines, service="postgres", event_type="startup")
def test_username_in_log(self, live_service):
port, drain = live_service("postgres")
import psycopg2
try:
psycopg2.connect(
host="127.0.0.1",
port=port,
user="dbattacker",
password="cracked",
dbname="secrets",
connect_timeout=5,
)
except psycopg2.OperationalError:
pass
lines = drain()
matched = assert_rfc5424(lines, service="postgres", event_type="startup")
assert "dbattacker" in matched, (
f"Expected username in log line. Got:\n{matched!r}"
)
def test_auth_hash_logged(self, live_service):
port, drain = live_service("postgres")
import psycopg2
try:
psycopg2.connect(
host="127.0.0.1",
port=port,
user="root",
password="toor",
dbname="prod",
connect_timeout=5,
)
except psycopg2.OperationalError:
pass
lines = drain()
assert_rfc5424(lines, service="postgres", event_type="auth")

View File

@@ -0,0 +1,44 @@
import pytest
import redis
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestRedisLive:
def test_ping_responds(self, live_service):
port, drain = live_service("redis")
r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5)
assert r.ping() is True
def test_connect_logged(self, live_service):
port, drain = live_service("redis")
r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5)
r.ping()
lines = drain()
assert_rfc5424(lines, service="redis", event_type="connect")
def test_auth_logged(self, live_service):
port, drain = live_service("redis")
r = redis.Redis(
host="127.0.0.1", port=port, password="wrongpassword", socket_timeout=5
)
try:
r.ping()
except Exception:
pass
lines = drain()
assert_rfc5424(lines, service="redis", event_type="auth")
def test_command_logged(self, live_service):
port, drain = live_service("redis")
r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5)
r.execute_command("KEYS", "*")
lines = drain()
assert_rfc5424(lines, service="redis", event_type="command")
def test_keys_returns_bait_data(self, live_service):
port, drain = live_service("redis")
r = redis.Redis(host="127.0.0.1", port=port, socket_timeout=5)
keys = r.keys("*")
assert len(keys) > 0, "Expected bait keys in fake store"

View File

@@ -0,0 +1,39 @@
import smtplib
import pytest
from tests.live.conftest import assert_rfc5424
@pytest.mark.live
class TestSMTPLive:
def test_banner_received(self, live_service):
port, drain = live_service("smtp")
with smtplib.SMTP("127.0.0.1", port, timeout=5) as s:
code, msg = s.ehlo("test.example.com")
assert code == 250
def test_ehlo_logged(self, live_service):
port, drain = live_service("smtp")
with smtplib.SMTP("127.0.0.1", port, timeout=5) as s:
s.ehlo("attacker.example.com")
lines = drain()
assert_rfc5424(lines, service="smtp", event_type="ehlo")
def test_auth_attempt_logged(self, live_service):
port, drain = live_service("smtp")
with smtplib.SMTP("127.0.0.1", port, timeout=5) as s:
s.ehlo("attacker.example.com")
try:
s.login("admin", "password123")
except smtplib.SMTPAuthenticationError:
pass # expected — honeypot rejects auth
lines = drain()
assert_rfc5424(lines, service="smtp", event_type="auth_attempt")
def test_connect_disconnect_logged(self, live_service):
port, drain = live_service("smtp")
with smtplib.SMTP("127.0.0.1", port, timeout=5) as s:
s.ehlo("scanner.example.com")
lines = drain()
assert_rfc5424(lines, service="smtp", event_type="connect")

View File

View File

@@ -0,0 +1,47 @@
"""
Shared helpers for binary-protocol service tests.
"""
import os
import threading
from types import ModuleType
from unittest.mock import MagicMock
import pytest
from hypothesis import HealthCheck
_FUZZ_SETTINGS = dict(
max_examples=int(os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "200")),
deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def run_with_timeout(fn, *args, timeout: float = 2.0) -> None:
"""Run fn(*args) in a daemon thread. pytest.fail if it doesn't return in time."""
exc_box: list[BaseException] = []
def _target():
try:
fn(*args)
except Exception as e:
exc_box.append(e)
t = threading.Thread(target=_target, daemon=True)
t.start()
t.join(timeout)
if t.is_alive():
pytest.fail(f"data_received hung for >{timeout}s — likely infinite loop")
if exc_box:
raise exc_box[0]

View File

@@ -0,0 +1,117 @@
# Nmap 7.92 scan initiated Thu Apr 9 02:09:54 2026 as: nmap -sS -sV -oN service-test.txt -p- 192.168.1.200
Nmap scan report for 192.168.1.200
Host is up (0.0000030s latency).
Not shown: 65510 closed tcp ports (reset)
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd (before 2.0.8) or WU-FTPD
23/tcp open telnet?
25/tcp open smtp Postfix smtpd
80/tcp open http Apache httpd 2.4.54 ((Debian))
110/tcp open pop3
143/tcp open imap
389/tcp open ldap Cisco LDAP server
445/tcp open microsoft-ds
1433/tcp open ms-sql-s?
1883/tcp open mqtt
2121/tcp open ccproxy-ftp?
2375/tcp open docker Docker 24.0.5
3306/tcp open mysql MySQL 5.7.38-log
3389/tcp open ms-wbt-server xrdp
5020/tcp open zenginkyo-1?
5060/tcp open sip (SIP end point; Status: 401 Unauthorized)
5432/tcp open postgresql?
5900/tcp open vnc VNC (protocol 3.8)
6379/tcp open redis?
6443/tcp open sun-sr-https?
8800/tcp open sunwebadmin?
9200/tcp open wap-wsp?
10201/tcp open rsms?
27017/tcp open mongod?
44818/tcp open EtherNetIP-2?
9 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at https://nmap.org/cgi-bin/submit.cgi?new-service :
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port23-TCP:V=7.92%I=7%D=4/9%Time=69D742B9%P=x86_64-redhat-linux-gnu%r(N
SF:ULL,7,"login:\x20")%r(GenericLines,2C,"login:\x20\xff\xfb\x01Password:\
SF:x20\nLogin\x20incorrect\nlogin:\x20")%r(tn3270,16,"login:\x20\xff\xfe\x
SF:18\xff\xfe\x19\xff\xfc\x19\xff\xfe\0\xff\xfc\0")%r(GetRequest,2C,"login
SF::\x20\xff\xfb\x01Password:\x20\nLogin\x20incorrect\nlogin:\x20")%r(HTTP
SF:Options,2C,"login:\x20\xff\xfb\x01Password:\x20\nLogin\x20incorrect\nlo
SF:gin:\x20")%r(RTSPRequest,2C,"login:\x20\xff\xfb\x01Password:\x20\nLogin
SF:\x20incorrect\nlogin:\x20")%r(RPCCheck,7,"login:\x20")%r(DNSVersionBind
SF:ReqTCP,7,"login:\x20")%r(DNSStatusRequestTCP,7,"login:\x20")%r(Help,14,
SF:"login:\x20\xff\xfb\x01Password:\x20")%r(SSLSessionReq,14,"login:\x20\x
SF:ff\xfb\x01Password:\x20")%r(TerminalServerCookie,14,"login:\x20\xff\xfb
SF:\x01Password:\x20")%r(Kerberos,14,"login:\x20\xff\xfb\x01Password:\x20"
SF:)%r(X11Probe,7,"login:\x20")%r(FourOhFourRequest,2C,"login:\x20\xff\xfb
SF:\x01Password:\x20\nLogin\x20incorrect\nlogin:\x20")%r(LPDString,14,"log
SF:in:\x20\xff\xfb\x01Password:\x20")%r(LDAPSearchReq,2C,"login:\x20\xff\x
SF:fb\x01Password:\x20\nLogin\x20incorrect\nlogin:\x20")%r(LDAPBindReq,7,"
SF:login:\x20")%r(SIPOptions,BE,"login:\x20\xff\xfb\x01Password:\x20\nLogi
SF:n\x20incorrect\nlogin:\x20Password:\x20\nLogin\x20incorrect\nlogin:\x20
SF:Password:\x20\nLogin\x20incorrect\nlogin:\x20Password:\x20\nLogin\x20in
SF:correct\nlogin:\x20Password:\x20\nLogin\x20incorrect\nlogin:\x20Passwor
SF:d:\x20")%r(LANDesk-RC,7,"login:\x20")%r(TerminalServer,7,"login:\x20")%
SF:r(NotesRPC,7,"login:\x20")%r(JavaRMI,7,"login:\x20")%r(WMSRequest,7,"lo
SF:gin:\x20")%r(afp,7,"login:\x20")%r(giop,7,"login:\x20");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port110-TCP:V=7.92%I=7%D=4/9%Time=69D742B9%P=x86_64-redhat-linux-gnu%r(
SF:NULL,23,"\+OK\x20omega-decky\x20POP3\x20server\x20ready\r\n")%r(Generic
SF:Lines,4F,"\+OK\x20omega-decky\x20POP3\x20server\x20ready\r\n-ERR\x20Unk
SF:nown\x20command\r\n-ERR\x20Unknown\x20command\r\n")%r(HTTPOptions,4F,"\
SF:+OK\x20omega-decky\x20POP3\x20server\x20ready\r\n-ERR\x20Unknown\x20com
SF:mand\r\n-ERR\x20Unknown\x20command\r\n");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port143-TCP:V=7.92%I=7%D=4/9%Time=69D742B9%P=x86_64-redhat-linux-gnu%r(
SF:NULL,2C,"\*\x20OK\x20\[omega-decky\]\x20IMAP4rev1\x20Service\x20Ready\r
SF:\n")%r(GetRequest,4C,"\*\x20OK\x20\[omega-decky\]\x20IMAP4rev1\x20Servi
SF:ce\x20Ready\r\nGET\x20BAD\x20Command\x20not\x20recognized\r\n")%r(Gener
SF:icLines,2C,"\*\x20OK\x20\[omega-decky\]\x20IMAP4rev1\x20Service\x20Read
SF:y\r\n");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port445-TCP:V=7.92%I=7%D=4/9%Time=69D742BE%P=x86_64-redhat-linux-gnu%r(
SF:SMBProgNeg,51,"\0\0\0M\xffSMBr\0\0\0\0\x80\0\xc0\0\0\0\0\0\0\0\0\0\0\0\
SF:0\0\0@\x06\0\0\x01\0\x11\x07\0\x03\x01\0\x01\0\0\xfa\0\0\0\0\x01\0\0\0\
SF:0\0p\0\0\0\0\0\0\0\0\0\0\0\0\0\x08\x08\0\x11\"3DUfw\x88");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port1433-TCP:V=7.92%I=7%D=4/9%Time=69D742BE%P=x86_64-redhat-linux-gnu%r
SF:(ms-sql-s,29,"\x04\x01\0\+\0\0\x01\0\0\0\x1a\0\x06\x01\0\x20\0\x01\x02\
SF:0!\0\x01\x03\0\"\0\x04\xff\x10\0\x03\xe8\0\0\x02\0\0\0\0\x01");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port2121-TCP:V=7.92%I=7%D=4/9%Time=69D742B9%P=x86_64-redhat-linux-gnu%r
SF:(NULL,17,"200\x20FTP\x20server\x20ready\.\r\n")%r(GenericLines,3A,"200\
SF:x20FTP\x20server\x20ready\.\r\n500\x20Command\x20'\\r\\n'\x20not\x20und
SF:erstood\r\n");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port5060-TCP:V=7.92%I=7%D=4/9%Time=69D742C5%P=x86_64-redhat-linux-gnu%r
SF:(SIPOptions,F1,"SIP/2\.0\x20401\x20Unauthorized\r\nVia:\x20SIP/2\.0/TCP
SF:\x20nm;branch=foo\r\nFrom:\x20<sip:nm@nm>;tag=root\r\nTo:\x20<sip:nm2@n
SF:m2>\r\nCall-ID:\x2050000\r\nCSeq:\x2042\x20OPTIONS\r\nWWW-Authenticate:
SF:\x20Digest\x20realm=\"omega-decky\",\x20nonce=\"decnet0000\",\x20algori
SF:thm=MD5\r\nContent-Length:\x200\r\n\r\n");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port5432-TCP:V=7.92%I=7%D=4/9%Time=69D742C8%P=x86_64-redhat-linux-gnu%r
SF:(SMBProgNeg,D,"R\0\0\0\x0c\0\0\0\x05\xde\xad\xbe\xef")%r(Kerberos,D,"R\
SF:0\0\0\x0c\0\0\0\x05\xde\xad\xbe\xef");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port6379-TCP:V=7.92%I=7%D=4/9%Time=69D742BE%P=x86_64-redhat-linux-gnu%r
SF:(redis-server,9F,"\$151\r\n#\x20Server\nredis_version:7\.0\.12\nredis_m
SF:ode:standalone\nos:Linux\x205\.15\.0\narch_bits:64\ntcp_port:6379\nupti
SF:me_in_seconds:864000\nconnected_clients:1\n#\x20Keyspace\n\r\n")%r(GetR
SF:equest,16,"-ERR\x20unknown\x20command\r\n")%r(HTTPOptions,16,"-ERR\x20u
SF:nknown\x20command\r\n")%r(RTSPRequest,16,"-ERR\x20unknown\x20command\r\
SF:n")%r(Help,16,"-ERR\x20unknown\x20command\r\n")%r(SSLSessionReq,16,"-ER
SF:R\x20unknown\x20command\r\n")%r(TerminalServerCookie,16,"-ERR\x20unknow
SF:n\x20command\r\n")%r(TLSSessionReq,16,"-ERR\x20unknown\x20command\r\n")
SF:%r(Kerberos,16,"-ERR\x20unknown\x20command\r\n")%r(FourOhFourRequest,16
SF:,"-ERR\x20unknown\x20command\r\n")%r(LPDString,16,"-ERR\x20unknown\x20c
SF:ommand\r\n")%r(LDAPSearchReq,2C,"-ERR\x20unknown\x20command\r\n-ERR\x20
SF:unknown\x20command\r\n")%r(SIPOptions,DC,"-ERR\x20unknown\x20command\r\
SF:n-ERR\x20unknown\x20command\r\n-ERR\x20unknown\x20command\r\n-ERR\x20un
SF:known\x20command\r\n-ERR\x20unknown\x20command\r\n-ERR\x20unknown\x20co
SF:mmand\r\n-ERR\x20unknown\x20command\r\n-ERR\x20unknown\x20command\r\n-E
SF:RR\x20unknown\x20command\r\n-ERR\x20unknown\x20command\r\n");
MAC Address: 56:0E:4B:0C:6D:A0 (Unknown)
Service Info: Hosts: Twisted, omega-decky
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu Apr 9 02:12:39 2026 -- 1 IP address (1 host up) scanned in 164.95 seconds

View File

@@ -0,0 +1,328 @@
"""
Tests for templates/imap/server.py
Exercises the full IMAP4rev1 state machine:
NOT_AUTHENTICATED → AUTHENTICATED → SELECTED
Uses asyncio Protocol directly — no network socket needed.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_imap():
"""Import imap server module, injecting a stub decnet_logging."""
env = {
"NODE_NAME": "testhost",
"IMAP_USERS": "admin:admin123,root:toor",
"IMAP_BANNER": "* OK [testhost] Dovecot ready.",
}
for key in list(sys.modules):
if key in ("imap_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location(
"imap_server", "templates/imap/server.py"
)
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
"""Return (protocol, transport, written). Banner already cleared."""
proto = mod.IMAPProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: str) -> None:
proto.data_received(data.encode() + b"\r\n")
def _replies(written: list[bytes]) -> bytes:
return b"".join(written)
def _login(proto, written):
_send(proto, "A0 LOGIN admin admin123")
written.clear()
def _select_inbox(proto, written):
_send(proto, "B0 SELECT INBOX")
written.clear()
@pytest.fixture
def imap_mod():
return _load_imap()
# ── Tests: banner & unauthenticated ──────────────────────────────────────────
def test_imap_banner_on_connect(imap_mod):
proto = imap_mod.IMAPProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
banner = b"".join(written)
assert banner.startswith(b"* OK")
def test_imap_capability_contains_idle_and_literal_plus(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "C1 CAPABILITY")
resp = _replies(written)
assert b"IMAP4rev1" in resp
assert b"IDLE" in resp
assert b"LITERAL+" in resp
assert b"AUTH=PLAIN" in resp
def test_imap_login_success(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "A1 LOGIN admin admin123")
assert b"A1 OK" in _replies(written)
assert proto._state == "AUTHENTICATED"
def test_imap_login_fail(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "A1 LOGIN admin wrongpass")
resp = _replies(written)
assert b"A1 NO" in resp
assert b"AUTHENTICATIONFAILED" in resp
assert proto._state == "NOT_AUTHENTICATED"
def test_imap_bad_creds_connection_stays_open(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_send(proto, "T1 LOGIN admin wrongpass")
transport.close.assert_not_called()
def test_imap_retry_after_bad_credentials_succeeds(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "T1 LOGIN admin wrongpass")
written.clear()
_send(proto, "T2 LOGIN admin admin123")
assert b"T2 OK" in _replies(written)
assert proto._state == "AUTHENTICATED"
def test_imap_select_before_auth_returns_bad(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "A2 SELECT INBOX")
assert b"A2 BAD" in _replies(written)
def test_imap_noop_unauthenticated_returns_ok(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "N1 NOOP")
assert b"N1 OK" in _replies(written)
def test_imap_unknown_command_returns_bad(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "X1 INVALID_COMMAND")
assert b"X1 BAD" in _replies(written)
# ── Tests: authenticated state ────────────────────────────────────────────────
def test_imap_list_returns_four_mailboxes(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, 'L1 LIST "" "*"')
resp = _replies(written)
assert b"INBOX" in resp
assert b"Sent" in resp
assert b"Drafts" in resp
assert b"Archive" in resp
assert b"LIST completed" in resp
def test_imap_lsub_mirrors_list(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, 'L2 LSUB "" "*"')
resp = _replies(written)
assert b"INBOX" in resp
assert b"LSUB completed" in resp
def test_imap_status_inbox_messages(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "S0 STATUS INBOX (MESSAGES)")
resp = _replies(written)
assert b"STATUS INBOX" in resp
assert b"MESSAGES 10" in resp
# ── Tests: SELECTED state ─────────────────────────────────────────────────────
def test_imap_select_inbox_exists_count(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "S1 SELECT INBOX")
resp = _replies(written)
assert b"* 10 EXISTS" in resp
def test_imap_select_inbox_uidnext(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "S1 SELECT INBOX")
resp = _replies(written)
assert b"UIDNEXT 11" in resp
def test_imap_select_inbox_read_write(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "S1 SELECT INBOX")
resp = _replies(written)
assert b"READ-WRITE" in resp
def test_imap_examine_inbox_read_only(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "S2 EXAMINE INBOX")
resp = _replies(written)
assert b"READ-ONLY" in resp
def test_imap_search_all_returns_all_seqs(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "Q1 SEARCH ALL")
resp = _replies(written)
assert b"* SEARCH 1 2 3 4 5 6 7 8 9 10" in resp
def test_imap_fetch_single_body_aws_key(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "F1 FETCH 1 BODY[]")
resp = _replies(written)
assert b"AKIAIOSFODNN7EXAMPLE" in resp
assert b"F1 OK" in resp
def test_imap_fetch_after_select(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "A1 LOGIN admin admin123")
written.clear()
_send(proto, "A2 SELECT INBOX")
written.clear()
_send(proto, "A3 FETCH 1 RFC822")
combined = _replies(written)
assert b"A3 OK" in combined
assert b"AKIAIOSFODNN7EXAMPLE" in combined
def test_imap_fetch_msg5_root_password(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "F2 FETCH 5 BODY[]")
resp = _replies(written)
assert b"r00tM3T00!" in resp
def test_imap_fetch_range_flags_envelope_count(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "F3 FETCH 1:3 (FLAGS ENVELOPE)")
resp = _replies(written)
assert b"* 1 FETCH" in resp
assert b"* 2 FETCH" in resp
assert b"* 3 FETCH" in resp
assert b"FETCH completed" in resp
def test_imap_fetch_star_rfc822size_10_responses(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "F4 FETCH 1:* RFC822.SIZE")
resp = _replies(written).decode(errors="replace")
assert resp.count(" FETCH ") >= 10
assert "F4 OK" in resp
def test_imap_uid_fetch_includes_uid_field(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "U1 UID FETCH 1:10 (FLAGS)")
resp = _replies(written)
assert b"UID 1" in resp
assert b"FETCH completed" in resp
def test_imap_close_returns_to_authenticated(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "C1 CLOSE")
resp = _replies(written)
assert b"CLOSE completed" in resp
assert proto._state == "AUTHENTICATED"
def test_imap_fetch_after_close_returns_bad(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_login(proto, written)
_select_inbox(proto, written)
_send(proto, "C1 CLOSE")
written.clear()
_send(proto, "C2 FETCH 1 FLAGS")
assert b"C2 BAD" in _replies(written)
def test_imap_logout_sends_bye_and_closes(imap_mod):
proto, transport, written = _make_protocol(imap_mod)
_login(proto, written)
_send(proto, "L1 LOGOUT")
resp = _replies(written)
assert b"* BYE" in resp
assert b"LOGOUT completed" in resp
transport.close.assert_called_once()
def test_imap_invalid_command(imap_mod):
proto, _, written = _make_protocol(imap_mod)
_send(proto, "A1 INVALID")
assert b"A1 BAD" in _replies(written)

View File

@@ -0,0 +1,161 @@
"""
Tests for templates/mongodb/server.py
Covers the MongoDB wire-protocol (OP_MSG / OP_QUERY) happy path and regression
tests for the zero-length msg_len infinite-loop bug and oversized msg_len.
"""
import importlib.util
import struct
import sys
from unittest.mock import MagicMock
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_mongodb():
for key in list(sys.modules):
if key in ("mongodb_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mongodb_server", "templates/mongodb/server.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MongoDBProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
return proto, transport, written
def _minimal_bson() -> bytes:
return b"\x05\x00\x00\x00\x00" # empty document
def _op_msg_packet(request_id: int = 1) -> bytes:
"""Build a valid OP_MSG with an empty BSON body."""
flag_bits = struct.pack("<I", 0)
section = b"\x00" + _minimal_bson()
body = flag_bits + section
total = 16 + len(body)
header = struct.pack("<iiii", total, request_id, 0, 2013)
return header + body
def _op_query_packet(request_id: int = 2) -> bytes:
"""Build a minimal OP_QUERY."""
flags = struct.pack("<I", 0)
coll = b"admin.$cmd\x00"
skip = struct.pack("<I", 0)
ret = struct.pack("<I", 1)
query = _minimal_bson()
body = flags + coll + skip + ret + query
total = 16 + len(body)
header = struct.pack("<iiii", total, request_id, 0, 2004)
return header + body
@pytest.fixture
def mongodb_mod():
return _load_mongodb()
# ── Happy path ────────────────────────────────────────────────────────────────
def test_op_msg_returns_response(mongodb_mod):
proto, _, written = _make_protocol(mongodb_mod)
proto.data_received(_op_msg_packet())
assert written, "expected a response to OP_MSG"
def test_op_msg_response_opcode_is_2013(mongodb_mod):
proto, _, written = _make_protocol(mongodb_mod)
proto.data_received(_op_msg_packet())
resp = b"".join(written)
assert len(resp) >= 16
opcode = struct.unpack("<i", resp[12:16])[0]
assert opcode == 2013
def test_op_query_returns_op_reply(mongodb_mod):
proto, _, written = _make_protocol(mongodb_mod)
proto.data_received(_op_query_packet())
resp = b"".join(written)
assert len(resp) >= 16
opcode = struct.unpack("<i", resp[12:16])[0]
assert opcode == 1
def test_partial_header_waits_for_more_data(mongodb_mod):
proto, transport, _ = _make_protocol(mongodb_mod)
proto.data_received(b"\x1a\x00\x00\x00") # only 4 bytes (< 16)
transport.close.assert_not_called()
def test_two_consecutive_messages(mongodb_mod):
proto, _, written = _make_protocol(mongodb_mod)
two = _op_msg_packet(1) + _op_msg_packet(2)
proto.data_received(two)
assert len(written) >= 2
def test_connection_lost_does_not_raise(mongodb_mod):
proto, _, _ = _make_protocol(mongodb_mod)
proto.connection_lost(None)
# ── Regression: malformed msg_len ────────────────────────────────────────────
def test_zero_msg_len_closes(mongodb_mod):
proto, transport, _ = _make_protocol(mongodb_mod)
# msg_len = 0 at bytes [0:4] LE — buffer has 16 bytes so outer while triggers
data = b"\x00\x00\x00\x00" + b"\x00" * 12
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_msg_len_15_closes(mongodb_mod):
proto, transport, _ = _make_protocol(mongodb_mod)
# msg_len = 15 (below 16-byte wire-protocol minimum)
data = struct.pack("<I", 15) + b"\x00" * 12
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_msg_len_over_48mb_closes(mongodb_mod):
proto, transport, _ = _make_protocol(mongodb_mod)
# msg_len = 48MB + 1
big = 48 * 1024 * 1024 + 1
data = struct.pack("<I", big) + b"\x00" * 12
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_msg_len_exactly_48mb_plus1_closes(mongodb_mod):
proto, transport, _ = _make_protocol(mongodb_mod)
# cap is strictly > 48MB, so 48MB+1 must close
data = struct.pack("<I", 48 * 1024 * 1024 + 1) + b"\x00" * 12
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
# ── Fuzz ──────────────────────────────────────────────────────────────────────
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_arbitrary_bytes(data):
mod = _load_mongodb()
proto, _, _ = _make_protocol(mod)
run_with_timeout(proto.data_received, data)

View File

@@ -0,0 +1,195 @@
"""
Tests for templates/mqtt/server.py
Exercises behavior with MQTT_ACCEPT_ALL=1 and customizable topics.
Uses asyncio transport/protocol directly.
"""
import importlib.util
import json
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_mqtt(accept_all: bool = True, custom_topics: str = "", persona: str = "water_plant"):
env = {
"MQTT_ACCEPT_ALL": "1" if accept_all else "0",
"NODE_NAME": "testhost",
"MQTT_PERSONA": persona,
"MQTT_CUSTOM_TOPICS": custom_topics,
}
for key in list(sys.modules):
if key in ("mqtt_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MQTTProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: bytes) -> None:
proto.data_received(data)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def mqtt_mod():
return _load_mqtt()
@pytest.fixture
def mqtt_no_auth_mod():
return _load_mqtt(accept_all=False)
# ── Packet Helpers ────────────────────────────────────────────────────────────
def _connect_packet() -> bytes:
# 0x10, len 14, 00 04 MQTT 04 02 00 3c 00 02 id
return b"\x10\x0e\x00\x04MQTT\x04\x02\x00\x3c\x00\x02id"
def _subscribe_packet(topic: str, pid: int = 1) -> bytes:
topic_bytes = topic.encode()
payload = pid.to_bytes(2, "big") + len(topic_bytes).to_bytes(2, "big") + topic_bytes + b"\x01" # qos 1
return bytes([0x82, len(payload)]) + payload
def _publish_packet(topic: str, payload: str, qos: int = 1, pid: int = 1) -> bytes:
topic_bytes = topic.encode()
payload_bytes = payload.encode()
flags = qos << 1
byte0 = 0x30 | flags
if qos > 0:
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
else:
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
return bytes([byte0, len(packet_payload)]) + packet_payload
def _pingreq_packet() -> bytes:
return b"\xc0\x00"
def _disconnect_packet() -> bytes:
return b"\xe0\x00"
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_connect_accept(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
assert len(written) == 1
assert written[0] == b"\x20\x02\x00\x00"
assert proto._auth is True
def test_connect_reject(mqtt_no_auth_mod):
proto, transport, written = _make_protocol(mqtt_no_auth_mod)
_send(proto, _connect_packet())
assert len(written) == 1
assert written[0] == b"\x20\x02\x00\x05"
assert transport.close.called
def test_pingreq(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _pingreq_packet())
assert written[0] == b"\xd0\x00"
def test_subscribe_wildcard_retained(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _subscribe_packet("plant/#"))
assert len(written) >= 2 # At least SUBACK + some publishes
assert written[0].startswith(b"\x90") # SUBACK
combined = b"".join(written[1:])
# Should contain some water plant topics
assert b"plant/water/tank1/level" in combined
def test_publish_qos1_returns_puback(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _publish_packet("target/topic", "malicious_payload", qos=1, pid=42))
assert len(written) == 1
# PUBACK (0x40), len=2, pid=42
assert written[0] == b"\x40\x02\x00\x2a"
def test_custom_topics():
custom = {"custom/1": "val1", "custom/2": "val2"}
mod = _load_mqtt(custom_topics=json.dumps(custom))
proto, _, written = _make_protocol(mod)
_send(proto, _connect_packet())
written.clear()
_send(proto, _subscribe_packet("custom/1"))
assert len(written) > 1
combined = b"".join(written[1:])
assert b"custom/1" in combined
assert b"val1" in combined
# ── Negative Tests ────────────────────────────────────────────────────────────
def test_subscribe_before_auth_closes(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _subscribe_packet("plant/#"))
assert transport.close.called
def test_publish_before_auth_closes(mqtt_mod):
proto, transport, written = _make_protocol(mqtt_mod)
_send(proto, _publish_packet("test", "test", qos=0))
assert transport.close.called
def test_malformed_connect_len(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, b"\x10\x05\x00\x04MQT")
# buffer handles it
_send(proto, b"\x10\x02\x00\x04")
# No crash
def test_bad_packet_type_closer(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, b"\xf0\x00") # Reserved type 15
assert transport.close.called
def test_invalid_json_config():
mod = _load_mqtt(custom_topics="{invalid: json}")
proto, _, _ = _make_protocol(mod)
assert len(proto._topics) > 0 # fell back to persona
def test_disconnect_packet(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
_send(proto, _connect_packet())
_send(proto, _disconnect_packet())
assert transport.close.called

View File

@@ -0,0 +1,185 @@
"""
Tests for templates/mqtt/server.py — protocol boundary and fuzz cases.
Focuses on the variable-length remaining-length field (MQTT spec: max 4 bytes).
A 5th continuation byte used to cause the server to get stuck waiting for a
payload it could never receive (remaining = hundreds of MB).
"""
import importlib.util
import struct
import sys
from unittest.mock import MagicMock, patch
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_mqtt():
for key in list(sys.modules):
if key in ("mqtt_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mqtt_server", "templates/mqtt/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", {"MQTT_ACCEPT_ALL": "1", "MQTT_PERSONA": "water_plant"}, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MQTTProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
return proto, transport, written
def _connect_packet(client_id: str = "test-client") -> bytes:
"""Build a minimal MQTT CONNECT packet."""
proto_name = b"\x00\x04MQTT"
proto_level = b"\x04" # 3.1.1
flags = b"\x02" # clean session
keepalive = b"\x00\x3c"
cid = client_id.encode()
cid_field = struct.pack(">H", len(cid)) + cid
payload = proto_name + proto_level + flags + keepalive + cid_field
remaining = len(payload)
# single-byte remaining length (works for short payloads)
return bytes([0x10, remaining]) + payload
def _encode_remaining(value: int) -> bytes:
"""Encode a value using MQTT variable-length encoding."""
result = []
while True:
encoded = value % 128
value //= 128
if value > 0:
encoded |= 128
result.append(encoded)
if value == 0:
break
return bytes(result)
@pytest.fixture
def mqtt_mod():
return _load_mqtt()
# ── Happy path ────────────────────────────────────────────────────────────────
def test_connect_returns_connack_accepted(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
proto.data_received(_connect_packet())
resp = b"".join(written)
assert resp[:2] == b"\x20\x02" # CONNACK
assert resp[3:4] == b"\x00" # return code 0 = accepted
def test_connect_sets_auth_flag(mqtt_mod):
proto, _, _ = _make_protocol(mqtt_mod)
proto.data_received(_connect_packet())
assert proto._auth is True
def test_pingreq_returns_pingresp(mqtt_mod):
proto, _, written = _make_protocol(mqtt_mod)
proto.data_received(_connect_packet())
written.clear()
proto.data_received(b"\xc0\x00") # PINGREQ
assert b"\xd0\x00" in b"".join(written)
def test_disconnect_closes_transport(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
proto.data_received(_connect_packet())
transport.reset_mock()
proto.data_received(b"\xe0\x00") # DISCONNECT
transport.close.assert_called()
def test_publish_without_auth_closes(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
# PUBLISH without prior CONNECT
topic = b"\x00\x04test"
payload = b"hello"
remaining = len(topic) + len(payload)
proto.data_received(bytes([0x30, remaining]) + topic + payload)
transport.close.assert_called()
def test_partial_packet_waits_for_more(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
proto.data_received(b"\x10") # just the first byte
transport.close.assert_not_called()
def test_connection_lost_does_not_raise(mqtt_mod):
proto, _, _ = _make_protocol(mqtt_mod)
proto.connection_lost(None)
# ── Regression: overlong remaining-length field ───────────────────────────────
def test_5_continuation_bytes_closes(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
# 5 bytes with continuation bit set, then a final byte
# MQTT spec allows max 4 bytes — this must be rejected
data = bytes([0x30, 0x80, 0x80, 0x80, 0x80, 0x01])
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_6_continuation_bytes_closes(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
data = bytes([0x30]) + bytes([0x80] * 6) + b"\x01"
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_4_continuation_bytes_is_accepted(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
# 4 bytes total for remaining length = max allowed.
# remaining = 0x0FFFFFFF = 268435455 bytes — huge but spec-valid encoding.
# With no data following, it simply returns (incomplete payload) — not closed.
data = bytes([0x30, 0xff, 0xff, 0xff, 0x7f])
run_with_timeout(proto.data_received, data)
transport.close.assert_not_called()
def test_zero_remaining_publish_does_not_close(mqtt_mod):
proto, transport, _ = _make_protocol(mqtt_mod)
proto.data_received(_connect_packet())
transport.reset_mock()
# PUBLISH with remaining=0 is unusual but not a protocol violation
proto.data_received(b"\x30\x00")
transport.close.assert_not_called()
# ── Fuzz ──────────────────────────────────────────────────────────────────────
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_unauthenticated(data):
mod = _load_mqtt()
proto, _, _ = _make_protocol(mod)
run_with_timeout(proto.data_received, data)
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_after_connect(data):
mod = _load_mqtt()
proto, _, _ = _make_protocol(mod)
proto.data_received(_connect_packet())
run_with_timeout(proto.data_received, data)

View File

@@ -0,0 +1,137 @@
"""
Tests for templates/mssql/server.py
Covers the TDS pre-login / login7 happy path and regression tests for the
zero-length pkt_len infinite-loop bug that was fixed (pkt_len < 8 guard).
"""
import importlib.util
import struct
import sys
from unittest.mock import MagicMock
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_mssql():
for key in list(sys.modules):
if key in ("mssql_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mssql_server", "templates/mssql/server.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MSSQLProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
transport.is_closing.return_value = False
proto.connection_made(transport)
return proto, transport, written
def _tds_header(pkt_type: int, pkt_len: int) -> bytes:
"""Build an 8-byte TDS packet header."""
return struct.pack(">BBHBBBB", pkt_type, 0x01, pkt_len, 0x00, 0x00, 0x01, 0x00)
def _prelogin_packet() -> bytes:
header = _tds_header(0x12, 8)
return header
def _login7_packet() -> bytes:
"""Minimal Login7 with 40-byte payload (username at offset 0, length 0)."""
payload = b"\x00" * 40
pkt_len = 8 + len(payload)
header = _tds_header(0x10, pkt_len)
return header + payload
@pytest.fixture
def mssql_mod():
return _load_mssql()
# ── Happy path ────────────────────────────────────────────────────────────────
def test_prelogin_response_is_tds_type4(mssql_mod):
proto, _, written = _make_protocol(mssql_mod)
proto.data_received(_prelogin_packet())
assert written, "expected a pre-login response"
assert written[0][0] == 0x04
def test_prelogin_response_length_matches_header(mssql_mod):
proto, _, written = _make_protocol(mssql_mod)
proto.data_received(_prelogin_packet())
resp = b"".join(written)
declared_len = struct.unpack(">H", resp[2:4])[0]
assert declared_len == len(resp)
def test_login7_auth_logged_and_closes(mssql_mod):
proto, transport, written = _make_protocol(mssql_mod)
proto.data_received(_prelogin_packet())
written.clear()
proto.data_received(_login7_packet())
transport.close.assert_called()
# error packet must be present
assert any(b"\xaa" in chunk for chunk in written)
def test_partial_header_waits_for_more_data(mssql_mod):
proto, transport, _ = _make_protocol(mssql_mod)
proto.data_received(b"\x12\x01")
transport.close.assert_not_called()
def test_connection_lost_does_not_raise(mssql_mod):
proto, _, _ = _make_protocol(mssql_mod)
proto.connection_lost(None)
# ── Regression: zero / small pkt_len ─────────────────────────────────────────
def test_zero_pkt_len_closes(mssql_mod):
proto, transport, _ = _make_protocol(mssql_mod)
# pkt_len = 0x0000 at bytes [2:4]
data = b"\x12\x01\x00\x00\x00\x00\x01\x00"
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_pkt_len_7_closes(mssql_mod):
proto, transport, _ = _make_protocol(mssql_mod)
# pkt_len = 7 (< 8 minimum)
data = _tds_header(0x12, 7) + b"\x00"
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_pkt_len_1_closes(mssql_mod):
proto, transport, _ = _make_protocol(mssql_mod)
data = _tds_header(0x12, 1) + b"\x00" * 7
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
# ── Fuzz ──────────────────────────────────────────────────────────────────────
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_arbitrary_bytes(data):
mod = _load_mssql()
proto, _, _ = _make_protocol(mod)
run_with_timeout(proto.data_received, data)

View File

@@ -0,0 +1,153 @@
"""
Tests for templates/mysql/server.py
Covers the MySQL handshake happy path and regression tests for oversized
length fields that could cause huge buffer allocations.
"""
import importlib.util
import struct
import sys
from unittest.mock import MagicMock
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_mysql():
for key in list(sys.modules):
if key in ("mysql_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("mysql_server", "templates/mysql/server.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.MySQLProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear() # clear the greeting sent on connect
return proto, transport, written
def _make_packet(payload: bytes, seq: int = 1) -> bytes:
length = len(payload)
return struct.pack("<I", length)[:3] + bytes([seq]) + payload
def _login_packet(username: str = "root") -> bytes:
"""Minimal MySQL client login packet."""
caps = struct.pack("<I", 0x000FA685)
max_pkt = struct.pack("<I", 16777216)
charset = b"\x21"
reserved = b"\x00" * 23
uname = username.encode() + b"\x00"
payload = caps + max_pkt + charset + reserved + uname
return _make_packet(payload, seq=1)
@pytest.fixture
def mysql_mod():
return _load_mysql()
# ── Happy path ────────────────────────────────────────────────────────────────
def test_connection_sends_greeting(mysql_mod):
proto = mysql_mod.MySQLProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
greeting = b"".join(written)
assert greeting[4] == 0x0a # protocol v10
assert b"mysql_native_password" in greeting
def test_login_packet_triggers_close(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
proto.data_received(_login_packet())
transport.close.assert_called()
def test_login_packet_returns_access_denied(mysql_mod):
proto, _, written = _make_protocol(mysql_mod)
proto.data_received(_login_packet())
resp = b"".join(written)
assert b"\xff" in resp # error packet marker
def test_login_logs_username():
mod = _load_mysql()
log_mock = sys.modules["decnet_logging"]
proto, _, _ = _make_protocol(mod)
proto.data_received(_login_packet(username="hacker"))
calls_str = str(log_mock.syslog_line.call_args_list)
assert "hacker" in calls_str
def test_empty_payload_packet_does_not_crash(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
proto.data_received(_make_packet(b"", seq=1))
# Empty payload is silently skipped — no crash, no close
transport.close.assert_not_called()
def test_partial_header_waits_for_more(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
proto.data_received(b"\x00\x00\x00") # only 3 bytes — not enough
transport.close.assert_not_called()
def test_connection_lost_does_not_raise(mysql_mod):
proto, _, _ = _make_protocol(mysql_mod)
proto.connection_lost(None)
# ── Regression: oversized length field ───────────────────────────────────────
def test_length_over_1mb_closes(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
# 1MB + 1 in 3-byte LE: 0x100001 → b'\x01\x00\x10'
over_1mb = struct.pack("<I", 1024 * 1024 + 1)[:3]
data = over_1mb + b"\x01" # seq=1
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_max_3byte_length_closes(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
# 0xFFFFFF = 16,777,215 — max representable in 3 bytes, clearly > 1MB cap
data = b"\xff\xff\xff\x01"
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
def test_length_just_over_1mb_closes(mysql_mod):
proto, transport, _ = _make_protocol(mysql_mod)
# 1MB + 1 byte — just over the cap
just_over = struct.pack("<I", 1024 * 1024 + 1)[:3]
data = just_over + b"\x01"
run_with_timeout(proto.data_received, data)
transport.close.assert_called()
# ── Fuzz ──────────────────────────────────────────────────────────────────────
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_arbitrary_bytes(data):
mod = _load_mysql()
proto, _, _ = _make_protocol(mod)
run_with_timeout(proto.data_received, data)

View File

@@ -0,0 +1,286 @@
"""
Tests for templates/pop3/server.py
Exercises the full POP3 state machine:
AUTHORIZATION → TRANSACTION
Uses asyncio Protocol directly — no network socket needed.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_pop3():
env = {
"NODE_NAME": "testhost",
"IMAP_USERS": "admin:admin123,root:toor",
"IMAP_BANNER": "+OK [testhost] Dovecot ready.",
}
for key in list(sys.modules):
if key in ("pop3_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location(
"pop3_server", "templates/pop3/server.py"
)
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
"""Return (protocol, transport, written). Banner already cleared."""
proto = mod.POP3Protocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, data: str) -> None:
proto.data_received(data.encode() + b"\r\n")
def _replies(written: list[bytes]) -> bytes:
return b"".join(written)
def _login(proto, written):
_send(proto, "USER admin")
_send(proto, "PASS admin123")
written.clear()
@pytest.fixture
def pop3_mod():
return _load_pop3()
# ── Tests: banner & unauthenticated ──────────────────────────────────────────
def test_pop3_banner_starts_with_ok(pop3_mod):
proto = pop3_mod.POP3Protocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
banner = b"".join(written)
assert banner.startswith(b"+OK")
def test_pop3_capa_contains_top_uidl_user(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "CAPA")
resp = _replies(written)
assert b"TOP" in resp
assert b"UIDL" in resp
assert b"USER" in resp
def test_pop3_login_success(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "USER admin")
assert b"+OK" in _replies(written)
written.clear()
_send(proto, "PASS admin123")
assert b"+OK Logged in" in _replies(written)
assert proto._state == "TRANSACTION"
def test_pop3_login_fail(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "USER admin")
written.clear()
_send(proto, "PASS wrongpass")
assert b"-ERR" in _replies(written)
assert proto._state == "AUTHORIZATION"
def test_pop3_bad_pass_connection_stays_open(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_send(proto, "USER admin")
_send(proto, "PASS wrongpass")
transport.close.assert_not_called()
def test_pop3_retry_after_bad_pass_succeeds(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "USER admin")
_send(proto, "PASS wrongpass")
written.clear()
_send(proto, "USER admin")
_send(proto, "PASS admin123")
assert b"+OK Logged in" in _replies(written)
def test_pop3_pass_before_user(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "PASS admin123")
assert b"-ERR" in _replies(written)
def test_pop3_stat_before_auth(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "STAT")
assert b"-ERR" in _replies(written)
def test_pop3_retr_before_auth(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "RETR 1")
assert b"-ERR" in _replies(written)
def test_pop3_invalid_command(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "INVALID")
assert b"-ERR" in _replies(written)
# ── Tests: TRANSACTION state ──────────────────────────────────────────────────
def test_pop3_stat_10_messages(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "STAT")
resp = _replies(written).decode()
assert resp.startswith("+OK 10 ")
def test_pop3_list_returns_10_entries(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "LIST")
resp = _replies(written).decode()
assert resp.startswith("+OK 10")
# Count individual message lines: "N size\r\n"
entries = [entry for entry in resp.split("\r\n") if entry and entry[0].isdigit()]
assert len(entries) == 10
def test_pop3_retr_after_auth_msg1(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_send(proto, "USER admin")
_send(proto, "PASS admin123")
written.clear()
_send(proto, "RETR 1")
combined = _replies(written)
assert b"+OK" in combined
assert b"AKIAIOSFODNN7EXAMPLE" in combined
def test_pop3_retr_msg5_root_password(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "RETR 5")
resp = _replies(written)
assert b"+OK" in resp
assert b"r00tM3T00!" in resp
def test_pop3_top_returns_headers_plus_lines(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "TOP 1 3")
resp = _replies(written).decode(errors="replace")
assert resp.startswith("+OK")
# Headers must be present
assert "From:" in resp
assert "Subject:" in resp
# Should NOT contain body content beyond 3 lines — but 3 lines of the
# AWS email body are enough to include the access key
assert ".\r\n" in resp
def test_pop3_top_3_body_lines_count(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
# Message 1 body after blank line:
# "Team,\r\n", "\r\n", "New AWS credentials...\r\n", ...
_send(proto, "TOP 1 3")
resp = _replies(written).decode(errors="replace")
# Strip headers up to blank line
parts = resp.split("\r\n\r\n", 1)
assert len(parts) == 2
body_section = parts[1].rstrip("\r\n.")
body_lines = [part for part in body_section.split("\r\n") if part != "."]
assert len(body_lines) <= 3
def test_pop3_uidl_returns_10_entries(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "UIDL")
resp = _replies(written).decode()
assert resp.startswith("+OK")
entries = [entry for entry in resp.split("\r\n") if entry and entry[0].isdigit()]
assert len(entries) == 10
def test_pop3_uidl_format_msg_n(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "UIDL")
resp = _replies(written).decode()
assert "1 msg-1" in resp
assert "5 msg-5" in resp
def test_pop3_dele_removes_message(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "DELE 3")
resp = _replies(written)
assert b"+OK" in resp
assert 2 in proto._deleted # 0-based
def test_pop3_rset_clears_deletions(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "DELE 1")
_send(proto, "DELE 2")
written.clear()
_send(proto, "RSET")
resp = _replies(written)
assert b"+OK" in resp
assert len(proto._deleted) == 0
def test_pop3_dele_then_stat_decrements_count(pop3_mod):
proto, _, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "DELE 1")
written.clear()
_send(proto, "STAT")
resp = _replies(written).decode()
assert resp.startswith("+OK 9 ")
def test_pop3_quit_closes_connection(pop3_mod):
proto, transport, written = _make_protocol(pop3_mod)
_login(proto, written)
_send(proto, "QUIT")
transport.close.assert_called_once()

View File

@@ -0,0 +1,189 @@
"""
Tests for templates/postgres/server.py
Covers the PostgreSQL startup / MD5-auth handshake happy path and regression
tests for zero/tiny/huge msg_len in both the startup and auth states.
"""
import importlib.util
import struct
import sys
from unittest.mock import MagicMock
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from .conftest import _FUZZ_SETTINGS, make_fake_decnet_logging, run_with_timeout
# ── Helpers ───────────────────────────────────────────────────────────────────
def _load_postgres():
for key in list(sys.modules):
if key in ("postgres_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("postgres_server", "templates/postgres/server.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.PostgresProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
return proto, transport, written
def _startup_msg(user: str = "postgres", database: str = "postgres") -> bytes:
"""Build a valid PostgreSQL startup message."""
params = f"user\x00{user}\x00database\x00{database}\x00\x00".encode()
protocol = struct.pack(">I", 0x00030000)
body = protocol + params
msg_len = struct.pack(">I", 4 + len(body))
return msg_len + body
def _ssl_request() -> bytes:
return struct.pack(">II", 8, 80877103)
def _password_msg(password: str = "wrongpass") -> bytes:
pw = password.encode() + b"\x00"
return b"p" + struct.pack(">I", 4 + len(pw)) + pw
@pytest.fixture
def postgres_mod():
return _load_postgres()
# ── Happy path ────────────────────────────────────────────────────────────────
def test_ssl_request_returns_N(postgres_mod):
proto, _, written = _make_protocol(postgres_mod)
proto.data_received(_ssl_request())
assert b"N" in b"".join(written)
def test_startup_sends_auth_challenge(postgres_mod):
proto, _, written = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
resp = b"".join(written)
# AuthenticationMD5Password = 'R' + len(12) + type(5) + salt(4)
assert resp[0:1] == b"R"
def test_startup_logs_username():
mod = _load_postgres()
log_mock = sys.modules["decnet_logging"]
proto, _, _ = _make_protocol(mod)
proto.data_received(_startup_msg(user="attacker"))
log_mock.syslog_line.assert_called()
calls_str = str(log_mock.syslog_line.call_args_list)
assert "attacker" in calls_str
def test_state_becomes_auth_after_startup(postgres_mod):
proto, _, _ = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
assert proto._state == "auth"
def test_password_triggers_close(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
transport.reset_mock()
proto.data_received(_password_msg())
transport.close.assert_called()
def test_partial_startup_waits(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
proto.data_received(b"\x00\x00\x00") # only 3 bytes — not enough for msg_len
transport.close.assert_not_called()
assert proto._state == "startup"
def test_connection_lost_does_not_raise(postgres_mod):
proto, _, _ = _make_protocol(postgres_mod)
proto.connection_lost(None)
# ── Regression: startup state bad msg_len ────────────────────────────────────
def test_zero_msg_len_startup_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
run_with_timeout(proto.data_received, b"\x00\x00\x00\x00")
transport.close.assert_called()
def test_msg_len_4_startup_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
# msg_len=4 means zero-byte body — too small for startup (needs protocol version)
run_with_timeout(proto.data_received, struct.pack(">I", 4) + b"\x00" * 4)
transport.close.assert_called()
def test_msg_len_7_startup_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
run_with_timeout(proto.data_received, struct.pack(">I", 7) + b"\x00" * 7)
transport.close.assert_called()
def test_huge_msg_len_startup_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
run_with_timeout(proto.data_received, struct.pack(">I", 0x7FFFFFFF) + b"\x00" * 4)
transport.close.assert_called()
# ── Regression: auth state bad msg_len ───────────────────────────────────────
def test_zero_msg_len_auth_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
transport.reset_mock()
# 'p' + msg_len=0
run_with_timeout(proto.data_received, b"p" + struct.pack(">I", 0))
transport.close.assert_called()
def test_msg_len_1_auth_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
transport.reset_mock()
run_with_timeout(proto.data_received, b"p" + struct.pack(">I", 1) + b"\x00" * 5)
transport.close.assert_called()
def test_huge_msg_len_auth_closes(postgres_mod):
proto, transport, _ = _make_protocol(postgres_mod)
proto.data_received(_startup_msg())
transport.reset_mock()
run_with_timeout(proto.data_received, b"p" + struct.pack(">I", 0x7FFFFFFF) + b"\x00" * 5)
transport.close.assert_called()
# ── Fuzz ──────────────────────────────────────────────────────────────────────
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_startup_state(data):
mod = _load_postgres()
proto, _, _ = _make_protocol(mod)
run_with_timeout(proto.data_received, data)
@pytest.mark.fuzz
@given(data=st.binary(min_size=0, max_size=512))
@settings(**_FUZZ_SETTINGS)
def test_fuzz_auth_state(data):
mod = _load_postgres()
proto, _, _ = _make_protocol(mod)
proto.data_received(_startup_msg())
run_with_timeout(proto.data_received, data)

View File

@@ -0,0 +1,104 @@
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_redis():
env = {"NODE_NAME": "testredis"}
for key in list(sys.modules):
if key in ("redis_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("redis_server", "templates/redis/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def redis_mod():
return _load_redis()
def _make_protocol(mod):
proto = mod.RedisProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, *lines: bytes) -> None:
for line in lines:
proto.data_received(line)
def test_auth_accepted(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"AUTH password\r\n")
assert b"".join(written) == b"+OK\r\n"
def test_keys_wildcard(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"*2\r\n$4\r\nKEYS\r\n$1\r\n*\r\n")
response = b"".join(written)
assert response.startswith(b"*10\r\n")
assert b"config:aws_access_key" in response
def test_keys_prefix(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"*2\r\n$4\r\nKEYS\r\n$6\r\nuser:*\r\n")
response = b"".join(written)
assert response.startswith(b"*2\r\n")
assert b"user:admin" in response
def test_get_valid_key(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"*2\r\n$3\r\nGET\r\n$13\r\ncache:api_key\r\n")
response = b"".join(written)
assert response == b"$38\r\nsk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n"
def test_get_invalid_key(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"*2\r\n$3\r\nGET\r\n$7\r\nunknown\r\n")
response = b"".join(written)
assert response == b"$-1\r\n"
def test_scan(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"*1\r\n$4\r\nSCAN\r\n")
response = b"".join(written)
assert response.startswith(b"*2\r\n$1\r\n0\r\n*10\r\n")
def test_type_and_ttl(redis_mod):
proto, _, written = _make_protocol(redis_mod)
_send(proto, b"TYPE somekey\r\n")
assert b"".join(written) == b"+string\r\n"
written.clear()
_send(proto, b"TTL somekey\r\n")
assert b"".join(written) == b":-1\r\n"

View File

@@ -0,0 +1,303 @@
"""
Tests for templates/smtp/server.py
Exercises both modes:
- credential-harvester (SMTP_OPEN_RELAY=0, default)
- open relay (SMTP_OPEN_RELAY=1)
Uses asyncio transport/protocol directly — no network socket needed.
"""
import base64
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
"""Return a stub decnet_logging module that does nothing."""
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_smtp(open_relay: bool):
"""Import smtp server module with desired OPEN_RELAY value.
Injects a stub decnet_logging into sys.modules so the template can import
it without needing the real file on sys.path.
"""
env = {"SMTP_OPEN_RELAY": "1" if open_relay else "0", "NODE_NAME": "testhost"}
for key in list(sys.modules):
if key in ("smtp_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("smtp_server", "templates/smtp/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
"""Return a (protocol, transport, written) triple. Banner is already discarded."""
proto = mod.SMTPProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, *lines: str) -> None:
"""Feed CRLF-terminated lines to the protocol."""
for line in lines:
proto.data_received((line + "\r\n").encode())
def _replies(written: list[bytes]) -> list[str]:
"""Flatten written bytes into a list of non-empty response lines."""
result = []
for chunk in written:
for line in chunk.decode().split("\r\n"):
if line:
result.append(line)
return result
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def relay_mod():
return _load_smtp(open_relay=True)
@pytest.fixture
def harvester_mod():
return _load_smtp(open_relay=False)
# ── Banner ────────────────────────────────────────────────────────────────────
def test_banner_is_220(relay_mod):
proto, transport, written = _make_protocol(relay_mod)
# written was cleared — re-trigger banner check via a fresh instance
proto2 = relay_mod.SMTPProtocol()
t2 = MagicMock()
w2: list[bytes] = []
t2.write.side_effect = w2.append
proto2.connection_made(t2)
banner = b"".join(w2).decode()
assert banner.startswith("220")
assert "ESMTP" in banner
# ── EHLO ──────────────────────────────────────────────────────────────────────
def test_ehlo_returns_250_multiline(relay_mod):
proto, _, written = _make_protocol(relay_mod)
_send(proto, "EHLO attacker.com")
combined = b"".join(written).decode()
assert "250" in combined
assert "AUTH" in combined
assert "PIPELINING" in combined
# ── OPEN RELAY MODE ───────────────────────────────────────────────────────────
class TestOpenRelay:
@staticmethod
def _session(relay_mod, *lines):
proto, _, written = _make_protocol(relay_mod)
_send(proto, *lines)
return _replies(written)
def test_auth_plain_accepted(self, relay_mod):
creds = base64.b64encode(b"\x00admin\x00password").decode()
replies = self._session(relay_mod, f"AUTH PLAIN {creds}")
assert any(r.startswith("235") for r in replies)
def test_auth_login_multistep_accepted(self, relay_mod):
proto, _, written = _make_protocol(relay_mod)
_send(proto, "AUTH LOGIN")
_send(proto, base64.b64encode(b"admin").decode())
_send(proto, base64.b64encode(b"password").decode())
replies = _replies(written)
assert any(r.startswith("235") for r in replies)
def test_rcpt_to_any_domain_accepted(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<spam@evil.com>",
"RCPT TO:<victim@anydomain.com>",
)
assert any(r.startswith("250 2.1.5") for r in replies)
def test_full_relay_flow(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO attacker.com",
"MAIL FROM:<hacker@evil.com>",
"RCPT TO:<admin@target.com>",
"DATA",
"Subject: hello",
"",
"Body line 1",
"Body line 2",
".",
"QUIT",
)
assert any(r.startswith("354") for r in replies), "Expected 354 after DATA"
assert any("queued as" in r for r in replies), "Expected queued-as ID"
assert any(r.startswith("221") for r in replies), "Expected 221 on QUIT"
def test_multi_recipient(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"RCPT TO:<e@f.com>",
"RCPT TO:<g@h.com>",
"DATA",
"Subject: spam",
"",
"hello",
".",
)
assert len([r for r in replies if r.startswith("250 2.1.5")]) == 3
def test_dot_stuffing_stripped(self, relay_mod):
"""Leading dot on a body line must be stripped per RFC 5321."""
proto, _, written = _make_protocol(relay_mod)
_send(proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"..real dot line",
"normal line",
".",
)
replies = _replies(written)
assert any("queued as" in r for r in replies)
def test_data_rejected_without_rcpt(self, relay_mod):
replies = self._session(relay_mod, "EHLO x.com", "MAIL FROM:<a@b.com>", "DATA")
assert any(r.startswith("503") for r in replies)
def test_rset_clears_transaction_state(self, relay_mod):
proto, _, _ = _make_protocol(relay_mod)
_send(proto, "EHLO x.com", "MAIL FROM:<a@b.com>", "RCPT TO:<c@d.com>", "RSET")
assert proto._mail_from == ""
assert proto._rcpt_to == []
assert proto._in_data is False
def test_second_send_after_rset(self, relay_mod):
"""A new transaction started after RSET must complete successfully."""
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"RSET",
"MAIL FROM:<new@b.com>",
"RCPT TO:<new@d.com>",
"DATA",
"body",
".",
)
assert any("queued as" in r for r in replies)
# ── CREDENTIAL HARVESTER MODE ─────────────────────────────────────────────────
class TestCredentialHarvester:
@staticmethod
def _session(harvester_mod, *lines):
proto, _, written = _make_protocol(harvester_mod)
_send(proto, *lines)
return _replies(written)
def test_auth_plain_rejected_535(self, harvester_mod):
creds = base64.b64encode(b"\x00admin\x00password").decode()
replies = self._session(harvester_mod, f"AUTH PLAIN {creds}")
assert any(r.startswith("535") for r in replies)
def test_auth_rejected_connection_stays_open(self, harvester_mod):
"""After 535 the connection must stay alive — old code closed it immediately."""
proto, transport, _ = _make_protocol(harvester_mod)
creds = base64.b64encode(b"\x00admin\x00password").decode()
_send(proto, f"AUTH PLAIN {creds}")
transport.close.assert_not_called()
def test_rcpt_to_denied_554(self, harvester_mod):
replies = self._session(
harvester_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<admin@target.com>",
)
assert any(r.startswith("554") for r in replies)
def test_relay_denied_blocks_data(self, harvester_mod):
"""With all RCPT TO rejected, DATA must return 503."""
replies = self._session(
harvester_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
)
assert any(r.startswith("503") for r in replies)
def test_noop_and_quit(self, harvester_mod):
replies = self._session(harvester_mod, "NOOP", "QUIT")
assert any(r.startswith("250") for r in replies)
assert any(r.startswith("221") for r in replies)
def test_unknown_command_502(self, harvester_mod):
replies = self._session(harvester_mod, "BADCMD foo")
assert any(r.startswith("502") for r in replies)
def test_starttls_declined_454(self, harvester_mod):
replies = self._session(harvester_mod, "STARTTLS")
assert any(r.startswith("454") for r in replies)
# ── Queue ID ──────────────────────────────────────────────────────────────────
def test_rand_msg_id_format(relay_mod):
for _ in range(50):
mid = relay_mod._rand_msg_id()
assert len(mid) == 12
assert mid.isalnum()
# ── AUTH PLAIN decode ─────────────────────────────────────────────────────────
def test_decode_auth_plain_normal(relay_mod):
blob = base64.b64encode(b"\x00alice\x00s3cr3t").decode()
user, pw = relay_mod._decode_auth_plain(blob)
assert user == "alice"
assert pw == "s3cr3t"
def test_decode_auth_plain_garbage_no_raise(relay_mod):
user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
assert isinstance(user, str)
assert isinstance(pw, str)

View File

@@ -0,0 +1,148 @@
"""
Tests for templates/snmp/server.py
Exercises behavior with SNMP_ARCHETYPE modifications.
Uses asyncio DatagramProtocol directly.
"""
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
mod = ModuleType("decnet_logging")
def syslog_line(*args, **kwargs):
print("LOG:", args, kwargs)
return ""
mod.syslog_line = syslog_line
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_snmp(archetype: str = "default"):
env = {
"NODE_NAME": "testhost",
"SNMP_ARCHETYPE": archetype,
}
for key in list(sys.modules):
if key in ("snmp_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("snmp_server", "templates/snmp/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
proto = mod.SNMPProtocol()
transport = MagicMock()
sent: list[tuple] = []
def sendto(data, addr):
sent.append((data, addr))
transport.sendto = sendto
proto.connection_made(transport)
sent.clear()
return proto, transport, sent
def _send(proto, data: bytes, addr=("127.0.0.1", 12345)) -> None:
proto.datagram_received(data, addr)
# ── Packet Helpers ────────────────────────────────────────────────────────────
def _ber_tlv(tag: int, value: bytes) -> bytes:
length = len(value)
if length < 0x80:
return bytes([tag, length]) + value
elif length < 0x100:
return bytes([tag, 0x81, length]) + value
else:
return bytes([tag, 0x82]) + int.to_bytes(length, 2, "big") + value
def _get_request_packet(community: str, request_id: int, oid_enc: bytes) -> bytes:
# Build a simple GetRequest for a single OID
varbind = _ber_tlv(0x30, _ber_tlv(0x06, oid_enc) + _ber_tlv(0x05, b"")) # 0x05 is NULL
varbind_list = _ber_tlv(0x30, varbind)
req_id_tlv = _ber_tlv(0x02, request_id.to_bytes(4, "big"))
err_stat = _ber_tlv(0x02, b"\x00")
err_idx = _ber_tlv(0x02, b"\x00")
pdu = _ber_tlv(0xa0, req_id_tlv + err_stat + err_idx + varbind_list)
ver = _ber_tlv(0x02, b"\x01") # v2c
comm = _ber_tlv(0x04, community.encode())
return _ber_tlv(0x30, ver + comm + pdu)
# 1.3.6.1.2.1.1.1.0 = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
SYS_DESCR_OID_ENC = b"\x2b\x06\x01\x02\x01\x01\x01\x00"
# ── Tests ─────────────────────────────────────────────────────────────────────
@pytest.fixture
def snmp_default():
return _load_snmp()
@pytest.fixture
def snmp_water_plant():
return _load_snmp("water_plant")
def test_sysdescr_default(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
_send(proto, packet)
assert len(sent) == 1
resp, addr = sent[0]
assert addr == ("127.0.0.1", 12345)
# default sysDescr has "Ubuntu SMP" in it
assert b"Ubuntu SMP" in resp
def test_sysdescr_water_plant(snmp_water_plant):
proto, transport, sent = _make_protocol(snmp_water_plant)
packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
_send(proto, packet)
assert len(sent) == 1
resp, _ = sent[0]
assert b"Debian" in resp
# ── Negative Tests ────────────────────────────────────────────────────────────
def test_invalid_asn1_sequence(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
# 0x31 instead of 0x30
_send(proto, b"\x31\x02\x00\x00")
assert len(sent) == 0 # Caught and logged
def test_truncated_packet(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 3, SYS_DESCR_OID_ENC)
_send(proto, packet[:10]) # chop it
assert len(sent) == 0
def test_invalid_pdu_type(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
packet = _get_request_packet("public", 4, SYS_DESCR_OID_ENC).replace(b"\xa0", b"\xa3", 1)
_send(proto, packet)
assert len(sent) == 0
def test_bad_oid_encoding(snmp_default):
proto, transport, sent = _make_protocol(snmp_default)
_send(proto, b"\x30\x84\xff\xff\xff\xff")
assert len(sent) == 0

312
tests/test_archetypes.py Normal file
View File

@@ -0,0 +1,312 @@
"""
Tests for machine archetypes and the amount= expansion feature.
"""
from __future__ import annotations
import textwrap
import tempfile
import os
import pytest
from decnet.archetypes import (
ARCHETYPES,
all_archetypes,
get_archetype,
random_archetype,
)
from decnet.ini_loader import load_ini
from decnet.distros import DISTROS
# ---------------------------------------------------------------------------
# Archetype registry
# ---------------------------------------------------------------------------
def test_all_archetypes_returns_all():
result = all_archetypes()
assert isinstance(result, dict)
assert len(result) == len(ARCHETYPES)
def test_get_archetype_known():
arch = get_archetype("linux-server")
assert arch.slug == "linux-server"
assert "ssh" in arch.services
def test_get_archetype_unknown_raises():
with pytest.raises(ValueError, match="Unknown archetype"):
get_archetype("does-not-exist")
def test_random_archetype_returns_valid():
arch = random_archetype()
assert arch.slug in ARCHETYPES
def test_every_archetype_has_services():
for slug, arch in ARCHETYPES.items():
assert arch.services, f"Archetype '{slug}' has no services"
def test_every_archetype_has_preferred_distros():
for slug, arch in ARCHETYPES.items():
assert arch.preferred_distros, f"Archetype '{slug}' has no preferred_distros"
def test_every_archetype_preferred_distro_is_valid():
valid_slugs = set(DISTROS.keys())
for slug, arch in ARCHETYPES.items():
for d in arch.preferred_distros:
assert d in valid_slugs, (
f"Archetype '{slug}' references unknown distro '{d}'"
)
# ---------------------------------------------------------------------------
# INI loader — archetype= parsing
# ---------------------------------------------------------------------------
def _write_ini(content: str) -> str:
"""Write INI content to a temp file and return the path."""
content = textwrap.dedent(content)
fd, path = tempfile.mkstemp(suffix=".ini")
os.write(fd, content.encode())
os.close(fd)
return path
def test_ini_archetype_parsed():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[my-server]
archetype=linux-server
""")
cfg = load_ini(path)
os.unlink(path)
assert len(cfg.deckies) == 1
assert cfg.deckies[0].archetype == "linux-server"
assert cfg.deckies[0].services is None # not overridden
def test_ini_archetype_with_explicit_services_override():
"""explicit services= must survive alongside archetype="""
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[my-server]
archetype=linux-server
services=ftp,smb
""")
cfg = load_ini(path)
os.unlink(path)
assert cfg.deckies[0].archetype == "linux-server"
assert cfg.deckies[0].services == ["ftp", "smb"]
# ---------------------------------------------------------------------------
# INI loader — amount= expansion
# ---------------------------------------------------------------------------
def test_ini_amount_one_keeps_section_name():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[my-printer]
archetype=printer
amount=1
""")
cfg = load_ini(path)
os.unlink(path)
assert len(cfg.deckies) == 1
assert cfg.deckies[0].name == "my-printer"
def test_ini_amount_expands_deckies():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[corp-ws]
archetype=windows-workstation
amount=5
""")
cfg = load_ini(path)
os.unlink(path)
assert len(cfg.deckies) == 5
for i, d in enumerate(cfg.deckies, start=1):
assert d.name == f"corp-ws-{i:02d}"
assert d.archetype == "windows-workstation"
assert d.ip is None # auto-allocated
def test_ini_amount_with_ip_raises():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[bad-group]
services=ssh
ip=10.0.0.50
amount=3
""")
with pytest.raises(ValueError, match="Cannot combine ip="):
load_ini(path)
os.unlink(path)
def test_ini_amount_invalid_value_raises():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[bad]
services=ssh
amount=potato
""")
with pytest.raises(ValueError, match="must be a positive integer"):
load_ini(path)
os.unlink(path)
def test_ini_amount_zero_raises():
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[bad]
services=ssh
amount=0
""")
with pytest.raises(ValueError, match="must be a positive integer"):
load_ini(path)
os.unlink(path)
def test_ini_amount_multiple_groups():
"""Two groups with different amounts expand independently."""
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[workers]
archetype=linux-server
amount=3
[printers]
archetype=printer
amount=2
""")
cfg = load_ini(path)
os.unlink(path)
assert len(cfg.deckies) == 5
names = [d.name for d in cfg.deckies]
assert names == ["workers-01", "workers-02", "workers-03", "printers-01", "printers-02"]
# ---------------------------------------------------------------------------
# INI loader — per-service subsections propagate to expanded deckies
# ---------------------------------------------------------------------------
def test_ini_subsection_propagates_to_expanded_deckies():
"""[group.ssh] must apply to group-01, group-02, ..."""
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[linux-hosts]
archetype=linux-server
amount=3
[linux-hosts.ssh]
kernel_version=5.15.0-76-generic
""")
cfg = load_ini(path)
os.unlink(path)
assert len(cfg.deckies) == 3
for d in cfg.deckies:
assert "ssh" in d.service_config
assert d.service_config["ssh"]["kernel_version"] == "5.15.0-76-generic"
def test_ini_subsection_direct_match_unaffected():
"""A direct [decky.svc] subsection must still work when amount=1."""
path = _write_ini("""
[general]
net=10.0.0.0/24
gw=10.0.0.1
[web-01]
services=http
[web-01.http]
server_header=Apache/2.4.51
""")
cfg = load_ini(path)
os.unlink(path)
assert cfg.deckies[0].service_config["http"]["server_header"] == "Apache/2.4.51"
# ---------------------------------------------------------------------------
# _build_deckies — archetype applied via CLI path
# ---------------------------------------------------------------------------
def test_build_deckies_archetype_sets_services():
from decnet.fleet import build_deckies as _build_deckies
from decnet.archetypes import get_archetype
arch = get_archetype("mail-server")
result = _build_deckies(
n=2,
ips=["10.0.0.10", "10.0.0.11"],
services_explicit=None,
randomize_services=False,
archetype=arch,
)
assert len(result) == 2
for d in result:
assert set(d.services) == set(arch.services)
assert d.archetype == "mail-server"
def test_build_deckies_archetype_preferred_distros():
from decnet.fleet import build_deckies as _build_deckies
from decnet.archetypes import get_archetype
arch = get_archetype("iot-device") # preferred_distros=["alpine"]
result = _build_deckies(
n=3,
ips=["10.0.0.10", "10.0.0.11", "10.0.0.12"],
services_explicit=None,
randomize_services=False,
archetype=arch,
)
for d in result:
assert d.distro == "alpine"
def test_build_deckies_explicit_services_override_archetype():
from decnet.fleet import build_deckies as _build_deckies
from decnet.archetypes import get_archetype
arch = get_archetype("linux-server")
result = _build_deckies(
n=1,
ips=["10.0.0.10"],
services_explicit=["ftp"],
randomize_services=False,
archetype=arch,
)
assert result[0].services == ["ftp"]
assert result[0].archetype == "linux-server"

39
tests/test_base_repo.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Mock test for BaseRepository to ensure coverage of abstract pass lines.
"""
import pytest
from decnet.web.db.repository import BaseRepository
class DummyRepo(BaseRepository):
async def initialize(self) -> None: await super().initialize()
async def add_log(self, data): await super().add_log(data)
async def get_logs(self, **kw): await super().get_logs(**kw)
async def get_total_logs(self, **kw): await super().get_total_logs(**kw)
async def get_stats_summary(self): await super().get_stats_summary()
async def get_deckies(self): await super().get_deckies()
async def get_user_by_username(self, u): await super().get_user_by_username(u)
async def get_user_by_uuid(self, u): await super().get_user_by_uuid(u)
async def create_user(self, d): await super().create_user(d)
async def update_user_password(self, *a, **kw): await super().update_user_password(*a, **kw)
async def add_bounty(self, d): await super().add_bounty(d)
async def get_bounties(self, **kw): await super().get_bounties(**kw)
async def get_total_bounties(self, **kw): await super().get_total_bounties(**kw)
@pytest.mark.asyncio
async def test_base_repo_coverage():
dr = DummyRepo()
# Call all to hit 'pass' statements
await dr.initialize()
await dr.add_log({})
await dr.get_logs()
await dr.get_total_logs()
await dr.get_stats_summary()
await dr.get_deckies()
await dr.get_user_by_username("a")
await dr.get_user_by_uuid("a")
await dr.create_user({})
await dr.update_user_password("a", "b")
await dr.add_bounty({})
await dr.get_bounties()
await dr.get_total_bounties()

View File

@@ -8,7 +8,13 @@ MODULES = [
"decnet.cli",
"decnet.config",
"decnet.composer",
"decnet.deployer",
"decnet.engine",
"decnet.engine.deployer",
"decnet.collector",
"decnet.collector.worker",
"decnet.mutator",
"decnet.mutator.engine",
"decnet.fleet",
"decnet.network",
"decnet.archetypes",
"decnet.distros",
@@ -51,7 +57,6 @@ MODULES = [
"decnet.services.imap",
"decnet.services.pop3",
"decnet.services.conpot",
"decnet.services.real_ssh",
"decnet.services.registry",
]

358
tests/test_cli.py Normal file
View File

@@ -0,0 +1,358 @@
"""
Tests for decnet/cli.py — CLI commands via Typer's CliRunner.
"""
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from decnet.cli import app
from decnet.config import DeckyConfig, DecnetConfig
runner = CliRunner()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _decky(name: str = "decky-01", ip: str = "192.168.1.10") -> DeckyConfig:
return DeckyConfig(
name=name, ip=ip, services=["ssh"],
distro="debian", base_image="debian", hostname="test-host",
build_base="debian:bookworm-slim", nmap_os="linux",
)
def _config() -> DecnetConfig:
return DecnetConfig(
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
gateway="192.168.1.1", deckies=[_decky()],
)
# ── services command ──────────────────────────────────────────────────────────
class TestServicesCommand:
def test_lists_services(self):
result = runner.invoke(app, ["services"])
assert result.exit_code == 0
assert "ssh" in result.stdout
# ── distros command ───────────────────────────────────────────────────────────
class TestDistrosCommand:
def test_lists_distros(self):
result = runner.invoke(app, ["distros"])
assert result.exit_code == 0
assert "debian" in result.stdout.lower()
# ── archetypes command ────────────────────────────────────────────────────────
class TestArchetypesCommand:
def test_lists_archetypes(self):
result = runner.invoke(app, ["archetypes"])
assert result.exit_code == 0
assert "deaddeck" in result.stdout.lower()
# ── deploy command ────────────────────────────────────────────────────────────
class TestDeployCommand:
@patch("decnet.engine.deploy")
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_dry_run(self, mock_iface, mock_subnet, mock_hip,
mock_ips, mock_deploy):
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--services", "ssh", "--dry-run",
])
assert result.exit_code == 0
mock_deploy.assert_called_once()
def test_deploy_no_interface_found(self):
with patch("decnet.cli.detect_interface", side_effect=ValueError("No interface")):
result = runner.invoke(app, ["deploy", "--deckies", "1"])
assert result.exit_code == 1
def test_deploy_no_subnet_found(self):
with patch("decnet.cli.detect_interface", return_value="eth0"), \
patch("decnet.cli.detect_subnet", side_effect=ValueError("No subnet")):
result = runner.invoke(app, ["deploy", "--deckies", "1", "--services", "ssh"])
assert result.exit_code == 1
def test_deploy_invalid_mode(self):
result = runner.invoke(app, ["deploy", "--mode", "invalid", "--deckies", "1"])
assert result.exit_code == 1
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_no_deckies_no_config(self, mock_iface):
result = runner.invoke(app, ["deploy", "--services", "ssh"])
assert result.exit_code == 1
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_no_services_no_randomize(self, mock_iface):
result = runner.invoke(app, ["deploy", "--deckies", "1"])
assert result.exit_code == 1
@patch("decnet.engine.deploy")
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_with_archetype(self, mock_iface, mock_subnet, mock_hip,
mock_ips, mock_deploy):
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--archetype", "deaddeck", "--dry-run",
])
assert result.exit_code == 0
def test_deploy_invalid_archetype(self):
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--archetype", "nonexistent_arch",
])
assert result.exit_code == 1
@patch("decnet.engine.deploy")
@patch("subprocess.Popen")
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_full_with_api(self, mock_iface, mock_subnet, mock_hip,
mock_ips, mock_popen, mock_deploy):
# Test non-dry-run with API and collector starts
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--services", "ssh", "--api",
])
assert result.exit_code == 0
assert mock_popen.call_count >= 1 # API
@patch("decnet.engine.deploy")
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_with_distro(self, mock_iface, mock_subnet, mock_hip,
mock_ips, mock_deploy):
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--services", "ssh", "--distro", "debian", "--dry-run",
])
assert result.exit_code == 0
def test_deploy_invalid_distro(self):
result = runner.invoke(app, [
"deploy", "--deckies", "1", "--services", "ssh", "--distro", "nonexistent_distro",
])
assert result.exit_code == 1
@patch("decnet.engine.deploy")
@patch("decnet.cli.load_ini")
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.detect_interface", return_value="eth0")
def test_deploy_with_config_file(self, mock_iface, mock_subnet, mock_hip,
mock_load_ini, mock_deploy, tmp_path):
from decnet.ini_loader import IniConfig, DeckySpec
ini_file = tmp_path / "test.ini"
ini_file.touch()
mock_load_ini.return_value = IniConfig(
deckies=[DeckySpec(name="test-1", services=["ssh"], ip="192.168.1.50")],
interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1",
)
result = runner.invoke(app, [
"deploy", "--config", str(ini_file), "--dry-run",
])
assert result.exit_code == 0
def test_deploy_config_file_not_found(self):
result = runner.invoke(app, [
"deploy", "--config", "/nonexistent/config.ini",
])
assert result.exit_code == 1
# ── teardown command ──────────────────────────────────────────────────────────
class TestTeardownCommand:
def test_teardown_no_args(self):
result = runner.invoke(app, ["teardown"])
assert result.exit_code == 1
@patch("decnet.cli._kill_api")
@patch("decnet.engine.teardown")
def test_teardown_all(self, mock_teardown, mock_kill):
result = runner.invoke(app, ["teardown", "--all"])
assert result.exit_code == 0
@patch("decnet.engine.teardown")
def test_teardown_by_id(self, mock_teardown):
result = runner.invoke(app, ["teardown", "--id", "decky-01"])
assert result.exit_code == 0
mock_teardown.assert_called_once_with(decky_id="decky-01")
@patch("decnet.engine.teardown", side_effect=Exception("Teardown failed"))
def test_teardown_error(self, mock_teardown):
result = runner.invoke(app, ["teardown", "--all"])
assert result.exit_code == 1
@patch("decnet.engine.teardown", side_effect=Exception("Specific ID failed"))
def test_teardown_id_error(self, mock_teardown):
result = runner.invoke(app, ["teardown", "--id", "decky-01"])
assert result.exit_code == 1
# ── status command ────────────────────────────────────────────────────────────
class TestStatusCommand:
@patch("decnet.engine.status", return_value=[])
def test_status_empty(self, mock_status):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
@patch("decnet.engine.status", return_value=[{"ID": "1", "Status": "running"}])
def test_status_active(self, mock_status):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
# ── mutate command ────────────────────────────────────────────────────────────
class TestMutateCommand:
@patch("decnet.mutator.mutate_all")
def test_mutate_default(self, mock_mutate_all):
result = runner.invoke(app, ["mutate"])
assert result.exit_code == 0
@patch("decnet.mutator.mutate_all")
def test_mutate_force_all(self, mock_mutate_all):
result = runner.invoke(app, ["mutate", "--all"])
assert result.exit_code == 0
@patch("decnet.mutator.mutate_decky")
def test_mutate_specific_decky(self, mock_mutate):
result = runner.invoke(app, ["mutate", "--decky", "decky-01"])
assert result.exit_code == 0
@patch("decnet.mutator.run_watch_loop")
def test_mutate_watch(self, mock_watch):
result = runner.invoke(app, ["mutate", "--watch"])
assert result.exit_code == 0
@patch("decnet.mutator.mutate_all", side_effect=Exception("Mutate error"))
def test_mutate_error(self, mock_mutate):
result = runner.invoke(app, ["mutate"])
assert result.exit_code == 1
# ── collect command ───────────────────────────────────────────────────────────
class TestCollectCommand:
@patch("asyncio.run")
def test_collect(self, mock_run):
result = runner.invoke(app, ["collect"])
assert result.exit_code == 0
@patch("asyncio.run", side_effect=KeyboardInterrupt)
def test_collect_interrupt(self, mock_run):
result = runner.invoke(app, ["collect"])
assert result.exit_code in (0, 130)
@patch("asyncio.run", side_effect=Exception("Collect error"))
def test_collect_error(self, mock_run):
result = runner.invoke(app, ["collect"])
assert result.exit_code == 1
# ── web command ───────────────────────────────────────────────────────────────
class TestWebCommand:
@patch("pathlib.Path.exists", return_value=False)
def test_web_no_dist(self, mock_exists):
result = runner.invoke(app, ["web"])
assert result.exit_code == 1
assert "Frontend build not found" in result.stdout
@patch("socketserver.TCPServer")
@patch("os.chdir")
@patch("pathlib.Path.exists", return_value=True)
def test_web_success(self, mock_exists, mock_chdir, mock_server):
# We need to simulate a KeyboardInterrupt to stop serve_forever
mock_server.return_value.__enter__.return_value.serve_forever.side_effect = KeyboardInterrupt
result = runner.invoke(app, ["web"])
assert result.exit_code == 0
assert "Serving DECNET Web Dashboard" in result.stdout
# ── correlate command ─────────────────────────────────────────────────────────
class TestCorrelateCommand:
def test_correlate_no_input(self):
with patch("sys.stdin.isatty", return_value=True):
result = runner.invoke(app, ["correlate"])
if result.exit_code != 0:
assert result.exit_code == 1
assert "Provide --log-file" in result.stdout
def test_correlate_with_file(self, tmp_path):
log_file = tmp_path / "test.log"
log_file.write_text(
"<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth "
'[decnet@55555 src_ip="10.0.0.5" username="admin"] login\n'
)
result = runner.invoke(app, ["correlate", "--log-file", str(log_file)])
assert result.exit_code == 0
# ── api command ───────────────────────────────────────────────────────────────
class TestApiCommand:
@patch("subprocess.run", side_effect=KeyboardInterrupt)
def test_api_keyboard_interrupt(self, mock_run):
result = runner.invoke(app, ["api"])
assert result.exit_code == 0
@patch("subprocess.run", side_effect=FileNotFoundError)
def test_api_not_found(self, mock_run):
result = runner.invoke(app, ["api"])
assert result.exit_code == 0
# ── _kill_api ─────────────────────────────────────────────────────────────────
class TestKillApi:
@patch("os.kill")
@patch("psutil.process_iter")
def test_kills_matching_processes(self, mock_iter, mock_kill):
from decnet.cli import _kill_api
mock_uvicorn = MagicMock()
mock_uvicorn.info = {
"pid": 111, "name": "python",
"cmdline": ["python", "-m", "uvicorn", "decnet.web.api:app"],
}
mock_mutate = MagicMock()
mock_mutate.info = {
"pid": 222, "name": "python",
"cmdline": ["python", "decnet.cli", "mutate", "--watch"],
}
mock_iter.return_value = [mock_uvicorn, mock_mutate]
_kill_api()
assert mock_kill.call_count == 2
@patch("psutil.process_iter")
def test_no_matching_processes(self, mock_iter):
from decnet.cli import _kill_api
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": ["bash"]}
mock_iter.return_value = [mock_proc]
_kill_api()
@patch("psutil.process_iter")
def test_handles_empty_cmdline(self, mock_iter):
from decnet.cli import _kill_api
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": None}
mock_iter.return_value = [mock_proc]
_kill_api()

View File

@@ -0,0 +1,80 @@
"""
Tests for the CLI service pool — verifies that --randomize-services draws
from all registered services, not just the original hardcoded 5.
"""
from decnet.fleet import all_service_names as _all_service_names, build_deckies as _build_deckies
from decnet.services.registry import all_services
ORIGINAL_5 = {"ssh", "smb", "rdp", "http", "ftp"}
def test_all_service_names_covers_full_registry():
"""_all_service_names() must return every service in the registry."""
pool = set(_all_service_names())
registry = set(all_services().keys())
assert pool == registry
def test_all_service_names_is_sorted():
names = _all_service_names()
assert names == sorted(names)
def test_all_service_names_includes_at_least_25():
assert len(_all_service_names()) >= 25
def test_all_service_names_includes_all_original_5():
pool = set(_all_service_names())
assert ORIGINAL_5.issubset(pool)
def test_randomize_services_pool_exceeds_original_5():
"""
After enough random draws, at least one service outside the original 5 must appear.
With 25 services and picking 1-3 at a time, 200 draws makes this ~100% certain.
"""
all_drawn: set[str] = set()
for _ in range(200):
deckies = _build_deckies(
n=1,
ips=["10.0.0.10"],
services_explicit=None,
randomize_services=True,
)
all_drawn.update(deckies[0].services)
beyond_original = all_drawn - ORIGINAL_5
assert beyond_original, (
f"After 200 draws only saw the original 5 services. "
f"All drawn: {sorted(all_drawn)}"
)
def test_build_deckies_randomize_services_valid():
"""All randomly chosen services must exist in the registry."""
registry = set(all_services().keys())
for _ in range(50):
deckies = _build_deckies(
n=3,
ips=["10.0.0.10", "10.0.0.11", "10.0.0.12"],
services_explicit=None,
randomize_services=True,
)
for decky in deckies:
unknown = set(decky.services) - registry
assert not unknown, f"Decky {decky.name} got unknown services: {unknown}"
def test_build_deckies_explicit_services_unchanged():
"""Explicit service list must pass through untouched."""
deckies = _build_deckies(
n=2,
ips=["10.0.0.10", "10.0.0.11"],
services_explicit=["ssh", "ftp"],
randomize_services=False,
)
for decky in deckies:
assert decky.services == ["ssh", "ftp"]

348
tests/test_collector.py Normal file
View File

@@ -0,0 +1,348 @@
"""Tests for the host-side Docker log collector."""
import json
import asyncio
import pytest
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
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"}
def _make_container(name="omega-decky-http"):
return SimpleNamespace(name=name)
class TestParseRfc5424:
def _make_line(self, fields_str="", msg=""):
sd = f"[decnet@55555 {fields_str}]" if fields_str else "-"
suffix = f" {msg}" if msg else ""
return f"<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request {sd}{suffix}"
def test_returns_none_for_non_decnet_line(self):
assert parse_rfc5424("not a syslog line") is None
def test_returns_none_for_empty_line(self):
assert parse_rfc5424("") is None
def test_parses_basic_fields(self):
line = self._make_line()
result = parse_rfc5424(line)
assert result is not None
assert result["decky"] == "decky-01"
assert result["service"] == "http"
assert result["event_type"] == "request"
def test_parses_structured_data_fields(self):
line = self._make_line('src_ip="1.2.3.4" method="GET" path="/login"')
result = parse_rfc5424(line)
assert result is not None
assert result["fields"]["src_ip"] == "1.2.3.4"
assert result["fields"]["method"] == "GET"
assert result["fields"]["path"] == "/login"
def test_extracts_attacker_ip_from_src_ip(self):
line = self._make_line('src_ip="10.0.0.5"')
result = parse_rfc5424(line)
assert result["attacker_ip"] == "10.0.0.5"
def test_extracts_attacker_ip_from_src(self):
line = self._make_line('src="10.0.0.5"')
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)
assert result["attacker_ip"] == "Unknown"
def test_parses_msg(self):
line = self._make_line(msg="hello world")
result = parse_rfc5424(line)
assert result["msg"] == "hello world"
def test_nilvalue_sd_with_msg(self):
line = "<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request - some message"
result = parse_rfc5424(line)
assert result is not None
assert result["msg"] == "some message"
assert result["fields"] == {}
def test_raw_line_preserved(self):
line = self._make_line('src_ip="1.2.3.4"')
result = parse_rfc5424(line)
assert result["raw_line"] == line
def test_timestamp_formatted(self):
line = self._make_line()
result = parse_rfc5424(line)
assert result["timestamp"] == "2024-01-15 12:00:00"
def test_unescapes_sd_values(self):
line = self._make_line(r'path="/foo\"bar"')
result = parse_rfc5424(line)
assert result["fields"]["path"] == '/foo"bar'
def test_result_json_serializable(self):
line = self._make_line('src_ip="1.2.3.4" username="admin" password="s3cr3t"')
result = parse_rfc5424(line)
# 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):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_container(_make_container("omega-decky-http")) is True
assert is_service_container(_make_container("omega-decky-smtp")) is True
assert is_service_container(_make_container("relay-decky-ftp")) is True
def test_base_container_returns_false(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_container(_make_container("omega-decky")) is False
def test_unrelated_container_returns_false(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_container(_make_container("nginx")) is False
def test_strips_leading_slash(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_container(_make_container("/omega-decky-http")) is True
assert is_service_container(_make_container("/omega-decky")) is False
def test_no_state_returns_false(self):
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):
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_base_event_returns_false(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_event({"name": "omega-decky"}) is False
def test_unrelated_event_returns_false(self):
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
assert is_service_event({"name": "nginx"}) is False
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)

244
tests/test_composer.py Normal file
View File

@@ -0,0 +1,244 @@
"""
Tests for the composer — verifies BASE_IMAGE injection and distro heterogeneity.
"""
import pytest
from decnet.config import DeckyConfig, DecnetConfig
from decnet.composer import generate_compose
from decnet.distros import all_distros, DISTROS
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
APT_COMPATIBLE = {
"debian:bookworm-slim",
"ubuntu:22.04",
"ubuntu:20.04",
"kalilinux/kali-rolling",
}
BUILD_SERVICES = [
"ssh", "telnet", "http", "rdp", "smb", "ftp", "smtp", "elasticsearch",
"pop3", "imap", "mysql", "mssql", "redis", "mongodb", "postgres",
"ldap", "vnc", "docker_api", "k8s", "sip",
"mqtt", "llmnr", "snmp", "tftp", "conpot"
]
UPSTREAM_SERVICES: list = []
def _make_config(services, distro="debian", base_image=None, build_base=None):
profile = DISTROS[distro]
decky = DeckyConfig(
name="decky-01",
ip="10.0.0.10",
services=services,
distro=distro,
base_image=base_image or profile.image,
build_base=build_base or profile.build_base,
hostname="test-host",
)
return DecnetConfig(
mode="unihost",
interface="eth0",
subnet="10.0.0.0/24",
gateway="10.0.0.1",
deckies=[decky],
)
# ---------------------------------------------------------------------------
# BASE_IMAGE injection — build services
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("svc", BUILD_SERVICES)
def test_build_service_gets_base_image_arg(svc):
"""Every build service must have BASE_IMAGE injected in compose args."""
config = _make_config([svc], distro="debian")
compose = generate_compose(config)
key = f"decky-01-{svc}"
fragment = compose["services"][key]
assert "build" in fragment, f"{svc}: missing 'build' key"
assert "args" in fragment["build"], f"{svc}: build section missing 'args'"
assert "BASE_IMAGE" in fragment["build"]["args"], f"{svc}: BASE_IMAGE not in args"
@pytest.mark.parametrize("distro,expected_build_base", [
("debian", "debian:bookworm-slim"),
("ubuntu22", "ubuntu:22.04"),
("ubuntu20", "ubuntu:20.04"),
("kali", "kalilinux/kali-rolling"),
("rocky9", "debian:bookworm-slim"),
("alpine", "debian:bookworm-slim"),
])
def test_build_service_base_image_matches_distro(distro, expected_build_base):
"""BASE_IMAGE arg must match the distro's build_base."""
config = _make_config(["http"], distro=distro)
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
assert fragment["build"]["args"]["BASE_IMAGE"] == expected_build_base
# ---------------------------------------------------------------------------
# BASE_IMAGE NOT injected for upstream-image services
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("svc", UPSTREAM_SERVICES)
def test_upstream_service_has_no_build_section(svc):
"""Upstream-image services must not receive a build section or BASE_IMAGE."""
config = _make_config([svc])
compose = generate_compose(config)
fragment = compose["services"][f"decky-01-{svc}"]
assert "build" not in fragment
assert "image" in fragment
# ---------------------------------------------------------------------------
# service_config propagation tests
# ---------------------------------------------------------------------------
def test_service_config_http_server_header():
"""service_config for http must inject SERVER_HEADER into compose env."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["http"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"http": {"server_header": "nginx/1.18.0"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
env = compose["services"]["decky-01-http"]["environment"]
assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_service_config_ssh_password():
"""service_config for ssh must inject SSH_ROOT_PASSWORD."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["ssh"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"ssh": {"password": "s3cr3t!"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
env = compose["services"]["decky-01-ssh"]["environment"]
assert env.get("SSH_ROOT_PASSWORD") == "s3cr3t!"
assert not any(k.startswith("COWRIE_") for k in env)
def test_service_config_for_one_service_does_not_affect_another():
"""service_config for http must not bleed into ftp fragment."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["http", "ftp"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"http": {"server_header": "nginx/1.18.0"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
ftp_env = compose["services"]["decky-01-ftp"]["environment"]
assert "SERVER_HEADER" not in ftp_env
def test_no_service_config_produces_no_extra_env():
"""A decky with no service_config must not have new persona env vars."""
config = _make_config(["http", "mysql"])
compose = generate_compose(config)
for svc in ("http", "mysql"):
env = compose["services"][f"decky-01-{svc}"]["environment"]
assert "SERVER_HEADER" not in env
assert "MYSQL_VERSION" not in env
# ---------------------------------------------------------------------------
# Base container uses distro image, not build_base
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("distro", list(DISTROS.keys()))
def test_base_container_uses_full_distro_image(distro):
"""The IP-holder base container must use distro.image, not build_base."""
config = _make_config(["ssh"], distro=distro)
compose = generate_compose(config)
base = compose["services"]["decky-01"]
expected = DISTROS[distro].image
assert base["image"] == expected, (
f"distro={distro}: base container image '{base['image']}' != '{expected}'"
)
# ---------------------------------------------------------------------------
# Distro profile — build_base is always apt-compatible
# ---------------------------------------------------------------------------
def test_all_distros_have_build_base():
for slug, profile in all_distros().items():
assert profile.build_base, f"Distro '{slug}' has empty build_base"
def test_all_distro_build_bases_are_apt_compatible():
for slug, profile in all_distros().items():
assert profile.build_base in APT_COMPATIBLE, (
f"Distro '{slug}' build_base '{profile.build_base}' is not apt-compatible. "
f"Allowed: {APT_COMPATIBLE}"
)
# ---------------------------------------------------------------------------
# Heterogeneity — multiple deckies with different distros get different images
# ---------------------------------------------------------------------------
def test_multiple_deckies_different_build_bases():
"""A multi-decky deployment with ubuntu22 and debian must differ in BASE_IMAGE."""
deckies = [
DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["http"], distro="debian",
base_image="debian:bookworm-slim", build_base="debian:bookworm-slim",
hostname="host-01",
),
DeckyConfig(
name="decky-02", ip="10.0.0.11",
services=["http"], distro="ubuntu22",
base_image="ubuntu:22.04", build_base="ubuntu:22.04",
hostname="host-02",
),
]
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=deckies,
)
compose = generate_compose(config)
base_img_01 = compose["services"]["decky-01-http"]["build"]["args"]["BASE_IMAGE"]
base_img_02 = compose["services"]["decky-02-http"]["build"]["args"]["BASE_IMAGE"]
assert base_img_01 == "debian:bookworm-slim"
assert base_img_02 == "ubuntu:22.04"
assert base_img_01 != base_img_02

143
tests/test_config.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Tests for decnet.config — Pydantic models, save/load/clear state.
Covers the uncovered lines: validators, save_state, load_state, clear_state.
"""
import pytest
import decnet.config as config_module
from decnet.config import (
DeckyConfig,
DecnetConfig,
save_state,
load_state,
clear_state,
)
# ---------------------------------------------------------------------------
# DeckyConfig validator
# ---------------------------------------------------------------------------
class TestDeckyConfig:
def _base(self, **kwargs):
defaults = dict(
name="decky-01", ip="192.168.1.10", services=["ssh"],
distro="debian", base_image="debian", hostname="host-01",
)
defaults.update(kwargs)
return defaults
def test_valid_decky(self):
d = DeckyConfig(**self._base())
assert d.name == "decky-01"
def test_empty_services_raises(self):
with pytest.raises(Exception, match="at least one service"):
DeckyConfig(**self._base(services=[]))
def test_multiple_services_ok(self):
d = DeckyConfig(**self._base(services=["ssh", "smb", "rdp"]))
assert len(d.services) == 3
# ---------------------------------------------------------------------------
# DecnetConfig validator
# ---------------------------------------------------------------------------
class TestDecnetConfig:
def _base_decky(self):
return DeckyConfig(
name="d", ip="10.0.0.2", services=["ssh"],
distro="debian", base_image="debian", hostname="h",
)
def test_valid_config(self):
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[self._base_decky()],
)
assert cfg.mode == "unihost"
def test_log_file_field(self):
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[self._base_decky()],
log_file="/var/log/decnet/decnet.log",
)
assert cfg.log_file == "/var/log/decnet/decnet.log"
def test_log_file_defaults_to_none(self):
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[self._base_decky()],
)
assert cfg.log_file is None
# ---------------------------------------------------------------------------
# save_state / load_state / clear_state
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def patch_state_file(tmp_path, monkeypatch):
monkeypatch.setattr(config_module, "STATE_FILE", tmp_path / "decnet-state.json")
def _sample_config():
return 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"],
distro="debian", base_image="debian", hostname="host-01",
)
],
)
def test_save_and_load_state(tmp_path):
cfg = _sample_config()
compose = tmp_path / "docker-compose.yml"
save_state(cfg, compose)
result = load_state()
assert result is not None
loaded_cfg, loaded_compose = result
assert loaded_cfg.mode == "unihost"
assert loaded_cfg.deckies[0].name == "decky-01"
assert loaded_compose == compose
def test_load_state_returns_none_when_missing(tmp_path, monkeypatch):
monkeypatch.setattr(config_module, "STATE_FILE", tmp_path / "nonexistent.json")
assert load_state() is None
def test_clear_state(tmp_path):
cfg = _sample_config()
save_state(cfg, tmp_path / "compose.yml")
assert config_module.STATE_FILE.exists()
clear_state()
assert not config_module.STATE_FILE.exists()
def test_clear_state_noop_when_missing(tmp_path, monkeypatch):
monkeypatch.setattr(config_module, "STATE_FILE", tmp_path / "nonexistent.json")
clear_state() # should not raise
def test_state_roundtrip_preserves_all_fields(tmp_path):
cfg = _sample_config()
cfg.deckies[0].archetype = "workstation"
cfg.deckies[0].mutate_interval = 45
compose = tmp_path / "compose.yml"
save_state(cfg, compose)
loaded_cfg, _ = load_state()
assert loaded_cfg.deckies[0].archetype == "workstation"
assert loaded_cfg.deckies[0].mutate_interval == 45

View File

@@ -0,0 +1,64 @@
"""
Tests for decnet.custom_service — BYOS (bring-your-own-service) support.
"""
from decnet.custom_service import CustomService
class TestCustomServiceComposeFragment:
def _svc(self, name="my-tool", image="myrepo/mytool:latest",
exec_cmd="", ports=None):
return CustomService(name=name, image=image,
exec_cmd=exec_cmd, ports=ports)
def test_basic_fragment_structure(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01")
assert frag["image"] == "myrepo/mytool:latest"
assert frag["container_name"] == "decky-01-my-tool"
assert frag["restart"] == "unless-stopped"
assert frag["environment"]["NODE_NAME"] == "decky-01"
def test_underscores_in_name_become_dashes(self):
svc = self._svc(name="my_custom_tool")
frag = svc.compose_fragment("decky-01")
assert frag["container_name"] == "decky-01-my-custom-tool"
def test_exec_cmd_is_split_into_list(self):
svc = self._svc(exec_cmd="/usr/bin/server --port 8080")
frag = svc.compose_fragment("decky-01")
assert frag["command"] == ["/usr/bin/server", "--port", "8080"]
def test_empty_exec_cmd_omits_command_key(self):
svc = self._svc(exec_cmd="")
frag = svc.compose_fragment("decky-01")
assert "command" not in frag
def test_log_target_injected_into_environment(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01", log_target="10.0.0.5:5140")
assert frag["environment"]["LOG_TARGET"] == "10.0.0.5:5140"
def test_no_log_target_omits_key(self):
svc = self._svc()
frag = svc.compose_fragment("decky-01", log_target=None)
assert "LOG_TARGET" not in frag["environment"]
def test_service_cfg_is_accepted_without_error(self):
svc = self._svc()
# service_cfg is accepted but not used by CustomService
frag = svc.compose_fragment("decky-01", service_cfg={"key": "val"})
assert frag is not None
def test_ports_stored_on_instance(self):
svc = CustomService("tool", "img", "", ports=[8080, 9090])
assert svc.ports == [8080, 9090]
def test_no_ports_defaults_to_empty_list(self):
svc = CustomService("tool", "img", "")
assert svc.ports == []
class TestCustomServiceDockerfileContext:
def test_returns_none(self):
svc = CustomService("tool", "img", "cmd")
assert svc.dockerfile_context() is None

BIN
tests/test_decnet.db-shm Normal file

Binary file not shown.

BIN
tests/test_decnet.db-wal Normal file

Binary file not shown.

308
tests/test_deployer.py Normal file
View File

@@ -0,0 +1,308 @@
"""
Tests for decnet/engine/deployer.py
Covers _compose, _compose_with_retry, _sync_logging_helper,
deploy (dry-run and mocked), teardown, status, and _print_status.
All Docker and subprocess calls are mocked.
"""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.config import DeckyConfig, DecnetConfig
# ── Helpers ───────────────────────────────────────────────────────────────────
def _decky(name: str = "decky-01", ip: str = "192.168.1.10",
services: list[str] | None = None) -> DeckyConfig:
return DeckyConfig(
name=name, ip=ip, services=services or ["ssh"],
distro="debian", base_image="debian", hostname="test-host",
build_base="debian:bookworm-slim", nmap_os="linux",
)
def _config(deckies: list[DeckyConfig] | None = None, ipvlan: bool = False) -> DecnetConfig:
return DecnetConfig(
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
gateway="192.168.1.1", deckies=deckies or [_decky()],
ipvlan=ipvlan,
)
# ── _compose ──────────────────────────────────────────────────────────────────
class TestCompose:
@patch("decnet.engine.deployer.subprocess.run")
def test_compose_constructs_correct_command(self, mock_run):
from decnet.engine.deployer import _compose
_compose("up", "-d", compose_file=Path("test.yml"))
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[:4] == ["docker", "compose", "-f", "test.yml"]
assert "up" in cmd
assert "-d" in cmd
@patch("decnet.engine.deployer.subprocess.run")
def test_compose_passes_env(self, mock_run):
from decnet.engine.deployer import _compose
_compose("build", env={"DOCKER_BUILDKIT": "1"})
_, kwargs = mock_run.call_args
assert "DOCKER_BUILDKIT" in kwargs["env"]
# ── _compose_with_retry ───────────────────────────────────────────────────────
class TestComposeWithRetry:
@patch("decnet.engine.deployer.subprocess.run")
def test_success_first_try(self, mock_run):
from decnet.engine.deployer import _compose_with_retry
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
_compose_with_retry("up", "-d") # should not raise
@patch("decnet.engine.deployer.time.sleep")
@patch("decnet.engine.deployer.subprocess.run")
def test_transient_failure_retries(self, mock_run, mock_sleep):
from decnet.engine.deployer import _compose_with_retry
fail_result = MagicMock(returncode=1, stdout="", stderr="temporary error")
ok_result = MagicMock(returncode=0, stdout="ok", stderr="")
mock_run.side_effect = [fail_result, ok_result]
_compose_with_retry("up", retries=3)
assert mock_run.call_count == 2
mock_sleep.assert_called_once()
@patch("decnet.engine.deployer.time.sleep")
@patch("decnet.engine.deployer.subprocess.run")
def test_permanent_error_no_retry(self, mock_run, mock_sleep):
from decnet.engine.deployer import _compose_with_retry
fail_result = MagicMock(returncode=1, stdout="", stderr="manifest unknown error")
mock_run.return_value = fail_result
with pytest.raises(subprocess.CalledProcessError):
_compose_with_retry("pull", retries=3)
assert mock_run.call_count == 1
mock_sleep.assert_not_called()
@patch("decnet.engine.deployer.time.sleep")
@patch("decnet.engine.deployer.subprocess.run")
def test_max_retries_exhausted(self, mock_run, mock_sleep):
from decnet.engine.deployer import _compose_with_retry
fail_result = MagicMock(returncode=1, stdout="", stderr="connection refused")
mock_run.return_value = fail_result
with pytest.raises(subprocess.CalledProcessError):
_compose_with_retry("up", retries=2)
assert mock_run.call_count == 2
@patch("decnet.engine.deployer.subprocess.run")
def test_stdout_printed_on_success(self, mock_run, capsys):
from decnet.engine.deployer import _compose_with_retry
mock_run.return_value = MagicMock(returncode=0, stdout="done\n", stderr="")
_compose_with_retry("build")
captured = capsys.readouterr()
assert "done" in captured.out
# ── _sync_logging_helper ─────────────────────────────────────────────────────
class TestSyncLoggingHelper:
@patch("decnet.engine.deployer.shutil.copy2")
@patch("decnet.engine.deployer._CANONICAL_LOGGING")
def test_copies_when_file_differs(self, mock_canonical, mock_copy):
from decnet.engine.deployer import _sync_logging_helper
mock_svc = MagicMock()
mock_svc.dockerfile_context.return_value = Path("/tmp/test_ctx")
mock_canonical.__truediv__ = Path.__truediv__
with patch("decnet.services.registry.get_service", return_value=mock_svc):
with patch("pathlib.Path.exists", return_value=False):
config = _config()
_sync_logging_helper(config)
# ── deploy ────────────────────────────────────────────────────────────────────
class TestDeploy:
@patch("decnet.engine.deployer._print_status")
@patch("decnet.engine.deployer._compose_with_retry")
@patch("decnet.engine.deployer.save_state")
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
@patch("decnet.engine.deployer._sync_logging_helper")
@patch("decnet.engine.deployer.setup_host_macvlan")
@patch("decnet.engine.deployer.create_macvlan_network")
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
def test_dry_run_no_containers(self, mock_docker, mock_range, mock_hip,
mock_create, mock_setup, mock_sync,
mock_compose, mock_save, mock_retry, mock_print):
from decnet.engine.deployer import deploy
config = _config()
deploy(config, dry_run=True)
mock_create.assert_not_called()
mock_retry.assert_not_called()
mock_save.assert_not_called()
@patch("decnet.engine.deployer._print_status")
@patch("decnet.engine.deployer._compose_with_retry")
@patch("decnet.engine.deployer.save_state")
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
@patch("decnet.engine.deployer._sync_logging_helper")
@patch("decnet.engine.deployer.setup_host_macvlan")
@patch("decnet.engine.deployer.create_macvlan_network")
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
def test_macvlan_deploy(self, mock_docker, mock_range, mock_hip,
mock_create, mock_setup, mock_sync,
mock_compose, mock_save, mock_retry, mock_print):
from decnet.engine.deployer import deploy
config = _config(ipvlan=False)
deploy(config)
mock_create.assert_called_once()
mock_setup.assert_called_once()
mock_save.assert_called_once()
mock_retry.assert_called()
@patch("decnet.engine.deployer._print_status")
@patch("decnet.engine.deployer._compose_with_retry")
@patch("decnet.engine.deployer.save_state")
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
@patch("decnet.engine.deployer._sync_logging_helper")
@patch("decnet.engine.deployer.setup_host_ipvlan")
@patch("decnet.engine.deployer.create_ipvlan_network")
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
def test_ipvlan_deploy(self, mock_docker, mock_range, mock_hip,
mock_create, mock_setup, mock_sync,
mock_compose, mock_save, mock_retry, mock_print):
from decnet.engine.deployer import deploy
config = _config(ipvlan=True)
deploy(config)
mock_create.assert_called_once()
mock_setup.assert_called_once()
@patch("decnet.engine.deployer._print_status")
@patch("decnet.engine.deployer._compose_with_retry")
@patch("decnet.engine.deployer.save_state")
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
@patch("decnet.engine.deployer._sync_logging_helper")
@patch("decnet.engine.deployer.setup_host_macvlan")
@patch("decnet.engine.deployer.create_macvlan_network")
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
def test_parallel_build(self, mock_docker, mock_range, mock_hip,
mock_create, mock_setup, mock_sync,
mock_compose, mock_save, mock_retry, mock_print):
from decnet.engine.deployer import deploy
config = _config()
deploy(config, parallel=True)
# Parallel mode calls _compose_with_retry for "build" and "up" separately
calls = mock_retry.call_args_list
assert any("build" in str(c) for c in calls)
@patch("decnet.engine.deployer._print_status")
@patch("decnet.engine.deployer._compose_with_retry")
@patch("decnet.engine.deployer.save_state")
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
@patch("decnet.engine.deployer._sync_logging_helper")
@patch("decnet.engine.deployer.setup_host_macvlan")
@patch("decnet.engine.deployer.create_macvlan_network")
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
def test_no_cache_build(self, mock_docker, mock_range, mock_hip,
mock_create, mock_setup, mock_sync,
mock_compose, mock_save, mock_retry, mock_print):
from decnet.engine.deployer import deploy
config = _config()
deploy(config, no_cache=True)
calls = mock_retry.call_args_list
assert any("--no-cache" in str(c) for c in calls)
# ── teardown ──────────────────────────────────────────────────────────────────
class TestTeardown:
@patch("decnet.engine.deployer.load_state", return_value=None)
def test_no_state(self, mock_load):
from decnet.engine.deployer import teardown
teardown() # should not raise
@patch("decnet.engine.deployer.clear_state")
@patch("decnet.engine.deployer.remove_macvlan_network")
@patch("decnet.engine.deployer.teardown_host_macvlan")
@patch("decnet.engine.deployer._compose")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
@patch("decnet.engine.deployer.load_state")
def test_full_teardown_macvlan(self, mock_load, mock_docker, mock_range,
mock_compose, mock_td_macvlan, mock_rm_net,
mock_clear):
config = _config()
mock_load.return_value = (config, Path("test.yml"))
from decnet.engine.deployer import teardown
teardown()
mock_compose.assert_called_once()
mock_td_macvlan.assert_called_once()
mock_rm_net.assert_called_once()
mock_clear.assert_called_once()
@patch("decnet.engine.deployer.clear_state")
@patch("decnet.engine.deployer.remove_macvlan_network")
@patch("decnet.engine.deployer.teardown_host_ipvlan")
@patch("decnet.engine.deployer._compose")
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
@patch("decnet.engine.deployer.docker.from_env")
@patch("decnet.engine.deployer.load_state")
def test_full_teardown_ipvlan(self, mock_load, mock_docker, mock_range,
mock_compose, mock_td_ipvlan, mock_rm_net,
mock_clear):
config = _config(ipvlan=True)
mock_load.return_value = (config, Path("test.yml"))
from decnet.engine.deployer import teardown
teardown()
mock_td_ipvlan.assert_called_once()
# ── status ────────────────────────────────────────────────────────────────────
class TestStatus:
@patch("decnet.engine.deployer.load_state", return_value=None)
def test_no_state(self, mock_load):
from decnet.engine.deployer import status
status() # should not raise
@patch("decnet.engine.deployer.docker.from_env")
@patch("decnet.engine.deployer.load_state")
def test_with_running_containers(self, mock_load, mock_docker):
config = _config()
mock_load.return_value = (config, Path("test.yml"))
mock_container = MagicMock()
mock_container.name = "decky-01-ssh"
mock_container.status = "running"
mock_docker.return_value.containers.list.return_value = [mock_container]
from decnet.engine.deployer import status
status() # should not raise
@patch("decnet.engine.deployer.docker.from_env")
@patch("decnet.engine.deployer.load_state")
def test_with_absent_containers(self, mock_load, mock_docker):
config = _config()
mock_load.return_value = (config, Path("test.yml"))
mock_docker.return_value.containers.list.return_value = []
from decnet.engine.deployer import status
status() # should not raise
# ── _print_status ─────────────────────────────────────────────────────────────
class TestPrintStatus:
def test_renders_table(self):
from decnet.engine.deployer import _print_status
config = _config(deckies=[_decky(), _decky("decky-02", "192.168.1.11")])
_print_status(config) # should not raise

191
tests/test_fleet.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Tests for decnet/fleet.py — fleet builder logic.
Covers build_deckies, build_deckies_from_ini, resolve_distros,
and edge cases like IP exhaustion and missing services.
"""
import pytest
from decnet.archetypes import get_archetype
from decnet.fleet import (
build_deckies,
build_deckies_from_ini,
resolve_distros,
)
from decnet.ini_loader import IniConfig, DeckySpec
# ── resolve_distros ───────────────────────────────────────────────────────────
class TestResolveDistros:
def test_explicit_distros_cycled(self):
result = resolve_distros(["debian", "ubuntu22"], False, 5)
assert result == ["debian", "ubuntu22", "debian", "ubuntu22", "debian"]
def test_explicit_single_distro(self):
result = resolve_distros(["rocky9"], False, 3)
assert result == ["rocky9", "rocky9", "rocky9"]
def test_randomize_returns_correct_count(self):
result = resolve_distros(None, True, 4)
assert len(result) == 4
# All returned slugs should be valid distro slugs
from decnet.distros import all_distros
valid = set(all_distros().keys())
for slug in result:
assert slug in valid
def test_archetype_preferred_distros(self):
arch = get_archetype("deaddeck")
result = resolve_distros(None, False, 3, archetype=arch)
for slug in result:
assert slug in arch.preferred_distros
def test_fallback_cycles_all_distros(self):
result = resolve_distros(None, False, 2)
from decnet.distros import all_distros
slugs = list(all_distros().keys())
assert result[0] == slugs[0]
assert result[1] == slugs[1]
# ── build_deckies ─────────────────────────────────────────────────────────────
class TestBuildDeckies:
_IPS: list[str] = ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
def test_explicit_services(self):
deckies = build_deckies(3, self._IPS, ["ssh", "http"], False)
assert len(deckies) == 3
for decky in deckies:
assert decky.services == ["ssh", "http"]
def test_archetype_services(self):
arch = get_archetype("deaddeck")
deckies = build_deckies(2, self._IPS[:2], None, False, archetype=arch)
assert len(deckies) == 2
for decky in deckies:
assert set(decky.services) == set(arch.services)
assert decky.archetype == "deaddeck"
assert decky.nmap_os == arch.nmap_os
def test_randomize_services(self):
deckies = build_deckies(3, self._IPS, None, True)
assert len(deckies) == 3
for decky in deckies:
assert len(decky.services) >= 1
def test_no_services_raises(self):
with pytest.raises(ValueError, match="Provide services_explicit"):
build_deckies(1, self._IPS[:1], None, False)
def test_names_sequential(self):
deckies = build_deckies(3, self._IPS, ["ssh"], False)
assert [d.name for d in deckies] == ["decky-01", "decky-02", "decky-03"]
def test_ips_assigned_correctly(self):
deckies = build_deckies(3, self._IPS, ["ssh"], False)
assert [d.ip for d in deckies] == self._IPS
def test_mutate_interval_propagated(self):
deckies = build_deckies(1, self._IPS[:1], ["ssh"], False, mutate_interval=15)
assert deckies[0].mutate_interval == 15
def test_distros_explicit(self):
deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, distros_explicit=["rocky9"])
for decky in deckies:
assert decky.distro == "rocky9"
def test_randomize_distros(self):
deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, randomize_distros=True)
from decnet.distros import all_distros
valid = set(all_distros().keys())
for decky in deckies:
assert decky.distro in valid
# ── build_deckies_from_ini ────────────────────────────────────────────────────
class TestBuildDeckiesFromIni:
_SUBNET: str = "192.168.1.0/24"
_GATEWAY: str = "192.168.1.1"
_HOST_IP: str = "192.168.1.2"
def _make_ini(self, deckies: list[DeckySpec], **kwargs) -> IniConfig:
defaults: dict = {
"interface": "eth0",
"subnet": None,
"gateway": None,
"mutate_interval": None,
"custom_services": [],
}
defaults.update(kwargs)
return IniConfig(deckies=deckies, **defaults)
def test_explicit_ip(self):
spec = DeckySpec(name="test-1", ip="192.168.1.50", services=["ssh"])
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
assert len(deckies) == 1
assert deckies[0].ip == "192.168.1.50"
def test_auto_ip_allocation(self):
spec = DeckySpec(name="test-1", services=["ssh"])
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
assert len(deckies) == 1
assert deckies[0].ip not in (self._GATEWAY, self._HOST_IP, "192.168.1.0", "192.168.1.255")
def test_archetype_services(self):
spec = DeckySpec(name="test-1", archetype="deaddeck")
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
arch = get_archetype("deaddeck")
assert set(deckies[0].services) == set(arch.services)
def test_randomize_services(self):
spec = DeckySpec(name="test-1")
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, True)
assert len(deckies[0].services) >= 1
def test_no_services_no_arch_no_randomize_raises(self):
spec = DeckySpec(name="test-1")
ini = self._make_ini([spec])
with pytest.raises(ValueError, match="has no services"):
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
def test_unknown_service_raises(self):
spec = DeckySpec(name="test-1", services=["nonexistent_svc_xyz"])
ini = self._make_ini([spec])
with pytest.raises(ValueError, match="Unknown service"):
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
def test_mutate_interval_from_cli(self):
spec = DeckySpec(name="test-1", services=["ssh"])
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(
ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=42
)
assert deckies[0].mutate_interval == 42
def test_mutate_interval_from_ini(self):
spec = DeckySpec(name="test-1", services=["ssh"])
ini = self._make_ini([spec], mutate_interval=99)
deckies = build_deckies_from_ini(
ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=None
)
assert deckies[0].mutate_interval == 99
def test_nmap_os_from_spec(self):
spec = DeckySpec(name="test-1", services=["ssh"], nmap_os="windows")
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
assert deckies[0].nmap_os == "windows"
def test_nmap_os_from_archetype(self):
spec = DeckySpec(name="test-1", archetype="deaddeck")
ini = self._make_ini([spec])
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
assert deckies[0].nmap_os == "linux"

217
tests/test_ingester.py Normal file
View File

@@ -0,0 +1,217 @@
"""
Tests for decnet/web/ingester.py
Covers log_ingestion_worker and _extract_bounty with
async tests using temporary files.
"""
import asyncio
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── _extract_bounty ───────────────────────────────────────────────────────────
class TestExtractBounty:
@pytest.mark.asyncio
async def test_credential_extraction(self):
from decnet.web.ingester import _extract_bounty
mock_repo = MagicMock()
mock_repo.add_bounty = AsyncMock()
log_data: dict = {
"decky": "decky-01",
"service": "ssh",
"attacker_ip": "10.0.0.5",
"fields": {"username": "admin", "password": "hunter2"},
}
await _extract_bounty(mock_repo, log_data)
mock_repo.add_bounty.assert_awaited_once()
bounty = mock_repo.add_bounty.call_args[0][0]
assert bounty["bounty_type"] == "credential"
assert bounty["payload"]["username"] == "admin"
assert bounty["payload"]["password"] == "hunter2"
@pytest.mark.asyncio
async def test_no_fields_skips(self):
from decnet.web.ingester import _extract_bounty
mock_repo = MagicMock()
mock_repo.add_bounty = AsyncMock()
await _extract_bounty(mock_repo, {"decky": "x"})
mock_repo.add_bounty.assert_not_awaited()
@pytest.mark.asyncio
async def test_fields_not_dict_skips(self):
from decnet.web.ingester import _extract_bounty
mock_repo = MagicMock()
mock_repo.add_bounty = AsyncMock()
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
mock_repo.add_bounty.assert_not_awaited()
@pytest.mark.asyncio
async def test_missing_password_skips(self):
from decnet.web.ingester import _extract_bounty
mock_repo = MagicMock()
mock_repo.add_bounty = AsyncMock()
await _extract_bounty(mock_repo, {"fields": {"username": "admin"}})
mock_repo.add_bounty.assert_not_awaited()
@pytest.mark.asyncio
async def test_missing_username_skips(self):
from decnet.web.ingester import _extract_bounty
mock_repo = MagicMock()
mock_repo.add_bounty = AsyncMock()
await _extract_bounty(mock_repo, {"fields": {"password": "pass"}})
mock_repo.add_bounty.assert_not_awaited()
# ── log_ingestion_worker ──────────────────────────────────────────────────────
class TestLogIngestionWorker:
@pytest.mark.asyncio
async def test_no_env_var_returns_immediately(self):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
with patch.dict(os.environ, {}, clear=False):
# Remove DECNET_INGEST_LOG_FILE if set
os.environ.pop("DECNET_INGEST_LOG_FILE", None)
await log_ingestion_worker(mock_repo)
# Should return immediately without error
@pytest.mark.asyncio
async def test_file_not_exists_waits(self, tmp_path):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
log_file = str(tmp_path / "nonexistent.log")
_call_count: int = 0
async def fake_sleep(secs):
nonlocal _call_count
_call_count += 1
if _call_count >= 2:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
with pytest.raises(asyncio.CancelledError):
await log_ingestion_worker(mock_repo)
mock_repo.add_log.assert_not_awaited()
@pytest.mark.asyncio
async def test_ingests_json_lines(self, tmp_path):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_bounty = AsyncMock()
log_file = str(tmp_path / "test.log")
json_file = tmp_path / "test.json"
json_file.write_text(
json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
"attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""}) + "\n"
)
_call_count: int = 0
async def fake_sleep(secs):
nonlocal _call_count
_call_count += 1
if _call_count >= 2:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
with pytest.raises(asyncio.CancelledError):
await log_ingestion_worker(mock_repo)
mock_repo.add_log.assert_awaited_once()
@pytest.mark.asyncio
async def test_handles_json_decode_error(self, tmp_path):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_bounty = AsyncMock()
log_file = str(tmp_path / "test.log")
json_file = tmp_path / "test.json"
json_file.write_text("not valid json\n")
_call_count: int = 0
async def fake_sleep(secs):
nonlocal _call_count
_call_count += 1
if _call_count >= 2:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
with pytest.raises(asyncio.CancelledError):
await log_ingestion_worker(mock_repo)
mock_repo.add_log.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_truncation_resets_position(self, tmp_path):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_bounty = AsyncMock()
log_file = str(tmp_path / "test.log")
json_file = tmp_path / "test.json"
_line: str = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
"attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""})
# Write 2 lines, then truncate to 1
json_file.write_text(_line + "\n" + _line + "\n")
_call_count: int = 0
async def fake_sleep(secs):
nonlocal _call_count
_call_count += 1
if _call_count == 2:
# Simulate truncation
json_file.write_text(_line + "\n")
if _call_count >= 4:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
with pytest.raises(asyncio.CancelledError):
await log_ingestion_worker(mock_repo)
# Should have ingested lines from original + after truncation
assert mock_repo.add_log.await_count >= 2
@pytest.mark.asyncio
async def test_partial_line_not_processed(self, tmp_path):
from decnet.web.ingester import log_ingestion_worker
mock_repo = MagicMock()
mock_repo.add_log = AsyncMock()
mock_repo.add_bounty = AsyncMock()
log_file = str(tmp_path / "test.log")
json_file = tmp_path / "test.json"
# Write a partial line (no newline at end)
json_file.write_text('{"partial": true')
_call_count: int = 0
async def fake_sleep(secs):
nonlocal _call_count
_call_count += 1
if _call_count >= 2:
raise asyncio.CancelledError()
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
with pytest.raises(asyncio.CancelledError):
await log_ingestion_worker(mock_repo)
mock_repo.add_log.assert_not_awaited()

27
tests/test_ini_spaces.py Normal file
View File

@@ -0,0 +1,27 @@
from decnet.ini_loader import load_ini_from_string
def test_load_ini_with_spaces_around_equals():
content = """
[general]
interface = eth0
[omega-decky]
services = http, ssh
"""
cfg = load_ini_from_string(content)
assert cfg.interface == "eth0"
assert len(cfg.deckies) == 1
assert cfg.deckies[0].name == "omega-decky"
assert cfg.deckies[0].services == ["http", "ssh"]
def test_load_ini_with_tabs_and_spaces():
content = """
[general]
interface = eth0
[omega-decky]
services = http, ssh
"""
cfg = load_ini_from_string(content)
assert cfg.interface == "eth0"
assert cfg.deckies[0].services == ["http", "ssh"]

View File

@@ -0,0 +1,41 @@
import pytest
from decnet.ini_loader import load_ini_from_string, validate_ini_string
def test_validate_ini_string_too_large():
content = "[" + "a" * (512 * 1024 + 1) + "]"
with pytest.raises(ValueError, match="too large"):
validate_ini_string(content)
def test_validate_ini_string_empty():
with pytest.raises(ValueError, match="is empty"):
validate_ini_string("")
with pytest.raises(ValueError, match="is empty"):
validate_ini_string(" ")
def test_validate_ini_string_no_sections():
with pytest.raises(ValueError, match="no sections found"):
validate_ini_string("key=value")
def test_load_ini_from_string_amount_limit():
content = """
[general]
net=192.168.1.0/24
[decky-01]
amount=101
archetype=linux-server
"""
with pytest.raises(ValueError, match="exceeds maximum allowed"):
load_ini_from_string(content)
def test_load_ini_from_string_valid():
content = """
[general]
net=192.168.1.0/24
[decky-01]
amount=5
archetype=linux-server
"""
cfg = load_ini_from_string(content)
assert len(cfg.deckies) == 5

View File

@@ -0,0 +1,73 @@
"""Tests for compose generation — logging block and absence of volume mounts."""
from decnet.composer import generate_compose, _DOCKER_LOGGING
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
def _make_config(log_file: str | None = None) -> DecnetConfig:
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01",
ip="10.0.0.10",
services=["http"],
distro="debian",
base_image=profile.image,
build_base=profile.build_base,
hostname="test-host",
)
return DecnetConfig(
mode="unihost",
interface="eth0",
subnet="10.0.0.0/24",
gateway="10.0.0.1",
deckies=[decky],
log_file=log_file,
)
class TestComposeLogging:
def test_service_container_has_logging_block(self):
config = _make_config()
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
assert "logging" in fragment
assert fragment["logging"] == _DOCKER_LOGGING
def test_logging_driver_is_json_file(self):
config = _make_config()
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
assert fragment["logging"]["driver"] == "json-file"
def test_logging_has_rotation_options(self):
config = _make_config()
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
opts = fragment["logging"]["options"]
assert "max-size" in opts
assert "max-file" in opts
def test_base_container_has_no_logging_block(self):
"""Base containers run sleep infinity and produce no app logs."""
config = _make_config()
compose = generate_compose(config)
base = compose["services"]["decky-01"]
assert "logging" not in base
def test_no_volume_mounts_on_service_container(self):
config = _make_config(log_file="/tmp/decnet.log")
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
assert not fragment.get("volumes")
def test_no_decnet_log_file_env_var(self):
config = _make_config(log_file="/tmp/decnet.log")
compose = generate_compose(config)
fragment = compose["services"]["decky-01-http"]
assert "DECNET_LOG_FILE" not in fragment.get("environment", {})
def test_no_log_network_in_networks(self):
config = _make_config()
compose = generate_compose(config)
assert "decnet_logs" not in compose["networks"]

View File

@@ -0,0 +1,61 @@
"""
Tests for decnet.logging.forwarder — parse_log_target, probe_log_target.
"""
from unittest.mock import MagicMock, patch
import pytest
from decnet.logging.forwarder import parse_log_target, probe_log_target
class TestParseLogTarget:
def test_valid_ip_port(self):
host, port = parse_log_target("192.168.1.5:5140")
assert host == "192.168.1.5"
assert port == 5140
def test_valid_hostname_port(self):
host, port = parse_log_target("logstash.internal:9600")
assert host == "logstash.internal"
assert port == 9600
def test_no_colon_raises(self):
with pytest.raises(ValueError, match="Invalid log_target"):
parse_log_target("192.168.1.5")
def test_non_digit_port_raises(self):
with pytest.raises(ValueError, match="Invalid log_target"):
parse_log_target("192.168.1.5:syslog")
def test_empty_string_raises(self):
with pytest.raises(ValueError):
parse_log_target("")
def test_multiple_colons_uses_last_as_port(self):
# IPv6-style or hostname with colons — rsplit takes the last segment
host, port = parse_log_target("::1:514")
assert port == 514
class TestProbeLogTarget:
def test_returns_true_when_reachable(self):
mock_conn = MagicMock()
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
mock_conn.__exit__ = MagicMock(return_value=False)
with patch("decnet.logging.forwarder.socket.create_connection",
return_value=mock_conn):
assert probe_log_target("192.168.1.5:5140") is True
def test_returns_false_when_connection_refused(self):
with patch("decnet.logging.forwarder.socket.create_connection",
side_effect=OSError("Connection refused")):
assert probe_log_target("192.168.1.5:5140") is False
def test_returns_false_on_timeout(self):
with patch("decnet.logging.forwarder.socket.create_connection",
side_effect=TimeoutError("timed out")):
assert probe_log_target("192.168.1.5:5140") is False
def test_returns_false_on_invalid_target(self):
# ValueError from parse_log_target is caught and returns False
assert probe_log_target("not-a-valid-target") is False

208
tests/test_mutator.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Tests for decnet.mutator — mutation engine, retry logic, due-time scheduling.
All subprocess and state I/O is mocked; no Docker or filesystem access.
"""
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.config import DeckyConfig, DecnetConfig
from decnet.engine import _compose_with_retry
from decnet.mutator import mutate_all, mutate_decky
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_decky(name="decky-01", services=None, archetype=None,
mutate_interval=30, last_mutated=0.0):
return DeckyConfig(
name=name,
ip="192.168.1.10",
services=services or ["ssh"],
distro="debian",
base_image="debian",
hostname="host-01",
archetype=archetype,
mutate_interval=mutate_interval,
last_mutated=last_mutated,
)
def _make_config(deckies=None, mutate_interval=30):
return DecnetConfig(
mode="unihost", interface="eth0",
subnet="192.168.1.0/24", gateway="192.168.1.1",
deckies=deckies or [_make_decky()],
mutate_interval=mutate_interval,
)
# ---------------------------------------------------------------------------
# _compose_with_retry
# ---------------------------------------------------------------------------
class TestComposeWithRetry:
def test_succeeds_on_first_attempt(self):
result = MagicMock(returncode=0, stdout="done\n")
with patch("decnet.engine.deployer.subprocess.run", return_value=result) as mock_run:
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"))
mock_run.assert_called_once()
def test_retries_on_failure_then_succeeds(self):
fail = MagicMock(returncode=1, stdout="", stderr="transient error")
ok = MagicMock(returncode=0, stdout="", stderr="")
with patch("decnet.engine.deployer.subprocess.run", side_effect=[fail, ok]) as mock_run, \
patch("decnet.engine.deployer.time.sleep"):
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
assert mock_run.call_count == 2
def test_raises_after_all_retries_exhausted(self):
fail = MagicMock(returncode=1, stdout="", stderr="hard error")
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
patch("decnet.engine.deployer.time.sleep"):
with pytest.raises(subprocess.CalledProcessError):
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
def test_exponential_backoff(self):
fail = MagicMock(returncode=1, stdout="", stderr="")
sleep_calls = []
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
patch("decnet.engine.deployer.time.sleep", side_effect=lambda d: sleep_calls.append(d)):
with pytest.raises(subprocess.CalledProcessError):
_compose_with_retry("up", compose_file=Path("c.yml"), retries=3, delay=1.0)
assert sleep_calls == [1.0, 2.0]
def test_correct_command_structure(self):
ok = MagicMock(returncode=0, stdout="")
with patch("decnet.engine.deployer.subprocess.run", return_value=ok) as mock_run:
_compose_with_retry("up", "-d", "--remove-orphans",
compose_file=Path("/tmp/compose.yml"))
cmd = mock_run.call_args[0][0]
assert cmd[:3] == ["docker", "compose", "-f"]
assert "up" in cmd
assert "--remove-orphans" in cmd
# ---------------------------------------------------------------------------
# mutate_decky
# ---------------------------------------------------------------------------
class TestMutateDecky:
def _patch(self, config=None, compose_path=Path("compose.yml")):
"""Return a context manager that mocks all I/O in mutate_decky."""
cfg = config or _make_config()
return (
patch("decnet.mutator.engine.load_state", return_value=(cfg, compose_path)),
patch("decnet.mutator.engine.save_state"),
patch("decnet.mutator.engine.write_compose"),
patch("decnet.mutator.engine._compose_with_retry"),
)
def test_returns_false_when_no_state(self):
with patch("decnet.mutator.engine.load_state", return_value=None):
assert mutate_decky("decky-01") is False
def test_returns_false_when_decky_not_found(self):
p = self._patch()
with p[0], p[1], p[2], p[3]:
assert mutate_decky("nonexistent") is False
def test_returns_true_on_success(self):
p = self._patch()
with p[0], p[1], p[2], p[3]:
assert mutate_decky("decky-01") is True
def test_saves_state_after_mutation(self):
p = self._patch()
with p[0], patch("decnet.mutator.engine.save_state") as mock_save, p[2], p[3]:
mutate_decky("decky-01")
mock_save.assert_called_once()
def test_regenerates_compose_after_mutation(self):
p = self._patch()
with p[0], p[1], patch("decnet.mutator.engine.write_compose") as mock_compose, p[3]:
mutate_decky("decky-01")
mock_compose.assert_called_once()
def test_returns_false_on_compose_failure(self):
p = self._patch()
err = subprocess.CalledProcessError(1, "docker", "", "compose failed")
with p[0], p[1], p[2], patch("decnet.mutator.engine._compose_with_retry", side_effect=err):
assert mutate_decky("decky-01") is False
def test_mutation_changes_services(self):
cfg = _make_config(deckies=[_make_decky(services=["ssh"])])
p = self._patch(config=cfg)
with p[0], p[1], p[2], p[3]:
mutate_decky("decky-01")
# Services may have changed (or stayed the same after 20 attempts)
assert isinstance(cfg.deckies[0].services, list)
assert len(cfg.deckies[0].services) >= 1
def test_updates_last_mutated_timestamp(self):
cfg = _make_config(deckies=[_make_decky(last_mutated=0.0)])
p = self._patch(config=cfg)
before = time.time()
with p[0], p[1], p[2], p[3]:
mutate_decky("decky-01")
assert cfg.deckies[0].last_mutated >= before
def test_archetype_constrains_service_pool(self):
"""A decky with an archetype must only mutate within its service pool."""
cfg = _make_config(deckies=[_make_decky(archetype="workstation", services=["rdp"])])
p = self._patch(config=cfg)
with p[0], p[1], p[2], p[3]:
result = mutate_decky("decky-01")
assert result is True
# ---------------------------------------------------------------------------
# mutate_all
# ---------------------------------------------------------------------------
class TestMutateAll:
def test_no_state_returns_early(self):
with patch("decnet.mutator.engine.load_state", return_value=None), \
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
mutate_all()
mock_mutate.assert_not_called()
def test_force_mutates_all_deckies(self):
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
mutate_all(force=True)
assert mock_mutate.call_count == 2
def test_skips_decky_not_yet_due(self):
# last_mutated = now, interval = 30 min → not due
now = time.time()
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
mutate_all(force=False)
mock_mutate.assert_not_called()
def test_mutates_decky_that_is_due(self):
# last_mutated = 2 hours ago, interval = 30 min → due
old_ts = time.time() - 7200
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
mutate_all(force=False)
mock_mutate.assert_called_once_with("decky-01")
def test_skips_decky_with_no_interval_and_no_force(self):
cfg = _make_config(
deckies=[_make_decky(mutate_interval=None)],
mutate_interval=None,
)
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
mutate_all(force=False)
mock_mutate.assert_not_called()

352
tests/test_network.py Normal file
View File

@@ -0,0 +1,352 @@
"""
Tests for decnet.network utility functions.
"""
from unittest.mock import MagicMock, patch
import pytest
from decnet.network import (
HOST_IPVLAN_IFACE,
HOST_MACVLAN_IFACE,
MACVLAN_NETWORK_NAME,
allocate_ips,
create_ipvlan_network,
create_macvlan_network,
detect_interface,
detect_subnet,
get_host_ip,
ips_to_range,
remove_macvlan_network,
setup_host_ipvlan,
setup_host_macvlan,
teardown_host_ipvlan,
teardown_host_macvlan,
)
# ---------------------------------------------------------------------------
# ips_to_range
# ---------------------------------------------------------------------------
class TestIpsToRange:
def test_single_ip(self):
assert ips_to_range(["192.168.1.100"]) == "192.168.1.100/32"
def test_consecutive_small_range(self):
# .97.101: max^min = 4, bit_length=3, prefix=29 → .96/29
result = ips_to_range([f"192.168.1.{i}" for i in range(97, 102)])
from ipaddress import IPv4Network, IPv4Address
net = IPv4Network(result)
for i in range(97, 102):
assert IPv4Address(f"192.168.1.{i}") in net
def test_range_crossing_cidr_boundary(self):
# .110.119 crosses the /28 boundary (.96.111 vs .112.127)
# Subtraction gives /28 (wrong), XOR gives /27 (correct)
ips = [f"192.168.1.{i}" for i in range(110, 120)]
result = ips_to_range(ips)
from ipaddress import IPv4Network, IPv4Address
net = IPv4Network(result)
for i in range(110, 120):
assert IPv4Address(f"192.168.1.{i}") in net, (
f"192.168.1.{i} not in computed range {result}"
)
def test_all_ips_covered(self):
# Larger spread: .10.200
ips = [f"10.0.0.{i}" for i in range(10, 201)]
result = ips_to_range(ips)
from ipaddress import IPv4Network, IPv4Address
net = IPv4Network(result)
for i in range(10, 201):
assert IPv4Address(f"10.0.0.{i}") in net
def test_two_ips_same_cidr(self):
# .100 and .101 share /31
result = ips_to_range(["192.168.1.100", "192.168.1.101"])
from ipaddress import IPv4Network, IPv4Address
net = IPv4Network(result)
assert IPv4Address("192.168.1.100") in net
assert IPv4Address("192.168.1.101") in net
# ---------------------------------------------------------------------------
# create_macvlan_network
# ---------------------------------------------------------------------------
class TestCreateMacvlanNetwork:
def _make_client(self, existing=None):
client = MagicMock()
nets = [MagicMock(name=n) for n in (existing or [])]
for net, n in zip(nets, (existing or [])):
net.name = n
client.networks.list.return_value = nets
return client
def test_creates_network_when_absent(self):
client = self._make_client([])
create_macvlan_network(client, "eth0", "192.168.1.0/24", "192.168.1.1", "192.168.1.96/27")
client.networks.create.assert_called_once()
kwargs = client.networks.create.call_args
assert kwargs[1]["driver"] == "macvlan"
assert kwargs[1]["name"] == MACVLAN_NETWORK_NAME
assert kwargs[1]["options"]["parent"] == "eth0"
def test_noop_when_network_exists(self):
client = self._make_client([MACVLAN_NETWORK_NAME])
create_macvlan_network(client, "eth0", "192.168.1.0/24", "192.168.1.1", "192.168.1.96/27")
client.networks.create.assert_not_called()
# ---------------------------------------------------------------------------
# create_ipvlan_network
# ---------------------------------------------------------------------------
class TestCreateIpvlanNetwork:
def _make_client(self, existing=None):
client = MagicMock()
nets = [MagicMock(name=n) for n in (existing or [])]
for net, n in zip(nets, (existing or [])):
net.name = n
client.networks.list.return_value = nets
return client
def test_creates_ipvlan_network(self):
client = self._make_client([])
create_ipvlan_network(client, "wlan0", "192.168.1.0/24", "192.168.1.1", "192.168.1.96/27")
client.networks.create.assert_called_once()
kwargs = client.networks.create.call_args
assert kwargs[1]["driver"] == "ipvlan"
assert kwargs[1]["options"]["parent"] == "wlan0"
assert kwargs[1]["options"]["ipvlan_mode"] == "l2"
def test_noop_when_network_exists(self):
client = self._make_client([MACVLAN_NETWORK_NAME])
create_ipvlan_network(client, "wlan0", "192.168.1.0/24", "192.168.1.1", "192.168.1.96/27")
client.networks.create.assert_not_called()
def test_uses_same_network_name_as_macvlan(self):
"""Both drivers share the same logical network name so compose files are identical."""
client = self._make_client([])
create_ipvlan_network(client, "wlan0", "192.168.1.0/24", "192.168.1.1", "192.168.1.96/27")
assert client.networks.create.call_args[1]["name"] == MACVLAN_NETWORK_NAME
# ---------------------------------------------------------------------------
# setup_host_macvlan / teardown_host_macvlan
# ---------------------------------------------------------------------------
class TestSetupHostMacvlan:
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_creates_interface_when_absent(self, mock_run, _):
# Simulate interface not existing (returncode != 0)
mock_run.side_effect = lambda cmd, **kw: MagicMock(returncode=1) if "show" in cmd else MagicMock(returncode=0)
setup_host_macvlan("eth0", "192.168.1.5", "192.168.1.96/27")
calls = [str(c) for c in mock_run.call_args_list]
assert any("macvlan" in c for c in calls)
assert any("mode" in c and "bridge" in c for c in calls)
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_skips_create_when_interface_exists(self, mock_run, _):
mock_run.return_value = MagicMock(returncode=0)
setup_host_macvlan("eth0", "192.168.1.5", "192.168.1.96/27")
calls = [c[0][0] for c in mock_run.call_args_list]
# "ip link add <iface> link ..." should not be called when iface exists
assert not any("link" in cmd and "add" in cmd and HOST_MACVLAN_IFACE in cmd for cmd in calls)
@patch("decnet.network.os.geteuid", return_value=1)
def test_requires_root(self, _):
with pytest.raises(PermissionError):
setup_host_macvlan("eth0", "192.168.1.5", "192.168.1.96/27")
# ---------------------------------------------------------------------------
# setup_host_ipvlan / teardown_host_ipvlan
# ---------------------------------------------------------------------------
class TestSetupHostIpvlan:
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_creates_ipvlan_interface(self, mock_run, _):
mock_run.side_effect = lambda cmd, **kw: MagicMock(returncode=1) if "show" in cmd else MagicMock(returncode=0)
setup_host_ipvlan("wlan0", "192.168.1.5", "192.168.1.96/27")
calls = [str(c) for c in mock_run.call_args_list]
assert any("ipvlan" in c for c in calls)
assert any("mode" in c and "l2" in c for c in calls)
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_uses_ipvlan_iface_name(self, mock_run, _):
mock_run.side_effect = lambda cmd, **kw: MagicMock(returncode=1) if "show" in cmd else MagicMock(returncode=0)
setup_host_ipvlan("wlan0", "192.168.1.5", "192.168.1.96/27")
calls = [str(c) for c in mock_run.call_args_list]
assert any(HOST_IPVLAN_IFACE in c for c in calls)
assert not any(HOST_MACVLAN_IFACE in c for c in calls)
@patch("decnet.network.os.geteuid", return_value=1)
def test_requires_root(self, _):
with pytest.raises(PermissionError):
setup_host_ipvlan("wlan0", "192.168.1.5", "192.168.1.96/27")
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_teardown_uses_ipvlan_iface(self, mock_run, _):
mock_run.return_value = MagicMock(returncode=0)
teardown_host_ipvlan("192.168.1.96/27")
calls = [str(c) for c in mock_run.call_args_list]
assert any(HOST_IPVLAN_IFACE in c for c in calls)
assert not any(HOST_MACVLAN_IFACE in c for c in calls)
# ---------------------------------------------------------------------------
# allocate_ips (pure logic — no subprocess / Docker)
# ---------------------------------------------------------------------------
class TestAllocateIps:
def test_basic_allocation(self):
ips = allocate_ips("192.168.1.0/24", "192.168.1.1", "192.168.1.100", count=3)
assert len(ips) == 3
assert "192.168.1.1" not in ips # gateway skipped
assert "192.168.1.100" not in ips # host IP skipped
def test_skips_network_and_broadcast(self):
ips = allocate_ips("10.0.0.0/30", "10.0.0.1", "10.0.0.3", count=1)
# /30 hosts: .1 (gateway), .2. .3 is host_ip → only .2 available
assert ips == ["10.0.0.2"]
def test_respects_ip_start(self):
ips = allocate_ips("192.168.1.0/24", "192.168.1.1", "192.168.1.1",
count=2, ip_start="192.168.1.50")
assert all(ip >= "192.168.1.50" for ip in ips)
def test_raises_when_not_enough_ips(self):
# /30 only has 2 host addresses; reserving both leaves 0
with pytest.raises(RuntimeError, match="Not enough free IPs"):
allocate_ips("10.0.0.0/30", "10.0.0.1", "10.0.0.2", count=3)
def test_no_duplicates(self):
ips = allocate_ips("10.0.0.0/24", "10.0.0.1", "10.0.0.2", count=10)
assert len(ips) == len(set(ips))
def test_exact_count_returned(self):
ips = allocate_ips("172.16.0.0/24", "172.16.0.1", "172.16.0.254", count=5)
assert len(ips) == 5
# ---------------------------------------------------------------------------
# detect_interface
# ---------------------------------------------------------------------------
class TestDetectInterface:
@patch("decnet.network._run")
def test_parses_dev_from_route(self, mock_run):
mock_run.return_value = MagicMock(
stdout="default via 192.168.1.1 dev eth0 proto dhcp\n"
)
assert detect_interface() == "eth0"
@patch("decnet.network._run")
def test_raises_when_no_dev_found(self, mock_run):
mock_run.return_value = MagicMock(stdout="")
with pytest.raises(RuntimeError, match="Could not auto-detect"):
detect_interface()
# ---------------------------------------------------------------------------
# detect_subnet
# ---------------------------------------------------------------------------
class TestDetectSubnet:
def _make_run(self, addr_output, route_output):
def side_effect(cmd, **kwargs):
if "addr" in cmd:
return MagicMock(stdout=addr_output)
return MagicMock(stdout=route_output)
return side_effect
@patch("decnet.network._run")
def test_parses_subnet_and_gateway(self, mock_run):
mock_run.side_effect = self._make_run(
" inet 192.168.1.5/24 brd 192.168.1.255 scope global eth0\n",
"default via 192.168.1.1 dev eth0\n",
)
subnet, gw = detect_subnet("eth0")
assert subnet == "192.168.1.0/24"
assert gw == "192.168.1.1"
@patch("decnet.network._run")
def test_raises_when_no_inet(self, mock_run):
mock_run.side_effect = self._make_run("", "default via 192.168.1.1 dev eth0\n")
with pytest.raises(RuntimeError, match="Could not detect subnet"):
detect_subnet("eth0")
@patch("decnet.network._run")
def test_raises_when_no_gateway(self, mock_run):
mock_run.side_effect = self._make_run(
" inet 192.168.1.5/24 brd 192.168.1.255 scope global eth0\n", ""
)
with pytest.raises(RuntimeError, match="Could not detect gateway"):
detect_subnet("eth0")
# ---------------------------------------------------------------------------
# get_host_ip
# ---------------------------------------------------------------------------
class TestGetHostIp:
@patch("decnet.network._run")
def test_returns_host_ip(self, mock_run):
mock_run.return_value = MagicMock(
stdout=" inet 10.0.0.5/24 brd 10.0.0.255 scope global eth0\n"
)
assert get_host_ip("eth0") == "10.0.0.5"
@patch("decnet.network._run")
def test_raises_when_no_inet(self, mock_run):
mock_run.return_value = MagicMock(stdout="link/ether aa:bb:cc:dd:ee:ff\n")
with pytest.raises(RuntimeError, match="Could not determine host IP"):
get_host_ip("eth0")
# ---------------------------------------------------------------------------
# remove_macvlan_network
# ---------------------------------------------------------------------------
class TestRemoveMacvlanNetwork:
def test_removes_matching_network(self):
client = MagicMock()
net = MagicMock()
net.name = MACVLAN_NETWORK_NAME
client.networks.list.return_value = [net]
remove_macvlan_network(client)
net.remove.assert_called_once()
def test_noop_when_no_matching_network(self):
client = MagicMock()
other = MagicMock()
other.name = "some-other-network"
client.networks.list.return_value = [other]
remove_macvlan_network(client)
other.remove.assert_not_called()
# ---------------------------------------------------------------------------
# teardown_host_macvlan
# ---------------------------------------------------------------------------
class TestTeardownHostMacvlan:
@patch("decnet.network.os.geteuid", return_value=0)
@patch("decnet.network._run")
def test_deletes_macvlan_iface(self, mock_run, _):
mock_run.return_value = MagicMock(returncode=0)
teardown_host_macvlan("192.168.1.96/27")
calls = [str(c) for c in mock_run.call_args_list]
assert any(HOST_MACVLAN_IFACE in c for c in calls)
@patch("decnet.network.os.geteuid", return_value=1)
def test_requires_root(self, _):
with pytest.raises(PermissionError):
teardown_host_macvlan("192.168.1.96/27")

View File

@@ -0,0 +1,475 @@
"""
Tests for the OS TCP/IP fingerprint spoof feature.
Covers:
- os_fingerprint.py: profiles, TTL values, fallback behaviour
- archetypes.py: every archetype has a valid nmap_os
- config.py: DeckyConfig carries nmap_os
- composer.py: base container gets sysctls + cap_add injected
- cli.py helpers: nmap_os propagated from archetype → DeckyConfig
"""
import pytest
from decnet.archetypes import ARCHETYPES
from decnet.composer import generate_compose
from decnet.config import DeckyConfig, DecnetConfig
from decnet.os_fingerprint import OS_SYSCTLS, all_os_families, get_os_sysctls
# ---------------------------------------------------------------------------
# os_fingerprint module — TTL
# ---------------------------------------------------------------------------
def test_linux_ttl_is_64():
assert get_os_sysctls("linux")["net.ipv4.ip_default_ttl"] == "64"
def test_windows_ttl_is_128():
assert get_os_sysctls("windows")["net.ipv4.ip_default_ttl"] == "128"
def test_embedded_ttl_is_255():
assert get_os_sysctls("embedded")["net.ipv4.ip_default_ttl"] == "255"
def test_cisco_ttl_is_255():
assert get_os_sysctls("cisco")["net.ipv4.ip_default_ttl"] == "255"
def test_bsd_ttl_is_64():
assert get_os_sysctls("bsd")["net.ipv4.ip_default_ttl"] == "64"
# ---------------------------------------------------------------------------
# os_fingerprint module — tcp_timestamps
# ---------------------------------------------------------------------------
def test_linux_tcp_timestamps_is_1():
assert get_os_sysctls("linux")["net.ipv4.tcp_timestamps"] == "1"
def test_windows_tcp_timestamps_is_0():
assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "0"
def test_embedded_tcp_timestamps_is_0():
assert get_os_sysctls("embedded")["net.ipv4.tcp_timestamps"] == "0"
def test_bsd_tcp_timestamps_is_1():
assert get_os_sysctls("bsd")["net.ipv4.tcp_timestamps"] == "1"
def test_cisco_tcp_timestamps_is_0():
assert get_os_sysctls("cisco")["net.ipv4.tcp_timestamps"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — tcp_sack
# ---------------------------------------------------------------------------
def test_linux_tcp_sack_is_1():
assert get_os_sysctls("linux")["net.ipv4.tcp_sack"] == "1"
def test_windows_tcp_sack_is_1():
assert get_os_sysctls("windows")["net.ipv4.tcp_sack"] == "1"
def test_embedded_tcp_sack_is_0():
assert get_os_sysctls("embedded")["net.ipv4.tcp_sack"] == "0"
def test_cisco_tcp_sack_is_0():
assert get_os_sysctls("cisco")["net.ipv4.tcp_sack"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — tcp_ecn
# ---------------------------------------------------------------------------
def test_linux_tcp_ecn_is_2():
assert get_os_sysctls("linux")["net.ipv4.tcp_ecn"] == "2"
def test_windows_tcp_ecn_is_0():
assert get_os_sysctls("windows")["net.ipv4.tcp_ecn"] == "0"
def test_embedded_tcp_ecn_is_0():
assert get_os_sysctls("embedded")["net.ipv4.tcp_ecn"] == "0"
def test_bsd_tcp_ecn_is_0():
assert get_os_sysctls("bsd")["net.ipv4.tcp_ecn"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — tcp_window_scaling
# ---------------------------------------------------------------------------
def test_linux_tcp_window_scaling_is_1():
assert get_os_sysctls("linux")["net.ipv4.tcp_window_scaling"] == "1"
def test_windows_tcp_window_scaling_is_1():
assert get_os_sysctls("windows")["net.ipv4.tcp_window_scaling"] == "1"
def test_embedded_tcp_window_scaling_is_0():
assert get_os_sysctls("embedded")["net.ipv4.tcp_window_scaling"] == "0"
def test_cisco_tcp_window_scaling_is_0():
assert get_os_sysctls("cisco")["net.ipv4.tcp_window_scaling"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — ip_no_pmtu_disc
# ---------------------------------------------------------------------------
def test_linux_ip_no_pmtu_disc_is_0():
assert get_os_sysctls("linux")["net.ipv4.ip_no_pmtu_disc"] == "0"
def test_windows_ip_no_pmtu_disc_is_0():
assert get_os_sysctls("windows")["net.ipv4.ip_no_pmtu_disc"] == "0"
def test_embedded_ip_no_pmtu_disc_is_1():
assert get_os_sysctls("embedded")["net.ipv4.ip_no_pmtu_disc"] == "1"
def test_cisco_ip_no_pmtu_disc_is_1():
assert get_os_sysctls("cisco")["net.ipv4.ip_no_pmtu_disc"] == "1"
# ---------------------------------------------------------------------------
# os_fingerprint module — tcp_fin_timeout
# ---------------------------------------------------------------------------
def test_linux_tcp_fin_timeout_is_60():
assert get_os_sysctls("linux")["net.ipv4.tcp_fin_timeout"] == "60"
def test_windows_tcp_fin_timeout_is_30():
assert get_os_sysctls("windows")["net.ipv4.tcp_fin_timeout"] == "30"
def test_embedded_tcp_fin_timeout_is_15():
assert get_os_sysctls("embedded")["net.ipv4.tcp_fin_timeout"] == "15"
def test_cisco_tcp_fin_timeout_is_15():
assert get_os_sysctls("cisco")["net.ipv4.tcp_fin_timeout"] == "15"
# ---------------------------------------------------------------------------
# os_fingerprint module — icmp_ratelimit
# ---------------------------------------------------------------------------
def test_linux_icmp_ratelimit_is_1000():
assert get_os_sysctls("linux")["net.ipv4.icmp_ratelimit"] == "1000"
def test_windows_icmp_ratelimit_is_0():
assert get_os_sysctls("windows")["net.ipv4.icmp_ratelimit"] == "0"
def test_bsd_icmp_ratelimit_is_250():
assert get_os_sysctls("bsd")["net.ipv4.icmp_ratelimit"] == "250"
def test_embedded_icmp_ratelimit_is_0():
assert get_os_sysctls("embedded")["net.ipv4.icmp_ratelimit"] == "0"
def test_cisco_icmp_ratelimit_is_0():
assert get_os_sysctls("cisco")["net.ipv4.icmp_ratelimit"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — icmp_ratemask
# ---------------------------------------------------------------------------
def test_linux_icmp_ratemask_is_6168():
assert get_os_sysctls("linux")["net.ipv4.icmp_ratemask"] == "6168"
def test_windows_icmp_ratemask_is_0():
assert get_os_sysctls("windows")["net.ipv4.icmp_ratemask"] == "0"
def test_bsd_icmp_ratemask_is_6168():
assert get_os_sysctls("bsd")["net.ipv4.icmp_ratemask"] == "6168"
def test_embedded_icmp_ratemask_is_0():
assert get_os_sysctls("embedded")["net.ipv4.icmp_ratemask"] == "0"
def test_cisco_icmp_ratemask_is_0():
assert get_os_sysctls("cisco")["net.ipv4.icmp_ratemask"] == "0"
# ---------------------------------------------------------------------------
# os_fingerprint module — structural / completeness
# ---------------------------------------------------------------------------
def test_unknown_os_falls_back_to_linux():
result = get_os_sysctls("nonexistent-os")
assert result == get_os_sysctls("linux")
def test_get_os_sysctls_returns_copy():
"""Mutating the returned dict must not alter the master profile."""
s = get_os_sysctls("windows")
s["net.ipv4.ip_default_ttl"] = "999"
assert OS_SYSCTLS["windows"]["net.ipv4.ip_default_ttl"] == "128"
def test_all_os_families_non_empty():
families = all_os_families()
assert len(families) > 0
assert "linux" in families
assert "windows" in families
assert "embedded" in families
@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"])
def test_all_os_profiles_have_required_sysctls(family: str):
"""Every OS profile must define the full canonical sysctl set."""
from decnet.os_fingerprint import _REQUIRED_SYSCTLS
result = get_os_sysctls(family)
missing = _REQUIRED_SYSCTLS - result.keys()
assert not missing, f"OS profile '{family}' is missing sysctls: {missing}"
@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"])
def test_all_os_sysctl_values_are_strings(family: str):
"""Docker Compose requires sysctl values to be strings, never ints."""
for _key, _val in get_os_sysctls(family).items():
assert isinstance(_val, str), (
f"OS profile '{family}': sysctl '{_key}' value {_val!r} is not a string"
)
# ---------------------------------------------------------------------------
# Archetypes carry valid nmap_os values
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("slug,arch", list(ARCHETYPES.items()))
def test_archetype_nmap_os_is_known(slug, arch):
assert arch.nmap_os in all_os_families(), (
f"Archetype '{slug}' has nmap_os='{arch.nmap_os}' which is not in OS_SYSCTLS"
)
@pytest.mark.parametrize("slug", ["windows-workstation", "windows-server", "domain-controller"])
def test_windows_archetypes_have_windows_nmap_os(slug):
assert ARCHETYPES[slug].nmap_os == "windows"
@pytest.mark.parametrize("slug", ["printer", "iot-device", "industrial-control"])
def test_embedded_archetypes_have_embedded_nmap_os(slug):
assert ARCHETYPES[slug].nmap_os == "embedded"
@pytest.mark.parametrize("slug", ["linux-server", "web-server", "database-server",
"mail-server", "file-server", "voip-server",
"monitoring-node", "devops-host"])
def test_linux_archetypes_have_linux_nmap_os(slug):
assert ARCHETYPES[slug].nmap_os == "linux"
# ---------------------------------------------------------------------------
# DeckyConfig default
# ---------------------------------------------------------------------------
def _make_decky(nmap_os: str = "linux") -> DeckyConfig:
return DeckyConfig(
name="decky-01",
ip="10.0.0.10",
services=["ssh"],
distro="debian",
base_image="debian:bookworm-slim",
build_base="debian:bookworm-slim",
hostname="test-host",
nmap_os=nmap_os,
)
def test_deckyconfig_default_nmap_os_is_linux():
cfg = DeckyConfig(
name="decky-01",
ip="10.0.0.10",
services=["ssh"],
distro="debian",
base_image="debian:bookworm-slim",
build_base="debian:bookworm-slim",
hostname="test-host",
)
assert cfg.nmap_os == "linux"
def test_deckyconfig_accepts_custom_nmap_os():
cfg = _make_decky(nmap_os="windows")
assert cfg.nmap_os == "windows"
# ---------------------------------------------------------------------------
# Composer injects sysctls + cap_add into base container
# ---------------------------------------------------------------------------
def _make_config(nmap_os: str = "linux") -> DecnetConfig:
return DecnetConfig(
mode="unihost",
interface="eth0",
subnet="10.0.0.0/24",
gateway="10.0.0.1",
deckies=[_make_decky(nmap_os=nmap_os)],
)
def test_compose_base_has_sysctls():
compose = generate_compose(_make_config("linux"))
base = compose["services"]["decky-01"]
assert "sysctls" in base
def test_compose_base_has_cap_net_admin():
compose = generate_compose(_make_config("linux"))
base = compose["services"]["decky-01"]
assert "cap_add" in base
assert "NET_ADMIN" in base["cap_add"]
def test_compose_linux_ttl_64():
compose = generate_compose(_make_config("linux"))
sysctls = compose["services"]["decky-01"]["sysctls"]
assert sysctls["net.ipv4.ip_default_ttl"] == "64"
def test_compose_windows_ttl_128():
compose = generate_compose(_make_config("windows"))
sysctls = compose["services"]["decky-01"]["sysctls"]
assert sysctls["net.ipv4.ip_default_ttl"] == "128"
def test_compose_embedded_ttl_255():
compose = generate_compose(_make_config("embedded"))
sysctls = compose["services"]["decky-01"]["sysctls"]
assert sysctls["net.ipv4.ip_default_ttl"] == "255"
def test_compose_service_containers_have_no_sysctls():
"""Service containers share the base network namespace — no sysctls needed there."""
compose = generate_compose(_make_config("windows"))
svc = compose["services"]["decky-01-ssh"]
assert "sysctls" not in svc
def test_compose_two_deckies_independent_nmap_os():
"""Each decky gets its own OS profile."""
decky_win = _make_decky(nmap_os="windows")
decky_lin = DeckyConfig(
name="decky-02",
ip="10.0.0.11",
services=["ssh"],
distro="debian",
base_image="debian:bookworm-slim",
build_base="debian:bookworm-slim",
hostname="test-host-2",
nmap_os="linux",
)
config = DecnetConfig(
mode="unihost",
interface="eth0",
subnet="10.0.0.0/24",
gateway="10.0.0.1",
deckies=[decky_win, decky_lin],
)
compose = generate_compose(config)
assert compose["services"]["decky-01"]["sysctls"]["net.ipv4.ip_default_ttl"] == "128"
assert compose["services"]["decky-02"]["sysctls"]["net.ipv4.ip_default_ttl"] == "64"
def test_compose_linux_sysctls_include_timestamps():
"""Linux compose output must have tcp_timestamps enabled (= 1)."""
compose = generate_compose(_make_config("linux"))
sysctls = compose["services"]["decky-01"]["sysctls"]
assert sysctls.get("net.ipv4.tcp_timestamps") == "1"
def test_compose_windows_sysctls_no_timestamps():
"""Windows compose output must have tcp_timestamps disabled (= 0)."""
compose = generate_compose(_make_config("windows"))
sysctls = compose["services"]["decky-01"]["sysctls"]
assert sysctls.get("net.ipv4.tcp_timestamps") == "0"
def test_compose_linux_sysctls_full_set():
"""Linux compose output must carry all 8 canonical sysctls."""
from decnet.os_fingerprint import _REQUIRED_SYSCTLS
compose = generate_compose(_make_config("linux"))
sysctls = compose["services"]["decky-01"]["sysctls"]
missing = _REQUIRED_SYSCTLS - sysctls.keys()
assert not missing, f"Compose output missing sysctls: {missing}"
def test_compose_embedded_sysctls_full_set():
"""Embedded compose output must carry all 8 canonical sysctls."""
from decnet.os_fingerprint import _REQUIRED_SYSCTLS
compose = generate_compose(_make_config("embedded"))
sysctls = compose["services"]["decky-01"]["sysctls"]
missing = _REQUIRED_SYSCTLS - sysctls.keys()
assert not missing, f"Compose output missing sysctls: {missing}"
# ---------------------------------------------------------------------------
# CLI helper: nmap_os flows from archetype into DeckyConfig
# ---------------------------------------------------------------------------
def test_build_deckies_windows_archetype_sets_nmap_os():
from decnet.archetypes import get_archetype
from decnet.fleet import build_deckies as _build_deckies
arch = get_archetype("windows-workstation")
deckies = _build_deckies(
n=1,
ips=["10.0.0.20"],
services_explicit=None,
randomize_services=False,
archetype=arch,
)
assert deckies[0].nmap_os == "windows"
def test_build_deckies_no_archetype_defaults_linux():
from decnet.fleet import build_deckies as _build_deckies
deckies = _build_deckies(
n=1,
ips=["10.0.0.20"],
services_explicit=["ssh"],
randomize_services=False,
archetype=None,
)
assert deckies[0].nmap_os == "linux"
def test_build_deckies_embedded_archetype_sets_nmap_os():
from decnet.archetypes import get_archetype
from decnet.fleet import build_deckies as _build_deckies
arch = get_archetype("iot-device")
deckies = _build_deckies(
n=1,
ips=["10.0.0.20"],
services_explicit=None,
randomize_services=False,
archetype=arch,
)
assert deckies[0].nmap_os == "embedded"

362
tests/test_services.py Normal file
View File

@@ -0,0 +1,362 @@
"""
Tests for all 25 DECNET service plugins.
Covers:
- Service registration via the plugin registry
- compose_fragment structure (container_name, restart, image/build)
- LOG_TARGET propagation for custom-build services
- dockerfile_context returns Path for build services, None for upstream-image services
- Per-service persona config (service_cfg) propagation
"""
import pytest
from pathlib import Path
from decnet.services.registry import all_services, get_service
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fragment(name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
return get_service(name).compose_fragment("test-decky", log_target, service_cfg)
def _is_build_service(name: str) -> bool:
svc = get_service(name)
return svc.default_image == "build"
# ---------------------------------------------------------------------------
# Tier 1: upstream-image services (non-build)
# ---------------------------------------------------------------------------
UPSTREAM_SERVICES: dict = {}
# ---------------------------------------------------------------------------
# Tier 2: custom-build services (including ssh, which now uses build)
# ---------------------------------------------------------------------------
BUILD_SERVICES = {
"ssh": ([22], "ssh"),
"telnet": ([23], "telnet"),
"http": ([80, 443], "http"),
"rdp": ([3389], "rdp"),
"smb": ([445, 139], "smb"),
"ftp": ([21], "ftp"),
"smtp": ([25, 587], "smtp"),
"elasticsearch": ([9200], "elasticsearch"),
"pop3": ([110, 995], "pop3"),
"imap": ([143, 993], "imap"),
"mysql": ([3306], "mysql"),
"mssql": ([1433], "mssql"),
"redis": ([6379], "redis"),
"mongodb": ([27017], "mongodb"),
"postgres": ([5432], "postgres"),
"ldap": ([389, 636], "ldap"),
"vnc": ([5900], "vnc"),
"docker_api": ([2375, 2376], "docker_api"),
"k8s": ([6443, 8080], "k8s"),
"sip": ([5060], "sip"),
"mqtt": ([1883], "mqtt"),
"llmnr": ([5355, 5353], "llmnr"),
"snmp": ([161], "snmp"),
"tftp": ([69], "tftp"),
"conpot": ([502, 161, 80], "conpot"),
}
ALL_SERVICE_NAMES = list(UPSTREAM_SERVICES) + list(BUILD_SERVICES)
# ---------------------------------------------------------------------------
# Registration tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
def test_service_registered(name):
"""Every service must appear in the registry."""
registry = all_services()
assert name in registry, f"Service '{name}' not found in registry"
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
def test_service_ports_defined(name):
"""Every service must declare at least one port."""
svc = get_service(name)
assert isinstance(svc.ports, list)
assert len(svc.ports) >= 1
# ---------------------------------------------------------------------------
# Upstream-image service tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
(n, (img, ports)) for n, (img, ports) in UPSTREAM_SERVICES.items()
])
def test_upstream_image(name, expected):
expected_image, _ = expected
frag = _fragment(name)
assert frag.get("image") == expected_image
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_no_dockerfile_context(name):
assert get_service(name).dockerfile_context() is None
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_container_name(name):
frag = _fragment(name)
assert frag["container_name"] == f"test-decky-{name.replace('_', '-')}"
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
def test_upstream_restart_policy(name):
frag = _fragment(name)
assert frag.get("restart") == "unless-stopped"
# ---------------------------------------------------------------------------
# Build-service tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_uses_build(name):
frag = _fragment(name)
assert "build" in frag, f"Service '{name}' fragment missing 'build' key"
assert "context" in frag["build"]
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_dockerfile_context_is_path(name):
ctx = get_service(name).dockerfile_context()
assert isinstance(ctx, Path), f"Service '{name}' dockerfile_context should return a Path"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_dockerfile_exists(name):
ctx = get_service(name).dockerfile_context()
dockerfile = ctx / "Dockerfile"
assert dockerfile.exists(), f"Dockerfile missing at {dockerfile}"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_container_name(name):
frag = _fragment(name)
slug = name.replace("_", "-")
assert frag["container_name"] == f"test-decky-{slug}"
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_restart_policy(name):
frag = _fragment(name)
assert frag.get("restart") == "unless-stopped"
_RSYSLOG_SERVICES = {"ssh", "real_ssh", "telnet"}
_NODE_NAME_SERVICES = [n for n in BUILD_SERVICES if n not in _RSYSLOG_SERVICES]
@pytest.mark.parametrize("name", _NODE_NAME_SERVICES)
def test_build_service_node_name_env(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "NODE_NAME" in env
assert env["NODE_NAME"] == "test-decky"
# ssh, real_ssh, and telnet do not use LOG_TARGET (rsyslog handles log forwarding inside the container)
_LOG_TARGET_SERVICES = [n for n in BUILD_SERVICES if n not in _RSYSLOG_SERVICES]
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_log_target_propagated(name):
frag = _fragment(name, log_target="10.0.0.1:5140")
env = frag.get("environment", {})
assert env.get("LOG_TARGET") == "10.0.0.1:5140"
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_no_log_target_by_default(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "LOG_TARGET" not in env
def test_ssh_no_log_target_env():
"""SSH uses rsyslog internally — no LOG_TARGET or COWRIE_* vars."""
env = _fragment("ssh", log_target="10.0.0.1:5140").get("environment", {})
assert "LOG_TARGET" not in env
assert not any(k.startswith("COWRIE_") for k in env)
# ---------------------------------------------------------------------------
# Port coverage tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
(n, ports) for n, (ports, _) in BUILD_SERVICES.items()
])
def test_build_service_ports(name, expected):
svc = get_service(name)
assert svc.ports == expected
@pytest.mark.parametrize("name,expected", [
(n, ports) for n, (_, ports) in UPSTREAM_SERVICES.items()
])
def test_upstream_service_ports(name, expected):
svc = get_service(name)
assert svc.ports == expected
# ---------------------------------------------------------------------------
# Registry completeness
# ---------------------------------------------------------------------------
def test_total_service_count():
"""Sanity check: at least 25 services registered."""
assert len(all_services()) >= 25
# ---------------------------------------------------------------------------
# Per-service persona config (service_cfg)
# ---------------------------------------------------------------------------
# HTTP -----------------------------------------------------------------------
def test_http_default_no_extra_env():
"""No service_cfg → none of the new env vars should appear."""
env = _fragment("http").get("environment", {})
for key in ("SERVER_HEADER", "RESPONSE_CODE", "FAKE_APP", "EXTRA_HEADERS", "CUSTOM_BODY", "FILES_DIR"):
assert key not in env, f"Expected {key} absent by default"
def test_http_server_header():
env = _fragment("http", service_cfg={"server_header": "nginx/1.18.0"}).get("environment", {})
assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_http_response_code():
env = _fragment("http", service_cfg={"response_code": 200}).get("environment", {})
assert env.get("RESPONSE_CODE") == "200"
def test_http_fake_app():
env = _fragment("http", service_cfg={"fake_app": "wordpress"}).get("environment", {})
assert env.get("FAKE_APP") == "wordpress"
def test_http_extra_headers():
import json
env = _fragment("http", service_cfg={"extra_headers": {"X-Frame-Options": "SAMEORIGIN"}}).get("environment", {})
assert "EXTRA_HEADERS" in env
assert json.loads(env["EXTRA_HEADERS"]) == {"X-Frame-Options": "SAMEORIGIN"}
def test_http_custom_body():
env = _fragment("http", service_cfg={"custom_body": "<html>hi</html>"}).get("environment", {})
assert env.get("CUSTOM_BODY") == "<html>hi</html>"
def test_http_empty_service_cfg_no_extra_env():
env = _fragment("http", service_cfg={}).get("environment", {})
assert "SERVER_HEADER" not in env
# SSH ------------------------------------------------------------------------
def test_ssh_default_env():
env = _fragment("ssh").get("environment", {})
assert env.get("SSH_ROOT_PASSWORD") == "admin"
assert not any(k.startswith("COWRIE_") for k in env)
assert "NODE_NAME" not in env
def test_ssh_custom_password():
env = _fragment("ssh", service_cfg={"password": "h4x!"}).get("environment", {})
assert env.get("SSH_ROOT_PASSWORD") == "h4x!"
def test_ssh_custom_hostname():
env = _fragment("ssh", service_cfg={"hostname": "prod-db"}).get("environment", {})
assert env.get("SSH_HOSTNAME") == "prod-db"
def test_ssh_no_hostname_by_default():
env = _fragment("ssh").get("environment", {})
assert "SSH_HOSTNAME" not in env
# SMTP -----------------------------------------------------------------------
def test_smtp_banner():
env = _fragment("smtp", service_cfg={"banner": "220 mail.corp.local ESMTP Sendmail"}).get("environment", {})
assert env.get("SMTP_BANNER") == "220 mail.corp.local ESMTP Sendmail"
def test_smtp_mta():
env = _fragment("smtp", service_cfg={"mta": "mail.corp.local"}).get("environment", {})
assert env.get("SMTP_MTA") == "mail.corp.local"
def test_smtp_default_no_extra_env():
env = _fragment("smtp").get("environment", {})
assert "SMTP_BANNER" not in env
assert "SMTP_MTA" not in env
# MySQL ----------------------------------------------------------------------
def test_mysql_version():
env = _fragment("mysql", service_cfg={"version": "8.0.33"}).get("environment", {})
assert env.get("MYSQL_VERSION") == "8.0.33"
def test_mysql_default_no_version_env():
env = _fragment("mysql").get("environment", {})
assert "MYSQL_VERSION" not in env
# Redis ----------------------------------------------------------------------
def test_redis_version():
env = _fragment("redis", service_cfg={"version": "6.2.14"}).get("environment", {})
assert env.get("REDIS_VERSION") == "6.2.14"
def test_redis_os_string():
env = _fragment("redis", service_cfg={"os_string": "Linux 4.19.0"}).get("environment", {})
assert env.get("REDIS_OS") == "Linux 4.19.0"
def test_redis_default_no_extra_env():
env = _fragment("redis").get("environment", {})
assert "REDIS_VERSION" not in env
assert "REDIS_OS" not in env
# Telnet ---------------------------------------------------------------------
def test_telnet_uses_build_context():
"""Telnet uses a build context (no Cowrie image)."""
frag = _fragment("telnet")
assert "build" in frag
assert "image" not in frag
def test_telnet_default_password():
env = _fragment("telnet").get("environment", {})
assert env.get("TELNET_ROOT_PASSWORD") == "admin"
def test_telnet_custom_password():
env = _fragment("telnet", service_cfg={"password": "s3cr3t"}).get("environment", {})
assert env.get("TELNET_ROOT_PASSWORD") == "s3cr3t"
def test_telnet_no_cowrie_env_vars():
"""Ensure no Cowrie env vars bleed into the real telnet service."""
env = _fragment("telnet").get("environment", {})
assert not any(k.startswith("COWRIE_") for k in env)

28
tests/test_smtp_relay.py Normal file
View File

@@ -0,0 +1,28 @@
"""
Tests for SMTP Relay service.
"""
from decnet.services.smtp_relay import SMTPRelayService
def test_smtp_relay_compose_fragment():
svc = SMTPRelayService()
fragment = svc.compose_fragment("test-decky", log_target="log-server")
assert fragment["container_name"] == "test-decky-smtp_relay"
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
assert fragment["environment"]["LOG_TARGET"] == "log-server"
def test_smtp_relay_custom_cfg():
svc = SMTPRelayService()
fragment = svc.compose_fragment(
"test-decky",
service_cfg={"banner": "Welcome", "mta": "Postfix"}
)
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
assert fragment["environment"]["SMTP_MTA"] == "Postfix"
def test_smtp_relay_dockerfile_context():
svc = SMTPRelayService()
ctx = svc.dockerfile_context()
assert ctx.name == "smtp"
assert ctx.is_dir()

168
tests/test_ssh.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Tests for the SSHService plugin (real OpenSSH, Cowrie removed).
"""
from decnet.services.registry import all_services, get_service
from decnet.archetypes import get_archetype
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fragment(service_cfg: dict | None = None, log_target: str | None = None) -> dict:
return get_service("ssh").compose_fragment(
"test-decky", log_target=log_target, service_cfg=service_cfg
)
def _dockerfile_text() -> str:
return (get_service("ssh").dockerfile_context() / "Dockerfile").read_text()
def _entrypoint_text() -> str:
return (get_service("ssh").dockerfile_context() / "entrypoint.sh").read_text()
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def test_ssh_registered():
assert "ssh" in all_services()
def test_real_ssh_not_registered():
assert "real_ssh" not in all_services()
def test_ssh_ports():
assert get_service("ssh").ports == [22]
def test_ssh_is_build_service():
assert get_service("ssh").default_image == "build"
def test_ssh_dockerfile_context_exists():
svc = get_service("ssh")
ctx = svc.dockerfile_context()
assert ctx.is_dir(), f"Dockerfile context missing: {ctx}"
assert (ctx / "Dockerfile").exists()
assert (ctx / "entrypoint.sh").exists()
# ---------------------------------------------------------------------------
# No Cowrie env vars
# ---------------------------------------------------------------------------
def test_no_cowrie_vars():
env = _fragment()["environment"]
cowrie_keys = [k for k in env if k.startswith("COWRIE_") or k == "NODE_NAME"]
assert cowrie_keys == [], f"Unexpected Cowrie vars: {cowrie_keys}"
# ---------------------------------------------------------------------------
# compose_fragment structure
# ---------------------------------------------------------------------------
def test_fragment_has_build():
frag = _fragment()
assert "build" in frag and "context" in frag["build"]
def test_fragment_container_name():
assert _fragment()["container_name"] == "test-decky-ssh"
def test_fragment_restart_policy():
assert _fragment()["restart"] == "unless-stopped"
def test_fragment_cap_add():
assert "NET_BIND_SERVICE" in _fragment().get("cap_add", [])
def test_default_password():
assert _fragment()["environment"]["SSH_ROOT_PASSWORD"] == "admin"
def test_custom_password():
assert _fragment(service_cfg={"password": "h4x!"})["environment"]["SSH_ROOT_PASSWORD"] == "h4x!"
def test_custom_hostname():
assert _fragment(service_cfg={"hostname": "prod-db-01"})["environment"]["SSH_HOSTNAME"] == "prod-db-01"
def test_no_hostname_by_default():
assert "SSH_HOSTNAME" not in _fragment()["environment"]
def test_no_log_target_in_env():
assert "LOG_TARGET" not in _fragment(log_target="10.0.0.1:5140").get("environment", {})
# ---------------------------------------------------------------------------
# Logging pipeline wiring (Dockerfile + entrypoint)
# ---------------------------------------------------------------------------
def test_dockerfile_has_rsyslog():
assert "rsyslog" in _dockerfile_text()
def test_dockerfile_runs_as_root():
lines = [line.strip() for line in _dockerfile_text().splitlines()]
user_lines = [line for line in lines if line.startswith("USER ")]
assert user_lines == [], f"Unexpected USER directive(s): {user_lines}"
def test_dockerfile_rsyslog_conf_created():
df = _dockerfile_text()
assert "99-decnet.conf" in df
assert "RFC5424fmt" in df
def test_dockerfile_sudoers_syslog():
df = _dockerfile_text()
assert "syslog=auth" in df
assert "log_input" in df
assert "log_output" in df
def test_dockerfile_prompt_command_logger():
df = _dockerfile_text()
assert "PROMPT_COMMAND" in df
assert "logger" in df
def test_entrypoint_creates_named_pipe():
assert "mkfifo" in _entrypoint_text()
def test_entrypoint_starts_rsyslogd():
assert "rsyslogd" in _entrypoint_text()
def test_entrypoint_sshd_no_dash_e():
ep = _entrypoint_text()
assert "sshd -D" in ep
assert "sshd -D -e" not in ep
# ---------------------------------------------------------------------------
# Deaddeck archetype
# ---------------------------------------------------------------------------
def test_deaddeck_uses_ssh():
arch = get_archetype("deaddeck")
assert "ssh" in arch.services
assert "real_ssh" not in arch.services
def test_deaddeck_nmap_os():
assert get_archetype("deaddeck").nmap_os == "linux"
def test_deaddeck_preferred_distros_not_empty():
assert len(get_archetype("deaddeck").preferred_distros) >= 1

154
tests/test_web_api.py Normal file
View File

@@ -0,0 +1,154 @@
"""
Tests for decnet/web/api.py lifespan and decnet/web/dependencies.py auth helpers.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from decnet.web.auth import create_access_token
# ── get_current_user ──────────────────────────────────────────────────────────
class TestGetCurrentUser:
@pytest.mark.asyncio
async def test_valid_token(self):
from decnet.web.dependencies import get_current_user
token = create_access_token({"uuid": "test-uuid-123"})
request = MagicMock()
request.headers = {"Authorization": f"Bearer {token}"}
result = await get_current_user(request)
assert result == "test-uuid-123"
@pytest.mark.asyncio
async def test_no_auth_header(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_current_user
request = MagicMock()
request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await get_current_user(request)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_invalid_jwt(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_current_user
request = MagicMock()
request.headers = {"Authorization": "Bearer invalid-token"}
with pytest.raises(HTTPException) as exc_info:
await get_current_user(request)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_missing_uuid_in_payload(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_current_user
token = create_access_token({"sub": "no-uuid-field"})
request = MagicMock()
request.headers = {"Authorization": f"Bearer {token}"}
with pytest.raises(HTTPException) as exc_info:
await get_current_user(request)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_bearer_prefix_required(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_current_user
token = create_access_token({"uuid": "test-uuid"})
request = MagicMock()
request.headers = {"Authorization": f"Token {token}"}
with pytest.raises(HTTPException):
await get_current_user(request)
# ── get_stream_user ───────────────────────────────────────────────────────────
class TestGetStreamUser:
@pytest.mark.asyncio
async def test_bearer_header(self):
from decnet.web.dependencies import get_stream_user
token = create_access_token({"uuid": "stream-uuid"})
request = MagicMock()
request.headers = {"Authorization": f"Bearer {token}"}
result = await get_stream_user(request, token=None)
assert result == "stream-uuid"
@pytest.mark.asyncio
async def test_query_param_fallback(self):
from decnet.web.dependencies import get_stream_user
token = create_access_token({"uuid": "query-uuid"})
request = MagicMock()
request.headers = {}
result = await get_stream_user(request, token=token)
assert result == "query-uuid"
@pytest.mark.asyncio
async def test_no_token_raises(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_stream_user
request = MagicMock()
request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await get_stream_user(request, token=None)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_invalid_token_raises(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_stream_user
request = MagicMock()
request.headers = {}
with pytest.raises(HTTPException):
await get_stream_user(request, token="bad-token")
@pytest.mark.asyncio
async def test_missing_uuid_raises(self):
from fastapi import HTTPException
from decnet.web.dependencies import get_stream_user
token = create_access_token({"sub": "no-uuid"})
request = MagicMock()
request.headers = {"Authorization": f"Bearer {token}"}
with pytest.raises(HTTPException):
await get_stream_user(request, token=None)
# ── web/api.py lifespan ──────────────────────────────────────────────────────
class TestLifespan:
@pytest.mark.asyncio
async def test_lifespan_startup_and_shutdown(self):
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
mock_repo.initialize = AsyncMock()
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
async with lifespan(mock_app):
mock_repo.initialize.assert_awaited_once()
@pytest.mark.asyncio
async def test_lifespan_db_retry(self):
from decnet.web.api import lifespan
mock_app = MagicMock()
mock_repo = MagicMock()
_call_count: int = 0
async def _failing_init():
nonlocal _call_count
_call_count += 1
if _call_count < 3:
raise Exception("DB locked")
mock_repo.initialize = _failing_init
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock):
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
async with lifespan(mock_app):
assert _call_count == 3