Files
muxplex/muxplex/tests/test_cli.py
T
Brian Krabach 33b2d52af8 fix: mock _check_dependencies in CLI tests for CI (ttyd not in Ubuntu repos)
11 tests calling main() failed in GitHub Actions because ttyd is not
available in standard Ubuntu packages. _check_dependencies() calls
sys.exit(1) when ttyd is missing. Fixed by adding a mock_check_deps
pytest fixture and applying it to all 11 affected tests.

Affected tests:
- test_main_calls_serve_by_default
- test_main_passes_custom_host_and_port
- test_main_default_host_is_localhost
- test_main_passes_auth_flag
- test_main_passes_session_ttl_flag
- test_main_passes_none_for_unset_flags
- test_main_passes_explicit_host_only
- test_main_serve_subcommand_accepts_flags
- test_main_passes_tls_cert_and_key_flags
- test_main_passes_none_for_unset_tls_flags
- test_serve_subcommand_accepts_tls_flags
2026-04-04 15:41:22 -07:00

2194 lines
70 KiB
Python

"""Tests for muxplex/cli.py — CLI entry point."""
import json
import os
import shutil
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_check_deps(monkeypatch):
"""No-op _check_dependencies so tests that call main() for serve work without ttyd installed.
ttyd is not available in standard Ubuntu repos (used by GitHub Actions CI runners).
Tests that exercise the serve path of main() should use this fixture to avoid
SystemExit(1) when ttyd is absent from the test environment.
"""
monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: None)
def test_cli_module_importable():
"""muxplex.cli must be importable."""
from muxplex.cli import main # noqa: F401
def test_main_calls_serve_by_default(mock_check_deps):
"""Calling main() with no args must invoke serve() with None defaults (settings layer resolves)."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex"]):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_main_passes_custom_host_and_port(mock_check_deps):
"""main() with --host/--port must forward them to serve(); unset flags are None."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex", "--host", "192.168.1.1", "--port", "9000"]):
main()
mock_serve.assert_called_once_with(
host="192.168.1.1",
port=9000,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_main_default_host_is_localhost(mock_check_deps):
"""Default --host must be None (settings layer resolves to 127.0.0.1)."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex"]):
main()
_, kwargs = mock_serve.call_args
assert kwargs["host"] is None
def test_main_passes_auth_flag(mock_check_deps):
"""main() with --auth password must forward auth='password'; unset flags are None."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex", "--auth", "password"]):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth="password",
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_main_passes_session_ttl_flag(mock_check_deps):
"""main() with --session-ttl 3600 must forward session_ttl=3600; unset flags are None."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex", "--session-ttl", "3600"]):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=3600,
tls_cert=None,
tls_key=None,
)
def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys):
"""show_password() prints the password when MUXPLEX_AUTH=password and file exists."""
from muxplex.cli import show_password
# Set up fake home with password file
fake_home = tmp_path / "home"
pw_dir = fake_home / ".config" / "muxplex"
pw_dir.mkdir(parents=True)
pw_file = pw_dir / "password"
pw_file.write_text("my-test-password\n")
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setenv("MUXPLEX_AUTH", "password")
show_password()
captured = capsys.readouterr()
assert "my-test-password" in captured.out
def test_show_password_no_file(tmp_path, monkeypatch, capsys):
"""show_password() tells user no file found when in password mode with no file."""
from muxplex.cli import show_password
# Set up fake home WITHOUT password file
fake_home = tmp_path / "home"
fake_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setenv("MUXPLEX_AUTH", "password")
show_password()
captured = capsys.readouterr()
output_lower = captured.out.lower()
assert "no password" in output_lower or "not found" in output_lower
def test_show_password_pam_mode(monkeypatch, capsys):
"""show_password() reports PAM mode when pam_available() is True and not password mode."""
from muxplex.cli import show_password
monkeypatch.delenv("MUXPLEX_AUTH", raising=False)
with patch("muxplex.cli.pam_available", return_value=True):
show_password()
captured = capsys.readouterr()
assert "pam" in captured.out.lower()
def test_reset_secret_writes_new_secret(tmp_path, monkeypatch):
"""reset_secret() writes a new secret file with content longer than 20 chars."""
from muxplex.cli import reset_secret
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
reset_secret()
secret_path = fake_home / ".config" / "muxplex" / "secret"
assert secret_path.exists(), "Secret file must be created"
content = secret_path.read_text().strip()
assert len(content) > 20, f"Secret must be longer than 20 chars, got {len(content)}"
def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch):
"""reset_secret() sets file permissions to 0o600."""
from muxplex.cli import reset_secret
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
reset_secret()
secret_path = fake_home / ".config" / "muxplex" / "secret"
file_mode = stat.S_IMODE(secret_path.stat().st_mode)
assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}"
def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
"""reset_secret() prints a warning that sessions are now invalid."""
from muxplex.cli import reset_secret
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
reset_secret()
captured = capsys.readouterr()
output_lower = captured.out.lower()
assert "invalid" in output_lower or "warning" in output_lower, (
f"Expected 'invalid' or 'warning' in output, got: {captured.out!r}"
)
def test_check_dependencies_exits_when_ttyd_missing(monkeypatch):
"""_check_dependencies() must sys.exit(1) when ttyd is not in PATH."""
import shutil
import pytest
from muxplex.cli import _check_dependencies
orig_which = shutil.which
def fake_which(name):
if name == "ttyd":
return None
return orig_which(name)
monkeypatch.setattr(shutil, "which", fake_which)
with pytest.raises(SystemExit) as exc_info:
_check_dependencies()
assert exc_info.value.code == 1
def test_check_dependencies_exits_when_tmux_missing(monkeypatch):
"""_check_dependencies() must sys.exit(1) when tmux is not in PATH."""
import shutil
import pytest
from muxplex.cli import _check_dependencies
orig_which = shutil.which
def fake_which(name):
if name == "tmux":
return None
return orig_which(name)
monkeypatch.setattr(shutil, "which", fake_which)
with pytest.raises(SystemExit) as exc_info:
_check_dependencies()
assert exc_info.value.code == 1
def test_check_dependencies_passes_when_all_present(monkeypatch):
"""_check_dependencies() must not raise when both tmux and ttyd are found."""
import shutil
from muxplex.cli import _check_dependencies
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
# Should not raise
_check_dependencies()
def test_main_check_dependencies_called_for_serve(monkeypatch):
"""main() must call _check_dependencies() when subcommand is serve."""
from muxplex.cli import main
calls = []
monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: calls.append(True))
with patch("muxplex.cli.serve"):
with patch("sys.argv", ["muxplex"]):
main()
assert len(calls) == 1, "_check_dependencies must be called once for serve"
def test_dunder_main_calls_main():
"""python -m muxplex must call cli.main()."""
import importlib.util
# Locate __main__.py without executing it (find_spec does not import)
spec = importlib.util.find_spec("muxplex.__main__")
assert spec is not None and spec.origin is not None
with patch("muxplex.cli.main") as mock_main:
exec(Path(spec.origin).read_text()) # noqa: S102
mock_main.assert_called_once()
# ---------------------------------------------------------------------------
# doctor() tests
# ---------------------------------------------------------------------------
def test_doctor_shows_python_version(capsys):
"""doctor must show Python version."""
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
assert "Python" in out
def test_doctor_checks_tmux(capsys, monkeypatch):
"""doctor must check for tmux."""
import subprocess
from muxplex.cli import doctor
monkeypatch.setattr(
"shutil.which", lambda name: "/usr/bin/tmux" if name == "tmux" else None
)
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **kw: type(
"R", (), {"returncode": 0, "stdout": "tmux 3.4", "stderr": ""}
)(),
)
doctor()
out = capsys.readouterr().out
assert "tmux" in out
def test_doctor_reports_missing_ttyd(capsys, monkeypatch):
"""doctor must report when ttyd is missing."""
from muxplex.cli import doctor
original_which = shutil.which
def mock_which(name):
if name == "ttyd":
return None
return original_which(name)
monkeypatch.setattr("shutil.which", mock_which)
doctor()
out = capsys.readouterr().out
assert "ttyd" in out
assert "not found" in out
def test_doctor_shows_platform(capsys):
"""doctor must show platform info."""
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
assert "Platform" in out
def test_doctor_subcommand_registered():
"""doctor must be a valid subcommand in main() argparse."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue().lower()
assert "doctor" in help_text
def test_main_dispatches_to_doctor(monkeypatch):
"""main() with 'doctor' subcommand must invoke doctor()."""
from muxplex.cli import main
calls = []
monkeypatch.setattr("muxplex.cli.doctor", lambda: calls.append(True))
with patch("sys.argv", ["muxplex", "doctor"]):
main()
assert len(calls) == 1, (
"doctor() must be called once when 'doctor' subcommand is used"
)
# ---------------------------------------------------------------------------
# upgrade / update subcommand tests
# ---------------------------------------------------------------------------
def test_upgrade_subcommand_registered():
"""upgrade must be a valid subcommand."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue().lower()
assert "upgrade" in help_text
def test_update_alias_registered():
"""update must be a valid subcommand (alias for upgrade)."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue().lower()
assert "update" in help_text
def test_upgrade_calls_uv_tool_install(monkeypatch, capsys):
"""upgrade must attempt uv tool install when update is available."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
# Mock version check so upgrade proceeds regardless of local install type
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (abc12345 → def67890)"),
)
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade()
# Should have called uv tool install
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
assert len(uv_calls) > 0, "upgrade must call uv tool install"
def test_main_dispatches_to_upgrade(monkeypatch):
"""main() with 'upgrade' subcommand must invoke upgrade()."""
from muxplex.cli import main
calls = []
monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True))
with patch("sys.argv", ["muxplex", "upgrade"]):
main()
assert len(calls) == 1, "upgrade() must be called once for 'upgrade' subcommand"
def test_main_dispatches_update_to_upgrade(monkeypatch):
"""main() with 'update' subcommand must also invoke upgrade()."""
from muxplex.cli import main
calls = []
monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True))
with patch("sys.argv", ["muxplex", "update"]):
main()
assert len(calls) == 1, "upgrade() must be called once for 'update' subcommand"
# ---------------------------------------------------------------------------
# Smart version-check tests (_get_install_info / _check_for_update)
# ---------------------------------------------------------------------------
def test_get_install_info_returns_dict():
"""_get_install_info must return a dict with all required keys."""
from muxplex.cli import _get_install_info
info = _get_install_info()
assert "source" in info
assert "version" in info
assert "commit" in info
assert "url" in info
assert info["source"] in ("git", "editable", "pypi", "unknown")
def test_check_for_update_editable_returns_false():
"""Editable installs must never suggest an update."""
from muxplex.cli import _check_for_update
info = {"source": "editable", "version": "0.1.0", "commit": None, "url": None}
available, msg = _check_for_update(info)
assert available is False
assert "editable" in msg
def test_upgrade_force_skips_version_check(monkeypatch, capsys):
"""upgrade(force=True) must skip the version check and proceed to install."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
# With force=True the version check must be bypassed entirely
check_calls = []
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: check_calls.append(info) or (True, "should not be reached"),
)
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade(force=True)
# _check_for_update must NOT have been called when force=True
assert len(check_calls) == 0, "Version check must be skipped when force=True"
# uv install must still be attempted
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
assert len(uv_calls) > 0, "upgrade(force=True) must still call uv tool install"
def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys):
"""upgrade() must print 'up to date' and NOT call uv when version check says current."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (False, "up to date (commit abcd1234)"),
)
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade()
out = capsys.readouterr().out
assert "up to date" in out.lower() or "already" in out.lower()
# uv install must NOT have been called
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
assert len(uv_calls) == 0, "uv must NOT be called when already up to date"
def test_upgrade_force_flag_registered():
"""upgrade --force must be accepted by argparse without error."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "upgrade", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
assert "--force" in help_text
# ---------------------------------------------------------------------------
# serve() settings.json integration tests
# ---------------------------------------------------------------------------
def test_serve_reads_host_from_settings(tmp_path, monkeypatch):
"""serve(host=None) must use host from settings.json."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({"host": "192.168.0.1"}))
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
calls = []
def fake_run(*args, **kwargs):
calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve(host=None)
assert len(calls) == 1
assert calls[0]["host"] == "192.168.0.1"
def test_serve_cli_flag_overrides_settings(tmp_path, monkeypatch):
"""serve(host='10.0.0.1') must override settings.json host."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({"host": "192.168.0.1"}))
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
calls = []
def fake_run(*args, **kwargs):
calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve(host="10.0.0.1")
assert len(calls) == 1
assert calls[0]["host"] == "10.0.0.1"
def test_serve_falls_back_to_default_when_no_settings_file(tmp_path, monkeypatch):
"""serve() with no settings file and no CLI flags uses hardcoded defaults."""
settings_file = tmp_path / "nonexistent_settings.json"
# Deliberately not written — file does not exist
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
calls = []
def fake_run(*args, **kwargs):
calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve()
assert len(calls) == 1
assert calls[0]["host"] == "127.0.0.1"
assert calls[0]["port"] == 8088
def test_serve_port_from_settings(tmp_path, monkeypatch):
"""serve(port=None) must use port from settings.json."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({"port": 9999}))
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
calls = []
def fake_run(*args, **kwargs):
calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve(port=None)
assert len(calls) == 1
assert calls[0]["port"] == 9999
def test_serve_session_ttl_from_settings(tmp_path, monkeypatch):
"""serve(session_ttl=None) must use session_ttl from settings.json."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({"session_ttl": 3600}))
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False)
with patch("uvicorn.run"):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve(session_ttl=None)
assert os.environ.get("MUXPLEX_SESSION_TTL") == "3600"
def test_serve_session_ttl_zero_is_valid(tmp_path, monkeypatch):
"""serve(session_ttl=0) must work — 0 means browser session, a valid value."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({"session_ttl": 3600}))
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False)
with patch("uvicorn.run"):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
from muxplex.cli import serve
serve(session_ttl=0)
assert os.environ.get("MUXPLEX_SESSION_TTL") == "0"
# ---------------------------------------------------------------------------
# argparse refactoring tests — None defaults, serve flags on both parsers,
# upgrade alias
# ---------------------------------------------------------------------------
def test_main_passes_none_for_unset_flags(mock_check_deps):
"""main() with no flags passes None for host/port/auth/session_ttl/tls_cert/tls_key to serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex"]):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_main_passes_explicit_host_only(mock_check_deps):
"""main() with --host 10.0.0.1 passes host='10.0.0.1', others as None."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex", "--host", "10.0.0.1"]):
main()
mock_serve.assert_called_once_with(
host="10.0.0.1",
port=None,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_main_serve_subcommand_accepts_flags(mock_check_deps):
"""'muxplex serve --host 10.0.0.1 --port 9000' passes values to serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch(
"sys.argv", ["muxplex", "serve", "--host", "10.0.0.1", "--port", "9000"]
):
main()
mock_serve.assert_called_once_with(
host="10.0.0.1",
port=9000,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
def test_help_shows_single_upgrade_line():
"""Help output shows 'upgrade (update)' alias notation, not two separate subcommand entries."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
# With aliases=['update'], argparse renders: 'upgrade (update) description'
# With separate parsers, 'upgrade' and 'update' each have their own help lines
assert "upgrade (update)" in help_text, (
"upgrade and update must appear as alias notation 'upgrade (update)', not two separate entries. "
f"Got help text:\n{help_text}"
)
def test_doctor_shows_serve_config(tmp_path, monkeypatch, capsys):
"""doctor() must show the current serve config (host, port, auth)."""
import json
import muxplex.settings as settings_mod
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"host": "0.0.0.0", "port": 9999, "auth": "password"})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
assert "0.0.0.0" in out
assert "9999" in out
assert "password" in out
# ---------------------------------------------------------------------------
# service subcommand dispatch tests
# ---------------------------------------------------------------------------
def test_service_install_dispatches():
"""muxplex service install must call service_install()."""
from muxplex.cli import main
with patch("muxplex.service.service_install") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "install"]):
main()
mock_fn.assert_called_once()
def test_service_uninstall_dispatches():
"""muxplex service uninstall must call service_uninstall()."""
from muxplex.cli import main
with patch("muxplex.service.service_uninstall") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "uninstall"]):
main()
mock_fn.assert_called_once()
def test_service_start_dispatches():
"""muxplex service start must call service_start()."""
from muxplex.cli import main
with patch("muxplex.service.service_start") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "start"]):
main()
mock_fn.assert_called_once()
def test_service_stop_dispatches():
"""muxplex service stop must call service_stop()."""
from muxplex.cli import main
with patch("muxplex.service.service_stop") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "stop"]):
main()
mock_fn.assert_called_once()
def test_service_restart_dispatches():
"""muxplex service restart must call service_restart()."""
from muxplex.cli import main
with patch("muxplex.service.service_restart") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "restart"]):
main()
mock_fn.assert_called_once()
def test_service_status_dispatches():
"""muxplex service status must call service_status()."""
from muxplex.cli import main
with patch("muxplex.service.service_status") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "status"]):
main()
mock_fn.assert_called_once()
def test_service_logs_dispatches():
"""muxplex service logs must call service_logs()."""
from muxplex.cli import main
with patch("muxplex.service.service_logs") as mock_fn:
with patch("sys.argv", ["muxplex", "service", "logs"]):
main()
mock_fn.assert_called_once()
def test_service_subcommand_in_help():
"""'service' must appear in muxplex --help output."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue().lower()
assert "service" in help_text
# ---------------------------------------------------------------------------
# task-6: Verify old launchd/systemd helpers removed from cli.py
# ---------------------------------------------------------------------------
def test_old_install_launchd_removed_from_cli():
"""_install_launchd must no longer exist in muxplex.cli (moved to muxplex.service)."""
import muxplex.cli as cli_mod
assert not hasattr(cli_mod, "_install_launchd"), (
"_install_launchd should be removed from cli.py; functionality is in muxplex.service"
)
def test_old_install_systemd_removed_from_cli():
"""_install_systemd must no longer exist in muxplex.cli (moved to muxplex.service)."""
import muxplex.cli as cli_mod
assert not hasattr(cli_mod, "_install_systemd"), (
"_install_systemd should be removed from cli.py; functionality is in muxplex.service"
)
# ---------------------------------------------------------------------------
# config subcommand tests
# ---------------------------------------------------------------------------
def test_config_list_shows_all_keys(capsys, tmp_path, monkeypatch):
"""config list must show all DEFAULT_SETTINGS keys."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_list
config_list()
out = capsys.readouterr().out
for key in settings_mod.DEFAULT_SETTINGS:
assert key in out, f"config list must show '{key}'"
def test_config_get_returns_value(capsys, tmp_path, monkeypatch):
"""config get must return the value of a known key."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_get
config_get("port")
out = capsys.readouterr().out.strip()
assert out == "8088"
def test_config_get_unknown_key_exits(tmp_path, monkeypatch):
"""config get with unknown key must exit 1."""
import pytest
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_get
with pytest.raises(SystemExit):
config_get("nonexistent_key")
def test_config_set_persists_value(tmp_path, monkeypatch):
"""config set must persist the value to settings.json."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_set
config_set("host", "0.0.0.0")
settings = settings_mod.load_settings()
assert settings["host"] == "0.0.0.0"
def test_config_set_coerces_int(tmp_path, monkeypatch):
"""config set must coerce port to int."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_set
config_set("port", "9090")
settings = settings_mod.load_settings()
assert settings["port"] == 9090
def test_config_set_coerces_bool(tmp_path, monkeypatch):
"""config set must coerce booleans."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_set
config_set("window_size_largest", "true")
settings = settings_mod.load_settings()
assert settings["window_size_largest"] is True
def test_config_reset_all(tmp_path, monkeypatch):
"""config reset (no key) must reset all settings to defaults."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_set, config_reset
config_set("host", "0.0.0.0")
config_set("port", "9090")
config_reset(None)
settings = settings_mod.load_settings()
assert settings["host"] == "127.0.0.1"
assert settings["port"] == 8088
def test_config_reset_single_key(tmp_path, monkeypatch):
"""config reset <key> must reset only that key."""
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json")
from muxplex.cli import config_set, config_reset
config_set("host", "0.0.0.0")
config_set("port", "9090")
config_reset("host")
settings = settings_mod.load_settings()
assert settings["host"] == "127.0.0.1"
assert settings["port"] == 9090 # unchanged
def test_config_subcommand_registered():
"""config must appear in --help."""
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "muxplex", "config", "--help"],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "list" in result.stdout
assert "get" in result.stdout
assert "set" in result.stdout
assert "reset" in result.stdout
# ---------------------------------------------------------------------------
# task-3: generate-federation-key subcommand tests
# ---------------------------------------------------------------------------
def test_generate_federation_key_creates_file(tmp_path, monkeypatch, capsys):
"""generate_federation_key() creates key file with mode 0600 and prints key info."""
import muxplex.settings as settings_mod
key_file = tmp_path / ".config" / "muxplex" / "federation_key"
monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_file)
from muxplex.cli import generate_federation_key
generate_federation_key()
# File must exist
assert key_file.exists(), "Federation key file must be created"
# Content must be longer than 20 chars (stripping the trailing newline)
content = key_file.read_text().strip()
assert len(content) > 20, f"Key must be > 20 chars, got {len(content)}"
# File mode must be 0600
file_mode = stat.S_IMODE(key_file.stat().st_mode)
assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}"
# Output must include key info
captured = capsys.readouterr()
assert "federation" in captured.out.lower() or "key" in captured.out.lower(), (
f"Output must mention key info, got: {captured.out!r}"
)
# The actual key value must appear in output
assert content in captured.out, "Key value must appear in output"
def test_main_dispatches_to_generate_federation_key(monkeypatch):
"""main() with 'generate-federation-key' subcommand must invoke generate_federation_key()."""
import muxplex.cli as cli_mod
calls = []
monkeypatch.setattr(cli_mod, "generate_federation_key", lambda: calls.append(True))
with patch("sys.argv", ["muxplex", "generate-federation-key"]):
cli_mod.main()
assert calls, (
"generate_federation_key() must be called once for 'generate-federation-key' subcommand"
)
# ---------------------------------------------------------------------------
# task: port-in-use crash-loop prevention — _kill_stale_port_holder
# ---------------------------------------------------------------------------
def test_kill_stale_port_holder_exists():
"""_kill_stale_port_holder must be importable from muxplex.cli."""
from muxplex.cli import _kill_stale_port_holder # noqa: F401
def test_kill_stale_port_holder_runs_lsof(monkeypatch):
"""_kill_stale_port_holder must invoke lsof -ti :<port> to find occupying PIDs."""
import subprocess
import muxplex.cli as cli_mod
lsof_calls = []
def fake_run(cmd, **kw):
lsof_calls.append(cmd)
return type("R", (), {"returncode": 1, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", fake_run)
cli_mod._kill_stale_port_holder(8088)
assert any("lsof" in str(c) for c in lsof_calls), (
"_kill_stale_port_holder must call lsof to discover port occupants"
)
assert any("8088" in str(c) for c in lsof_calls), (
"_kill_stale_port_holder must include the port number in the lsof call"
)
def test_kill_stale_port_holder_kills_foreign_pid(monkeypatch):
"""_kill_stale_port_holder must send SIGTERM to PIDs that are not our own."""
import os
import signal
import subprocess
import muxplex.cli as cli_mod
foreign_pid = 99999
killed = []
def fake_run(cmd, **kw):
return type(
"R", (), {"returncode": 0, "stdout": f"{foreign_pid}\n", "stderr": ""}
)()
def fake_kill(pid, sig):
killed.append((pid, sig))
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(os, "kill", fake_kill)
monkeypatch.setattr(os, "getpid", lambda: 12345) # not the same as foreign_pid
# Patch time.sleep so test doesn't actually sleep
import time
monkeypatch.setattr(time, "sleep", lambda _: None)
cli_mod._kill_stale_port_holder(8088)
assert (foreign_pid, signal.SIGTERM) in killed, (
f"Expected SIGTERM sent to foreign PID {foreign_pid}, got: {killed}"
)
def test_kill_stale_port_holder_skips_own_pid(monkeypatch):
"""_kill_stale_port_holder must NOT kill its own PID."""
import os
import subprocess
import muxplex.cli as cli_mod
my_pid = 12345
killed = []
def fake_run(cmd, **kw):
return type("R", (), {"returncode": 0, "stdout": f"{my_pid}\n", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(os, "kill", lambda pid, sig: killed.append(pid))
monkeypatch.setattr(os, "getpid", lambda: my_pid)
import time
monkeypatch.setattr(time, "sleep", lambda _: None)
cli_mod._kill_stale_port_holder(8088)
assert my_pid not in killed, "_kill_stale_port_holder must not kill its own PID"
def test_kill_stale_port_holder_survives_lsof_not_available(monkeypatch):
"""_kill_stale_port_holder must not raise when lsof is unavailable."""
import subprocess
import muxplex.cli as cli_mod
def fake_run(cmd, **kw):
raise FileNotFoundError("lsof not found")
monkeypatch.setattr(subprocess, "run", fake_run)
# Should not raise
cli_mod._kill_stale_port_holder(8088)
def test_serve_calls_kill_stale_port_holder(tmp_path, monkeypatch):
"""serve() must call _kill_stale_port_holder(port) before starting uvicorn."""
import muxplex.cli as cli_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
killed_ports = []
monkeypatch.setattr(
cli_mod, "_kill_stale_port_holder", lambda port: killed_ports.append(port)
)
with patch("uvicorn.run"):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve(port=9876)
assert 9876 in killed_ports, (
"serve() must call _kill_stale_port_holder with the resolved port before uvicorn.run"
)
def test_upgrade_uses_service_module_install(monkeypatch, capsys):
"""upgrade() must call muxplex.service.service_install."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (abc12345 \u2192 def67890)"),
)
service_install_calls = []
with patch(
"muxplex.service.service_install", lambda: service_install_calls.append(True)
):
cli_mod.upgrade()
assert len(service_install_calls) > 0, (
"upgrade() must call muxplex.service.service_install() to regenerate the service file"
)
# ---------------------------------------------------------------------------
# task-2-serve-ssl: serve() TLS / SSL parameter tests
# ---------------------------------------------------------------------------
def test_serve_passes_ssl_params_to_uvicorn(tmp_path, monkeypatch):
"""serve() with valid tls_cert and tls_key paths must pass ssl_certfile/ssl_keyfile to uvicorn."""
import muxplex.cli as cli_mod
# Create real cert/key files
cert_file = tmp_path / "server.crt"
key_file = tmp_path / "server.key"
cert_file.write_text("fake cert content")
key_file.write_text("fake key content")
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
uvicorn_calls = []
def fake_run(*args, **kwargs):
uvicorn_calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve(tls_cert=str(cert_file), tls_key=str(key_file))
assert len(uvicorn_calls) == 1
kwargs = uvicorn_calls[0]
assert "ssl_certfile" in kwargs, (
"uvicorn.run must receive ssl_certfile when TLS paths are set"
)
assert "ssl_keyfile" in kwargs, (
"uvicorn.run must receive ssl_keyfile when TLS paths are set"
)
assert kwargs["ssl_certfile"] == str(cert_file)
assert kwargs["ssl_keyfile"] == str(key_file)
def test_serve_no_ssl_when_tls_paths_empty(tmp_path, monkeypatch):
"""serve() with no TLS paths (default) must NOT pass ssl_certfile/ssl_keyfile to uvicorn."""
import muxplex.cli as cli_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
uvicorn_calls = []
def fake_run(*args, **kwargs):
uvicorn_calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve() # Default: tls_cert=None, tls_key=None
assert len(uvicorn_calls) == 1
kwargs = uvicorn_calls[0]
assert "ssl_certfile" not in kwargs, (
"uvicorn.run must NOT receive ssl_certfile when no TLS"
)
assert "ssl_keyfile" not in kwargs, (
"uvicorn.run must NOT receive ssl_keyfile when no TLS"
)
def test_serve_falls_back_to_http_when_cert_file_missing(tmp_path, monkeypatch, capsys):
"""serve() prints a warning and skips SSL when tls_cert/tls_key paths don't exist on disk."""
import muxplex.cli as cli_mod
# Paths are set but the files do NOT exist
cert_file = tmp_path / "nonexistent.crt"
key_file = tmp_path / "nonexistent.key"
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
uvicorn_calls = []
def fake_run(*args, **kwargs):
uvicorn_calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve(tls_cert=str(cert_file), tls_key=str(key_file))
# Warning must be printed
captured = capsys.readouterr()
out_lower = captured.out.lower()
assert "not found" in out_lower or "falling back" in out_lower, (
f"Must print warning about missing TLS files, got: {captured.out!r}"
)
# SSL must NOT be passed to uvicorn
assert len(uvicorn_calls) == 1
kwargs = uvicorn_calls[0]
assert "ssl_certfile" not in kwargs, (
"Must not pass ssl_certfile when cert file missing"
)
assert "ssl_keyfile" not in kwargs, (
"Must not pass ssl_keyfile when cert file missing"
)
def test_serve_prints_https_url_when_tls_active(tmp_path, monkeypatch, capsys):
"""serve() must print 'https://' URL when TLS is active."""
import muxplex.cli as cli_mod
cert_file = tmp_path / "server.crt"
key_file = tmp_path / "server.key"
cert_file.write_text("fake cert")
key_file.write_text("fake key")
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
with patch("uvicorn.run"):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve(tls_cert=str(cert_file), tls_key=str(key_file))
captured = capsys.readouterr()
assert "https://" in captured.out, (
f"Must print 'https://' when TLS is active, got: {captured.out!r}"
)
def test_serve_prints_http_url_when_no_tls(tmp_path, monkeypatch, capsys):
"""serve() must print 'http://' URL when TLS is not configured."""
import muxplex.cli as cli_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
with patch("uvicorn.run"):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve() # No TLS
captured = capsys.readouterr()
assert "http://" in captured.out, (
f"Must print 'http://' when no TLS, got: {captured.out!r}"
)
assert "https://" not in captured.out, (
f"Must NOT print 'https://' when no TLS, got: {captured.out!r}"
)
# ---------------------------------------------------------------------------
# TLS CLI flags — task-3-cli-flags
# ---------------------------------------------------------------------------
def test_main_passes_tls_cert_and_key_flags(mock_check_deps):
"""main() with --tls-cert and --tls-key must forward exact paths to serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch(
"sys.argv",
["muxplex", "--tls-cert", "/path/cert.pem", "--tls-key", "/path/key.pem"],
):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=None,
tls_cert="/path/cert.pem",
tls_key="/path/key.pem",
)
def test_main_passes_none_for_unset_tls_flags(mock_check_deps):
"""main() with no TLS flags must call serve() with tls_cert=None and tls_key=None."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch("sys.argv", ["muxplex"]):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=None,
tls_cert=None,
tls_key=None,
)
# ---------------------------------------------------------------------------
# task-5: setup-tls subcommand tests
# ---------------------------------------------------------------------------
def test_setup_tls_subcommand_registered():
"""'setup-tls' must appear in muxplex --help output."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
assert "setup-tls" in help_text, (
f"'setup-tls' must appear in --help output, got:\n{help_text}"
)
def test_main_dispatches_to_setup_tls(monkeypatch):
"""main() with 'setup-tls' subcommand must invoke setup_tls(method='auto')."""
import muxplex.cli as cli_mod
calls = []
monkeypatch.setattr(
cli_mod, "setup_tls", lambda method="auto": calls.append(method)
)
with patch("sys.argv", ["muxplex", "setup-tls"]):
cli_mod.main()
assert len(calls) == 1, "setup_tls() must be called once for 'setup-tls' subcommand"
assert calls[0] == "auto", (
f"setup_tls must be called with method='auto', got {calls[0]!r}"
)
def test_setup_tls_selfsigned_creates_certs(tmp_path, monkeypatch, capsys):
"""setup_tls(method='selfsigned') generates cert and key in config dir, updates settings,
prints summary mentioning 'self-signed'/'selfsigned' and 'restart'."""
import muxplex.settings as settings_mod
from muxplex.cli import setup_tls
# Redirect SETTINGS_PATH to tmp_path
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
setup_tls(method="selfsigned")
# Cert and key files must exist in the config dir (SETTINGS_PATH.parent = tmp_path)
cert_files = list(tmp_path.glob("*.crt")) + list(tmp_path.glob("*.pem"))
key_files = list(tmp_path.glob("*.key"))
assert cert_files, (
f"Cert file must exist in {tmp_path}, found: {list(tmp_path.iterdir())}"
)
assert key_files, (
f"Key file must exist in {tmp_path}, found: {list(tmp_path.iterdir())}"
)
# Settings must be updated with non-empty tls_cert and tls_key
settings = settings_mod.load_settings()
assert settings.get("tls_cert"), (
"tls_cert must be non-empty in settings after setup_tls"
)
assert settings.get("tls_key"), (
"tls_key must be non-empty in settings after setup_tls"
)
# Output must mention self-signed and restart
captured = capsys.readouterr()
out_lower = captured.out.lower()
assert "self-signed" in out_lower or "selfsigned" in out_lower, (
f"Output must mention 'self-signed' or 'selfsigned', got: {captured.out!r}"
)
assert "restart" in out_lower, (
f"Output must mention 'restart', got: {captured.out!r}"
)
def test_serve_subcommand_accepts_tls_flags(mock_check_deps):
"""'muxplex serve --tls-cert ... --tls-key ...' must forward both paths to serve()."""
from muxplex.cli import main
with patch("muxplex.cli.serve") as mock_serve:
with patch(
"sys.argv",
[
"muxplex",
"serve",
"--tls-cert",
"/path/cert.pem",
"--tls-key",
"/path/key.pem",
],
):
main()
mock_serve.assert_called_once_with(
host=None,
port=None,
auth=None,
session_ttl=None,
tls_cert="/path/cert.pem",
tls_key="/path/key.pem",
)
# ---------------------------------------------------------------------------
# task-6-doctor-tls: TLS status section in doctor()
# ---------------------------------------------------------------------------
def test_doctor_shows_tls_disabled(tmp_path, monkeypatch, capsys):
"""doctor() shows TLS disabled when no TLS configured."""
import muxplex.settings as settings_mod
settings_file = tmp_path / "settings.json"
# No tls_cert/tls_key — just use empty settings
settings_file.write_text("{}")
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
out_lower = out.lower()
assert "tls" in out_lower, f"Expected 'tls' in doctor output, got: {out!r}"
assert "disabled" in out_lower, (
f"Expected 'disabled' in doctor output, got: {out!r}"
)
def test_doctor_shows_tls_enabled(tmp_path, monkeypatch, capsys):
"""doctor() shows TLS enabled when valid certs are configured."""
import json
import muxplex.settings as settings_mod
from muxplex.tls import generate_self_signed
# Generate real self-signed certs in tmp_path
cert_path = tmp_path / "muxplex.crt"
key_path = tmp_path / "muxplex.key"
generate_self_signed(cert_path, key_path)
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"tls_cert": str(cert_path), "tls_key": str(key_path)})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
out_lower = out.lower()
assert "tls" in out_lower, f"Expected 'tls' in doctor output, got: {out!r}"
assert "enabled" in out_lower, f"Expected 'enabled' in doctor output, got: {out!r}"
def test_doctor_shows_tls_clipboard_warning(tmp_path, monkeypatch, capsys):
"""doctor() mentions clipboard or https when TLS is disabled on network host."""
import json
import muxplex.settings as settings_mod
settings_file = tmp_path / "settings.json"
# Set host to network to trigger the TLS warning (not localhost)
settings_file.write_text(json.dumps({"host": "0.0.0.0"}))
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
out_lower = out.lower()
assert "clipboard" in out_lower or "https" in out_lower, (
f"Expected 'clipboard' or 'https' in doctor TLS-disabled output for network host, got: {out!r}"
)
# ---------------------------------------------------------------------------
# task-7: Edge case tests for serve() TLS behavior
# ---------------------------------------------------------------------------
def test_serve_no_ssl_when_only_cert_set(tmp_path, monkeypatch, capsys):
"""serve() must NOT enable SSL when tls_cert is set but tls_key is empty string."""
import muxplex.cli as cli_mod
# Create a real cert file so tls_cert path check passes the "file exists" guard
cert_file = tmp_path / "server.crt"
cert_file.write_text("fake cert content")
settings_file = tmp_path / "settings.json"
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
uvicorn_calls = []
def fake_run(*args, **kwargs):
uvicorn_calls.append(kwargs)
with patch("uvicorn.run", fake_run):
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
cli_mod.serve(tls_cert=str(cert_file), tls_key="")
assert len(uvicorn_calls) == 1
kwargs = uvicorn_calls[0]
assert "ssl_certfile" not in kwargs, (
"serve() must NOT pass ssl_certfile to uvicorn when tls_key is empty string — "
"SSL requires both cert and key"
)
# ---------------------------------------------------------------------------
# task-4: Auto-detection chain tests for setup_tls()
# ---------------------------------------------------------------------------
def test_setup_tls_auto_uses_tailscale_when_available(tmp_path, monkeypatch, capsys):
"""setup_tls(method='auto') uses Tailscale when detect_tailscale() returns info."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
ts_hostname = "myhost.tailscale.net"
ts_info = {
"hostname": ts_hostname,
"ips": ["100.0.0.1"],
"cert_domains": [ts_hostname],
}
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
ts_result = {
"method": "tailscale",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": [ts_hostname],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: ts_info)
monkeypatch.setattr(tls_mod, "generate_tailscale", lambda cp, kp, h: ts_result)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
assert "tailscale" in out.lower(), f"Expected 'tailscale' in output, got: {out!r}"
def test_setup_tls_auto_falls_to_mkcert_when_no_tailscale(
tmp_path, monkeypatch, capsys
):
"""setup_tls(method='auto') falls back to mkcert when Tailscale not available."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
mkcert_result = {
"method": "mkcert",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": ["localhost"],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: True)
monkeypatch.setattr(
tls_mod,
"generate_mkcert",
lambda cp, kp, extra_hostnames=None: mkcert_result,
)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
assert "mkcert" in out.lower(), f"Expected 'mkcert' in output, got: {out!r}"
def test_setup_tls_auto_falls_to_selfsigned_when_nothing_available(
tmp_path, monkeypatch, capsys
):
"""setup_tls(method='auto') falls back to self-signed when nothing else is available."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
selfsigned_result = {
"method": "selfsigned",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": ["localhost"],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)
monkeypatch.setattr(
tls_mod, "generate_self_signed", lambda cp, kp: selfsigned_result
)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
out_lower = out.lower()
assert "self-signed" in out_lower or "selfsigned" in out_lower, (
f"Expected 'self-signed' or 'selfsigned' in output, got: {out!r}"
)
# ---------------------------------------------------------------------------
# task-5-status-display: setup-tls --status tests
# ---------------------------------------------------------------------------
def test_setup_tls_status_shows_disabled(tmp_path, monkeypatch, capsys):
"""setup_tls_status() shows 'not configured' when no TLS certs are configured."""
import muxplex.settings as settings_mod
# Empty settings — no tls_cert or tls_key
settings_file = tmp_path / "settings.json"
settings_file.write_text("{}")
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import setup_tls_status
setup_tls_status()
out = capsys.readouterr().out
out_lower = out.lower()
assert "not configured" in out_lower or "disabled" in out_lower, (
f"Expected 'not configured' or 'disabled' in output, got: {out!r}"
)
def test_setup_tls_status_shows_enabled(tmp_path, monkeypatch, capsys):
"""setup_tls_status() shows 'enabled' and 'expires' when valid certs are configured."""
import json
import muxplex.settings as settings_mod
from muxplex.tls import generate_self_signed
# Generate real self-signed certs in tmp_path
cert_path = tmp_path / "muxplex.crt"
key_path = tmp_path / "muxplex.key"
generate_self_signed(cert_path, key_path)
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"tls_cert": str(cert_path), "tls_key": str(key_path)})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import setup_tls_status
setup_tls_status()
out = capsys.readouterr().out
out_lower = out.lower()
assert "enabled" in out_lower or "certificate" in out_lower, (
f"Expected 'enabled' or 'certificate' in output, got: {out!r}"
)
assert "expires" in out_lower, f"Expected 'expires' in output, got: {out!r}"
def test_setup_tls_status_flag_registered():
"""setup-tls --status must be accepted by argparse."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "setup-tls", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
assert "--status" in help_text, (
f"Expected '--status' in setup-tls --help output, got:\n{help_text}"
)
def test_main_dispatches_status_flag_to_setup_tls_status(monkeypatch):
"""main() with 'setup-tls --status' must invoke setup_tls_status(), not setup_tls()."""
import muxplex.cli as cli_mod
status_calls = []
setup_calls = []
monkeypatch.setattr(cli_mod, "setup_tls_status", lambda: status_calls.append(True))
monkeypatch.setattr(
cli_mod, "setup_tls", lambda method="auto": setup_calls.append(method)
)
with patch("sys.argv", ["muxplex", "setup-tls", "--status"]):
cli_mod.main()
assert len(status_calls) == 1, (
"setup_tls_status() must be called once for 'setup-tls --status'"
)
assert len(setup_calls) == 0, "setup_tls() must NOT be called when --status is used"
def test_setup_tls_method_choices_expanded():
"""setup-tls --help must show 'tailscale' and 'mkcert' as method choices."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "setup-tls", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
assert "tailscale" in help_text, (
f"Expected 'tailscale' in setup-tls --help output, got:\n{help_text}"
)
assert "mkcert" in help_text, (
f"Expected 'mkcert' in setup-tls --help output, got:\n{help_text}"
)
# ---------------------------------------------------------------------------
# task-6-existing-cert-regenerate-prompt: Existing cert detection & prompt
# ---------------------------------------------------------------------------
def test_setup_tls_prompts_when_certs_exist(tmp_path, monkeypatch, capsys):
"""setup_tls() prints 'already configured' and prompts when certs already exist.
When tls_cert/tls_key are set in settings and the cert file exists,
setup_tls() must inform the user and prompt before overwriting.
When the user answers 'n', it must keep existing certs and return early.
"""
import json
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
from muxplex.tls import generate_self_signed
from muxplex.cli import setup_tls
# Generate real self-signed cert in tmp_path
cert_path = tmp_path / "muxplex.crt"
key_path = tmp_path / "muxplex.key"
generate_self_signed(cert_path, key_path)
# Write settings pointing to the generated cert
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"tls_cert": str(cert_path), "tls_key": str(key_path)})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Monkeypatch input to return 'n' (user declines regeneration)
monkeypatch.setattr("builtins.input", lambda prompt="": "n")
# Monkeypatch detection functions to isolate prompt behavior
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)
setup_tls()
out = capsys.readouterr().out
out_lower = out.lower()
assert "already configured" in out_lower or "regenerate" in out_lower, (
f"Expected 'already configured' or 'regenerate' in output, got: {out!r}"
)
# User said 'n' — should keep existing certs and return early
assert "keeping" in out_lower, (
f"Expected 'keeping' in output (user declined regeneration), got: {out!r}"
)
# Must NOT proceed to generate new certs (no "TLS setup complete" message)
assert "tls setup complete" not in out_lower, (
f"setup_tls() must return early when user says 'n', got: {out!r}"
)
def test_setup_tls_regenerates_on_eof(tmp_path, monkeypatch, capsys):
"""setup_tls() handles EOFError from input() gracefully (non-interactive mode).
When running in a non-interactive environment (e.g. piped stdin), input()
raises EOFError. The function must treat this as 'n' (keep existing certs)
and return normally without crashing.
"""
import json
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
from muxplex.tls import generate_self_signed
from muxplex.cli import setup_tls
# Generate real self-signed cert in tmp_path
cert_path = tmp_path / "muxplex.crt"
key_path = tmp_path / "muxplex.key"
generate_self_signed(cert_path, key_path)
# Write settings pointing to the generated cert
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"tls_cert": str(cert_path), "tls_key": str(key_path)})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Monkeypatch input to raise EOFError (non-interactive environment)
def raise_eof(prompt=""):
raise EOFError("non-interactive stdin")
monkeypatch.setattr("builtins.input", raise_eof)
# Monkeypatch detection functions to isolate behavior
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)
# Must not crash — EOFError is caught and treated as 'n'
setup_tls() # No exception should propagate
out = capsys.readouterr().out
out_lower = out.lower()
# EOFError → default 'n' → keep existing certs
assert "keeping" in out_lower, (
f"Expected 'keeping' in output after EOFError (default 'n'), got: {out!r}"
)
# ---------------------------------------------------------------------------
# task: TLS nudge hints in doctor and service install
# ---------------------------------------------------------------------------
def test_doctor_tls_nudge_shows_run_command_on_network_host(
capsys, tmp_path, monkeypatch
):
"""doctor must show 'Run: muxplex setup-tls' when host is network and TLS disabled."""
import json
import muxplex.settings as settings_mod
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"host": "0.0.0.0", "tls_cert": "", "tls_key": ""})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
assert "muxplex setup-tls" in out, (
f"Expected 'muxplex setup-tls' in doctor output when host is 0.0.0.0 and TLS disabled, got: {out!r}"
)
def test_doctor_tls_nudge_hidden_on_localhost(capsys, tmp_path, monkeypatch):
"""doctor must NOT show TLS nudge when host is 127.0.0.1."""
import json
import muxplex.settings as settings_mod
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"host": "127.0.0.1", "tls_cert": "", "tls_key": ""})
)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
assert "muxplex setup-tls" not in out, (
f"TLS nudge must NOT appear in doctor output when host is 127.0.0.1, got: {out!r}"
)
# ---------------------------------------------------------------------------
# task-1-pypi-metadata: pyproject.toml metadata tests
# ---------------------------------------------------------------------------
def test_pyproject_has_authors():
"""pyproject.toml must declare at least one author with name and email."""
import tomllib
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text())
authors = data["project"].get("authors", [])
assert len(authors) >= 1
assert "name" in authors[0]
assert "email" in authors[0]
def test_pyproject_has_classifiers():
"""pyproject.toml must declare at least 3 classifiers including License and Python version."""
import tomllib
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text())
classifiers = data["project"].get("classifiers", [])
assert len(classifiers) >= 3
texts = " ".join(classifiers)
assert "License" in texts
assert "Python :: 3" in texts
def test_pyproject_has_keywords():
"""pyproject.toml must declare at least 3 keywords for PyPI discoverability."""
import tomllib
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text())
keywords = data["project"].get("keywords", [])
assert len(keywords) >= 3
# ---------------------------------------------------------------------------
# task-4-upgrade-routing: upgrade routes based on install source
# ---------------------------------------------------------------------------
def test_upgrade_pypi_install_uses_package_name(monkeypatch, capsys):
"""upgrade() for PyPI installs must use 'muxplex' not git+https URL."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_get_install_info",
lambda: {"source": "pypi", "version": "0.1.0", "commit": None, "url": None},
)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (v0.1.0 → v0.2.0)"),
)
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade()
uv_calls = [
c for c in calls if isinstance(c, list) and "tool" in c and "install" in c
]
assert len(uv_calls) > 0
install_cmd = uv_calls[0]
assert "muxplex" in install_cmd
assert not any("git+" in str(arg) for arg in install_cmd)
def test_upgrade_git_install_uses_git_url(monkeypatch, capsys):
"""upgrade() for git installs must still use git+https URL."""
import subprocess
import muxplex.cli as cli_mod
calls = []
def mock_run(cmd, **kwargs):
calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_get_install_info",
lambda: {
"source": "git",
"version": "0.1.0",
"commit": "abc12345",
"url": "https://github.com/bkrabach/muxplex",
},
)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (abc12345 → def67890)"),
)
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade()
uv_calls = [
c for c in calls if isinstance(c, list) and "tool" in c and "install" in c
]
assert len(uv_calls) > 0
install_cmd = uv_calls[0]
assert any("git+" in str(arg) for arg in install_cmd)