# Configurable Certificate Lifetime Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let the operator choose how long an issued certificate is valid, via a `CERT_DAYS` setting, a `--days` CLI flag, and a Days field in the TUI cert form — while leaving today's behaviour unchanged for anyone who doesn't set it. **Architecture:** One resolver, `resolve_cert_days()`, turns every input path (SETTINGS constant, external `.conf`, CLI flag, TUI field) into either `""` (omit the flag, let EasyRSA's own `EASYRSA_CERT_EXPIRE` win) or a normalised decimal string. `build_client_full()` appends `--days=N` to its own argument list when that string is non-empty. The CLI resolves once in `main()` and overwrites the module global, exactly as `CA_PASSPHRASE` already does; the TUI carries a per-cert value out of the form instead. **Tech Stack:** Python 3.9, stdlib `argparse` and `curses`, existing `ConfigError` / `_base_cmd()` / `InputField` / `show_cert_form()` in `openvpncertupdate.py`. Tests via `pytest` + `unittest.mock`, following the mock-curses patterns already in `tests/test_dialogs.py` and `tests/test_widgets.py`. **Design doc:** `docs/superpowers/specs/2026-08-15-cert-days-design.md` ## Global Constraints - Single-file script: all production code stays in `openvpncertupdate.py`; no new modules. - `"default"` and `""` both mean *inherit from EasyRSA* — deploying this must never silently shorten certificates on an existing PKI. Do not change the shipped value to a number. - `--days` goes in `build_client_full()`'s own argument list. **Never** in `_base_cmd()` — that helper is shared with `gen-crl`, where `--days` sets CRL validity instead (`easyrsa:7270`). - Rejecting `0` locally is a fast-fail mirror of EasyRSA's own gate (`easyrsa:5701`), not a substitute for it. Do not add a `--days=0` code path expecting EasyRSA to accept it. - Leading zeros are normalised (`"090"` → `"90"`) because EasyRSA's own validation pattern `*[!1234567890]*|0*` (`easyrsa:7178`) rejects them with a message that makes no sense to someone who typed only digits. - Follow existing conventions: `CliRunner` reads module globals directly (`EASYRSA_DIR`, `EASYRSA_PKI_DIR`, `CA_PASSPHRASE`) rather than threading them as parameters. - Every task ends with the full suite green: `python3 -m pytest tests/ -q`. **Two deliberate deviations from the spec:** 1. The spec's data-flow sketch has `CliRunner.create()`/`.reissue()` take a `days` parameter. This plan instead resolves into the module global `CERT_DAYS` in `main()`. Reason: it matches the established `CA_PASSPHRASE` pattern exactly, avoids threading one value through three call layers, and leaves the existing `test_main_dispatches_*` signature assertions intact. Behaviour is identical. 2. The spec says the TUI shows "the `resolve_cert_days()` message" on the hint line. The form window is 62 columns wide, leaving 58 for the hint, and the resolver's full sentence is longer than that — it would be truncated mid-word. The form still *validates* with `resolve_cert_days()`, so what counts as valid never diverges, but renders its own short message. --- ## File Structure - **Modify `openvpncertupdate.py`:** - SETTINGS: add `CERT_DAYS` constant; add it to `_OVERRIDABLE_SETTINGS`. - EASYRSA section: add `resolve_cert_days()` beside `resolve_ca_passphrase()`; add a `days` parameter to `build_client_full()`. - TUI WIDGETS: add an `allowed` charset parameter to `InputField`. - TUI DIALOGS: add a `days` field to `CertFormResult` and `show_cert_form()`; grow the window; validate on submit. - APP: `_process_cert()` seeds the form from `CERT_DAYS` and forwards `form.days`. - CLI: add `--days` to `_build_parser()`; resolve in `main()`; `_issue()` passes the global. - **Modify `tests/test_easyrsa.py`:** `resolve_cert_days()` table; `build_client_full` flag presence/absence; `--days` absent from revoke/gen-crl commands. - **Modify `tests/test_widgets.py`:** `InputField` charset filtering. - **Modify `tests/test_dialogs.py`:** Days field behaviour; **fix two existing tests whose keypress counts assume three fields**. - **Modify `tests/test_cli.py`:** `--days` parsing and dispatch. - **Modify `tests/test_config_file.py`:** `CERT_DAYS` survives a `.conf` override. - **Modify `CLAUDE.md`:** flags table, SETTINGS mention, key constraints. --- ### Task 1: `CERT_DAYS` setting and `resolve_cert_days()` **Files:** - Modify: `openvpncertupdate.py` (SETTINGS block ~line 75; `_OVERRIDABLE_SETTINGS` ~line 81; EASYRSA section, after `resolve_ca_passphrase()` ~line 380) - Test: `tests/test_easyrsa.py`, `tests/test_config_file.py` **Interfaces:** - Consumes: `ConfigError` (defined in the SETTINGS OVERRIDE section). - Produces: `resolve_cert_days(configured, name: str = "CERT_DAYS") -> str` — returns `""` or a normalised decimal string; raises `ConfigError`. Module constant `CERT_DAYS: str`. - [ ] **Step 1: Write the failing tests** Add to `tests/test_easyrsa.py`, importing `resolve_cert_days` and `ConfigError` alongside the existing imports: ```python # --------------------------------------------------------------------------- # resolve_cert_days # --------------------------------------------------------------------------- @pytest.mark.parametrize("configured", ["default", "", " ", " default "]) def test_resolve_cert_days_sentinels_mean_inherit(configured): # "" and "default" leave the lifetime to EasyRSA's own EASYRSA_CERT_EXPIRE, # so deploying this cannot silently shorten certs on an existing PKI. assert resolve_cert_days(configured) == "" @pytest.mark.parametrize("configured,expected", [ (90, "90"), ("90", "90"), (" 90 ", "90"), (1, "1"), (3650, "3650"), ]) def test_resolve_cert_days_accepts_positive_numbers(configured, expected): assert resolve_cert_days(configured) == expected def test_resolve_cert_days_normalises_leading_zeros(): # EasyRSA's own pattern rejects "090" with "Number expected", which reads # as nonsense to someone who typed only digits. assert resolve_cert_days("090") == "90" def test_resolve_cert_days_rejects_zero(): with pytest.raises(ConfigError, match="positive"): resolve_cert_days("0") @pytest.mark.parametrize("configured", ["-5", -5, "abc", "9 0", "90.5", 90.5, None]) def test_resolve_cert_days_rejects_junk(configured): with pytest.raises(ConfigError): resolve_cert_days(configured) def test_resolve_cert_days_error_names_the_source(): # The same resolver serves CERT_DAYS, --days and the TUI field; the message # must say which one the user actually touched. with pytest.raises(ConfigError, match="--days"): resolve_cert_days("0", "--days") ``` Add to `tests/test_config_file.py`: ```python def test_cert_days_is_overridable(tmp_path): conf = tmp_path / "app.conf" conf.write_text('CERT_DAYS = 90\n') out = load_settings_overrides(str(conf), "", str(tmp_path / "app.py")) assert out["CERT_DAYS"] == 90 ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_easyrsa.py -k cert_days tests/test_config_file.py -q` Expected: FAIL — `ImportError: cannot import name 'resolve_cert_days'`, and `KeyError: 'CERT_DAYS'`. - [ ] **Step 3: Add the setting** In the SETTINGS block, directly after `DAYS_AHEAD = 14`: ```python CERT_DAYS = "default" # lifetime of certs this tool issues, in days. # "default" or "" = leave it to EasyRSA (your vars' # EASYRSA_CERT_EXPIRE). A positive integer overrides # it — e.g. 90. ``` In `_OVERRIDABLE_SETTINGS`, change the `DAYS_PAST` line to: ```python "DAYS_PAST", "DAYS_AHEAD", "CERT_DAYS", ``` - [ ] **Step 4: Add the resolver** In the EASYRSA section, directly after `resolve_ca_passphrase()`: ```python def resolve_cert_days(configured, name: str = "CERT_DAYS") -> str: """Resolve a configured certificate lifetime into an EasyRSA --days value. "default" / "" → "" (omit --days; EasyRSA's EASYRSA_CERT_EXPIRE wins) positive number → that many days, normalised ("090" → "90") Rejects 0, negatives and non-numeric input. EasyRSA rejects those itself, but failing here keeps the error next to the input the user actually typed — in the TUI that means the form is still open, and on the CLI it means no CA passphrase prompt and no subprocess round-trip first. `name` names the source ("CERT_DAYS", "--days", "Days") for the message. """ text = str(configured).strip() if text in ("", "default"): return "" try: days = int(text) except ValueError: raise ConfigError( f'{name} must be a positive number of days, "default" or "" ' f'(inherit from EasyRSA); got {configured!r}' ) if days <= 0: raise ConfigError( f"{name} must be a positive number of days " f"(EasyRSA rejects 0); got {configured!r}" ) return str(days) ``` Note `str(configured)` first: it makes `int`, `str` and `float` inputs behave uniformly, and `str(90.5)` → `"90.5"` → `ValueError`, so a float is rejected rather than silently truncated. `str(None)` → `"None"` → `ValueError`, which is also what we want. - [ ] **Step 5: Run tests to verify they pass** Run: `python3 -m pytest tests/test_easyrsa.py -k cert_days tests/test_config_file.py -q` Expected: PASS - [ ] **Step 6: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS, no regressions. - [ ] **Step 7: Commit** ```bash git add openvpncertupdate.py tests/test_easyrsa.py tests/test_config_file.py git commit -m "Add CERT_DAYS setting and resolve_cert_days()" ``` --- ### Task 2: Pass `--days` to `build-client-full` **Files:** - Modify: `openvpncertupdate.py` — `build_client_full()` (EASYRSA section, ~line 315) - Test: `tests/test_easyrsa.py` **Interfaces:** - Consumes: `_base_cmd()`, `_run_easyrsa()`, `resolve_cert_days()` output format from Task 1. - Produces: `build_client_full(easyrsa_dir, pki_dir, cn, key_passphrase, ca_passphrase, email="", days="") -> None`. - [ ] **Step 1: Write the failing tests** Add to `tests/test_easyrsa.py`: ```python @patch("openvpncertupdate.subprocess.run") def test_build_client_full_includes_days_when_set(mock_run): mock_run.return_value = ok_result() build_client_full("/er", "/pki", "bob", "keypass", "capass", days="90") assert "--days=90" in mock_run.call_args[0][0] @patch("openvpncertupdate.subprocess.run") def test_build_client_full_omits_days_when_inheriting(mock_run): mock_run.return_value = ok_result() build_client_full("/er", "/pki", "bob", "keypass", "capass", days="") assert not any(a.startswith("--days") for a in mock_run.call_args[0][0]) @patch("openvpncertupdate.subprocess.run") def test_build_client_full_days_precedes_the_verb(mock_run): # --days is a global option: EasyRSA parses it before the command word. mock_run.return_value = ok_result() build_client_full("/er", "/pki", "bob", "keypass", "capass", days="90") args = mock_run.call_args[0][0] assert args.index("--days=90") < args.index("build-client-full") @patch("openvpncertupdate.subprocess.run") def test_revoke_and_gen_crl_never_carry_days(mock_run): # _base_cmd() is shared with gen-crl, where --days means CRL validity. # Putting the flag there would quietly change how long CRLs are valid. mock_run.return_value = ok_result() revoke_issued("/er", "/pki", "alice", "capass") assert not any(a.startswith("--days") for a in mock_run.call_args[0][0]) gen_crl("/er", "/pki", "capass") assert not any(a.startswith("--days") for a in mock_run.call_args[0][0]) @patch("openvpncertupdate.subprocess.run") def test_error_verb_detection_survives_days_flag(mock_run): # _run_easyrsa picks the verb as the first non-flag arg; "--days=90" # must not be mistaken for it. mock_run.return_value = err_result(stdout="Error\nboom", stderr="") with pytest.raises(EasyRSAError, match="build-client-full"): build_client_full("/er", "/pki", "bob", "keypass", "capass", days="90") ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_easyrsa.py -k days -q` Expected: FAIL — `TypeError: build_client_full() got an unexpected keyword argument 'days'`. - [ ] **Step 3: Add the parameter** Replace `build_client_full()` entirely: ```python def build_client_full( easyrsa_dir: str, pki_dir: str, cn: str, key_passphrase: str, ca_passphrase: str, email: str = "", days: str = "", ) -> None: extra_env = {"EASYRSA_REQ_EMAIL": email} if email else None _run_easyrsa( _base_cmd(easyrsa_dir, pki_dir, ca_passphrase) + [f"--passout=pass:{key_passphrase}"] # Global option, so it must precede the verb. Deliberately not in # _base_cmd(): gen-crl reads --days as CRL validity instead. + ([f"--days={days}"] if days else []) + ["build-client-full", cn], cwd=easyrsa_dir, extra_env=extra_env, ) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `python3 -m pytest tests/test_easyrsa.py -q` Expected: PASS - [ ] **Step 5: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add openvpncertupdate.py tests/test_easyrsa.py git commit -m "Pass --days to build-client-full when a lifetime is set" ``` --- ### Task 3: `--days` CLI flag **Files:** - Modify: `openvpncertupdate.py` — `_build_parser()` (~line 1559), `main()` (~line 1593), `CliRunner._issue()` (~line 1425) - Test: `tests/test_cli.py` **Interfaces:** - Consumes: `resolve_cert_days()` (Task 1), `build_client_full(days=...)` (Task 2). - Produces: `args.days` (`Optional[str]`, default `None`); module global `CERT_DAYS` holding the resolved value by the time any `CliRunner` method runs. - [ ] **Step 1: Write the failing tests** Add to `tests/test_cli.py`: ```python # --------------------------------------------------------------------------- # --days # --------------------------------------------------------------------------- def test_days_flag_parses(): args = _build_parser().parse_args(["--create", "alice", "--email", "a@b.c", "--days", "90"]) assert args.days == "90" def test_days_defaults_to_none_when_absent(): args = _build_parser().parse_args(["--create", "alice", "--email", "a@b.c"]) assert args.days is None def test_issue_passes_resolved_days_to_build_client_full(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "90") CliRunner().create("alice", "alice@example.com") assert mocks["build_client_full"].call_args.kwargs["days"] == "90" def test_issue_passes_empty_days_when_inheriting(monkeypatch, capsys): mocks = _patch_issue(monkeypatch) monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "") CliRunner().create("alice", "alice@example.com") assert mocks["build_client_full"].call_args.kwargs["days"] == "" def test_main_days_flag_overrides_cert_days(monkeypatch): import openvpncertupdate monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.c", "--days", "30"]) monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "default") monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value="")) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", MagicMock(return_value=runner)) main() assert openvpncertupdate.CERT_DAYS == "30" def test_main_resolves_cert_days_when_no_flag(monkeypatch): import openvpncertupdate monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.c"]) monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "090") monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value="")) monkeypatch.setattr("openvpncertupdate.CliRunner", MagicMock(return_value=MagicMock())) main() assert openvpncertupdate.CERT_DAYS == "90" def test_main_rejects_bad_days_flag(monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["prog", "--create", "alice", "--email", "a@b.c", "--days", "0"]) monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", MagicMock(return_value="")) with pytest.raises(SystemExit): main() assert "--days" in capsys.readouterr().err ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_cli.py -k days -q` Expected: FAIL — `AttributeError: 'Namespace' object has no attribute 'days'`. - [ ] **Step 3: Add the flag** In `_build_parser()`, directly after the `--email` argument: ```python parser.add_argument("--days", metavar="N", help="Certificate lifetime in days for --create/--reissue " "(overrides CERT_DAYS; omit to use CERT_DAYS)") ``` - [ ] **Step 4: Resolve it in `main()`** In `main()`, directly after `globals().update(overrides)` and **before** the `if args.list:` early return — this validates the config on every path, and unlike the CA passphrase it never prompts: ```python global CERT_DAYS try: CERT_DAYS = (resolve_cert_days(args.days, "--days") if args.days is not None else resolve_cert_days(CERT_DAYS)) except ConfigError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) ``` - [ ] **Step 5: Forward it from `_issue()`** In `CliRunner._issue()`, change the `build_client_full` call to: ```python build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, cn, password, CA_PASSPHRASE, email=email_addr, days=CERT_DAYS) ``` - [ ] **Step 6: Run tests to verify they pass** Run: `python3 -m pytest tests/test_cli.py -q` Expected: PASS - [ ] **Step 7: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 8: Commit** ```bash git add openvpncertupdate.py tests/test_cli.py git commit -m "Add --days CLI flag overriding CERT_DAYS" ``` --- ### Task 4: `InputField` charset restriction **Files:** - Modify: `openvpncertupdate.py` — `InputField.__init__()` and `.handle_key()` (TUI WIDGETS, ~line 647) - Test: `tests/test_widgets.py` **Interfaces:** - Produces: `InputField(win, y, x, width, initial="", mask=False, allowed=None)`. When `allowed` is a string, `handle_key()` inserts only characters found in it; when `None`, behaviour is unchanged. - [ ] **Step 1: Write the failing tests** Add to `tests/test_widgets.py`, using whatever mock-window helper that file already defines for `InputField`: ```python def test_allowed_charset_accepts_listed_characters(): f = InputField(MagicMock(), 0, 0, 10, initial="", allowed="0123456789") for ch in "90": f.handle_key(ord(ch)) assert f.value == "90" def test_allowed_charset_drops_other_characters(): f = InputField(MagicMock(), 0, 0, 10, initial="", allowed="0123456789") for ch in "9a0-!": f.handle_key(ord(ch)) assert f.value == "90" def test_allowed_none_accepts_everything(): f = InputField(MagicMock(), 0, 0, 10, initial="") for ch in "a-1!": f.handle_key(ord(ch)) assert f.value == "a-1!" def test_allowed_charset_still_supports_editing_keys(): f = InputField(MagicMock(), 0, 0, 10, initial="90", allowed="0123456789") f.handle_key(_curses.KEY_BACKSPACE) assert f.value == "9" ``` If `tests/test_widgets.py` does not already import `MagicMock` or bind `_curses`, add those imports to match the file's existing style. - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_widgets.py -k allowed -q` Expected: FAIL — `TypeError: __init__() got an unexpected keyword argument 'allowed'`. - [ ] **Step 3: Add the parameter** Change `InputField.__init__()`'s signature and add one attribute: ```python def __init__( self, win, y: int, x: int, width: int, initial: str = "", mask: bool = False, allowed: Optional[str] = None, ) -> None: self._win = win self._y = y self._x = x self._width = width self._mask = mask self._allowed = allowed self._buf = list(initial) self._cur = len(self._buf) ``` Change the printable-character branch at the end of `handle_key()`: ```python elif 32 <= key <= 126: ch = chr(key) if self._allowed is not None and ch not in self._allowed: return self._buf.insert(self._cur, ch) self._cur += 1 ``` - [ ] **Step 4: Run tests to verify they pass** Run: `python3 -m pytest tests/test_widgets.py -q` Expected: PASS - [ ] **Step 5: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add openvpncertupdate.py tests/test_widgets.py git commit -m "Add optional charset restriction to InputField" ``` --- ### Task 5: Days field in the cert form **Files:** - Modify: `openvpncertupdate.py` — `CertFormResult`, `_FORM_FIELDS`, `_FORM_LABELS`, `_FORM_FIELD_Y`, `show_cert_form()` (TUI DIALOGS, ~line 738-903) - Test: `tests/test_dialogs.py` **Interfaces:** - Consumes: `InputField(allowed=...)` (Task 4), `resolve_cert_days()` (Task 1), `ConfigError`. - Produces: `CertFormResult(cn, email, password, confirmed, days="")`; `show_cert_form(stdscr, cn="", email="", password="", days="", cn_readonly=False)`. **Watch out:** two existing tests hard-code keypress counts that assume three fields. Both must change in this task or the suite breaks. - [ ] **Step 1: Fix the two existing tests for a fourth field** In `tests/test_dialogs.py`, `test_show_cert_form_confirm` — the field list becomes `cn, email, days, password`, so reaching Continue takes one more Enter: ```python 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.""" 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") assert isinstance(result, CertFormResult) assert result.confirmed is True assert result.cn == "bob" assert result.email == "bob@example.com" assert len(result.password) > 0 ``` And `test_show_cert_form_cancel_button`: ```python 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 ``` In `test_show_cert_form_clamps_to_narrow_screen`, update the docstring only — `13x62` becomes `15x62`. The assertions still hold. - [ ] **Step 2: Write the failing tests for the new behaviour** Add to `tests/test_dialogs.py`: ```python 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" ``` - [ ] **Step 3: Run tests to verify they fail** Run: `python3 -m pytest tests/test_dialogs.py -q` Expected: FAIL — `TypeError: show_cert_form() got an unexpected keyword argument 'days'`, plus the two rewritten tests failing on keypress counts. - [ ] **Step 4: Add the field to the dataclass and layout constants** ```python @dataclass class CertFormResult: cn: str email: str password: str confirmed: bool days: str = "" _FORM_FIELDS = ("cn", "email", "days", "password") _FORM_LABELS = { "cn": "CN", "email": "Email", "days": "Days (blank = EasyRSA default)", "password": "Password", } _FORM_FIELD_Y = {"cn": 3, "email": 5, "days": 7, "password": 9} ``` - [ ] **Step 5: Grow the window and move the buttons** In `show_cert_form()`, change the signature, the window height and the button row: ```python def show_cert_form( stdscr, cn: str = "", email: str = "", password: str = "", days: str = "", cn_readonly: bool = False, ) -> CertFormResult: ``` ```python h, w = min(15, sh), min(62, sw) ``` ```python _BTN_Y = 11 ``` The `curses.error` fallback near the top returns a cancelled result; leave it as is — `days` defaults to `""`. - [ ] **Step 6: Add the field and submit validation** Add the days field to the `fields` dict: ```python 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), "days": InputField(win, _FORM_FIELD_Y["days"], 3, fw, initial=days, allowed=string.digits), "password": InputField(win, _FORM_FIELD_Y["password"], 3, fw, initial=password if password else generate_password(), mask=True), } focus = 0 error = "" ``` Replace `_submit()`. It now returns `None` when the input is bad, leaving the form open: ```python def _submit() -> Optional[CertFormResult]: nonlocal error try: # Validate with the same resolver the CLI and .conf use, so what # counts as valid never diverges between entry points. days_value = resolve_cert_days(fields["days"].value, "Days") except ConfigError: # The resolver's full sentence does not fit a 62-column window. error = "Days must be a positive number, or blank to inherit" return None error = "" final_cn = cn if cn_readonly else fields["cn"].value return CertFormResult( cn=final_cn, email=fields["email"].value, password=fields["password"].value, days=days_value, confirmed=True, ) ``` `string` is already imported at the top of the script (the password generator uses it); confirm with `grep -n "^import string" openvpncertupdate.py` and add it if absent. - [ ] **Step 7: Show the error and handle the two submit sites** Replace the hint block: ```python hint = "Tab=next F5=regen pwd Ctrl-G=confirm Esc=cancel" hint_attr = curses.color_pair(COLOR_DISABLED) if error: hint, hint_attr = error, curses.color_pair(COLOR_ERROR) try: win.addstr(h - 2, 2, hint[:w - 4], hint_attr) except curses.error: pass ``` Replace the Ctrl-G handler: ```python if key == 0x07: # Ctrl-G: immediate submit result = _submit() if result is not None: curses.curs_set(0) return result continue ``` And the Continue-button branch: ```python if key in (10, 13, curses.KEY_ENTER, ord(" ")): if current == _BTN_CONTINUE: result = _submit() if result is not None: curses.curs_set(0) return result continue curses.curs_set(0) return CertFormResult(cn=cn, email="", password="", confirmed=False) ``` - [ ] **Step 8: Run tests to verify they pass** Run: `python3 -m pytest tests/test_dialogs.py -q` Expected: PASS - [ ] **Step 9: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 10: Commit** ```bash git add openvpncertupdate.py tests/test_dialogs.py git commit -m "Add Days field to the TUI cert form" ``` --- ### Task 6: Wire the TUI form through to EasyRSA **Files:** - Modify: `openvpncertupdate.py` — `CursesApp._process_cert()` (APP section, ~line 1165-1210) - Test: `tests/test_app_reissue.py` **Interfaces:** - Consumes: `show_cert_form(days=...)` and `CertFormResult.days` (Task 5), `build_client_full(days=...)` (Task 2), module global `CERT_DAYS` (Task 1). - [ ] **Step 1: Write the failing tests** Add to `tests/test_app_reissue.py`: ```python def test_tui_seeds_days_field_from_cert_days(monkeypatch): import openvpncertupdate as m _patch_workflow(monkeypatch, cert_file_present=True) monkeypatch.setattr("openvpncertupdate.CERT_DAYS", "90") CursesApp()._process_cert(_stdscr(), "y.kuts", "", is_renewal=True) assert m.show_cert_form.call_args.kwargs["days"] == "90" def test_tui_forwards_form_days_to_build_client_full(monkeypatch): mocks = _patch_workflow(monkeypatch, cert_file_present=True) monkeypatch.setattr( "openvpncertupdate.show_cert_form", MagicMock(return_value=CertFormResult( cn="y.kuts", email="", password="Testpass1234567890abcdefgh", days="30", confirmed=True))) CursesApp()._process_cert(_stdscr(), "y.kuts", "", is_renewal=True) assert mocks["build_client_full"].call_args.kwargs["days"] == "30" ``` `_patch_workflow` already patches `show_cert_form`; the second test replaces that patch with one returning a `days` value. Import `CertFormResult` at the top of the file alongside `CursesApp`. - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_app_reissue.py -k days -q` Expected: FAIL — `KeyError: 'days'` on the `show_cert_form` kwargs, and on `build_client_full`'s. - [ ] **Step 3: Seed the form and forward the result** In `_process_cert()`, change the form call: ```python form = show_cert_form(stdscr, cn=cn, email=email, days=CERT_DAYS, cn_readonly=is_renewal) ``` And the build call: ```python build_client_full(EASYRSA_DIR, EASYRSA_PKI_DIR, final_cn, form.password, CA_PASSPHRASE, email=form.email, days=form.days) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `python3 -m pytest tests/test_app_reissue.py -q` Expected: PASS - [ ] **Step 5: Run the full suite** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 6: Verify by mutation** Revert each of the two edits in turn (restore the original call without `days=`), confirm the matching test goes red, then restore. Run: `python3 -m pytest tests/ -q` Expected: PASS after restoring. - [ ] **Step 7: Commit** ```bash git add openvpncertupdate.py tests/test_app_reissue.py git commit -m "Wire the TUI Days field through to build-client-full" ``` --- ### Task 7: Documentation **Files:** - Modify: `CLAUDE.md` — CLI flags table (~line 32), Running section (~line 20), Key constraints (~line 88) **Interfaces:** - Consumes: everything above. No code changes. - [ ] **Step 1: Add the flag to the CLI flags table** After the `--email EMAIL` row: ```markdown | `--days N` | Certificate lifetime in days for `--create`/`--reissue`; overrides `CERT_DAYS` for that run | ``` - [ ] **Step 2: Add an example to the Running section** After the existing `--reissue` example: ```bash python3 openvpncertupdate.py --create CN --email user@example.com --days 90 ``` - [ ] **Step 3: Add a key constraint** After the CA-passphrase bullet: ```markdown - Certificate lifetime (`resolve_cert_days()`, run once in `main()` right after the config overrides are applied — before the `--list` early return, since it validates config and never prompts): `CERT_DAYS = "default"` or `""` → omit `--days` entirely and let EasyRSA's own `EASYRSA_CERT_EXPIRE` (from `vars`) decide; a positive number → passed as `--days=N`, normalised so `"090"` becomes `"90"`. `0`, negatives and non-numeric values raise `ConfigError` — a fast-fail mirror of EasyRSA's own gate (`Cannot use --days=0 for command build-client-full`), not a substitute for it. `--days N` overrides `CERT_DAYS` for one CLI run; the TUI's Days field overrides it per certificate. The flag is added in `build_client_full()` only, never in `_base_cmd()` — `gen-crl` reads `--days` as CRL validity ``` - [ ] **Step 4: Update the section symbol table** In the "File layout" table, replace the EASYRSA row with: ```markdown | EASYRSA | `EasyRSAError`, `_easyrsa_diagnostics()`, `issued_cert_path()`, `has_issued_cert()`, `revoke_issued()`, `build_client_full()`, `gen_crl()`, `copy_crl()`, `is_ca_key_encrypted()`, `resolve_ca_passphrase()`, `resolve_cert_days()` | ``` Leave the TUI WIDGETS row alone — `InputField` is already listed and its new `allowed` parameter needs no separate entry. - [ ] **Step 5: Run the full suite one last time** Run: `python3 -m pytest tests/ -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add CLAUDE.md git commit -m "Document CERT_DAYS and --days" ``` --- ## Manual verification after Task 7 The test suite never invokes EasyRSA. Confirm the flag actually reaches it, on a machine with the PKI: ```bash python3 openvpncertupdate.py --create testcert --email you@example.com --days 90 --no-send-email openssl x509 -in /issued/testcert.crt -noout -dates ``` `notAfter` minus `notBefore` should be 90 days. Then revoke the test cert: ```bash python3 openvpncertupdate.py --revoke testcert ```