diff --git a/docs/superpowers/plans/2026-08-15-cert-days.md b/docs/superpowers/plans/2026-08-15-cert-days.md new file mode 100644 index 0000000..a66233d --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-cert-days.md @@ -0,0 +1,940 @@ +# 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 +```