"""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, email: str = "") -> 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, email=email, ) 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_draw_uses_erase_not_clear(): # clear() forces a full-screen repaint on every refresh(); erase() lets # curses diff against the previous frame instead — important over a # low-bandwidth link like a serial console. stdscr = _make_stdscr() stdscr.getch.side_effect = [ord("q")] with patch("openvpncertupdate.init_colors"): show_main_screen(stdscr, _CERTS) assert stdscr.erase.called assert not stdscr.clear.called def test_key_resize_forces_one_full_clear(): # A genuine terminal resize should force one true full repaint rather # than relying on erase()'s diff against pre-resize screen contents. stdscr = _make_stdscr() stdscr.getch.side_effect = [_curses.KEY_RESIZE, ord("q")] with patch("openvpncertupdate.init_colors"): show_main_screen(stdscr, _CERTS) assert stdscr.clear.call_count == 1 assert stdscr.erase.call_count == 2 # once before the resize key, once after def test_capital_Q_exits(): result = _run([ord("Q")]) assert result.action == Action.EXIT def test_cursor_row_has_reverse_attribute(): # A_REVERSE must mark the cursor row so it's still visible even when a # terminal (e.g. a serial console) silently rejects the COLOR_SELECTED # pair and color_pair() becomes a no-op. stdscr = _make_stdscr() stdscr.getch.side_effect = [ord("q")] with patch("openvpncertupdate.init_colors"): show_main_screen(stdscr, _CERTS) # cursor defaults to row 0 ("bob") bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])] alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])] assert bob_calls and bob_calls[0].args[3] & _curses.A_REVERSE assert alice_calls and not (alice_calls[0].args[3] & _curses.A_REVERSE) def test_cert_row_shows_email(): certs = [_cert("alice", 10, email="alice@example.com"), _cert("bob", 3)] stdscr = _make_stdscr() stdscr.getch.side_effect = [ord("q")] with patch("openvpncertupdate.init_colors"): show_main_screen(stdscr, certs) alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])] assert alice_calls assert "alice@example.com" in alice_calls[0].args[2] def test_cert_row_blank_when_no_email(): certs = [_cert("bob", 3)] # email defaults to "" stdscr = _make_stdscr() stdscr.getch.side_effect = [ord("q")] with patch("openvpncertupdate.init_colors"): show_main_screen(stdscr, certs) bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])] assert bob_calls assert "(none)" not in bob_calls[0].args[2] # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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)