From fefec14929f3550667cd6e537c88bbc906c9e442 Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Wed, 24 Jun 2026 00:07:38 +0300 Subject: [PATCH] feat: TUI modal dialogs (confirm Y/N, cert detail form) Co-Authored-By: Claude Sonnet 4.6 --- openvpncertupdate.py | 132 ++++++++++++++++++++++++++++++++++++++++++ tests/test_dialogs.py | 96 ++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 tests/test_dialogs.py diff --git a/openvpncertupdate.py b/openvpncertupdate.py index e4967c9..9801c9b 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -453,6 +453,138 @@ class InputField: self._cur += 1 +# ============================================================ +# === TUI DIALOGS === +# ============================================================ + + +def show_confirm(stdscr, message: str) -> bool: + """Y/N modal dialog. Returns True if user presses y/Y.""" + sh, sw = stdscr.getmaxyx() + lines = message.splitlines() + h = len(lines) + 6 + w = max(max(len(l) for l in lines) + 6, 38) + win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) + win.keypad(True) + draw_box(win, "Confirm") + for i, line in enumerate(lines): + try: + win.addstr(2 + i, 3, line, curses.color_pair(COLOR_NORMAL)) + except curses.error: + pass + prompt = " [Y] Confirm [N / Esc] Cancel " + try: + win.addstr(h - 2, max(1, (w - len(prompt)) // 2), prompt, + curses.color_pair(COLOR_NORMAL)) + except curses.error: + pass + win.refresh() + while True: + key = win.getch() + if key in (ord("y"), ord("Y")): + return True + if key in (ord("n"), ord("N"), 27, ord("q")): + return False + + +@dataclass +class CertFormResult: + cn: str + email: str + password: str + cancelled: bool + + +_FORM_FIELDS = ["cn", "email", "password"] +_FORM_LABELS = { + "cn": "Common Name (CN):", + "email": "Email address:", + "password": "Password [F5 = regenerate]:", +} +_FORM_FIELD_Y = {"cn": 3, "email": 6, "password": 9} + + +def show_cert_form( + stdscr, + cn: str = "", + email: str = "", + cn_readonly: bool = False, +) -> CertFormResult: + """Modal cert-detail form. + Tab / Shift-Tab cycle fields. F5 regenerates password. + Enter on last field confirms. Escape cancels.""" + sh, sw = stdscr.getmaxyx() + h, w = 16, 62 + win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) + win.keypad(True) + fw = w - 6 # field width + + active = [n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)] + fields: dict[str, InputField] = { + "cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn), + "email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email), + "password": InputField(win, _FORM_FIELD_Y["password"], 3, fw, + initial=generate_password()), + } + focus = 0 + + while True: + win.clear() + draw_box(win, "Certificate Details") + for name in _FORM_FIELDS: + fy = _FORM_FIELD_Y[name] + label = _FORM_LABELS[name] + ro = name == "cn" and cn_readonly + attr = curses.color_pair(COLOR_DISABLED if ro else COLOR_NORMAL) + try: + win.addstr(fy - 1, 3, label, attr) + except curses.error: + pass + if ro: + try: + win.addstr(fy, 3, cn.ljust(fw)[:fw], + curses.color_pair(COLOR_DISABLED) | curses.A_UNDERLINE) + except curses.error: + pass + else: + fields[name].draw() + + hint = "Tab=next F5=regen password Enter=confirm Esc=cancel" + try: + win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED)) + except curses.error: + pass + win.refresh() + + key = win.getch() + + if key == 27: + return CertFormResult(cn=cn, email="", password="", cancelled=True) + if key == 9: # Tab + focus = (focus + 1) % len(active) + continue + if key == curses.KEY_BTAB: # Shift-Tab + focus = (focus - 1) % len(active) + continue + if key == curses.KEY_F5: + fields["password"] = InputField( + win, _FORM_FIELD_Y["password"], 3, fw, initial=generate_password(), + ) + continue + if key in (10, 13, curses.KEY_ENTER): + if focus < len(active) - 1: + focus += 1 + continue + final_cn = cn if cn_readonly else fields["cn"].value + return CertFormResult( + cn=final_cn, + email=fields["email"].value, + password=fields["password"].value, + cancelled=False, + ) + fields[active[focus]].handle_key(key) + + # (remaining sections added in later tasks) # ============================================================ diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py new file mode 100644 index 0000000..40009d6 --- /dev/null +++ b/tests/test_dialogs.py @@ -0,0 +1,96 @@ +"""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.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 + + +# --------------------------------------------------------------------------- +# show_cert_form tests +# --------------------------------------------------------------------------- + +def test_show_cert_form_cancel(): + """Pressing Escape returns a CertFormResult with cancelled=True.""" + 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.cancelled is True + + +def test_show_cert_form_confirm(): + """Pressing Enter three times advances through all 3 fields and confirms. + + The form has 3 active fields (cn, email, password). Enter on field 0 + advances to field 1; Enter on field 1 advances to field 2; Enter on + field 2 (the last) confirms. We therefore need 3 Enter keypresses. + """ + stdscr = _make_stdscr() + win = _make_win(rows=20, cols=70) + # Three Enter keypresses to advance through each field and confirm. + win.getch.side_effect = [10, 10, 10] + with patch("curses.newwin", return_value=win): + result = show_cert_form(stdscr, cn="bob", email="bob@example.com") + assert isinstance(result, CertFormResult) + assert result.cancelled is False + assert result.cn == "bob" + assert result.email == "bob@example.com" + assert len(result.password) > 0