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>
This commit is contained in:
Vlad Doloman
2026-08-15 04:08:25 +03:00
parent 6b39c1209f
commit 9e5df8d9bb
5 changed files with 316 additions and 9 deletions

92
tests/test_app_reissue.py Normal file
View File

@@ -0,0 +1,92 @@
"""Tests for the TUI re-issue workflow (CursesApp._process_cert).
Uses the same mock-curses approach as test_dialogs.py — no real terminal
needed. _msg()/_error() swallow curses.error, so a MagicMock stdscr is enough
as long as getmaxyx() returns real ints.
"""
from unittest.mock import MagicMock, patch
import curses as _curses
# Stub curses constants/callables before importing the module under test.
_curses.color_pair = lambda x: 0
_curses.A_BOLD = 0
_curses.A_UNDERLINE = 0
_curses.curs_set = lambda x: None
from openvpncertupdate import CursesApp, CertFormResult
def _stdscr():
s = MagicMock()
s.getmaxyx.return_value = (24, 80) # `sh - 2` needs a real int
s.getch.return_value = ord("q")
return s
def _patch_workflow(monkeypatch, cert_file_present):
"""Patch everything _process_cert touches after the form is confirmed."""
monkeypatch.setattr(
"openvpncertupdate.show_cert_form",
MagicMock(return_value=CertFormResult(
cn="y.kuts", email="", password="Testpass1234567890abcdefgh",
confirmed=True)))
monkeypatch.setattr("openvpncertupdate.has_issued_cert",
MagicMock(return_value=cert_file_present))
for name, retval in (
("revoke_issued", None),
("gen_crl", None),
("copy_crl", None),
("build_client_full", None),
("build_ovpn", "/out/y.kuts_2026-08-15_01/client.ovpn"),
("create_note", "https://cg.example.com/note/abc#deadbeef"),
):
monkeypatch.setattr(f"openvpncertupdate.{name}",
MagicMock(return_value=retval))
import openvpncertupdate as m
return {n: getattr(m, n) for n in (
"revoke_issued", "gen_crl", "copy_crl", "build_client_full")}
def test_tui_reissue_skips_revoke_when_cert_file_missing(monkeypatch):
# The bug as reported: the TUI offered renewal for a CN whose .crt was
# never carried over from the older EasyRSA install, and revoke-issued
# failed. There is nothing to revoke — build the replacement instead.
mocks = _patch_workflow(monkeypatch, cert_file_present=False)
app = CursesApp()
assert app._process_cert(_stdscr(), "y.kuts", "", is_renewal=True) is True
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_tui_reissue_logs_the_skip(monkeypatch):
_patch_workflow(monkeypatch, cert_file_present=False)
app = CursesApp()
app._process_cert(_stdscr(), "y.kuts", "", is_renewal=True)
assert any("revoke skipped" in e for e in app._session_log)
def test_tui_reissue_revokes_when_cert_file_present(monkeypatch):
mocks = _patch_workflow(monkeypatch, cert_file_present=True)
app = CursesApp()
assert app._process_cert(_stdscr(), "y.kuts", "", is_renewal=True) is True
mocks["revoke_issued"].assert_called_once()
mocks["gen_crl"].assert_called_once()
mocks["copy_crl"].assert_called_once()
mocks["build_client_full"].assert_called_once()
def test_tui_skipped_revoke_build_failure_does_not_claim_revocation(monkeypatch):
# Nothing was revoked, so the "has been revoked" warning would be a lie.
import openvpncertupdate as m
_patch_workflow(monkeypatch, cert_file_present=False)
monkeypatch.setattr("openvpncertupdate.build_client_full",
MagicMock(side_effect=m.EasyRSAError("boom")))
app = CursesApp()
shown = []
monkeypatch.setattr(CursesApp, "_error",
lambda self, stdscr, text: shown.append(text))
app._process_cert(_stdscr(), "y.kuts", "", is_renewal=True)
assert shown and "has been revoked" not in shown[0]

View File

@@ -4,6 +4,9 @@ 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
# ---------------------------------------------------------------------------
@@ -13,6 +16,9 @@ from openvpncertupdate import CliRunner, _build_parser, main
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())
@@ -108,6 +114,70 @@ 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):
# 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.
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):
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):
# Nothing was revoked, so the "has been revoked but no new cert" warning
# would be a lie here.
import openvpncertupdate
_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 _reissue_against_real_pki(monkeypatch, tmp_path, cert_files):
"""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")
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)

View File

@@ -5,14 +5,28 @@ 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.stderr = ""; return r
r = MagicMock(); r.returncode = 0; r.stdout = ""; r.stderr = ""; return r
def err_result():
r = MagicMock(); r.returncode = 1; r.stderr = "oops"; 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")
@@ -43,6 +57,43 @@ def test_revoke_raises_on_failure(mock_run):
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()
@@ -99,6 +150,30 @@ def test_copy_crl_restorecon_failure_is_non_fatal(mock_chmod, mock_copy, mock_ru
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
# ---------------------------------------------------------------------------