Files
openvpncertupdate/tests/test_easyrsa.py
Vlad Doloman 9e5df8d9bb Fix re-issue for CNs whose issued cert file is missing
Renewal failed with an empty error box for any CN listed in index.txt
without a corresponding pki/issued/<CN>.crt — the state you get when an
index.txt is carried over from an older EasyRSA install but the issued/
files are not.

Two defects:

1. EasyRSA writes its diagnostics to stdout, not stderr: print() is
   `printf '%s\n'`, and both die() and user_error() route through it.
   stderr only carries output from the tools EasyRSA shells out to, and
   even that is silenced under -S/--silent-ssl. _run_easyrsa built its
   message from stderr alone, so every EasyRSA failure reported blank.
   _easyrsa_diagnostics() now merges both streams (stderr first, so the
   specific openssl message is not what the dialog clips) and drops the
   version banner and blank padding.

2. EasyRSA reads the serial out of the .crt itself, so revoke-issued
   cannot revoke a CN whose cert file is gone — and there is nothing to
   add to the CRL either. has_issued_cert() now gates the revoke and CRL
   steps in both CliRunner._issue() and CursesApp._process_cert(); the
   workflow warns and goes straight to build-client-full. An explicit
   --revoke / TUI `r` still fails loudly rather than silently no-op.

The post-build failure message now keys off whether a revoke actually
happened, not off is_renewal, so it no longer claims "has been revoked"
when nothing was.

Adds tests/test_app_reissue.py: the TUI re-issue path had no coverage at
all, and it is the path this bug was reported from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:08:25 +03:00

247 lines
9.2 KiB
Python

import subprocess
import pytest
from unittest.mock import patch, MagicMock
from openvpncertupdate import (
revoke_issued, build_client_full, gen_crl, copy_crl, EasyRSAError,
is_ca_key_encrypted, resolve_ca_passphrase,
has_issued_cert, issued_cert_path,
)
def ok_result():
r = MagicMock(); r.returncode = 0; r.stdout = ""; r.stderr = ""; return r
def err_result(stdout="", stderr="oops"):
r = MagicMock(); r.returncode = 1; r.stdout = stdout; r.stderr = stderr; return r
# EasyRSA's print() is `printf '%s\n'` -> stdout, and both user_error() and
# die() route through it, so this is what a real failure looks like on the wire.
EASYRSA_USER_ERROR = """
EasyRSA version 3.2.6
Error
-----
Unable to revoke as no certificate was found.
Certificate was expected at:
* /etc/easy-rsa/pki/issued/alice.crt
"""
@patch("openvpncertupdate.subprocess.run")
def test_revoke_includes_required_args(mock_run):
mock_run.return_value = ok_result()
revoke_issued("/er", "/pki", "alice", "capass")
args = mock_run.call_args[0][0]
assert args[0] == "/er/easyrsa"
assert "--batch" in args
assert "--pki=/pki" in args
assert "--passin=pass:capass" in args
assert "revoke-issued" in args
assert "alice" in args
@patch("openvpncertupdate.subprocess.run")
def test_revoke_omits_passin_when_empty(mock_run):
mock_run.return_value = ok_result()
revoke_issued("/er", "/pki", "alice", "")
args = mock_run.call_args[0][0]
assert not any(a.startswith("--passin") for a in args)
@patch("openvpncertupdate.subprocess.run")
def test_revoke_raises_on_failure(mock_run):
mock_run.return_value = err_result()
with pytest.raises(EasyRSAError, match="revoke-issued"):
revoke_issued("/er", "/pki", "alice", "")
@patch("openvpncertupdate.subprocess.run")
def test_error_reports_easyrsa_stdout_diagnostics(mock_run):
# EasyRSA writes its diagnostics to stdout; reporting stderr alone left the
# user with a blank error box.
mock_run.return_value = err_result(stdout=EASYRSA_USER_ERROR, stderr="")
with pytest.raises(EasyRSAError) as exc:
revoke_issued("/er", "/pki", "alice", "")
msg = str(exc.value)
assert "Unable to revoke as no certificate was found." in msg
assert "* /etc/easy-rsa/pki/issued/alice.crt" in msg
@patch("openvpncertupdate.subprocess.run")
def test_error_strips_banner_and_blank_padding(mock_run):
mock_run.return_value = err_result(stdout=EASYRSA_USER_ERROR, stderr="")
with pytest.raises(EasyRSAError) as exc:
revoke_issued("/er", "/pki", "alice", "")
lines = str(exc.value).splitlines()
assert "" not in lines # no blank padding
assert not any(l.startswith("EasyRSA version") for l in lines)
assert "-----" not in lines # no rule under "Error"
@patch("openvpncertupdate.subprocess.run")
def test_error_keeps_both_streams(mock_run):
# openssl failures land on stderr while EasyRSA's die() text lands on stdout.
mock_run.return_value = err_result(
stdout="Easy-RSA error:\n\nFailed to revoke certificate.",
stderr="unable to load CA private key",
)
with pytest.raises(EasyRSAError) as exc:
revoke_issued("/er", "/pki", "alice", "")
msg = str(exc.value)
assert "Failed to revoke certificate." in msg
assert "unable to load CA private key" in msg
@patch("openvpncertupdate.subprocess.run")
def test_build_client_full_args(mock_run):
mock_run.return_value = ok_result()
build_client_full("/er", "/pki", "bob", "keypass", "capass")
args = mock_run.call_args[0][0]
assert "--passout=pass:keypass" in args
assert "--passin=pass:capass" in args
assert "build-client-full" in args
assert "bob" in args
@patch("openvpncertupdate.subprocess.run")
def test_gen_crl_args(mock_run):
mock_run.return_value = ok_result()
gen_crl("/er", "/pki", "capass")
args = mock_run.call_args[0][0]
assert "gen-crl" in args
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy):
copy_crl("/pki", "/etc/openvpn/crl.pem")
mock_copy.assert_called_once_with("/pki/crl.pem", "/etc/openvpn/crl.pem")
mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)
@patch("openvpncertupdate.subprocess.run")
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_skips_restorecon_when_binary_empty(mock_chmod, mock_copy, mock_run):
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="")
mock_run.assert_not_called()
@patch("openvpncertupdate.subprocess.run")
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_runs_restorecon_when_binary_set(mock_chmod, mock_copy, mock_run):
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon")
mock_run.assert_called_once_with(
["restorecon", "/etc/openvpn/crl.pem"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
@patch("openvpncertupdate.subprocess.run", side_effect=FileNotFoundError("no restorecon"))
@patch("openvpncertupdate.shutil.copy2")
@patch("openvpncertupdate.os.chmod")
def test_copy_crl_restorecon_failure_is_non_fatal(mock_chmod, mock_copy, mock_run):
# Missing/misconfigured restorecon must not break CRL deployment.
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon") # must not raise
mock_copy.assert_called_once()
mock_chmod.assert_called_once()
# ---------------------------------------------------------------------------
# has_issued_cert
# ---------------------------------------------------------------------------
def test_issued_cert_path(tmp_path):
assert issued_cert_path("/pki", "y.kuts") == "/pki/issued/y.kuts.crt"
def test_has_issued_cert_true_when_file_present(tmp_path):
issued = tmp_path / "issued"
issued.mkdir()
(issued / "y.kuts.crt").write_text("-----BEGIN CERTIFICATE-----\n")
assert has_issued_cert(str(tmp_path), "y.kuts") is True
def test_has_issued_cert_false_when_index_lists_cert_but_file_is_gone(tmp_path):
(tmp_path / "issued").mkdir()
assert has_issued_cert(str(tmp_path), "y.kuts") is False
def test_has_issued_cert_false_when_issued_dir_missing(tmp_path):
assert has_issued_cert(str(tmp_path), "y.kuts") is False
# ---------------------------------------------------------------------------
# is_ca_key_encrypted
# ---------------------------------------------------------------------------
def _write_ca_key(pki_dir, content):
private = pki_dir / "private"
private.mkdir(parents=True, exist_ok=True)
(private / "ca.key").write_text(content)
def test_is_ca_key_encrypted_true_for_pkcs8_encrypted_header(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is True
def test_is_ca_key_encrypted_true_for_legacy_proc_type_header(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,...\n\nAAA\n-----END RSA PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is True
def test_is_ca_key_encrypted_false_for_plain_key(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is False
def test_is_ca_key_encrypted_false_when_key_missing(tmp_path):
assert is_ca_key_encrypted(str(tmp_path)) is False
# ---------------------------------------------------------------------------
# resolve_ca_passphrase
# ---------------------------------------------------------------------------
def test_resolve_empty_string_prompts_when_ca_is_encrypted(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
prompt = MagicMock(return_value="typed-pass")
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
assert result == "typed-pass"
prompt.assert_called_once()
def test_resolve_empty_string_skips_prompt_when_ca_not_encrypted(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
prompt = MagicMock()
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
assert result == ""
prompt.assert_not_called()
def test_resolve_empty_sentinel_never_checks_or_prompts(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
prompt = MagicMock()
result = resolve_ca_passphrase("!empty", str(tmp_path), prompt=prompt)
assert result == ""
prompt.assert_not_called()
def test_resolve_ask_sentinel_always_prompts_without_checking(tmp_path):
# no ca.key on disk at all -- must not be checked/read for "!ask"
prompt = MagicMock(return_value="typed-pass")
result = resolve_ca_passphrase("!ask", str(tmp_path), prompt=prompt)
assert result == "typed-pass"
prompt.assert_called_once()
def test_resolve_literal_passphrase_passed_through_unchanged(tmp_path):
prompt = MagicMock()
result = resolve_ca_passphrase("mysecret", str(tmp_path), prompt=prompt)
assert result == "mysecret"
prompt.assert_not_called()