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 _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"