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
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:
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
0
tests/api/auth/__init__.py
Normal file
0
tests/api/auth/__init__.py
Normal file
61
tests/api/auth/test_change_pass.py
Normal file
61
tests/api/auth/test_change_pass.py
Normal 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
|
||||
50
tests/api/auth/test_login.py
Normal file
50
tests/api/auth/test_login.py
Normal 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
|
||||
0
tests/api/bounty/__init__.py
Normal file
0
tests/api/bounty/__init__.py
Normal file
42
tests/api/bounty/test_get_bounties.py
Normal file
42
tests/api/bounty/test_get_bounties.py
Normal 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
132
tests/api/conftest.py
Normal 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],
|
||||
}
|
||||
0
tests/api/fleet/__init__.py
Normal file
0
tests/api/fleet/__init__.py
Normal file
25
tests/api/fleet/test_get_deckies.py
Normal file
25
tests/api/fleet/test_get_deckies.py
Normal 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
|
||||
41
tests/api/fleet/test_mutate_decky.py
Normal file
41
tests/api/fleet/test_mutate_decky.py
Normal 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
|
||||
89
tests/api/fleet/test_mutate_interval.py
Normal file
89
tests/api/fleet/test_mutate_interval.py
Normal 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
|
||||
0
tests/api/logs/__init__.py
Normal file
0
tests/api/logs/__init__.py
Normal file
43
tests/api/logs/test_get_logs.py
Normal file
43
tests/api/logs/test_get_logs.py
Normal 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)
|
||||
116
tests/api/logs/test_histogram.py
Normal file
116
tests/api/logs/test_histogram.py
Normal 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}")
|
||||
0
tests/api/stats/__init__.py
Normal file
0
tests/api/stats/__init__.py
Normal file
47
tests/api/stats/test_get_stats.py
Normal file
47
tests/api/stats/test_get_stats.py
Normal 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
|
||||
1
tests/api/stream/__init__.py
Normal file
1
tests/api/stream/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Stream test package
|
||||
52
tests/api/stream/test_stream_events.py
Normal file
52
tests/api/stream/test_stream_events.py
Normal 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
|
||||
200
tests/api/test_repository.py
Normal file
200
tests/api/test_repository.py
Normal 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}")
|
||||
26
tests/api/test_schemathesis.py
Normal file
26
tests/api/test_schemathesis.py
Normal 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
11
tests/conftest.py
Normal 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
0
tests/live/__init__.py
Normal file
160
tests/live/conftest.py
Normal file
160
tests/live/conftest.py
Normal 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()
|
||||
39
tests/live/test_ftp_live.py
Normal file
39
tests/live/test_ftp_live.py
Normal 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])
|
||||
41
tests/live/test_http_live.py
Normal file
41
tests/live/test_http_live.py
Normal 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
|
||||
80
tests/live/test_imap_live.py
Normal file
80
tests/live/test_imap_live.py
Normal 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
|
||||
70
tests/live/test_mongodb_live.py
Normal file
70
tests/live/test_mongodb_live.py
Normal 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"
|
||||
)
|
||||
63
tests/live/test_mqtt_live.py
Normal file
63
tests/live/test_mqtt_live.py
Normal 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")
|
||||
65
tests/live/test_mysql_live.py
Normal file
65
tests/live/test_mysql_live.py
Normal 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")
|
||||
58
tests/live/test_pop3_live.py
Normal file
58
tests/live/test_pop3_live.py
Normal 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}"
|
||||
75
tests/live/test_postgres_live.py
Normal file
75
tests/live/test_postgres_live.py
Normal 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")
|
||||
44
tests/live/test_redis_live.py
Normal file
44
tests/live/test_redis_live.py
Normal 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"
|
||||
39
tests/live/test_smtp_live.py
Normal file
39
tests/live/test_smtp_live.py
Normal 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")
|
||||
0
tests/service_testing/__init__.py
Normal file
0
tests/service_testing/__init__.py
Normal file
47
tests/service_testing/conftest.py
Normal file
47
tests/service_testing/conftest.py
Normal 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]
|
||||
117
tests/service_testing/service-test.txt
Normal file
117
tests/service_testing/service-test.txt
Normal 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
|
||||
328
tests/service_testing/test_imap.py
Normal file
328
tests/service_testing/test_imap.py
Normal 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)
|
||||
161
tests/service_testing/test_mongodb.py
Normal file
161
tests/service_testing/test_mongodb.py
Normal 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)
|
||||
195
tests/service_testing/test_mqtt.py
Normal file
195
tests/service_testing/test_mqtt.py
Normal 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
|
||||
185
tests/service_testing/test_mqtt_fuzz.py
Normal file
185
tests/service_testing/test_mqtt_fuzz.py
Normal 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)
|
||||
137
tests/service_testing/test_mssql.py
Normal file
137
tests/service_testing/test_mssql.py
Normal 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)
|
||||
153
tests/service_testing/test_mysql.py
Normal file
153
tests/service_testing/test_mysql.py
Normal 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)
|
||||
286
tests/service_testing/test_pop3.py
Normal file
286
tests/service_testing/test_pop3.py
Normal 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()
|
||||
189
tests/service_testing/test_postgres.py
Normal file
189
tests/service_testing/test_postgres.py
Normal 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)
|
||||
104
tests/service_testing/test_redis.py
Normal file
104
tests/service_testing/test_redis.py
Normal 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"
|
||||
303
tests/service_testing/test_smtp.py
Normal file
303
tests/service_testing/test_smtp.py
Normal 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)
|
||||
148
tests/service_testing/test_snmp.py
Normal file
148
tests/service_testing/test_snmp.py
Normal 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
312
tests/test_archetypes.py
Normal 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
39
tests/test_base_repo.py
Normal 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()
|
||||
@@ -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
358
tests/test_cli.py
Normal 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()
|
||||
80
tests/test_cli_service_pool.py
Normal file
80
tests/test_cli_service_pool.py
Normal 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
348
tests/test_collector.py
Normal 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
244
tests/test_composer.py
Normal 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
143
tests/test_config.py
Normal 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
|
||||
64
tests/test_custom_service.py
Normal file
64
tests/test_custom_service.py
Normal 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
BIN
tests/test_decnet.db-shm
Normal file
Binary file not shown.
BIN
tests/test_decnet.db-wal
Normal file
BIN
tests/test_decnet.db-wal
Normal file
Binary file not shown.
308
tests/test_deployer.py
Normal file
308
tests/test_deployer.py
Normal 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
191
tests/test_fleet.py
Normal 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
217
tests/test_ingester.py
Normal 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
27
tests/test_ini_spaces.py
Normal 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"]
|
||||
41
tests/test_ini_validation.py
Normal file
41
tests/test_ini_validation.py
Normal 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
|
||||
73
tests/test_log_file_mount.py
Normal file
73
tests/test_log_file_mount.py
Normal 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"]
|
||||
61
tests/test_logging_forwarder.py
Normal file
61
tests/test_logging_forwarder.py
Normal 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
208
tests/test_mutator.py
Normal 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
352
tests/test_network.py
Normal 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")
|
||||
475
tests/test_os_fingerprint.py
Normal file
475
tests/test_os_fingerprint.py
Normal 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
362
tests/test_services.py
Normal 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
28
tests/test_smtp_relay.py
Normal 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
168
tests/test_ssh.py
Normal 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
154
tests/test_web_api.py
Normal 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
|
||||
Reference in New Issue
Block a user