merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/cli/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
391
tests/cli/test_cli.py
Normal file
391
tests/cli/test_cli.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# ── 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")
|
||||
495
tests/cli/test_init.py
Normal file
495
tests/cli/test_init.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""Orchestration tests for ``decnet init``.
|
||||
|
||||
The command is a thin orchestrator over privileged system calls. We
|
||||
exercise every branch by monkeypatching subprocess + identity lookups
|
||||
and using the hidden ``--prefix`` option to redirect filesystem writes
|
||||
into a pytest ``tmp_path``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from decnet.cli import app
|
||||
from decnet.cli import init as _init
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subprocess_calls(monkeypatch: Any) -> List[List[str]]:
|
||||
calls: List[List[str]] = []
|
||||
|
||||
def _fake_run(argv: List[str], *a: Any, **kw: Any) -> Any:
|
||||
calls.append(list(argv))
|
||||
|
||||
class _Ok:
|
||||
returncode = 0
|
||||
return _Ok()
|
||||
|
||||
monkeypatch.setattr(_init.subprocess, "run", _fake_run)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_missing_tools(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(_init.shutil, "which", lambda _: "/usr/bin/fake")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def present_user_and_group(monkeypatch: Any) -> None:
|
||||
class _Stub:
|
||||
pw_uid = 1000
|
||||
gr_gid = 1000
|
||||
|
||||
monkeypatch.setattr(_init.pwd, "getpwnam", lambda _: _Stub())
|
||||
monkeypatch.setattr(_init.grp, "getgrnam", lambda _: _Stub())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_user_and_group(monkeypatch: Any) -> None:
|
||||
def _raise(_: str) -> None:
|
||||
raise KeyError
|
||||
|
||||
monkeypatch.setattr(_init.pwd, "getpwnam", _raise)
|
||||
monkeypatch.setattr(_init.grp, "getgrnam", _raise)
|
||||
|
||||
|
||||
def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> Path:
|
||||
"""Point `_deploy_root()` at a faked deploy tree under tmp_path.
|
||||
|
||||
Services are Jinja2 templates keyed on ``{{ install_dir }}`` —
|
||||
matching production layout since the refactor that made install
|
||||
path configurable.
|
||||
"""
|
||||
deploy = tmp_path / "deploy"
|
||||
(deploy / "polkit").mkdir(parents=True)
|
||||
(deploy / "tmpfiles.d").mkdir()
|
||||
(deploy / "decnet-bus.service.j2").write_text(
|
||||
"[Service]\nExecStart={{ install_dir }}/venv/bin/decnet bus\n"
|
||||
)
|
||||
(deploy / "decnet-api.service.j2").write_text(
|
||||
"[Service]\nWorkingDirectory={{ install_dir }}\n"
|
||||
"ExecStart={{ install_dir }}/venv/bin/decnet api\n"
|
||||
)
|
||||
(deploy / "decnet.target").write_text("# target\n")
|
||||
(deploy / "polkit" / "50-decnet-workers.rules.j2").write_text(
|
||||
'// rule for {{ group }}\n'
|
||||
)
|
||||
(deploy / "tmpfiles.d" / "decnet.conf").write_text("d /run/decnet\n")
|
||||
(deploy / "logrotate.d").mkdir()
|
||||
(deploy / "logrotate.d" / "decnet").write_text(
|
||||
"/var/log/decnet/*.log {\n daily\n rotate 7\n copytruncate\n}\n"
|
||||
)
|
||||
monkeypatch.setattr(_init, "_deploy_root", lambda: deploy)
|
||||
return deploy
|
||||
|
||||
|
||||
def test_non_root_exits_one(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(_init.os, "geteuid", lambda: 1000)
|
||||
result = runner.invoke(app, ["init"])
|
||||
assert result.exit_code == 1
|
||||
assert "must run as root" in result.output
|
||||
|
||||
|
||||
def test_dry_run_issues_no_subprocess_calls(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--dry-run", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert subprocess_calls == [], (
|
||||
f"dry-run must not exec anything, got {subprocess_calls}"
|
||||
)
|
||||
assert "would run:" in result.output
|
||||
# No real files created either.
|
||||
assert not (tmp_path / "root" / "etc/systemd/system").exists()
|
||||
|
||||
|
||||
def test_missing_user_and_group_triggers_useradd_groupadd(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
groupadds = [c for c in subprocess_calls if c[:1] == ["groupadd"]]
|
||||
useradds = [c for c in subprocess_calls if c[:1] == ["useradd"]]
|
||||
assert groupadds == [["groupadd", "--system", "decnet"]]
|
||||
assert useradds and useradds[0][:6] == [
|
||||
"useradd", "--system", "--gid", "decnet", "--home-dir", "/opt/decnet",
|
||||
]
|
||||
assert useradds[0][-1] == "decnet"
|
||||
|
||||
|
||||
def test_present_user_and_group_skipped(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert all(c[0] not in ("groupadd", "useradd") for c in subprocess_calls)
|
||||
assert "[SKIP]" in result.output
|
||||
|
||||
|
||||
def test_unit_files_are_installed_then_idempotent(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
# First run: installs.
|
||||
r1 = runner.invoke(
|
||||
app, ["init", "--no-start", "--prefix", str(prefix)],
|
||||
)
|
||||
assert r1.exit_code == 0, r1.output
|
||||
target = prefix / "etc/systemd/system" / "decnet.target"
|
||||
assert target.is_file()
|
||||
assert target.read_text() == "# target\n"
|
||||
|
||||
# Second run: every copy should SKIP.
|
||||
subprocess_calls.clear()
|
||||
r2 = runner.invoke(
|
||||
app, ["init", "--no-start", "--prefix", str(prefix)],
|
||||
)
|
||||
assert r2.exit_code == 0, r2.output
|
||||
assert "unit files up to date" in r2.output
|
||||
|
||||
|
||||
def test_init_installs_logrotate_config(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
"""`decnet init` must drop /etc/logrotate.d/decnet so /var/log/decnet/
|
||||
can't fill the disk on a noisy honeypot day."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
r = runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
||||
assert r.exit_code == 0, r.output
|
||||
cfg = prefix / "etc/logrotate.d/decnet"
|
||||
assert cfg.is_file(), "logrotate config should be installed"
|
||||
body = cfg.read_text()
|
||||
assert "copytruncate" in body, (
|
||||
"must use copytruncate; ingester holds the file open and won't "
|
||||
"reopen on a rename rotation"
|
||||
)
|
||||
|
||||
|
||||
def test_init_writes_decnet_ini_not_config_ini(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
"""Placeholder target is /etc/decnet/decnet.ini (new name) — matches
|
||||
what decnet.config_ini.load_ini_config() actually reads. Guards
|
||||
against regressing to the old `config.ini` name."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
r = runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
||||
assert r.exit_code == 0, r.output
|
||||
|
||||
ini = prefix / "etc/decnet/decnet.ini"
|
||||
legacy = prefix / "etc/decnet/config.ini"
|
||||
assert ini.is_file(), "decnet.ini should be written"
|
||||
assert not legacy.exists(), "legacy config.ini must not be written"
|
||||
|
||||
body = ini.read_text()
|
||||
# Admin-facing sections are documented as commented examples so
|
||||
# the placeholder teaches the file shape.
|
||||
for header in ("[decnet]", "[api]", "[web]", "[database]",
|
||||
"[bus]", "[swarm]", "[logging]", "[ingester]",
|
||||
"[tracing]", "[agent]"):
|
||||
assert header in body, f"placeholder missing {header} example"
|
||||
|
||||
|
||||
def test_install_dir_renders_into_service_units(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
"""`--install-dir /srv/decnet` must land in the rendered service
|
||||
files. Regression guard for the Jinja2 templating refactor."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
r = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init", "--no-start",
|
||||
"--prefix", str(prefix),
|
||||
"--install-dir", "/srv/decnet",
|
||||
],
|
||||
)
|
||||
assert r.exit_code == 0, r.output
|
||||
|
||||
api_unit = prefix / "etc/systemd/system" / "decnet-api.service"
|
||||
bus_unit = prefix / "etc/systemd/system" / "decnet-bus.service"
|
||||
assert api_unit.is_file()
|
||||
api_text = api_unit.read_text()
|
||||
assert "/srv/decnet" in api_text
|
||||
assert "/opt/decnet" not in api_text
|
||||
assert "{{" not in api_text, "unrendered Jinja tag leaked through"
|
||||
assert "/srv/decnet" in bus_unit.read_text()
|
||||
|
||||
# useradd --home-dir must match the install_dir override too.
|
||||
useradds = [c for c in subprocess_calls if c and c[0] == "useradd"]
|
||||
assert useradds, "expected useradd call"
|
||||
assert "/srv/decnet" in useradds[0]
|
||||
assert "/opt/decnet" not in useradds[0]
|
||||
|
||||
# And /srv/decnet on disk should be the dir we created.
|
||||
assert (prefix / "srv/decnet").is_dir()
|
||||
assert not (prefix / "opt/decnet").exists()
|
||||
|
||||
|
||||
def test_install_dir_defaults_to_opt(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
"""Default --install-dir is /opt/decnet — existing installs remain
|
||||
byte-identical with no explicit flag."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
r = runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
||||
assert r.exit_code == 0, r.output
|
||||
api_unit = prefix / "etc/systemd/system" / "decnet-api.service"
|
||||
assert "/opt/decnet" in api_unit.read_text()
|
||||
|
||||
|
||||
def test_install_dir_rejects_relative_path(
|
||||
monkeypatch: Any, tmp_path: Path,
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
"""Relative install_dir would break every absolute path in a
|
||||
rendered service. Reject at the CLI boundary with a clear message."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
r = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init", "--no-start",
|
||||
"--prefix", str(tmp_path / "root"),
|
||||
"--install-dir", "relative/path",
|
||||
],
|
||||
)
|
||||
assert r.exit_code == 1
|
||||
assert "must be absolute" in r.output
|
||||
|
||||
|
||||
def test_install_dir_custom_idempotent_second_run(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
"""Rendering the same templates twice with the same context must
|
||||
produce byte-identical output — second run SKIPs, no churn."""
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init", "--no-start",
|
||||
"--prefix", str(prefix),
|
||||
"--install-dir", "/srv/decnet",
|
||||
],
|
||||
)
|
||||
r2 = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init", "--no-start",
|
||||
"--prefix", str(prefix),
|
||||
"--install-dir", "/srv/decnet",
|
||||
],
|
||||
)
|
||||
assert r2.exit_code == 0, r2.output
|
||||
assert "unit files up to date" in r2.output
|
||||
|
||||
|
||||
def test_force_overwrites_existing_units(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
deploy = _seed_deploy(monkeypatch, tmp_path)
|
||||
prefix = tmp_path / "root"
|
||||
runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
||||
# Mutate the installed copy so SHA-256 matches source, but we ask
|
||||
# for --force anyway: source wins.
|
||||
target = prefix / "etc/systemd/system" / "decnet.target"
|
||||
target.write_text("# tampered\n")
|
||||
r = runner.invoke(
|
||||
app,
|
||||
["init", "--no-start", "--force", "--prefix", str(prefix)],
|
||||
)
|
||||
assert r.exit_code == 0, r.output
|
||||
assert target.read_text() == (deploy / "decnet.target").read_text()
|
||||
|
||||
|
||||
def test_no_start_suppresses_target_start(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
runner.invoke(
|
||||
app,
|
||||
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
enables = [
|
||||
c for c in subprocess_calls
|
||||
if c[:2] == ["systemctl", "enable"]
|
||||
]
|
||||
assert enables == []
|
||||
|
||||
|
||||
def test_default_invokes_target_start(
|
||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
_seed_deploy(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app, ["init", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert ["systemctl", "enable", "--now", "decnet.target"] in subprocess_calls
|
||||
assert ["systemctl", "daemon-reload"] in subprocess_calls
|
||||
|
||||
|
||||
def _seed_installed_state(prefix: Path) -> None:
|
||||
"""Create the files a prior `decnet init` would have installed."""
|
||||
systemd = prefix / "etc/systemd/system"
|
||||
systemd.mkdir(parents=True)
|
||||
(systemd / "decnet-bus.service").write_text("# bus\n")
|
||||
(systemd / "decnet-api.service").write_text("# api\n")
|
||||
(systemd / "decnet.target").write_text("# target\n")
|
||||
polkit = prefix / "etc/polkit-1/rules.d"
|
||||
polkit.mkdir(parents=True)
|
||||
(polkit / "50-decnet-workers.rules").write_text("// rule\n")
|
||||
tmpfiles = prefix / "etc/tmpfiles.d"
|
||||
tmpfiles.mkdir(parents=True)
|
||||
(tmpfiles / "decnet.conf").write_text("d /run/decnet\n")
|
||||
logrotate = prefix / "etc/logrotate.d"
|
||||
logrotate.mkdir(parents=True)
|
||||
(logrotate / "decnet").write_text("/var/log/decnet/*.log {}\n")
|
||||
etc_decnet = prefix / "etc/decnet"
|
||||
etc_decnet.mkdir(parents=True)
|
||||
(etc_decnet / "decnet.ini").write_text("[decnet]\n")
|
||||
# Also seed the legacy config.ini so we cover the legacy-cleanup path.
|
||||
(etc_decnet / "config.ini").write_text("[decnet]\n")
|
||||
(prefix / "opt/decnet").mkdir(parents=True)
|
||||
(prefix / "run/decnet").mkdir(parents=True)
|
||||
(prefix / "var/lib/decnet").mkdir(parents=True)
|
||||
(prefix / "var/log/decnet").mkdir(parents=True)
|
||||
(prefix / "var/log/decnet/events.jsonl").write_text("{}\n")
|
||||
|
||||
|
||||
def test_deinit_removes_units_polkit_tmpfiles_and_preserves_data(
|
||||
tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
prefix = tmp_path / "root"
|
||||
_seed_installed_state(prefix)
|
||||
result = runner.invoke(
|
||||
app, ["init", "--deinit", "--prefix", str(prefix)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Units + polkit + tmpfiles.d gone.
|
||||
assert not (prefix / "etc/systemd/system/decnet-bus.service").exists()
|
||||
assert not (prefix / "etc/systemd/system/decnet.target").exists()
|
||||
assert not (prefix / "etc/polkit-1/rules.d/50-decnet-workers.rules").exists()
|
||||
assert not (prefix / "etc/tmpfiles.d/decnet.conf").exists()
|
||||
assert not (prefix / "etc/logrotate.d/decnet").exists()
|
||||
assert not (prefix / "etc/decnet").exists()
|
||||
assert not (prefix / "opt/decnet").exists()
|
||||
|
||||
# Data dirs preserved.
|
||||
assert (prefix / "var/lib/decnet").exists()
|
||||
assert (prefix / "var/log/decnet/events.jsonl").read_text() == "{}\n"
|
||||
|
||||
# systemctl disable + daemon-reload invoked.
|
||||
assert ["systemctl", "disable", "--now", "decnet.target"] in subprocess_calls
|
||||
assert ["systemctl", "daemon-reload"] in subprocess_calls
|
||||
# User / group are PRESERVED without --purge — an operator who
|
||||
# passed --user $USER during dev must not lose their login account.
|
||||
assert ["userdel", "decnet"] not in subprocess_calls
|
||||
assert ["groupdel", "decnet"] not in subprocess_calls
|
||||
|
||||
|
||||
def test_deinit_purge_wipes_data_dirs(
|
||||
tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
prefix = tmp_path / "root"
|
||||
_seed_installed_state(prefix)
|
||||
result = runner.invoke(
|
||||
app, ["init", "--deinit", "--purge", "--prefix", str(prefix)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (prefix / "var/lib/decnet").exists()
|
||||
assert not (prefix / "var/log/decnet").exists()
|
||||
# --purge also removes the service user/group.
|
||||
assert ["userdel", "decnet"] in subprocess_calls
|
||||
assert ["groupdel", "decnet"] in subprocess_calls
|
||||
|
||||
|
||||
def test_deinit_is_idempotent_on_clean_host(
|
||||
tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, missing_user_and_group: None,
|
||||
) -> None:
|
||||
prefix = tmp_path / "root"
|
||||
# Nothing seeded — everything should SKIP.
|
||||
result = runner.invoke(
|
||||
app, ["init", "--deinit", "--prefix", str(prefix)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.count("[SKIP]") >= 5
|
||||
# userdel / groupdel never invoked because user/group are absent.
|
||||
assert ["userdel", "decnet"] not in subprocess_calls
|
||||
assert ["groupdel", "decnet"] not in subprocess_calls
|
||||
|
||||
|
||||
def test_deinit_dry_run_touches_nothing(
|
||||
tmp_path: Path, subprocess_calls: List[List[str]],
|
||||
no_missing_tools: None, present_user_and_group: None,
|
||||
) -> None:
|
||||
prefix = tmp_path / "root"
|
||||
_seed_installed_state(prefix)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--deinit", "--purge", "--dry-run", "--prefix", str(prefix)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert subprocess_calls == []
|
||||
assert (prefix / "etc/systemd/system/decnet.target").exists()
|
||||
assert (prefix / "var/lib/decnet").exists()
|
||||
|
||||
|
||||
def test_purge_without_deinit_errors(tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
app, ["init", "--purge", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--purge only applies with --deinit" in result.output
|
||||
|
||||
|
||||
def test_missing_deploy_dir_errors_clearly(monkeypatch: Any, tmp_path: Path) -> None:
|
||||
def _boom() -> Path:
|
||||
raise RuntimeError("cannot locate deploy/ directory (looked at /nope)")
|
||||
|
||||
monkeypatch.setattr(_init, "_deploy_root", _boom)
|
||||
monkeypatch.setattr(_init.shutil, "which", lambda _: "/bin/x")
|
||||
result = runner.invoke(
|
||||
app, ["init", "--prefix", str(tmp_path / "root")],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "cannot locate deploy/" in result.output
|
||||
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")
|
||||
97
tests/cli/test_realism_gating.py
Normal file
97
tests/cli/test_realism_gating.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""``decnet realism`` is master-only.
|
||||
|
||||
Two layers per CLAUDE.md:
|
||||
|
||||
* registration-time hide via :data:`MASTER_ONLY_GROUPS` so agents don't
|
||||
see ``decnet realism`` in ``--help`` at all,
|
||||
* body-guard ``_require_master_mode()`` so a direct callable import (e.g.
|
||||
from a third-party tool) still bails on agent hosts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
DECNET_BIN = Path(sys.executable).parent / "decnet"
|
||||
|
||||
|
||||
def _clean_env(**overrides: str) -> dict[str, str]:
|
||||
base = {"PATH": os.environ["PATH"], "HOME": "/nonexistent-for-test"}
|
||||
base["DECNET_CONFIG"] = "/nonexistent/decnet.ini"
|
||||
base.setdefault("DECNET_JWT_SECRET", "x" * 32)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_realism_visible_in_master_mode():
|
||||
result = subprocess.run(
|
||||
[str(DECNET_BIN), "--help"],
|
||||
env=_clean_env(DECNET_MODE="master"),
|
||||
cwd=str(REPO),
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "realism" in result.stdout
|
||||
|
||||
|
||||
def test_realism_hidden_in_agent_mode():
|
||||
result = subprocess.run(
|
||||
[str(DECNET_BIN), "--help"],
|
||||
env=_clean_env(DECNET_MODE="agent", DECNET_DISALLOW_MASTER="true"),
|
||||
cwd=str(REPO),
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
# The sub-app's help string must be gone too — bare "realism" can
|
||||
# appear in other command descriptions.
|
||||
assert "realism content engine" not in result.stdout
|
||||
|
||||
|
||||
def test_realism_subprocess_import_personas_rejects_in_agent_mode(tmp_path):
|
||||
src = tmp_path / "personas.json"
|
||||
src.write_text(json.dumps([{
|
||||
"name": "X", "email": "x@y.com", "role": "X", "tone": "formal",
|
||||
"mannerisms": [],
|
||||
}, {
|
||||
"name": "Y", "email": "y@y.com", "role": "Y", "tone": "formal",
|
||||
"mannerisms": [],
|
||||
}]))
|
||||
result = subprocess.run(
|
||||
[str(DECNET_BIN), "realism", "import-personas", str(src)],
|
||||
env=_clean_env(DECNET_MODE="agent", DECNET_DISALLOW_MASTER="true"),
|
||||
cwd=str(REPO),
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
def test_require_master_mode_body_guard_fires_directly(monkeypatch):
|
||||
"""Defence-in-depth: even bypassing Typer registration, the body-level
|
||||
``_require_master_mode('realism ...')`` raises ``typer.Exit``. Same
|
||||
mechanism is verified for `api`/`deploy` in test_mode_gating.py."""
|
||||
import typer
|
||||
|
||||
from decnet.cli.gating import _require_master_mode
|
||||
|
||||
monkeypatch.setenv("DECNET_MODE", "agent")
|
||||
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_require_master_mode("realism import-personas")
|
||||
|
||||
|
||||
def test_master_mode_falls_through_body_guard(monkeypatch):
|
||||
"""In master mode the guard is a no-op (raises nothing)."""
|
||||
from decnet.cli.gating import _require_master_mode # noqa: F401
|
||||
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
# Should simply return.
|
||||
_require_master_mode("realism import-personas")
|
||||
129
tests/cli/test_realism_import_personas.py
Normal file
129
tests/cli/test_realism_import_personas.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""``decnet realism import-personas`` CLI command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from decnet.cli import app
|
||||
from decnet.realism import personas_pool as global_pool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pool():
|
||||
global_pool.reset_cache()
|
||||
yield
|
||||
global_pool.reset_cache()
|
||||
|
||||
|
||||
_TWO = [
|
||||
{
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
},
|
||||
{
|
||||
"name": "Sarah Johnson",
|
||||
"email": "sarah@corp.com",
|
||||
"role": "PM",
|
||||
"tone": "direct",
|
||||
"mannerisms": ["uses bullets"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_import_personas_writes_canonical_file(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps(_TWO))
|
||||
dest = tmp_path / "global_pool.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert dest.exists()
|
||||
written = json.loads(dest.read_text())
|
||||
assert {p["email"] for p in written} == {"john@corp.com", "sarah@corp.com"}
|
||||
|
||||
|
||||
def test_import_personas_explicit_output_overrides_env(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps(_TWO))
|
||||
env_dest = tmp_path / "env.json"
|
||||
explicit = tmp_path / "explicit.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(env_dest))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["realism", "import-personas", str(src), "--output", str(explicit)],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert explicit.exists()
|
||||
assert not env_dest.exists()
|
||||
|
||||
|
||||
def test_import_personas_rejects_invalid_json(tmp_path):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text("{not valid")
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid JSON" in result.stdout
|
||||
|
||||
|
||||
def test_import_personas_rejects_non_list(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps({"not": "a list"}))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "out.json"))
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "list" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_import_personas_rejects_all_invalid_entries(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps([
|
||||
{"name": "broken", "email": "no-at-symbol"},
|
||||
]))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "out.json"))
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "No valid personas" in result.stdout
|
||||
|
||||
|
||||
def test_import_personas_warns_on_single_persona(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps(_TWO[:1]))
|
||||
dest = tmp_path / "out.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Warning" in result.stdout
|
||||
assert dest.exists()
|
||||
|
||||
|
||||
def test_imported_personas_load_via_global_pool(tmp_path, monkeypatch):
|
||||
src = tmp_path / "src.json"
|
||||
src.write_text(json.dumps(_TWO))
|
||||
dest = tmp_path / "out.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app, ["realism", "import-personas", str(src)]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
|
||||
personas = global_pool.load()
|
||||
assert len(personas) == 2
|
||||
assert {p.email for p in personas} == {"john@corp.com", "sarah@corp.com"}
|
||||
36
tests/cli/test_web_proxy_target.py
Normal file
36
tests/cli/test_web_proxy_target.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""The web dashboard proxy must follow DECNET_API_HOST.
|
||||
|
||||
Hardcoding 127.0.0.1 broke deploys where the operator binds the API to
|
||||
a specific tailnet/VPN address: the API drops loopback entirely and the
|
||||
proxy gets ECONNREFUSED. Wildcard binds still proxy via loopback because
|
||||
both processes share the host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.cli.web import _proxy_target
|
||||
|
||||
|
||||
def test_loopback_passthrough() -> None:
|
||||
assert _proxy_target("127.0.0.1") == "127.0.0.1"
|
||||
|
||||
|
||||
def test_wildcard_v4_falls_back_to_loopback() -> None:
|
||||
assert _proxy_target("0.0.0.0") == "127.0.0.1"
|
||||
|
||||
|
||||
def test_wildcard_v6_falls_back_to_loopback() -> None:
|
||||
assert _proxy_target("::") == "127.0.0.1"
|
||||
|
||||
|
||||
def test_empty_falls_back_to_loopback() -> None:
|
||||
assert _proxy_target("") == "127.0.0.1"
|
||||
|
||||
|
||||
def test_specific_address_is_followed() -> None:
|
||||
# The case that was broken: API bound only on tailnet IP, proxy
|
||||
# tried loopback and got ECONNREFUSED.
|
||||
assert _proxy_target("100.64.1.7") == "100.64.1.7"
|
||||
|
||||
|
||||
def test_hostname_is_followed() -> None:
|
||||
assert _proxy_target("decnet-master.tailnet.ts.net") == "decnet-master.tailnet.ts.net"
|
||||
Reference in New Issue
Block a user