Mark CNs with no issued cert file in the TUI and CLI lists

Renewing such a CN now works (it skips the revoke), but the list gave no
hint that it was a special case until the workflow printed its warning.
Flag it at selection time instead.

_load_current_certs() sets CertInfo.has_cert_file, so every view built on
it — the TUI list, --list and --list-all — gets the flag for free. The
stat happens after the per-CN dedup, so a CN with several V-lines in
index.txt is checked once.

TUI rows render "(no cert file)" between the CN and the email, placed
before the email so a long address truncating at the right edge cannot
push the marker off screen. --list/--list-all grow a trailing CERT
column holding MISSING; the column is omitted entirely when every CN has
its .crt, since it is pure noise on a healthy PKI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-08-15 04:11:37 +03:00
parent 9e5df8d9bb
commit 8adee23a69
5 changed files with 139 additions and 9 deletions

View File

@@ -308,6 +308,47 @@ def test_format_cert_table_columns_are_aligned(monkeypatch):
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
# ---------------------------------------------------------------------------

View File

@@ -28,13 +28,15 @@ from openvpncertupdate import (
# Helpers
# ---------------------------------------------------------------------------
def _cert(cn: str, days_left: int, email: str = "") -> CertInfo:
def _cert(cn: str, days_left: int, email: str = "",
has_cert_file: bool = True) -> CertInfo:
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
return CertInfo(
cn=cn,
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
days_left=days_left,
email=email,
has_cert_file=has_cert_file,
)
@@ -156,6 +158,31 @@ def test_cert_row_shows_email():
assert "alice@example.com" in alice_calls[0].args[2]
def test_cert_row_marks_missing_cert_file():
# index.txt lists the CN but pki/issued/<CN>.crt is gone — renewing it
# cannot revoke anything, so say so on the row rather than at the point
# of failure.
certs = [_cert("y.kuts", -2, email="y@example.com", has_cert_file=False)]
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
calls = [c for c in stdscr.addstr.call_args_list if "y.kuts" in str(c.args[2])]
assert calls
assert "(no cert)" in calls[0].args[2]
def test_cert_row_unmarked_when_cert_file_present():
certs = [_cert("alice", 10, email="alice@example.com")]
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, certs)
calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])]
assert calls
assert "(no cert)" not in calls[0].args[2]
def test_cert_row_blank_when_no_email():
certs = [_cert("bob", 3)] # email defaults to ""
stdscr = _make_stdscr()

View File

@@ -22,6 +22,40 @@ def make_pki(tmp_path, now):
return str(pki)
# ---------------------------------------------------------------------------
# has_cert_file — index.txt can list a CN whose pki/issued/<CN>.crt is gone
# (index carried over from an older EasyRSA install). Those CNs cannot be
# revoked, so the list marks them.
# ---------------------------------------------------------------------------
def test_has_cert_file_false_when_issued_dir_missing(tmp_path):
now = datetime.now(tz=timezone.utc)
pki = make_pki(tmp_path, now)
assert all(c.has_cert_file is False for c in load_all_certs(pki))
def test_has_cert_file_tracks_issued_dir(tmp_path):
now = datetime.now(tz=timezone.utc)
pki = make_pki(tmp_path, now)
issued = tmp_path / "pki" / "issued"
issued.mkdir()
(issued / "soon.crt").write_text("-----BEGIN CERTIFICATE-----\n")
by_cn = {c.cn: c for c in load_all_certs(pki)}
assert by_cn["soon"].has_cert_file is True
assert by_cn["later"].has_cert_file is False
def test_has_cert_file_set_on_expiring_view_too(tmp_path):
now = datetime.now(tz=timezone.utc)
pki = make_pki(tmp_path, now)
issued = tmp_path / "pki" / "issued"
issued.mkdir()
(issued / "past15.crt").write_text("-----BEGIN CERTIFICATE-----\n")
by_cn = {c.cn: c for c in load_expiring_certs(pki, days_past=30, days_ahead=14)}
assert by_cn["past15"].has_cert_file is True
assert by_cn["soon"].has_cert_file is False
def test_filters_within_window(tmp_path):
now = datetime.now(tz=timezone.utc)
pki = make_pki(tmp_path, now)