merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

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

391
tests/cli/test_cli.py Normal file
View 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()

View File

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

View File

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

View File

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

495
tests/cli/test_init.py Normal file
View 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

View File

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

View 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")

View 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"}

View 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"