Code review findings on the "skip revoke when .crt is missing" migration path: - CliRunner._issue() took the skip path whenever has_issued_cert() was False, which is also true for a typo'd/nonexistent CN — it would warn, skip the revoke, and go on to build, package, and email a brand-new certificate for a CN nobody asked to renew. The skip now only fires when the CN has a current index.txt entry (via _load_current_certs()); an unknown CN prints an error and exits 1 with nothing built. The TUI's _process_cert() doesn't need the same guard — renewal there always opens on an existing row (cn_readonly pins the CN), so a typo'd CN can't reach the branch. - Skipping the revoke leaves pki/reqs/<CN>.req and pki/private/<CN>.key in place (normally revoke-issued archives both), which makes EasyRSA's build-client-full abort. Both CliRunner._issue() and CursesApp._process_cert() now check for those leftovers before building and fail fast with the exact paths, rather than surfacing EasyRSA's confusing error after the CA passphrase prompt. Neither path touches the files itself. Also: strengthened two under-specified tests (test_main_rejects_bad_days_flag now checks the resolver's message text, not just "--days", which also appears in argparse's unrelated error; test_show_cert_form_confirm now pins the Enter-keypress count so a partial "days" field reversion is caught), and folded a malformed CLAUDE.md table row into its neighbor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
797 lines
34 KiB
Python
797 lines
34 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
|
|
# Bound before _patch_issue() rebinds the module attribute, so tests can put
|
|
# the real filesystem check back and exercise the wiring end to end.
|
|
from openvpncertupdate import has_issued_cert as real_has_issued_cert
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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."""
|
|
# Default to "the issued .crt is there", the normal case; the tests that
|
|
# care about a missing cert file override this.
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=True))
|
|
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 _write_index(pki_dir, cns):
|
|
"""Write an index.txt with one far-future V-status line per CN, so
|
|
_load_current_certs()/has_issued_cert()'s "is this CN known?" check has
|
|
something real to read. Mirrors make_pki() in test_pki.py."""
|
|
lines = "".join(
|
|
f"V\t350101000000Z\t\t01\tunknown\t/CN={cn}/emailAddress={cn}@example.com\n"
|
|
for cn in cns
|
|
)
|
|
(pki_dir / "index.txt").write_text(lines)
|
|
|
|
|
|
def test_reissue_skips_revoke_when_cert_file_missing(monkeypatch, capsys, tmp_path):
|
|
# An index.txt copied from an older EasyRSA install lists V-status certs
|
|
# whose .crt was never carried over. EasyRSA reads the serial out of the
|
|
# .crt, so revoke-issued can only fail — issue the replacement instead.
|
|
_write_index(tmp_path, ["y.kuts"])
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
mocks["revoke_issued"].assert_not_called()
|
|
mocks["gen_crl"].assert_not_called()
|
|
mocks["copy_crl"].assert_not_called()
|
|
mocks["build_client_full"].assert_called_once()
|
|
assert mocks["build_client_full"].call_args.args[2] == "y.kuts"
|
|
|
|
|
|
def test_reissue_warns_when_revoke_skipped(monkeypatch, capsys, tmp_path):
|
|
_write_index(tmp_path, ["y.kuts"])
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
err = capsys.readouterr().err
|
|
assert "nothing to revoke for y.kuts" in err
|
|
assert "issued/y.kuts.crt" in err
|
|
|
|
|
|
def test_reissue_skipped_revoke_build_failure_does_not_claim_revocation(monkeypatch, capsys, tmp_path):
|
|
# Nothing was revoked, so the "has been revoked but no new cert" warning
|
|
# would be a lie here.
|
|
import openvpncertupdate
|
|
_write_index(tmp_path, ["y.kuts"])
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
_patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
monkeypatch.setattr("openvpncertupdate.build_client_full",
|
|
MagicMock(side_effect=openvpncertupdate.EasyRSAError("boom")))
|
|
with pytest.raises(SystemExit):
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
assert "has been revoked" not in capsys.readouterr().err
|
|
|
|
|
|
def test_reissue_unknown_cn_exits_without_issuing(monkeypatch, capsys, tmp_path):
|
|
# A typo'd/nonexistent CN must not fall into the "migration gap" skip
|
|
# path: before this fix it warned, skipped the revoke, and issued (and
|
|
# could email) a brand-new certificate for a CN nobody asked to renew.
|
|
_write_index(tmp_path, ["someone.else"])
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
CliRunner().reissue("totally-made-up-cn", "ghost@example.com")
|
|
assert exc_info.value.code == 1
|
|
err = capsys.readouterr().err
|
|
assert "unknown" in err.lower()
|
|
assert "totally-made-up-cn" in err
|
|
mocks["build_client_full"].assert_not_called()
|
|
mocks["build_ovpn"].assert_not_called()
|
|
mocks["create_note"].assert_not_called()
|
|
mocks["send_email"].assert_not_called()
|
|
|
|
|
|
def test_reissue_leftover_req_blocks_skip(monkeypatch, capsys, tmp_path):
|
|
# revoke-issued normally archives pki/reqs/<CN>.req and
|
|
# pki/private/<CN>.key into pki/revoked/; when it's skipped (no issued
|
|
# .crt to revoke) those leftovers make build-client-full abort. Catch it
|
|
# before the CA passphrase prompt with an actionable message instead.
|
|
_write_index(tmp_path, ["y.kuts"])
|
|
(tmp_path / "reqs").mkdir()
|
|
req_path = tmp_path / "reqs" / "y.kuts.req"
|
|
req_path.write_text("leftover request")
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
assert exc_info.value.code == 1
|
|
err = capsys.readouterr().err
|
|
assert str(req_path) in err
|
|
mocks["build_client_full"].assert_not_called()
|
|
|
|
|
|
def test_reissue_leftover_key_blocks_skip(monkeypatch, capsys, tmp_path):
|
|
_write_index(tmp_path, ["y.kuts"])
|
|
(tmp_path / "private").mkdir()
|
|
key_path = tmp_path / "private" / "y.kuts.key"
|
|
key_path.write_text("leftover key")
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
assert exc_info.value.code == 1
|
|
err = capsys.readouterr().err
|
|
assert str(key_path) in err
|
|
mocks["build_client_full"].assert_not_called()
|
|
|
|
|
|
def test_reissue_missing_index_txt_exits_cleanly(monkeypatch, capsys, tmp_path):
|
|
# Covers only the _load_current_certs() call added inside _issue()'s new
|
|
# unknown-CN check: with an --email supplied, reissue()'s own
|
|
# get_email(EASYRSA_PKI_DIR, cn) fallback lookup is short-circuited
|
|
# (`final_email = email_addr or get_email(...)`) and never runs, so this
|
|
# does not exercise (or claim to fix) get_email()'s own bare open() on a
|
|
# missing index.txt when no --email is given — that gap predates this
|
|
# branch and is not one of the findings in scope here.
|
|
pki = tmp_path / "no-such-pki"
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(pki))
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", MagicMock(return_value=False))
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
assert exc_info.value.code == 1
|
|
assert "index.txt" in capsys.readouterr().err
|
|
mocks["build_client_full"].assert_not_called()
|
|
|
|
|
|
def _reissue_against_real_pki(monkeypatch, tmp_path, cert_files, index_cns=("y.kuts",)):
|
|
"""Run --reissue with the real has_issued_cert against a temp PKI layout."""
|
|
issued = tmp_path / "issued"
|
|
issued.mkdir()
|
|
for name in cert_files:
|
|
(issued / name).write_text("-----BEGIN CERTIFICATE-----\n")
|
|
if index_cns:
|
|
_write_index(tmp_path, index_cns)
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.has_issued_cert", real_has_issued_cert)
|
|
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
|
|
CliRunner().reissue("y.kuts", "y.kuts@example.com")
|
|
return mocks
|
|
|
|
|
|
def test_reissue_reads_pki_dir_and_skips_revoke_for_missing_crt(monkeypatch, capsys, tmp_path):
|
|
# issued/ exists but holds other people's certs — exactly the server state.
|
|
mocks = _reissue_against_real_pki(
|
|
monkeypatch, tmp_path, ["ivan.radchenko.crt", "s.krasota.crt"])
|
|
mocks["revoke_issued"].assert_not_called()
|
|
mocks["build_client_full"].assert_called_once()
|
|
|
|
|
|
def test_reissue_reads_pki_dir_and_revokes_when_crt_present(monkeypatch, capsys, tmp_path):
|
|
mocks = _reissue_against_real_pki(monkeypatch, tmp_path, ["y.kuts.crt"])
|
|
mocks["revoke_issued"].assert_called_once()
|
|
mocks["gen_crl"].assert_called_once()
|
|
mocks["build_client_full"].assert_called_once()
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_format_cert_table_has_no_cert_column_when_all_files_present(monkeypatch):
|
|
# The extra column is noise when nothing is missing, so it only appears
|
|
# when it has something to say.
|
|
import openvpncertupdate
|
|
certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24)]
|
|
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value=""))
|
|
table = openvpncertupdate._format_cert_table(certs)
|
|
assert "CERT" not in table
|
|
assert "MISSING" not in table
|
|
|
|
|
|
def test_format_cert_table_marks_missing_cert_file(monkeypatch):
|
|
import openvpncertupdate
|
|
certs = [
|
|
openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24),
|
|
openvpncertupdate.CertInfo(cn="y.kuts", expires=None, days_left=-2,
|
|
has_cert_file=False),
|
|
]
|
|
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value=""))
|
|
table = openvpncertupdate._format_cert_table(certs)
|
|
lines = table.splitlines()
|
|
assert lines[0].split() == ["CN", "EXPIRES", "EMAIL", "CERT"]
|
|
alice = next(l for l in lines if l.startswith("alice"))
|
|
kuts = next(l for l in lines if l.startswith("y.kuts"))
|
|
assert "MISSING" in kuts
|
|
assert "MISSING" not in alice
|
|
|
|
|
|
def test_format_cert_table_stays_aligned_with_cert_column(monkeypatch):
|
|
import openvpncertupdate
|
|
certs = [
|
|
openvpncertupdate.CertInfo(cn="a", expires=None, days_left=24),
|
|
openvpncertupdate.CertInfo(cn="a-much-longer", expires=None, days_left=5,
|
|
has_cert_file=False),
|
|
]
|
|
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value=""))
|
|
lines = openvpncertupdate._format_cert_table(certs).splitlines()
|
|
cert_col = lines[0].index("CERT")
|
|
assert lines[2].index("MISSING") == cert_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
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --days
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_days_flag_parses():
|
|
args = _build_parser().parse_args(["--create", "alice", "--email", "a@b.c",
|
|
"--days", "90"])
|
|
assert args.days == "90"
|
|
|
|
|
|
def test_days_defaults_to_none_when_absent():
|
|
args = _build_parser().parse_args(["--create", "alice", "--email", "a@b.c"])
|
|
assert args.days is None
|
|
|
|
|
|
def test_issue_passes_resolved_days_to_build_client_full(monkeypatch, capsys):
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "90")
|
|
CliRunner().create("alice", "alice@example.com")
|
|
assert mocks["build_client_full"].call_args.kwargs["days"] == "90"
|
|
|
|
|
|
def test_issue_passes_empty_days_when_inheriting(monkeypatch, capsys):
|
|
mocks = _patch_issue(monkeypatch)
|
|
monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "")
|
|
CliRunner().create("alice", "alice@example.com")
|
|
assert mocks["build_client_full"].call_args.kwargs["days"] == ""
|
|
|
|
|
|
def test_main_days_flag_overrides_cert_days(monkeypatch):
|
|
import openvpncertupdate
|
|
monkeypatch.setattr(sys, "argv",
|
|
["prog", "--create", "alice", "--email", "a@b.c", "--days", "30"])
|
|
monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "default")
|
|
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value=""))
|
|
runner = MagicMock()
|
|
monkeypatch.setattr("openvpncertupdate.CliRunner", MagicMock(return_value=runner))
|
|
main()
|
|
assert openvpncertupdate.CERT_DAYS == "30"
|
|
|
|
|
|
def test_main_resolves_cert_days_when_no_flag(monkeypatch):
|
|
import openvpncertupdate
|
|
monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.c"])
|
|
monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "090")
|
|
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value=""))
|
|
monkeypatch.setattr("openvpncertupdate.CliRunner", MagicMock(return_value=MagicMock()))
|
|
main()
|
|
assert openvpncertupdate.CERT_DAYS == "90"
|
|
|
|
|
|
def test_main_rejects_bad_days_flag(monkeypatch, capsys):
|
|
monkeypatch.setattr(sys, "argv",
|
|
["prog", "--create", "alice", "--email", "a@b.c", "--days", "0"])
|
|
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value=""))
|
|
with pytest.raises(SystemExit):
|
|
main()
|
|
err = capsys.readouterr().err
|
|
# Must come from resolve_cert_days()'s own message, not just from
|
|
# argparse's "unrecognized arguments: --days 0" (which also contains the
|
|
# substring "--days" and would pass even if the --days flag were removed
|
|
# entirely — see resolve_cert_days()).
|
|
assert "--days" in err
|
|
assert "must be a positive number of days" in err
|
|
assert "EasyRSA rejects 0" in err
|