From ca6fe9f525b08c70717b956663646e6b332f600e Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Wed, 24 Jun 2026 00:20:10 +0300 Subject: [PATCH] feat: TUI main selection screen with checkboxes, menu items, revoke hotkey --- openvpncertupdate.py | 147 ++++++++++++++++++++ tests/test_main_screen.py | 284 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 tests/test_main_screen.py diff --git a/openvpncertupdate.py b/openvpncertupdate.py index e397a61..9dbde1c 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -599,6 +599,153 @@ def show_cert_form( fields[active[focus]].handle_key(key) +# ============================================================ +# === TUI SCREEN === +# ============================================================ + + +class Action(Enum): + RENEW_SELECTED = auto() + NEW_CERT = auto() + REGEN_CRL = auto() + REVOKE = auto() + EXIT = auto() + + +@dataclass +class ScreenResult: + action: Action + selected_cns: list[str] = field(default_factory=list) + + +_MENU_NEW = "Create New Certificate" +_MENU_CRL = "Regenerate CRL" +_MENU_EXIT = "Exit" +_MENU_ITEMS = [_MENU_NEW, _MENU_CRL, _MENU_EXIT] + + +def _expiry_label(c: CertInfo) -> str: + if c.days_left < 0: + return f"[EXPIRED {abs(c.days_left)}d ago]" + if c.days_left == 0: + return "[EXPIRES TODAY!]" + return f"[exp in {c.days_left}d] " + + +def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult: + """Display cert selection list. Returns when user activates an action.""" + init_colors() + curses.curs_set(0) + + n_certs = len(certs) + n_items = n_certs + len(_MENU_ITEMS) + checked: set[str] = set() + cursor = 0 + + def is_cert(idx): return idx < n_certs + def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None + + def draw(): + stdscr.clear() + sh, sw = stdscr.getmaxyx() + + # Header bar + hdr = " openvpncertupdate — VPN Certificate Manager " + try: + stdscr.addstr(0, 0, hdr.ljust(sw)[:sw], + curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + except curses.error: + pass + + # Footer hint + hint = " ↑↓:Navigate Space:Check Enter:Confirm R:Revoke Q:Quit" + try: + stdscr.addstr(sh - 1, 0, hint[:sw], curses.color_pair(COLOR_DISABLED)) + except curses.error: + pass + + list_h = sh - 2 + scroll = clamp(cursor - list_h // 2, 0, max(0, n_items - list_h)) + row_y = 1 + + for i in range(scroll, min(scroll + list_h, n_items)): + if row_y >= sh - 1: + break + is_cur = (i == cursor) + sel_attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_NORMAL) + + if is_cert(i): + cert = certs[i] + box = "[X]" if cert.cn in checked else "[ ]" + lbl = _expiry_label(cert) + line = f" {box} {lbl:<18} {cert.cn}" + try: + stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr) + except curses.error: + pass + else: + item = menu_item(i) + any_check = bool(checked) + if item == _MENU_NEW and any_check: + notice = " ← clear all checkboxes to use" + line = f" {item}{notice}" + attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_DISABLED) + else: + line = f" {item}" + attr = sel_attr + try: + stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], attr) + except curses.error: + pass + row_y += 1 + + stdscr.refresh() + + while True: + draw() + key = stdscr.getch() + + if key in (ord("q"), ord("Q")): + return ScreenResult(action=Action.EXIT) + + elif key == curses.KEY_UP: + cursor = clamp(cursor - 1, 0, n_items - 1) + + elif key == curses.KEY_DOWN: + cursor = clamp(cursor + 1, 0, n_items - 1) + + elif key == ord(" ") and is_cert(cursor): + cn = certs[cursor].cn + if cn in checked: + checked.discard(cn) + else: + checked.add(cn) + + elif key in (10, 13, curses.KEY_ENTER): + if is_cert(cursor): + # Enter on a cert row: add to selection and confirm immediately + checked.add(certs[cursor].cn) + return ScreenResult(action=Action.RENEW_SELECTED, + selected_cns=sorted(checked)) + else: + item = menu_item(cursor) + if item == _MENU_NEW and not checked: + return ScreenResult(action=Action.NEW_CERT) + elif item == _MENU_CRL: + return ScreenResult(action=Action.REGEN_CRL) + elif item == _MENU_EXIT: + return ScreenResult(action=Action.EXIT) + # _MENU_NEW when checked → grayed, ignore + + elif key in (ord("r"), ord("R")) and is_cert(cursor): + cert = certs[cursor] + if show_confirm( + stdscr, + f"Revoke certificate for:\n CN: {cert.cn}\n\nThis cannot be undone.", + ): + return ScreenResult(action=Action.REVOKE, selected_cns=[cert.cn]) + + # (remaining sections added in later tasks) # ============================================================ diff --git a/tests/test_main_screen.py b/tests/test_main_screen.py new file mode 100644 index 0000000..b3a2514 --- /dev/null +++ b/tests/test_main_screen.py @@ -0,0 +1,284 @@ +"""Tests for TUI SCREEN (show_main_screen, Action, ScreenResult, _expiry_label). + +Uses the same mock-curses approach as test_dialogs.py — no real terminal needed. +""" +from unittest.mock import MagicMock, patch +import curses as _curses +from datetime import datetime, timezone + +# Stub curses constants/callables before importing the module under test. +_curses.color_pair = lambda x: 0 +_curses.A_BOLD = 0 +_curses.A_UNDERLINE = 0 +_curses.curs_set = lambda x: None +_curses.KEY_UP = 259 +_curses.KEY_DOWN = 258 +_curses.KEY_ENTER = 343 + +from openvpncertupdate import ( + CertInfo, + Action, + ScreenResult, + _expiry_label, + show_main_screen, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _cert(cn: str, days_left: int) -> 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, + ) + + +def _make_stdscr(rows: int = 40, cols: int = 120) -> MagicMock: + stdscr = MagicMock() + stdscr.getmaxyx.return_value = (rows, cols) + return stdscr + + +# --------------------------------------------------------------------------- +# _expiry_label +# --------------------------------------------------------------------------- + +def test_expiry_label_expired(): + c = _cert("alice", -5) + assert _expiry_label(c) == "[EXPIRED 5d ago]" + + +def test_expiry_label_today(): + c = _cert("alice", 0) + assert _expiry_label(c) == "[EXPIRES TODAY!]" + + +def test_expiry_label_future(): + c = _cert("alice", 10) + label = _expiry_label(c) + assert "10d" in label + assert label.startswith("[exp in") + + +# --------------------------------------------------------------------------- +# Fixtures shared across screen tests +# --------------------------------------------------------------------------- + +# Two certs for list-based tests; order matters — bob is index 0, alice is 1. +_BOB = _cert("bob", 3) +_ALICE = _cert("alice", 10) +_CERTS = [_BOB, _ALICE] + +# Indices for the three menu items (after 2 certs): +# 2 = "Create New Certificate" +# 3 = "Regenerate CRL" +# 4 = "Exit" +_IDX_NEW = 2 +_IDX_CRL = 3 +_IDX_EXIT = 4 + + +def _run(keys, certs=None): + """Run show_main_screen with patched init_colors and the given key sequence.""" + if certs is None: + certs = _CERTS + stdscr = _make_stdscr() + stdscr.getch.side_effect = keys + with patch("openvpncertupdate.init_colors"): + return show_main_screen(stdscr, certs) + + +# --------------------------------------------------------------------------- +# q / Q → EXIT +# --------------------------------------------------------------------------- + +def test_q_exits(): + result = _run([ord("q")]) + assert result.action == Action.EXIT + assert result.selected_cns == [] + + +def test_capital_Q_exits(): + result = _run([ord("Q")]) + assert result.action == Action.EXIT + + +# --------------------------------------------------------------------------- +# Enter on cert → RENEW_SELECTED +# --------------------------------------------------------------------------- + +def test_enter_on_cert_renew_selected(): + """Enter while cursor is on the first cert returns RENEW_SELECTED.""" + result = _run([10]) # cursor starts at 0 = _BOB + assert result.action == Action.RENEW_SELECTED + assert result.selected_cns == ["bob"] + + +def test_enter_on_cert_includes_checked_cns(): + """Space-check alice, then Enter on bob → both CNs appear sorted.""" + # Navigate to alice (index 1), space-check her, go back to bob, Enter + keys = [ + _curses.KEY_DOWN, # cursor → 1 (alice) + ord(" "), # check alice + _curses.KEY_UP, # cursor → 0 (bob) + 10, # Enter on bob: adds bob + returns + ] + result = _run(keys) + assert result.action == Action.RENEW_SELECTED + assert result.selected_cns == ["alice", "bob"] # sorted + + +# --------------------------------------------------------------------------- +# Space toggles checkbox +# --------------------------------------------------------------------------- + +def test_space_check_then_enter_on_same_cert(): + """Space then Enter on the same cert returns that CN in selected_cns.""" + keys = [ord(" "), 10] # check bob, then Enter + result = _run(keys) + assert result.action == Action.RENEW_SELECTED + assert "bob" in result.selected_cns + + +def test_space_toggle_uncheck(): + """Checking then unchecking a cert via Space, then Enter produces only + the cert where Enter was pressed (since the checked set was cleared).""" + keys = [ + ord(" "), # check bob + ord(" "), # uncheck bob + 10, # Enter on bob — checked set is empty → bob added fresh + ] + result = _run(keys) + assert result.action == Action.RENEW_SELECTED + assert result.selected_cns == ["bob"] + + +# --------------------------------------------------------------------------- +# Menu: Create New Certificate +# --------------------------------------------------------------------------- + +def _nav_to_menu_item(offset: int) -> list[int]: + """Return keystrokes to move cursor from 0 to n_certs + offset.""" + return [_curses.KEY_DOWN] * (len(_CERTS) + offset) + + +def test_enter_new_cert_no_checked(): + """Enter on 'Create New Certificate' with no certs checked → NEW_CERT.""" + keys = _nav_to_menu_item(0) + [10] + result = _run(keys) + assert result.action == Action.NEW_CERT + assert result.selected_cns == [] + + +def test_enter_new_cert_with_checked_ignored(): + """Enter on 'Create New Certificate' when a cert is checked is a no-op. + After the ignored press the user presses Q to quit.""" + keys = ( + [ord(" ")] # check bob + + _nav_to_menu_item(0) # navigate to "Create New" + + [10] # Enter on "Create New" — should be ignored + + [ord("q")] # quit to end the loop + ) + result = _run(keys) + assert result.action == Action.EXIT # Q exit, not NEW_CERT + + +# --------------------------------------------------------------------------- +# Menu: Regenerate CRL +# --------------------------------------------------------------------------- + +def test_enter_regen_crl(): + keys = _nav_to_menu_item(1) + [10] + result = _run(keys) + assert result.action == Action.REGEN_CRL + assert result.selected_cns == [] + + +# --------------------------------------------------------------------------- +# Menu: Exit +# --------------------------------------------------------------------------- + +def test_enter_exit_menu(): + keys = _nav_to_menu_item(2) + [10] + result = _run(keys) + assert result.action == Action.EXIT + + +# --------------------------------------------------------------------------- +# r / R hotkey → REVOKE +# --------------------------------------------------------------------------- + +def test_r_on_cert_confirms_revoke(): + """r key on a cert, confirm=True → REVOKE with [cn].""" + keys = [ord("r")] + with patch("openvpncertupdate.init_colors"), \ + patch("openvpncertupdate.show_confirm", return_value=True): + stdscr = _make_stdscr() + stdscr.getch.side_effect = keys + result = show_main_screen(stdscr, _CERTS) + assert result.action == Action.REVOKE + assert result.selected_cns == ["bob"] + + +def test_r_on_cert_deny_continues(): + """r key on a cert, confirm=False → loop continues; then q exits.""" + keys = [ord("r"), ord("q")] + with patch("openvpncertupdate.init_colors"), \ + patch("openvpncertupdate.show_confirm", return_value=False): + stdscr = _make_stdscr() + stdscr.getch.side_effect = keys + result = show_main_screen(stdscr, _CERTS) + assert result.action == Action.EXIT + + +def test_capital_R_on_cert_revoke(): + """R (uppercase) on a cert with confirm → REVOKE.""" + keys = [ord("R")] + with patch("openvpncertupdate.init_colors"), \ + patch("openvpncertupdate.show_confirm", return_value=True): + stdscr = _make_stdscr() + stdscr.getch.side_effect = keys + result = show_main_screen(stdscr, _CERTS) + assert result.action == Action.REVOKE + assert result.selected_cns == ["bob"] + + +# --------------------------------------------------------------------------- +# Navigation boundaries +# --------------------------------------------------------------------------- + +def test_key_down_navigation(): + """DOWN then Enter moves cursor to cert index 1 (alice).""" + keys = [_curses.KEY_DOWN, 10] + result = _run(keys) + assert result.action == Action.RENEW_SELECTED + assert "alice" in result.selected_cns + + +def test_key_up_wraps_at_zero(): + """UP at top should clamp to 0; cursor stays on bob; Enter gives bob.""" + keys = [_curses.KEY_UP, 10] + result = _run(keys) + assert result.action == Action.RENEW_SELECTED + assert "bob" in result.selected_cns + + +# --------------------------------------------------------------------------- +# Empty cert list — only menu items +# --------------------------------------------------------------------------- + +def test_empty_cert_list_new_cert(): + """With no certs, Enter on first item (Create New) → NEW_CERT.""" + keys = [10] + result = _run(keys, certs=[]) + assert result.action == Action.NEW_CERT + + +def test_empty_cert_list_quit(): + keys = [ord("q")] + result = _run(keys, certs=[]) + assert result.action == Action.EXIT