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:
411
tests/cli/test_cli.py
Normal file
411
tests/cli/test_cli.py
Normal 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()
|
||||
134
tests/cli/test_cli_db_reset.py
Normal file
134
tests/cli/test_cli_db_reset.py
Normal 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()
|
||||
81
tests/cli/test_cli_service_pool.py
Normal file
81
tests/cli/test_cli_service_pool.py
Normal 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"]
|
||||
45
tests/cli/test_embedded_workers.py
Normal file
45
tests/cli/test_embedded_workers.py
Normal 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")
|
||||
89
tests/cli/test_mode_gating.py
Normal file
89
tests/cli/test_mode_gating.py
Normal 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")
|
||||
Reference in New Issue
Block a user