refactor(tests): move flat tests/*.py into per-subsystem subfolders

Groups every flat test_*.py under the module it exercises, matching the
existing tests/{profiler,sniffer,prober,collector,correlation,cli,web,
topology,swarm,bus,updater,api,docker,geoip,...} layout. New folders:
services/, fleet/, config/, logging/, db/ (+ db/mysql/), telemetry/,
mutator/, core/.

Path-dependent __file__ references bumped an extra .parent in three
files that moved one level deeper:
- tests/sniffer/test_sniffer_ja3.py   (template path)
- tests/services/test_ssh_capture_emit.py (template path)
- tests/cli/test_mode_gating.py  (REPO root)
- tests/web/test_env_lazy_jwt.py (repo var)

Also drops two SQLite runtime artifacts (test_decnet.db-{shm,wal}) that
were leaking into the repo from a previous test run.

Fixes two test_service_isolation cases that patched asyncio.sleep (no
longer on the profiler main-loop hot path — same pre-existing bug I
fixed earlier in test_attacker_worker.py) by patching asyncio.wait_for
and passing interval=0.
This commit is contained in:
2026-04-23 21:34:25 -04:00
parent 21e6820714
commit ea95a009df
78 changed files with 18 additions and 10 deletions

411
tests/cli/test_cli.py Normal file
View File

@@ -0,0 +1,411 @@
"""
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.deploy.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.deploy.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.deploy.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.deploy.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.deploy.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.deploy.detect_interface", return_value="eth0"), \
patch("decnet.cli.deploy.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.deploy.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.deploy.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.deploy.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.deploy.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.deploy.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.deploy.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.deploy.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.deploy.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.deploy.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.deploy.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.deploy.allocate_ips", return_value=["192.168.1.10"])
@patch("decnet.cli.deploy.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.deploy.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.deploy.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.deploy.load_ini")
@patch("decnet.cli.deploy.get_host_ip", return_value="192.168.1.2")
@patch("decnet.cli.deploy.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
@patch("decnet.cli.deploy.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.utils._kill_all_services")
@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
def test_status_available_in_agent_mode(self, monkeypatch):
# Agents run deckies locally and must be able to inspect them;
# `status` is intentionally NOT in MASTER_ONLY_COMMANDS.
import importlib
import decnet.cli as cli_mod
monkeypatch.setenv("DECNET_MODE", "agent")
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
reloaded = importlib.reload(cli_mod)
try:
names = {
(c.name or c.callback.__name__)
for c in reloaded.app.registered_commands
}
assert "status" in names
assert "deploy" not in names # sanity: master-only still gated
finally:
monkeypatch.delenv("DECNET_MODE", raising=False)
monkeypatch.delenv("DECNET_DISALLOW_MASTER", raising=False)
importlib.reload(cli_mod)
# ── 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
def test_web_success(self):
with (
patch("pathlib.Path.exists", return_value=True),
patch("os.chdir"),
patch(
"socketserver.TCPServer.__init__",
lambda self, *a, **kw: None,
),
patch(
"socketserver.TCPServer.__enter__",
lambda self: self,
),
patch(
"socketserver.TCPServer.__exit__",
lambda self, *a: None,
),
patch(
"socketserver.TCPServer.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 "
'[relay@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("os.killpg")
@patch("subprocess.Popen")
def test_api_keyboard_interrupt(self, mock_popen, mock_killpg, monkeypatch):
monkeypatch.setenv("DECNET_MODE", "master")
monkeypatch.delenv("DECNET_DISALLOW_MASTER", raising=False)
proc = MagicMock()
proc.wait.side_effect = [KeyboardInterrupt, 0]
proc.pid = 4321
mock_popen.return_value = proc
result = runner.invoke(app, ["api"])
assert result.exit_code == 0
mock_killpg.assert_called()
@patch("subprocess.Popen", side_effect=FileNotFoundError)
def test_api_not_found(self, mock_popen, monkeypatch):
monkeypatch.setenv("DECNET_MODE", "master")
monkeypatch.delenv("DECNET_DISALLOW_MASTER", raising=False)
result = runner.invoke(app, ["api"])
assert result.exit_code == 0
# ── _kill_all_services ────────────────────────────────────────────────────────
class TestKillAllServices:
@patch("os.kill")
@patch("psutil.process_iter")
def test_kills_matching_processes(self, mock_iter, mock_kill):
from decnet.cli import _kill_all_services
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_collector = MagicMock()
mock_collector.info = {
"pid": 333, "name": "python",
"cmdline": ["python", "-m", "decnet.cli", "collect", "--log-file", "/tmp/decnet.log"],
}
mock_iter.return_value = [mock_uvicorn, mock_mutate, mock_collector]
_kill_all_services()
assert mock_kill.call_count == 3
@patch("psutil.process_iter")
def test_no_matching_processes(self, mock_iter):
from decnet.cli import _kill_all_services
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": ["bash"]}
mock_iter.return_value = [mock_proc]
_kill_all_services()
@patch("psutil.process_iter")
def test_handles_empty_cmdline(self, mock_iter):
from decnet.cli import _kill_all_services
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": None}
mock_iter.return_value = [mock_proc]
_kill_all_services()

View File

@@ -0,0 +1,134 @@
"""
Tests for the `decnet db-reset` CLI command.
No live MySQL required — the async worker is mocked.
"""
from unittest.mock import AsyncMock, patch
import pytest
from typer.testing import CliRunner
from decnet.cli import app, _db_reset_mysql_async
runner = CliRunner()
# ── Guard-rails ───────────────────────────────────────────────────────────────
class TestDbResetGuards:
def test_refuses_when_backend_is_sqlite(self, monkeypatch):
monkeypatch.setenv("DECNET_DB_TYPE", "sqlite")
result = runner.invoke(app, ["db-reset", "--i-know-what-im-doing"])
assert result.exit_code == 2
assert "MySQL-only" in result.stdout
def test_refuses_invalid_mode(self, monkeypatch):
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
result = runner.invoke(app, ["db-reset", "--mode", "nuke"])
assert result.exit_code == 2
assert "Invalid --mode" in result.stdout
def test_reports_missing_connection_info(self, monkeypatch):
"""With no URL and no component env vars, build_mysql_url raises — surface it."""
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"):
monkeypatch.delenv(v, raising=False)
# Strip pytest env so build_mysql_url's safety check trips (needs a
# password when we're "not in tests" per its own heuristic).
import os
for k in list(os.environ):
if k.startswith("PYTEST"):
monkeypatch.delenv(k, raising=False)
result = runner.invoke(app, ["db-reset"])
assert result.exit_code == 2
assert "DECNET_DB_PASSWORD" in result.stdout
# ── Dry-run vs. confirmed execution ───────────────────────────────────────────
class TestDbResetDispatch:
def test_dry_run_skips_destructive_phase(self, monkeypatch):
"""Without the flag, the command must still call into the worker
(to show row counts) but signal confirm=False."""
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d")
mock = AsyncMock()
with patch("decnet.cli.db._db_reset_mysql_async", new=mock):
result = runner.invoke(app, ["db-reset"])
assert result.exit_code == 0, result.stdout
mock.assert_awaited_once()
kwargs = mock.await_args.kwargs
assert kwargs["confirm"] is False
assert kwargs["mode"] == "truncate"
def test_confirmed_execution_passes_confirm_true(self, monkeypatch):
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d")
mock = AsyncMock()
with patch("decnet.cli.db._db_reset_mysql_async", new=mock):
result = runner.invoke(app, ["db-reset", "--i-know-what-im-doing"])
assert result.exit_code == 0, result.stdout
assert mock.await_args.kwargs["confirm"] is True
def test_drop_tables_mode_propagates(self, monkeypatch):
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d")
mock = AsyncMock()
with patch("decnet.cli.db._db_reset_mysql_async", new=mock):
result = runner.invoke(
app, ["db-reset", "--mode", "drop-tables", "--i-know-what-im-doing"]
)
assert result.exit_code == 0, result.stdout
assert mock.await_args.kwargs["mode"] == "drop-tables"
def test_explicit_url_overrides_env(self, monkeypatch):
monkeypatch.setenv("DECNET_DB_TYPE", "mysql")
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://from-env/db")
mock = AsyncMock()
with patch("decnet.cli.db._db_reset_mysql_async", new=mock):
result = runner.invoke(app, [
"db-reset", "--url", "mysql+aiomysql://override/db2",
])
assert result.exit_code == 0, result.stdout
# First positional arg to the async worker is the DSN.
assert mock.await_args.args[0] == "mysql+aiomysql://override/db2"
# ── Destructive-phase skip when flag is absent ───────────────────────────────
class TestDbResetWorker:
@pytest.mark.anyio
async def test_dry_run_does_not_open_begin_transaction(self):
"""Confirm=False must stop after the row-count inspection — no DDL/DML."""
from unittest.mock import MagicMock
mock_conn = AsyncMock()
# Every table shows as "missing" so row-count loop exits cleanly.
mock_conn.execute.side_effect = Exception("no such table")
mock_connect_cm = AsyncMock()
mock_connect_cm.__aenter__.return_value = mock_conn
mock_connect_cm.__aexit__.return_value = False
mock_engine = MagicMock()
mock_engine.connect.return_value = mock_connect_cm
mock_engine.begin = MagicMock() # must NOT be awaited in dry-run
mock_engine.dispose = AsyncMock()
with patch("sqlalchemy.ext.asyncio.create_async_engine", return_value=mock_engine):
await _db_reset_mysql_async(
"mysql+aiomysql://u:p@h/d", mode="truncate", confirm=False
)
mock_engine.begin.assert_not_called()
mock_engine.dispose.assert_awaited_once()

View File

@@ -0,0 +1,81 @@
"""
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_per_decky_services():
"""_all_service_names() must return every per-decky service (not fleet singletons)."""
pool = set(_all_service_names())
registry = all_services()
per_decky = {name for name, svc in registry.items() if not svc.fleet_singleton}
assert pool == per_decky
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"]

View File

@@ -0,0 +1,45 @@
"""
Regression guards for workers that duplicate standalone daemons.
`decnet deploy` starts standalone `decnet sniffer --daemon` and
`decnet profiler --daemon` processes. The API's lifespan must not spawn
its own copies unless the operator explicitly opts in via env flags.
These tests are intentionally static: we don't spin up lifespan, because
scapy's sniff thread doesn't cooperate with asyncio cancellation and
hangs pytest teardown.
"""
import importlib
import inspect
def test_embed_sniffer_defaults_off(monkeypatch):
monkeypatch.delenv("DECNET_EMBED_SNIFFER", raising=False)
import decnet.env
importlib.reload(decnet.env)
assert decnet.env.DECNET_EMBED_SNIFFER is False
def test_embed_sniffer_flag_is_truthy_on_opt_in(monkeypatch):
monkeypatch.setenv("DECNET_EMBED_SNIFFER", "true")
import decnet.env
importlib.reload(decnet.env)
assert decnet.env.DECNET_EMBED_SNIFFER is True
def test_api_lifespan_gates_sniffer_on_embed_flag():
"""The lifespan source must reference the gate flag before spawning the
sniffer task — catches accidental removal of the guard in future edits."""
import decnet.web.api
src = inspect.getsource(decnet.web.api.lifespan)
assert "DECNET_EMBED_SNIFFER" in src, "sniffer gate removed from lifespan"
assert "sniffer_worker" in src
# Gate must appear before the task creation.
assert src.index("DECNET_EMBED_SNIFFER") < src.index("sniffer_worker")
def test_api_lifespan_gates_profiler_on_embed_flag():
import decnet.web.api
src = inspect.getsource(decnet.web.api.lifespan)
assert "DECNET_EMBED_PROFILER" in src
assert src.index("DECNET_EMBED_PROFILER") < src.index("attacker_profile_worker")

View File

@@ -0,0 +1,89 @@
"""CLI mode gating — master-only commands hidden when DECNET_MODE=agent."""
from __future__ import annotations
import os
import pathlib
import subprocess
import sys
from pathlib import Path
import pytest
REPO = pathlib.Path(__file__).resolve().parent.parent.parent
#DECNET_BIN = REPO / ".venv" / "bin" / "decnet"
DECNET_BIN = Path(sys.executable).parent / "decnet"
def _clean_env(**overrides: str) -> dict[str, str]:
"""Env with no DECNET_* / PYTEST_* leakage from the parent test run.
Keeps only PATH so subprocess can locate the interpreter. HOME is
stubbed below so .env.local from the user's home doesn't leak in."""
base = {"PATH": os.environ["PATH"], "HOME": "/nonexistent-for-test"}
base.update(overrides)
# Ensure no stale DECNET_CONFIG pointing at some fixture INI
base["DECNET_CONFIG"] = "/nonexistent/decnet.ini"
# decnet.web.auth needs a JWT secret to import; provide one so
# `decnet --help` can walk the command tree.
base.setdefault("DECNET_JWT_SECRET", "x" * 32)
return base
def _help_text(env: dict[str, str]) -> str:
result = subprocess.run(
[str(DECNET_BIN), "--help"],
env=env, cwd=str(REPO),
capture_output=True, text=True, timeout=20,
)
assert result.returncode == 0, result.stderr
return result.stdout
def test_master_mode_lists_master_commands():
out = _help_text(_clean_env(DECNET_MODE="master"))
for cmd in ("api", "swarmctl", "swarm", "deploy", "teardown"):
assert cmd in out, f"expected '{cmd}' in master-mode --help"
# Agent commands are also visible on master (dual-use hosts).
for cmd in ("agent", "forwarder", "updater"):
assert cmd in out
def test_agent_mode_hides_master_commands():
out = _help_text(_clean_env(DECNET_MODE="agent", DECNET_DISALLOW_MASTER="true"))
for cmd in ("api", "swarmctl", "deploy", "teardown", "listener"):
assert cmd not in out, f"'{cmd}' leaked into agent-mode --help"
# The `swarm` subcommand group must also disappear — identify it by its
# unique help string (plain 'swarm' appears in other command descriptions).
assert "Manage swarm workers" not in out
# Worker-legitimate commands must remain.
for cmd in ("agent", "forwarder", "updater"):
assert cmd in out
def test_agent_mode_can_opt_in_to_master_via_disallow_false():
"""A hybrid dev host sets DECNET_DISALLOW_MASTER=false and keeps
full access even though DECNET_MODE=agent. This is the escape hatch
for single-machine development."""
out = _help_text(_clean_env(
DECNET_MODE="agent", DECNET_DISALLOW_MASTER="false",
))
assert "api" in out
assert "swarmctl" in out
def test_defence_in_depth_direct_call_fails_in_agent_mode(monkeypatch):
"""Typer's dispatch table hides the command in agent mode, but if
something imports the command function directly it must still bail.
_require_master_mode('api') is the belt-and-braces guard."""
monkeypatch.setenv("DECNET_MODE", "agent")
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
# Re-import cli so the module-level gate re-runs (harmless here;
# we're exercising the in-function guard).
for mod in list(sys.modules):
if mod == "decnet.cli":
sys.modules.pop(mod)
from decnet.cli import _require_master_mode
import typer
with pytest.raises(typer.Exit):
_require_master_mode("api")