clear() forces a full physical screen repaint on the next refresh(); erase() just blanks the buffer content and lets curses diff against what's already on the terminal, sending only the changed cells. The main cert list and the cert-entry form both redraw on every keystroke, so clear() there meant retransmitting the whole screen on every arrow key / typed character — slow and flickery over a low-bandwidth link like a serial console. One-shot dialogs (show_confirm, _msg, _error) draw once and don't loop-redraw, so they keep clear() as-is — no benefit to changing them and it's one less thing to get wrong. Also handle curses.KEY_RESIZE explicitly in the main list loop: force one real clear() right after a detected resize, since erase()'s diff against the pre-resize screen model isn't reliable across a genuine dimension change. Screen-size changes were already picked up on the next redraw before this (draw() runs unconditionally every loop iteration), this just guarantees a clean repaint at the moment of the actual resize instead of trusting the diff engine through it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""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)
|