Files
openvpncertupdate/tests/test_pki.py
Vlad Doloman 4798ce3f18 fix TUI crash on serial consoles; make index.txt the sole email store
curses.use_default_colors() raises on terminals (e.g. serial consoles
using TERM=linux/vt100) whose terminfo lacks default-color support;
init_colors() now falls back to an explicit black background and caps
the "disabled" color to 8-color terminals.

--reissue without --email silently sent no mail when the CN wasn't in
openvpncertupdate-metadata.json, even though its email was already
embedded in the cert's index.txt subject (via EASYRSA_REQ_EMAIL) and
the TUI already fell back to it. Since build_client_full() always
writes that subject field whenever an email is known, metadata.json
was a redundant, driftable copy — remove it and have get_email() read
index.txt directly, picking the last non-revoked entry for a CN since
index.txt can contain revoked/duplicate lines for the same CN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 15:46:10 +03:00

151 lines
5.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)
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 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"