feat: TUI main selection screen with checkboxes, menu items, revoke hotkey
This commit is contained in:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
|
||||
284
tests/test_main_screen.py
Normal file
284
tests/test_main_screen.py
Normal 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
|
||||
Reference in New Issue
Block a user