Files
openvpncertupdate/tests/test_cli.py
Vlad Doloman bd17cdd68d fix Cryptgeon protocol and add CA passphrase / external config resolution
Cryptgeon's real protocol (verified against upstream occulto/frontend/backend
source) never matched what create_note() sent: it used a SHA-256-derived key
instead of the raw one, a single-blob ciphertext instead of the delimited
AES-GCM--nonce--ciphertext format, an empty meta field instead of a JSON
string, and a hash-bang URL instead of the real /note/<id>#<key> route -
notes uploaded fine but were undecryptable in the browser.

Also adds CA_PASSPHRASE auto-detection/prompting ("" auto-detects an
encrypted CA key and prompts, "!empty"/"!ask" opt out of/force the prompt)
and an external .conf file (--config / CONFIG_PATH / <script>.conf) that can
override the SETTINGS block without editing the script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 12:58:21 +03:00

360 lines
15 KiB
Python

"""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.build_client_full", MagicMock())
monkeypatch.setattr("openvpncertupdate.save_email", 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,
"build_client_full": sys.modules["openvpncertupdate"].build_client_full,
"save_email": sys.modules["openvpncertupdate"].save_email,
"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_saves_email(monkeypatch, capsys):
mocks = _patch_issue(monkeypatch)
CliRunner().create("alice", "alice@example.com")
mocks["save_email"].assert_called_once()
assert mocks["save_email"].call_args.args[1] == "alice"
assert mocks["save_email"].call_args.args[2] == "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_falls_back_to_stored_email(monkeypatch, capsys):
mocks = _patch_issue(monkeypatch)
monkeypatch.setattr("openvpncertupdate.get_email",
MagicMock(return_value="stored@example.com"))
CliRunner().reissue("bob", "") # no --email supplied
mocks["send_email"].assert_called_once()
assert mocks["send_email"].call_args.args[0] == "stored@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 = capsys.readouterr().out
assert "config:" in out
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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_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
)