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>
222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
import textwrap
|
|
import pytest
|
|
from datetime import datetime, timezone, timedelta
|
|
from openvpncertupdate import load_all_certs, load_expiring_certs, CertInfo, _parse_index_line, get_email
|
|
|
|
|
|
def _fmt(dt: datetime) -> str:
|
|
return dt.strftime("%y%m%d%H%M%S") + "Z"
|
|
|
|
|
|
def make_pki(tmp_path, now):
|
|
pki = tmp_path / "pki"
|
|
pki.mkdir()
|
|
content = textwrap.dedent(f"""\
|
|
V\t{_fmt(now + timedelta(days=10))}\t\t02\tunknown\t/CN=soon
|
|
V\t{_fmt(now + timedelta(days=90))}\t\t03\tunknown\t/CN=later
|
|
V\t{_fmt(now - timedelta(days=15))}\t\t04\tunknown\t/CN=past15
|
|
V\t{_fmt(now - timedelta(days=40))}\t\t05\tunknown\t/CN=past40
|
|
R\t{_fmt(now + timedelta(days=10))}\t230101Z,keyCompromise\t06\tunknown\t/CN=revoked
|
|
""")
|
|
(pki / "index.txt").write_text(content)
|
|
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)
|
|
certs = load_expiring_certs(pki, days_past=30, days_ahead=14)
|
|
cns = {c.cn for c in certs}
|
|
assert "soon" in cns # 10d ahead < 14d threshold
|
|
assert "later" not in cns # 90d > 14d
|
|
assert "past15" in cns # 15d past < 30d threshold
|
|
assert "past40" not in cns # 40d past > 30d threshold
|
|
assert "revoked" not in cns # R status skipped
|
|
|
|
|
|
def test_sorted_ascending(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
certs = load_expiring_certs(pki, days_past=30, days_ahead=14)
|
|
dates = [c.expires for c in certs]
|
|
assert dates == sorted(dates)
|
|
|
|
|
|
def test_days_left_negative_for_expired(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
certs = load_expiring_certs(pki, days_past=30, days_ahead=14)
|
|
expired = next(c for c in certs if c.cn == "past15")
|
|
assert expired.days_left < 0
|
|
|
|
|
|
def test_parse_4digit_year():
|
|
line = "V\t20251231120000Z\t\t07\tunknown\t/CN=future"
|
|
result = _parse_index_line(line)
|
|
assert result is not None
|
|
cn, dt, email = result
|
|
assert cn == "future"
|
|
assert dt.year == 2025
|
|
assert email == ""
|
|
|
|
|
|
def test_parse_email_from_dn():
|
|
line = "V\t20251231120000Z\t\t07\tunknown\t/CN=alice/emailAddress=alice@example.com"
|
|
result = _parse_index_line(line)
|
|
assert result is not None
|
|
cn, dt, email = result
|
|
assert cn == "alice"
|
|
assert email == "alice@example.com"
|
|
|
|
|
|
def test_parse_email_absent_returns_empty():
|
|
line = "V\t20251231120000Z\t\t07\tunknown\t/CN=bob"
|
|
result = _parse_index_line(line)
|
|
assert result is not None
|
|
cn, dt, email = result
|
|
assert cn == "bob"
|
|
assert email == ""
|
|
|
|
|
|
def test_load_expiring_certs_populates_email(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = tmp_path / "pki"
|
|
pki.mkdir()
|
|
expiry = now + timedelta(days=5)
|
|
line = f"V\t{expiry.strftime('%y%m%d%H%M%S')}Z\t\t01\tunknown\t/CN=carol/emailAddress=carol@example.com\n"
|
|
(pki / "index.txt").write_text(line)
|
|
certs = load_expiring_certs(str(pki), days_past=0, days_ahead=14)
|
|
assert len(certs) == 1
|
|
assert certs[0].email == "carol@example.com"
|
|
|
|
|
|
def test_load_all_certs_includes_all_valid(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
certs = load_all_certs(pki)
|
|
cns = {c.cn for c in certs}
|
|
# All V-status entries included regardless of expiry window
|
|
assert cns == {"soon", "later", "past15", "past40"}
|
|
# Revoked entry excluded
|
|
assert "revoked" not in cns
|
|
|
|
|
|
def test_load_all_certs_sorted_ascending(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
certs = load_all_certs(pki)
|
|
dates = [c.expires for c in certs]
|
|
assert dates == sorted(dates)
|
|
|
|
|
|
def _make_duplicate_cn_pki(tmp_path, now):
|
|
"""index.txt with an old (still V-status, never revoked) entry for
|
|
"dave" plus a newer reissued V-status entry for the same CN."""
|
|
pki = tmp_path / "pki"
|
|
pki.mkdir()
|
|
e_old = (now - timedelta(days=5)).strftime("%y%m%d%H%M%S") + "Z" # expired, left unrevoked
|
|
e_new = (now + timedelta(days=365)).strftime("%y%m%d%H%M%S") + "Z" # reissued, far future
|
|
content = (
|
|
f"V\t{e_old}\t\t01\tunknown\t/CN=dave/emailAddress=old@example.com\n"
|
|
f"V\t{e_new}\t\t02\tunknown\t/CN=dave/emailAddress=newest@example.com\n"
|
|
)
|
|
(pki / "index.txt").write_text(content)
|
|
return str(pki), e_old, e_new
|
|
|
|
|
|
def test_load_all_certs_dedups_to_last_valid_entry(tmp_path):
|
|
# A CN left unrevoked after expiring, then reissued, must appear once
|
|
# — as its most recent entry — not as two rows.
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki, _e_old, _e_new = _make_duplicate_cn_pki(tmp_path, now)
|
|
certs = load_all_certs(pki)
|
|
dave_rows = [c for c in certs if c.cn == "dave"]
|
|
assert len(dave_rows) == 1
|
|
assert dave_rows[0].email == "newest@example.com"
|
|
assert dave_rows[0].days_left > 300
|
|
|
|
|
|
def test_load_expiring_certs_filters_by_current_entry_not_stale_duplicate(tmp_path):
|
|
# The stale (unrevoked) old entry for "dave" falls inside the
|
|
# recently-expired window, but the *current* entry (reissued, far in
|
|
# the future) doesn't — so "dave" must NOT show up as "expiring soon".
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki, _e_old, _e_new = _make_duplicate_cn_pki(tmp_path, now)
|
|
certs = load_expiring_certs(pki, days_past=30, days_ahead=14)
|
|
assert "dave" not in {c.cn for c in certs}
|
|
|
|
|
|
def test_get_email_reads_from_index_txt(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = tmp_path / "pki"
|
|
pki.mkdir()
|
|
expiry = now + timedelta(days=5)
|
|
line = f"V\t{expiry.strftime('%y%m%d%H%M%S')}Z\t\t01\tunknown\t/CN=carol/emailAddress=carol@example.com\n"
|
|
(pki / "index.txt").write_text(line)
|
|
assert get_email(str(pki), "carol") == "carol@example.com"
|
|
|
|
|
|
def test_get_email_missing_cn_returns_empty(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
assert get_email(pki, "nobody") == ""
|
|
|
|
|
|
def test_get_email_ignores_revoked_entry(tmp_path):
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = make_pki(tmp_path, now)
|
|
# "revoked" CN in make_pki() has status R, not V
|
|
assert get_email(pki, "revoked") == ""
|
|
|
|
|
|
def test_get_email_picks_last_valid_entry_among_duplicates(tmp_path):
|
|
# index.txt is append-only: a CN can have several lines (old ones R,
|
|
# or multiple V if a cert was reissued without going through this
|
|
# tool's revoke-first flow). The most recently appended V line wins.
|
|
now = datetime.now(tz=timezone.utc)
|
|
pki = tmp_path / "pki"
|
|
pki.mkdir()
|
|
e1 = (now + timedelta(days=5)).strftime("%y%m%d%H%M%S") + "Z"
|
|
e2 = (now + timedelta(days=365)).strftime("%y%m%d%H%M%S") + "Z"
|
|
content = (
|
|
f"R\t{e1}\t230101Z,superseded\t01\tunknown\t/CN=dave/emailAddress=old@example.com\n"
|
|
f"V\t{e1}\t\t02\tunknown\t/CN=dave/emailAddress=older-valid@example.com\n"
|
|
f"V\t{e2}\t\t03\tunknown\t/CN=dave/emailAddress=newest@example.com\n"
|
|
)
|
|
(pki / "index.txt").write_text(content)
|
|
assert get_email(str(pki), "dave") == "newest@example.com"
|