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:
92
tests/test_app_reissue.py
Normal file
92
tests/test_app_reissue.py
Normal 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]
|
||||
Reference in New Issue
Block a user