Guard --reissue against unknown CNs and leftover build files
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>
This commit is contained in:
@@ -114,10 +114,23 @@ def test_create_does_not_touch_crl(monkeypatch, capsys):
|
||||
mocks["copy_crl"].assert_not_called()
|
||||
|
||||
|
||||
def test_reissue_skips_revoke_when_cert_file_missing(monkeypatch, capsys):
|
||||
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")
|
||||
@@ -128,7 +141,9 @@ def test_reissue_skips_revoke_when_cert_file_missing(monkeypatch, capsys):
|
||||
assert mocks["build_client_full"].call_args.args[2] == "y.kuts"
|
||||
|
||||
|
||||
def test_reissue_warns_when_revoke_skipped(monkeypatch, capsys):
|
||||
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")
|
||||
@@ -137,10 +152,12 @@ def test_reissue_warns_when_revoke_skipped(monkeypatch, capsys):
|
||||
assert "issued/y.kuts.crt" in err
|
||||
|
||||
|
||||
def test_reissue_skipped_revoke_build_failure_does_not_claim_revocation(monkeypatch, capsys):
|
||||
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",
|
||||
@@ -150,12 +167,89 @@ def test_reissue_skipped_revoke_build_failure_does_not_claim_revocation(monkeypa
|
||||
assert "has been revoked" not in capsys.readouterr().err
|
||||
|
||||
|
||||
def _reissue_against_real_pki(monkeypatch, tmp_path, cert_files):
|
||||
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))
|
||||
@@ -692,4 +786,11 @@ def test_main_rejects_bad_days_flag(monkeypatch, capsys):
|
||||
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value=""))
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
assert "--days" in capsys.readouterr().err
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user