Remove Python 3.7+ constructs so the tool runs on 3.6: - Drop `from __future__ import annotations` (3.7+) and convert all builtin-generic annotations (list[]/dict[]/tuple[]/set[]) to their typing.List/Dict/Tuple/Set equivalents, since without the future import these annotations are evaluated eagerly and builtin generic subscription only exists on 3.9+. - Replace subprocess.run(capture_output=, text=) — both 3.7+ — with stdout/stderr=PIPE and universal_newlines=True; update the matching test assertion. - requirements.txt: gate cryptography>=41 behind python_version>=3.7 and pin cryptography<41 (last 3.6-compatible line, 40.0.2) plus the dataclasses backport for 3.6. - Suppress cryptography 40.x's per-import "Python 3.6 is no longer supported" deprecation warning so it doesn't pollute CLI/cron stderr; the <41 pin keeps us on a supported release regardless. The only cryptography API used is AESGCM (present since 2.0), so no x509/API-surface changes are needed for the older pin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
import subprocess
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from openvpncertupdate import (
|
|
revoke_issued, build_client_full, gen_crl, copy_crl, EasyRSAError,
|
|
is_ca_key_encrypted, resolve_ca_passphrase,
|
|
)
|
|
|
|
|
|
def ok_result():
|
|
r = MagicMock(); r.returncode = 0; r.stderr = ""; return r
|
|
|
|
def err_result():
|
|
r = MagicMock(); r.returncode = 1; r.stderr = "oops"; return r
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
def test_revoke_includes_required_args(mock_run):
|
|
mock_run.return_value = ok_result()
|
|
revoke_issued("/er", "/pki", "alice", "capass")
|
|
args = mock_run.call_args[0][0]
|
|
assert args[0] == "/er/easyrsa"
|
|
assert "--batch" in args
|
|
assert "--pki=/pki" in args
|
|
assert "--passin=pass:capass" in args
|
|
assert "revoke-issued" in args
|
|
assert "alice" in args
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
def test_revoke_omits_passin_when_empty(mock_run):
|
|
mock_run.return_value = ok_result()
|
|
revoke_issued("/er", "/pki", "alice", "")
|
|
args = mock_run.call_args[0][0]
|
|
assert not any(a.startswith("--passin") for a in args)
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
def test_revoke_raises_on_failure(mock_run):
|
|
mock_run.return_value = err_result()
|
|
with pytest.raises(EasyRSAError, match="revoke-issued"):
|
|
revoke_issued("/er", "/pki", "alice", "")
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
def test_build_client_full_args(mock_run):
|
|
mock_run.return_value = ok_result()
|
|
build_client_full("/er", "/pki", "bob", "keypass", "capass")
|
|
args = mock_run.call_args[0][0]
|
|
assert "--passout=pass:keypass" in args
|
|
assert "--passin=pass:capass" in args
|
|
assert "build-client-full" in args
|
|
assert "bob" in args
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
def test_gen_crl_args(mock_run):
|
|
mock_run.return_value = ok_result()
|
|
gen_crl("/er", "/pki", "capass")
|
|
args = mock_run.call_args[0][0]
|
|
assert "gen-crl" in args
|
|
|
|
|
|
@patch("openvpncertupdate.shutil.copy2")
|
|
@patch("openvpncertupdate.os.chmod")
|
|
def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy):
|
|
copy_crl("/pki", "/etc/openvpn/crl.pem")
|
|
mock_copy.assert_called_once_with("/pki/crl.pem", "/etc/openvpn/crl.pem")
|
|
mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
@patch("openvpncertupdate.shutil.copy2")
|
|
@patch("openvpncertupdate.os.chmod")
|
|
def test_copy_crl_skips_restorecon_when_binary_empty(mock_chmod, mock_copy, mock_run):
|
|
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="")
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run")
|
|
@patch("openvpncertupdate.shutil.copy2")
|
|
@patch("openvpncertupdate.os.chmod")
|
|
def test_copy_crl_runs_restorecon_when_binary_set(mock_chmod, mock_copy, mock_run):
|
|
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon")
|
|
mock_run.assert_called_once_with(
|
|
["restorecon", "/etc/openvpn/crl.pem"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
universal_newlines=True)
|
|
|
|
|
|
@patch("openvpncertupdate.subprocess.run", side_effect=FileNotFoundError("no restorecon"))
|
|
@patch("openvpncertupdate.shutil.copy2")
|
|
@patch("openvpncertupdate.os.chmod")
|
|
def test_copy_crl_restorecon_failure_is_non_fatal(mock_chmod, mock_copy, mock_run):
|
|
# Missing/misconfigured restorecon must not break CRL deployment.
|
|
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon") # must not raise
|
|
mock_copy.assert_called_once()
|
|
mock_chmod.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ca_key_encrypted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _write_ca_key(pki_dir, content):
|
|
private = pki_dir / "private"
|
|
private.mkdir(parents=True, exist_ok=True)
|
|
(private / "ca.key").write_text(content)
|
|
|
|
|
|
def test_is_ca_key_encrypted_true_for_pkcs8_encrypted_header(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
|
|
assert is_ca_key_encrypted(str(tmp_path)) is True
|
|
|
|
|
|
def test_is_ca_key_encrypted_true_for_legacy_proc_type_header(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,...\n\nAAA\n-----END RSA PRIVATE KEY-----\n")
|
|
assert is_ca_key_encrypted(str(tmp_path)) is True
|
|
|
|
|
|
def test_is_ca_key_encrypted_false_for_plain_key(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
|
|
assert is_ca_key_encrypted(str(tmp_path)) is False
|
|
|
|
|
|
def test_is_ca_key_encrypted_false_when_key_missing(tmp_path):
|
|
assert is_ca_key_encrypted(str(tmp_path)) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_ca_passphrase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_resolve_empty_string_prompts_when_ca_is_encrypted(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
|
|
prompt = MagicMock(return_value="typed-pass")
|
|
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
|
|
assert result == "typed-pass"
|
|
prompt.assert_called_once()
|
|
|
|
|
|
def test_resolve_empty_string_skips_prompt_when_ca_not_encrypted(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
|
|
prompt = MagicMock()
|
|
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
|
|
assert result == ""
|
|
prompt.assert_not_called()
|
|
|
|
|
|
def test_resolve_empty_sentinel_never_checks_or_prompts(tmp_path):
|
|
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
|
|
prompt = MagicMock()
|
|
result = resolve_ca_passphrase("!empty", str(tmp_path), prompt=prompt)
|
|
assert result == ""
|
|
prompt.assert_not_called()
|
|
|
|
|
|
def test_resolve_ask_sentinel_always_prompts_without_checking(tmp_path):
|
|
# no ca.key on disk at all -- must not be checked/read for "!ask"
|
|
prompt = MagicMock(return_value="typed-pass")
|
|
result = resolve_ca_passphrase("!ask", str(tmp_path), prompt=prompt)
|
|
assert result == "typed-pass"
|
|
prompt.assert_called_once()
|
|
|
|
|
|
def test_resolve_literal_passphrase_passed_through_unchanged(tmp_path):
|
|
prompt = MagicMock()
|
|
result = resolve_ca_passphrase("mysecret", str(tmp_path), prompt=prompt)
|
|
assert result == "mysecret"
|
|
prompt.assert_not_called()
|