diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 675ccc7..4760266 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -124,6 +124,25 @@ def load_expiring_certs( return results +def load_all_certs(pki_dir: str) -> list[CertInfo]: + """Return all valid (V-status) certs, sorted by expiry date.""" + now = datetime.now(tz=timezone.utc) + results: list[CertInfo] = [] + with open(os.path.join(pki_dir, "index.txt")) as fh: + for line in fh: + parsed = _parse_index_line(line) + if parsed is None: + continue + cn, expiry = parsed + results.append(CertInfo( + cn=cn, + expires=expiry, + days_left=(expiry - now).days, + )) + results.sort(key=lambda c: c.expires) + return results + + # ============================================================ # === PASSWORD === # ============================================================ @@ -609,6 +628,7 @@ class Action(Enum): NEW_CERT = auto() REGEN_CRL = auto() REVOKE = auto() + TOGGLE_ALL = auto() EXIT = auto() @@ -632,7 +652,9 @@ def _expiry_label(c: CertInfo) -> str: return f"[exp in {c.days_left}d] " -def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: +def show_main_screen( + stdscr, certs: list[CertInfo], show_all: bool = False +) -> ScreenResult: """Display cert selection list. Returns when user activates an action.""" init_colors() curses.curs_set(0) @@ -649,8 +671,9 @@ def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: stdscr.clear() sh, sw = stdscr.getmaxyx() - # Header bar - hdr = " openvpncertupdate — VPN Certificate Manager " + # Header bar — shows current filter mode + mode = "ALL certs" if show_all else "expiring/expired" + hdr = f" openvpncertupdate — VPN Certificate Manager [{mode}] " try: stdscr.addstr(0, 0, hdr.ljust(sw)[:sw], curses.color_pair(COLOR_TITLE) | curses.A_BOLD) @@ -658,7 +681,7 @@ def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: pass # Footer hint - hint = " ↑↓:Navigate Space:Check Enter:Confirm R:Revoke Q:Quit" + hint = " ↑↓:Navigate Space:Check Enter:Confirm R:Revoke A:Toggle view Q:Quit" try: stdscr.addstr(sh - 1, 0, hint[:sw], curses.color_pair(COLOR_DISABLED)) except curses.error: @@ -737,6 +760,9 @@ def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: return ScreenResult(action=Action.EXIT) # _MENU_NEW when checked → grayed, ignore + elif key in (ord("a"), ord("A")): + return ScreenResult(action=Action.TOGGLE_ALL) + elif key in (ord("r"), ord("R")) and is_cert(cursor): cert = certs[cursor] if show_confirm( @@ -759,19 +785,25 @@ class CursesApp: curses.curs_set(0) init_colors() + show_all = False + while True: try: - certs = load_expiring_certs( - EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD, + certs = ( + load_all_certs(EASYRSA_PKI_DIR) + if show_all + else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD) ) except FileNotFoundError as exc: self._fatal(stdscr, f"Cannot read PKI index.txt:\n{exc}") return - result = show_main_screen(stdscr, certs) + result = show_main_screen(stdscr, certs, show_all=show_all) if result.action == Action.EXIT: return + elif result.action == Action.TOGGLE_ALL: + show_all = not show_all elif result.action == Action.REGEN_CRL: self._regen_crl(stdscr) elif result.action == Action.REVOKE: diff --git a/tests/test_main_screen.py b/tests/test_main_screen.py index b3a2514..b972bbc 100644 --- a/tests/test_main_screen.py +++ b/tests/test_main_screen.py @@ -282,3 +282,44 @@ def test_empty_cert_list_quit(): keys = [ord("q")] result = _run(keys, certs=[]) assert result.action == Action.EXIT + + +# --------------------------------------------------------------------------- +# A / a → TOGGLE_ALL +# --------------------------------------------------------------------------- + +def test_a_lowercase_returns_toggle_all(): + result = _run([ord("a")]) + assert result.action == Action.TOGGLE_ALL + + +def test_A_uppercase_returns_toggle_all(): + result = _run([ord("A")]) + assert result.action == Action.TOGGLE_ALL + + +def test_toggle_all_with_no_certs(): + """A works even when the cert list is empty.""" + result = _run([ord("a")], certs=[]) + assert result.action == Action.TOGGLE_ALL + + +def test_show_all_flag_passed_through_to_header(): + """show_all=True causes 'ALL certs' to appear in the header addstr call.""" + stdscr = _make_stdscr() + stdscr.getch.side_effect = [ord("q")] + with patch("openvpncertupdate.init_colors"): + show_main_screen(stdscr, _CERTS, show_all=True) + # The header addstr call (row 0) should contain 'ALL certs' + calls = [str(c) for c in stdscr.addstr.call_args_list] + assert any("ALL certs" in c for c in calls) + + +def test_show_all_false_header_shows_expiring(): + """show_all=False (default) shows 'expiring/expired' in the header.""" + stdscr = _make_stdscr() + stdscr.getch.side_effect = [ord("q")] + with patch("openvpncertupdate.init_colors"): + show_main_screen(stdscr, _CERTS) + calls = [str(c) for c in stdscr.addstr.call_args_list] + assert any("expiring/expired" in c for c in calls) diff --git a/tests/test_pki.py b/tests/test_pki.py index 49fa171..2b7c425 100644 --- a/tests/test_pki.py +++ b/tests/test_pki.py @@ -1,7 +1,7 @@ import textwrap import pytest from datetime import datetime, timezone, timedelta -from openvpncertupdate import load_expiring_certs, CertInfo, _parse_index_line +from openvpncertupdate import load_all_certs, load_expiring_certs, CertInfo, _parse_index_line def _fmt(dt: datetime) -> str: @@ -57,3 +57,22 @@ def test_parse_4digit_year(): cn, dt = result assert cn == "future" assert dt.year == 2025 + + +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)