Files
openvpncertupdate/docs/superpowers/plans/2026-08-15-cert-days.md
Vlad Doloman 749cec8b37 Add implementation plan for configurable certificate lifetime
Seven TDD tasks: resolve_cert_days() and the CERT_DAYS setting, the
--days plumbing into build-client-full, the CLI flag, an optional
charset on InputField, the Days field in the cert form, the TUI wiring,
and docs.

Flags two deliberate deviations from the spec (module global instead of
a threaded parameter, short validation message instead of the resolver's
full sentence) and the two existing dialog tests whose keypress counts
assume a three-field form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 05:03:34 +03:00

941 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <PKI_DIR>/issued/testcert.crt -noout -dates
```
`notAfter` minus `notBefore` should be 90 days. Then revoke the test cert:
```bash
python3 openvpncertupdate.py --revoke testcert
```