feat: TUI main selection screen with checkboxes, menu items, revoke hotkey

This commit is contained in:
Vlad Doloman
2026-06-24 00:20:10 +03:00
parent 565a152243
commit ca6fe9f525
2 changed files with 431 additions and 0 deletions

284
tests/test_main_screen.py Normal file
View File

@@ -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