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>
35 KiB
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.--daysgoes inbuild_client_full()'s own argument list. Never in_base_cmd()— that helper is shared withgen-crl, where--dayssets CRL validity instead (easyrsa:7270).- Rejecting
0locally is a fast-fail mirror of EasyRSA's own gate (easyrsa:5701), not a substitute for it. Do not add a--days=0code 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:
CliRunnerreads 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:
- The spec's data-flow sketch has
CliRunner.create()/.reissue()take adaysparameter. This plan instead resolves into the module globalCERT_DAYSinmain(). Reason: it matches the establishedCA_PASSPHRASEpattern exactly, avoids threading one value through three call layers, and leaves the existingtest_main_dispatches_*signature assertions intact. Behaviour is identical. - 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 withresolve_cert_days(), so what counts as valid never diverges, but renders its own short message.
File Structure
- Modify
openvpncertupdate.py:- SETTINGS: add
CERT_DAYSconstant; add it to_OVERRIDABLE_SETTINGS. - EASYRSA section: add
resolve_cert_days()besideresolve_ca_passphrase(); add adaysparameter tobuild_client_full(). - TUI WIDGETS: add an
allowedcharset parameter toInputField. - TUI DIALOGS: add a
daysfield toCertFormResultandshow_cert_form(); grow the window; validate on submit. - APP:
_process_cert()seeds the form fromCERT_DAYSand forwardsform.days. - CLI: add
--daysto_build_parser(); resolve inmain();_issue()passes the global.
- SETTINGS: add
- Modify
tests/test_easyrsa.py:resolve_cert_days()table;build_client_fullflag presence/absence;--daysabsent from revoke/gen-crl commands. - Modify
tests/test_widgets.py:InputFieldcharset filtering. - Modify
tests/test_dialogs.py: Days field behaviour; fix two existing tests whose keypress counts assume three fields. - Modify
tests/test_cli.py:--daysparsing and dispatch. - Modify
tests/test_config_file.py:CERT_DAYSsurvives a.confoverride. - 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, afterresolve_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; raisesConfigError. Module constantCERT_DAYS: str. -
Step 1: Write the failing tests
Add to tests/test_easyrsa.py, importing resolve_cert_days and ConfigError alongside the existing imports:
# ---------------------------------------------------------------------------
# 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:
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:
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:
"DAYS_PAST", "DAYS_AHEAD", "CERT_DAYS",
- Step 4: Add the resolver
In the EASYRSA section, directly after resolve_ca_passphrase():
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
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:
@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:
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
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], defaultNone); module globalCERT_DAYSholding the resolved value by the time anyCliRunnermethod runs. -
Step 1: Write the failing tests
Add to tests/test_cli.py:
# ---------------------------------------------------------------------------
# --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:
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:
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:
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
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). Whenallowedis a string,handle_key()inserts only characters found in it; whenNone, 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:
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:
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():
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
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:
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:
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:
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
@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:
def show_cert_form(
stdscr,
cn: str = "",
email: str = "",
password: str = "",
days: str = "",
cn_readonly: bool = False,
) -> CertFormResult:
h, w = min(15, sh), min(62, sw)
_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:
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:
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:
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:
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:
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
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=...)andCertFormResult.days(Task 5),build_client_full(days=...)(Task 2), module globalCERT_DAYS(Task 1). -
Step 1: Write the failing tests
Add to tests/test_app_reissue.py:
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:
form = show_cert_form(stdscr, cn=cn, email=email, days=CERT_DAYS,
cn_readonly=is_renewal)
And the build call:
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
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:
| `--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:
python3 openvpncertupdate.py --create CN --email user@example.com --days 90
- Step 3: Add a key constraint
After the CA-passphrase bullet:
- 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:
| 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
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:
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:
python3 openvpncertupdate.py --revoke testcert