"""Tests for CLI mode (CliRunner + main() argument dispatch).""" import sys from unittest.mock import MagicMock, patch, call import pytest from openvpncertupdate import CliRunner, _build_parser, main # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _patch_issue(monkeypatch, ovpn_path="/out/cn_2026-01-01/client.ovpn", one_time_url="https://cg.example.com/#/note/abc/xyz"): """Patch all side-effectful callables used by CliRunner._issue.""" monkeypatch.setattr("openvpncertupdate.revoke_issued", MagicMock()) monkeypatch.setattr("openvpncertupdate.gen_crl", MagicMock()) monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock()) monkeypatch.setattr("openvpncertupdate.build_client_full", MagicMock()) monkeypatch.setattr("openvpncertupdate.build_ovpn", MagicMock(return_value=ovpn_path)) monkeypatch.setattr("openvpncertupdate.create_note", MagicMock(return_value=one_time_url)) monkeypatch.setattr("openvpncertupdate.send_email", MagicMock()) monkeypatch.setattr("openvpncertupdate.generate_password", MagicMock(return_value="Testpass1234567890abcdefgh")) return { "revoke_issued": sys.modules["openvpncertupdate"].revoke_issued, "gen_crl": sys.modules["openvpncertupdate"].gen_crl, "copy_crl": sys.modules["openvpncertupdate"].copy_crl, "build_client_full": sys.modules["openvpncertupdate"].build_client_full, "build_ovpn": sys.modules["openvpncertupdate"].build_ovpn, "create_note": sys.modules["openvpncertupdate"].create_note, "send_email": sys.modules["openvpncertupdate"].send_email, } # --------------------------------------------------------------------------- # CliRunner.create # --------------------------------------------------------------------------- def test_create_calls_build_not_revoke(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) CliRunner().create("alice", "alice@example.com") mocks["revoke_issued"].assert_not_called() mocks["build_client_full"].assert_called_once() _, kwargs = mocks["build_client_full"].call_args # cn is the third positional arg assert mocks["build_client_full"].call_args.args[2] == "alice" def test_create_passes_email_to_build_client_full(monkeypatch, capsys): # build-client-full embeds email into the cert subject (EASYRSA_REQ_EMAIL), # which is what index.txt / get_email() reads back later — there's no # separate email store to save to. mocks = _patch_issue(monkeypatch) CliRunner().create("alice", "alice@example.com") assert mocks["build_client_full"].call_args.kwargs["email"] == "alice@example.com" def test_create_sends_email_and_prints_url(monkeypatch, capsys): url = "https://cg.example.com/#/note/abc/xyz" mocks = _patch_issue(monkeypatch, one_time_url=url) CliRunner().create("alice", "alice@example.com") mocks["send_email"].assert_called_once() out = capsys.readouterr().out assert "password-url:" in out assert url in out assert "password:" not in out def test_create_prints_password_when_cryptgeon_fails(monkeypatch, capsys): _patch_issue(monkeypatch) import openvpncertupdate monkeypatch.setattr("openvpncertupdate.create_note", MagicMock(side_effect=openvpncertupdate.CryptgeonError("down"))) CliRunner().create("alice", "alice@example.com") out = capsys.readouterr().out assert "password:" in out assert "password-url:" not in out # --------------------------------------------------------------------------- # CliRunner.reissue # --------------------------------------------------------------------------- def test_reissue_calls_revoke_then_build(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) CliRunner().reissue("bob", "bob@example.com") mocks["revoke_issued"].assert_called_once() mocks["build_client_full"].assert_called_once() # revoke must happen before build assert mocks["revoke_issued"].call_args.args[2] == "bob" assert mocks["build_client_full"].call_args.args[2] == "bob" def test_reissue_regenerates_and_copies_crl(monkeypatch, capsys): # The old cert is revoked as part of reissue, so the published CRL is # stale until regenerated — reissue must do that itself now. mocks = _patch_issue(monkeypatch) CliRunner().reissue("bob", "bob@example.com") mocks["gen_crl"].assert_called_once() mocks["copy_crl"].assert_called_once() def test_create_does_not_touch_crl(monkeypatch, capsys): # --create never revokes anything, so there's nothing stale to fix. mocks = _patch_issue(monkeypatch) CliRunner().create("alice", "alice@example.com") mocks["gen_crl"].assert_not_called() mocks["copy_crl"].assert_not_called() def test_reissue_continues_building_when_crl_regen_fails(monkeypatch, capsys): import openvpncertupdate mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.gen_crl", MagicMock(side_effect=openvpncertupdate.EasyRSAError("gen-crl failed"))) CliRunner().reissue("bob", "bob@example.com") mocks["build_client_full"].assert_called_once() assert "CRL update failed" in capsys.readouterr().err def test_reissue_falls_back_to_index_txt_email(monkeypatch, capsys): # get_email() reads straight from index.txt; this stubs that lookup # rather than the file itself (index.txt parsing is covered in test_pki.py). mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="fromindex@example.com")) CliRunner().reissue("bob", "") # no --email supplied mocks["send_email"].assert_called_once() assert mocks["send_email"].call_args.args[0] == "fromindex@example.com" def test_reissue_no_email_no_send(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="")) CliRunner().reissue("bob", "") mocks["send_email"].assert_not_called() out, err = capsys.readouterr() assert "config:" in out assert "warning: email skipped (no email address on file for bob)" in err # --------------------------------------------------------------------------- # CliRunner.revoke # --------------------------------------------------------------------------- def test_revoke_calls_revoke_gen_crl_copy_crl(monkeypatch, capsys): rev = MagicMock() gcrl = MagicMock() ccrl = MagicMock() monkeypatch.setattr("openvpncertupdate.revoke_issued", rev) monkeypatch.setattr("openvpncertupdate.gen_crl", gcrl) monkeypatch.setattr("openvpncertupdate.copy_crl", ccrl) CliRunner().revoke("charlie") rev.assert_called_once() gcrl.assert_called_once() ccrl.assert_called_once() assert "charlie" in capsys.readouterr().out def test_revoke_exits_on_error(monkeypatch): import openvpncertupdate monkeypatch.setattr("openvpncertupdate.revoke_issued", MagicMock(side_effect=openvpncertupdate.EasyRSAError("fail"))) with pytest.raises(SystemExit) as exc_info: CliRunner().revoke("charlie") assert exc_info.value.code == 1 # --------------------------------------------------------------------------- # CliRunner.regen_crl # --------------------------------------------------------------------------- def test_regen_crl_calls_gen_and_copy(monkeypatch, capsys): gcrl = MagicMock() ccrl = MagicMock() monkeypatch.setattr("openvpncertupdate.gen_crl", gcrl) monkeypatch.setattr("openvpncertupdate.copy_crl", ccrl) CliRunner().regen_crl() gcrl.assert_called_once() ccrl.assert_called_once() assert "CRL" in capsys.readouterr().out # --------------------------------------------------------------------------- # _format_cert_table # --------------------------------------------------------------------------- def test_format_cert_table_empty_list(): import openvpncertupdate assert openvpncertupdate._format_cert_table([]) == "(no certificates)" def test_format_cert_table_header_and_columns(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5), ] monkeypatch.setattr( "openvpncertupdate.get_email", MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")), ) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() assert lines[0].split() == ["CN", "EXPIRES", "EMAIL"] assert len(lines) == 3 def test_format_cert_table_shows_email_and_none_placeholder(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5), ] monkeypatch.setattr( "openvpncertupdate.get_email", MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")), ) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() assert "alice@example.com" in lines[1] assert "(none)" in lines[2] def test_format_cert_table_columns_are_aligned(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="a", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="a-much-longer", expires=None, days_left=5), ] monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="")) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() # The EMAIL column must start at the same character offset on every row. email_col = len(lines[0]) - len("EMAIL") for line in lines[1:]: assert line.rstrip().endswith("(none)") assert line.index("(none)") == email_col # --------------------------------------------------------------------------- # CliRunner.list_certs # --------------------------------------------------------------------------- def test_list_certs_expiring_uses_days_settings(monkeypatch, capsys): mock_load = MagicMock(return_value=[]) monkeypatch.setattr("openvpncertupdate.load_expiring_certs", mock_load) monkeypatch.setattr("openvpncertupdate.DAYS_PAST", 30) monkeypatch.setattr("openvpncertupdate.DAYS_AHEAD", 14) monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki") CliRunner().list_certs(show_all=False) mock_load.assert_called_once_with("/pki", 30, 14) assert "(no certificates)" in capsys.readouterr().out def test_list_certs_all_uses_load_all_certs(monkeypatch, capsys): mock_load = MagicMock(return_value=[]) monkeypatch.setattr("openvpncertupdate.load_all_certs", mock_load) monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki") CliRunner().list_certs(show_all=True) mock_load.assert_called_once_with("/pki") assert "(no certificates)" in capsys.readouterr().out def test_list_certs_prints_formatted_table(monkeypatch, capsys): import openvpncertupdate certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=10)] monkeypatch.setattr("openvpncertupdate.load_expiring_certs", MagicMock(return_value=certs)) monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="alice@example.com")) CliRunner().list_certs(show_all=False) out = capsys.readouterr().out assert "alice" in out assert "alice@example.com" in out # --------------------------------------------------------------------------- # main() argument dispatch # --------------------------------------------------------------------------- def test_main_create_requires_email(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice"]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code != 0 def test_main_dispatches_create(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.com"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.create.assert_called_once_with("alice", "a@b.com", send_email_flag=True, show_eml=False) def test_main_dispatches_reissue(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--reissue", "bob"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.reissue.assert_called_once_with("bob", "", send_email_flag=True, show_eml=False) def test_main_dispatches_revoke(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--revoke", "charlie"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.revoke.assert_called_once_with("charlie") def test_main_dispatches_gen_crl(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.regen_crl.assert_called_once_with() def test_main_dispatches_list(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.list_certs.assert_called_once_with(show_all=False) def test_main_dispatches_list_all(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list-all"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.list_certs.assert_called_once_with(show_all=True) def test_main_list_skips_ca_passphrase_resolution(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list"]) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock()) mock_resolve = MagicMock() monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve) main() mock_resolve.assert_not_called() def test_main_list_all_skips_ca_passphrase_resolution(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list-all"]) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock()) mock_resolve = MagicMock() monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve) main() mock_resolve.assert_not_called() def test_main_no_args_launches_tui(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog"]) app = MagicMock() monkeypatch.setattr("openvpncertupdate.CursesApp", lambda: app) main() app.run.assert_called_once_with() def test_main_resolves_ca_passphrase_before_dispatch(monkeypatch, tmp_path): """The configured CA_PASSPHRASE sentinel must be resolved to an actual passphrase before any EasyRSA call, and that resolved value (not the sentinel) must be what reaches them.""" monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl"]) monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!ask") monkeypatch.setattr("openvpncertupdate.EASYRSA_DIR", "/er") monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path)) mock_gen_crl = MagicMock() monkeypatch.setattr("openvpncertupdate.gen_crl", mock_gen_crl) monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock()) mock_resolve = MagicMock(return_value="typed-pass") monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve) main() mock_resolve.assert_called_once_with("!ask", str(tmp_path)) def test_main_applies_config_overrides_before_dispatch(monkeypatch, tmp_path): """Overrides returned by load_settings_overrides() must land in the module globals before EasyRSA calls, so a config file can redirect EASYRSA_PKI_DIR etc.""" monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl"]) monkeypatch.setattr("openvpncertupdate.EASYRSA_DIR", "/er") monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/original/pki") monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!empty") mock_gen_crl = MagicMock() monkeypatch.setattr("openvpncertupdate.gen_crl", mock_gen_crl) monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock()) mock_load = MagicMock(return_value={"EASYRSA_PKI_DIR": "/overridden/pki"}) monkeypatch.setattr("openvpncertupdate.load_settings_overrides", mock_load) main() mock_gen_crl.assert_called_once_with("/er", "/overridden/pki", "") def test_main_passes_config_flag_to_load_overrides(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl", "--config", "/explicit/path.conf"]) monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!empty") monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock()) mock_load = MagicMock(return_value={}) monkeypatch.setattr("openvpncertupdate.load_settings_overrides", mock_load) main() assert mock_load.call_args[0][0] == "/explicit/path.conf" def test_main_exits_with_error_when_explicit_config_missing(monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl", "--config", "/does/not/exist.conf"]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code != 0 assert "/does/not/exist.conf" in capsys.readouterr().err # --------------------------------------------------------------------------- # --send-email / --no-send-email # --------------------------------------------------------------------------- def test_no_send_email_skips_delivery(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="")) CliRunner().create("alice", "alice@example.com", send_email_flag=False) mocks["send_email"].assert_not_called() out = capsys.readouterr().out assert "config:" in out def test_main_no_send_email_flag(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--reissue", "bob", "--no-send-email"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.reissue.assert_called_once_with("bob", "", send_email_flag=False, show_eml=False) # --------------------------------------------------------------------------- # --show-eml # --------------------------------------------------------------------------- def test_show_eml_outputs_base64(monkeypatch, capsys, tmp_path): """--show-eml prints a non-empty base64 string to stdout.""" import base64 as _b64 # Write a minimal template and .ovpn so build_mime_message can read them template = tmp_path / "tpl.txt" template.write_text("Hi {cn}, url={url}, cfg={config_name}") ovpn = tmp_path / "c.ovpn" ovpn.write_bytes(b"ovpn-data") mocks = _patch_issue(monkeypatch, ovpn_path=str(ovpn)) import openvpncertupdate as m monkeypatch.setattr(m, "EMAIL_TEMPLATE_PATH", str(template)) monkeypatch.setattr(m, "build_mime_message", m.build_mime_message) # keep real implementation CliRunner().create("alice", "alice@example.com", send_email_flag=False, show_eml=True) out = capsys.readouterr().out # Find the base64 block (before the "config:" line) b64_line = [l for l in out.splitlines() if l and not l.startswith(("config:", "password"))][0] decoded = _b64.b64decode(b64_line) assert b"alice@example.com" in decoded assert b"alice" in decoded def test_show_eml_implies_no_send(monkeypatch, capsys): """--show-eml without --send-email must not call send_email.""" mocks = _patch_issue(monkeypatch) import openvpncertupdate as m monkeypatch.setattr(m, "build_mime_message", MagicMock( return_value=MagicMock(as_bytes=MagicMock(return_value=b"eml")) )) CliRunner().create("alice", "alice@example.com", send_email_flag=False, show_eml=True) mocks["send_email"].assert_not_called() def test_show_eml_with_send_email_still_sends(monkeypatch, capsys): """--show-eml --send-email: both show eml and send.""" mocks = _patch_issue(monkeypatch) import openvpncertupdate as m monkeypatch.setattr(m, "build_mime_message", MagicMock( return_value=MagicMock(as_bytes=MagicMock(return_value=b"eml")) )) CliRunner().create("alice", "alice@example.com", send_email_flag=True, show_eml=True) mocks["send_email"].assert_called_once() def test_main_show_eml_defaults_to_no_send(monkeypatch): """--show-eml with no explicit --send-email → send_email_flag=False.""" monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.com", "--show-eml"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.create.assert_called_once_with( "alice", "a@b.com", send_email_flag=False, show_eml=True ) def test_main_show_eml_send_email_override(monkeypatch): """--show-eml --send-email → send_email_flag=True.""" monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.com", "--show-eml", "--send-email"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.create.assert_called_once_with( "alice", "a@b.com", send_email_flag=True, show_eml=True )