diff --git a/muxplex/cli.py b/muxplex/cli.py index dc27aaf..3fc0ba2 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -718,19 +718,36 @@ def setup_tls(method: str = "auto") -> None: Auto-detection chain (method='auto'): Tailscale → mkcert → self-signed. Use --method to force a specific certificate source. """ - from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415 + from muxplex.settings import SETTINGS_PATH, load_settings, patch_settings # noqa: PLC0415 from muxplex.tls import ( # noqa: PLC0415 detect_mkcert, detect_tailscale, generate_mkcert, generate_self_signed, generate_tailscale, + get_cert_info, ) config_dir = SETTINGS_PATH.parent cert_path = config_dir / "muxplex.crt" key_path = config_dir / "muxplex.key" + # Check for existing certificates and prompt before overwriting + _settings = load_settings() + _existing_cert = _settings.get("tls_cert", "") + _existing_key = _settings.get("tls_key", "") + if _existing_cert and _existing_key and Path(_existing_cert).exists(): + _info = get_cert_info(_existing_cert) + if _info is not None: + print(f"TLS already configured (expires {str(_info['expires'])[:10]}).") + try: + _answer = input("Regenerate? [y/N] ") + except (EOFError, KeyboardInterrupt): + _answer = "n" + if _answer.lower() not in ("y", "yes"): + print("Keeping existing certificates.") + return + result = None tailscale_info = None diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 6f4db37..c53738c 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -1901,3 +1901,107 @@ def test_setup_tls_method_choices_expanded(): 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}" + )