Code review findings on the "skip revoke when .crt is missing" migration path: - CliRunner._issue() took the skip path whenever has_issued_cert() was False, which is also true for a typo'd/nonexistent CN — it would warn, skip the revoke, and go on to build, package, and email a brand-new certificate for a CN nobody asked to renew. The skip now only fires when the CN has a current index.txt entry (via _load_current_certs()); an unknown CN prints an error and exits 1 with nothing built. The TUI's _process_cert() doesn't need the same guard — renewal there always opens on an existing row (cn_readonly pins the CN), so a typo'd CN can't reach the branch. - Skipping the revoke leaves pki/reqs/<CN>.req and pki/private/<CN>.key in place (normally revoke-issued archives both), which makes EasyRSA's build-client-full abort. Both CliRunner._issue() and CursesApp._process_cert() now check for those leftovers before building and fail fast with the exact paths, rather than surfacing EasyRSA's confusing error after the CA passphrase prompt. Neither path touches the files itself. Also: strengthened two under-specified tests (test_main_rejects_bad_days_flag now checks the resolver's message text, not just "--days", which also appears in argparse's unrelated error; test_show_cert_form_confirm now pins the Enter-keypress count so a partial "days" field reversion is caught), and folded a malformed CLAUDE.md table row into its neighbor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.9 KiB
Python
231 lines
8.9 KiB
Python
"""Tests for TUI DIALOGS (show_confirm, show_cert_form).
|
||
|
||
Uses the same mock-curses approach as test_widgets.py — no real terminal needed.
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
import curses as _curses
|
||
|
||
# Stub curses constants before importing the module under test, mirroring
|
||
# what test_widgets.py does for InputField's dependencies.
|
||
_curses.color_pair = lambda x: 0
|
||
_curses.curs_set = lambda x: None
|
||
_curses.A_UNDERLINE = 0
|
||
_curses.A_BOLD = 0
|
||
_curses.KEY_BACKSPACE = 263
|
||
_curses.KEY_LEFT = 260
|
||
_curses.KEY_RIGHT = 261
|
||
_curses.KEY_HOME = 262
|
||
_curses.KEY_END = 360
|
||
_curses.KEY_DC = 330
|
||
_curses.KEY_BTAB = 353
|
||
_curses.KEY_F5 = 269
|
||
_curses.KEY_ENTER = 343
|
||
|
||
from openvpncertupdate import show_confirm, show_cert_form, CertFormResult
|
||
|
||
|
||
def _make_stdscr(rows=40, cols=120):
|
||
"""Return a minimal MagicMock that looks like a curses stdscr."""
|
||
stdscr = MagicMock()
|
||
stdscr.getmaxyx.return_value = (rows, cols)
|
||
return stdscr
|
||
|
||
|
||
def _make_win(rows=20, cols=60):
|
||
"""Return a minimal MagicMock for the subwindow created by curses.newwin."""
|
||
win = MagicMock()
|
||
win.getmaxyx.return_value = (rows, cols)
|
||
return win
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# show_confirm tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_show_confirm_yes():
|
||
"""Pressing 'y' returns True."""
|
||
stdscr = _make_stdscr()
|
||
win = _make_win()
|
||
win.getch.side_effect = [ord("y")]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_confirm(stdscr, "Do you want to continue?")
|
||
assert result is True
|
||
|
||
|
||
def test_show_confirm_esc():
|
||
"""Pressing Escape (27) returns False."""
|
||
stdscr = _make_stdscr()
|
||
win = _make_win()
|
||
win.getch.side_effect = [27]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_confirm(stdscr, "Do you want to continue?")
|
||
assert result is False
|
||
|
||
|
||
def test_show_confirm_clamps_width_to_screen_for_long_line():
|
||
"""A long message line (e.g. a Cryptgeon password URL) must not make the
|
||
requested newwin() width exceed the screen — that's what made
|
||
curses.newwin() raise 'curses function returned NULL' on a narrow
|
||
terminal such as a serial console."""
|
||
stdscr = _make_stdscr(rows=24, cols=80)
|
||
win = _make_win()
|
||
win.getch.side_effect = [ord("y")]
|
||
long_url = "https://cryptgeon.example.com/note/" + "a" * 100 + "#" + "b" * 64
|
||
with patch("curses.newwin", return_value=win) as mock_newwin:
|
||
show_confirm(stdscr, f"Send email?\n\nPassword URL: {long_url}")
|
||
h, w, y, x = mock_newwin.call_args.args
|
||
assert w <= 80
|
||
assert x >= 0
|
||
|
||
|
||
def test_show_confirm_newwin_failure_returns_false():
|
||
"""If curses.newwin() still fails despite clamping, show_confirm must
|
||
degrade to 'declined' rather than crash the whole app."""
|
||
stdscr = _make_stdscr()
|
||
with patch("curses.newwin", side_effect=_curses.error("returned NULL")):
|
||
result = show_confirm(stdscr, "Do you want to continue?")
|
||
assert result is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# show_cert_form tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_show_cert_form_cancel():
|
||
"""Pressing Escape returns a CertFormResult with confirmed=False."""
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [27]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||
assert isinstance(result, CertFormResult)
|
||
assert result.confirmed is False
|
||
|
||
|
||
def test_show_cert_form_uses_erase_not_clear():
|
||
# clear() forces a full-screen repaint on every refresh(); the form
|
||
# redraws on every keystroke, so it must use erase() instead.
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [9, 27] # Tab, then Escape
|
||
with patch("curses.newwin", return_value=win):
|
||
show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||
assert win.erase.called
|
||
assert not win.clear.called
|
||
|
||
|
||
def test_show_cert_form_confirm():
|
||
"""Enter advances through the 4 fields to the Continue button; Enter on
|
||
Continue confirms. That's 5 Enter keypresses total.
|
||
|
||
days="90" (non-blank) is used deliberately: a blank default would still
|
||
read back as "" even from a reverted three-field form (CertFormResult.days
|
||
defaults to ""), so it would not actually prove the days field was
|
||
traversed. A non-blank value only round-trips if the days field exists,
|
||
was reached by the Enter sequence, and was carried into the result."""
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [10, 10, 10, 10, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="bob@example.com", days="90")
|
||
assert isinstance(result, CertFormResult)
|
||
assert result.confirmed is True
|
||
assert result.cn == "bob"
|
||
assert result.email == "bob@example.com"
|
||
assert result.days == "90"
|
||
assert len(result.password) > 0
|
||
# Pins the field count itself: a partial reversion that drops "days"
|
||
# from _FORM_FIELDS while CertFormResult/_submit() keep it would still
|
||
# round-trip days="90" above (it's passed straight through, untraversed),
|
||
# but would only consume 4 Enters, not 5.
|
||
assert win.getch.call_count == 5
|
||
|
||
|
||
def test_show_cert_form_cancel_button():
|
||
"""Tab to the Cancel button (5 Tabs from start) and press Enter cancels."""
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
# Tab×5: cn→email→days→password→Continue→Cancel, then Enter
|
||
win.getch.side_effect = [9, 9, 9, 9, 9, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||
assert isinstance(result, CertFormResult)
|
||
assert result.confirmed is False
|
||
|
||
|
||
def test_show_cert_form_clamps_to_narrow_screen():
|
||
"""The form's fixed 15x62 size must not exceed a smaller-than-usual
|
||
screen, which would make curses.newwin() raise."""
|
||
stdscr = _make_stdscr(rows=10, cols=40)
|
||
win = _make_win()
|
||
win.getch.side_effect = [27]
|
||
with patch("curses.newwin", return_value=win) as mock_newwin:
|
||
show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||
h, w, y, x = mock_newwin.call_args.args
|
||
assert h <= 10
|
||
assert w <= 40
|
||
assert y >= 0
|
||
assert x >= 0
|
||
|
||
|
||
def test_show_cert_form_returns_days():
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [10, 10, 10, 10, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="90")
|
||
assert result.confirmed is True
|
||
assert result.days == "90"
|
||
|
||
|
||
def test_show_cert_form_blank_days_means_inherit():
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [10, 10, 10, 10, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="")
|
||
assert result.confirmed is True
|
||
assert result.days == ""
|
||
|
||
|
||
def test_show_cert_form_days_field_is_digits_only():
|
||
# cn is read-only here, so focus starts on email: Tab once to reach days.
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [9] + [ord(c) for c in "9a0!"] + [10, 10, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="",
|
||
cn_readonly=True)
|
||
assert result.confirmed is True
|
||
assert result.days == "90"
|
||
|
||
|
||
def test_show_cert_form_refuses_zero_days():
|
||
# Submitting "0" must keep the form open rather than closing and failing
|
||
# later inside EasyRSA. Enter×5 tries to submit, Esc then cancels.
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [10, 10, 10, 10, 10, 27]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="0")
|
||
assert result.confirmed is False
|
||
|
||
|
||
def test_show_cert_form_normalises_leading_zero_days():
|
||
stdscr = _make_stdscr()
|
||
win = _make_win(rows=20, cols=70)
|
||
win.getch.side_effect = [10, 10, 10, 10, 10]
|
||
with patch("curses.newwin", return_value=win):
|
||
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="090")
|
||
assert result.days == "90"
|
||
|
||
|
||
def test_show_cert_form_newwin_failure_returns_cancelled():
|
||
"""If curses.newwin() still fails despite clamping, show_cert_form must
|
||
degrade to an unconfirmed result rather than crash the whole app."""
|
||
stdscr = _make_stdscr()
|
||
with patch("curses.newwin", side_effect=_curses.error("returned NULL")):
|
||
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||
assert isinstance(result, CertFormResult)
|
||
assert result.confirmed is False
|