import tempfile from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch import pytest from lib import acme class TestFindAcme: @patch("lib.acme.shutil.which") @patch("lib.acme._ACME_HOME") def test_finds_in_acme_home(self, mock_acme_home, mock_which): mock_acme_home = Path("/tmp/fake-acme-home") mock_acme_home.mkdir(parents=True, exist_ok=True) acme_bin = mock_acme_home / "acme.sh" acme_bin.write_text("#!/bin/sh\n") acme_bin.chmod(0o755) with patch.object(acme, "_ACME_HOME", mock_acme_home): result = acme._find_acme() assert "acme.sh" in result acme_bin.unlink() @patch("lib.acme._find_acme") def test_raises_when_not_found(self, mock_find): mock_find.side_effect = FileNotFoundError() with pytest.raises(FileNotFoundError): acme._find_acme() class TestRunAcme: @patch("lib.acme._find_acme") @patch("lib.acme.subprocess.run") def test_success(self, mock_run, mock_find): mock_find.return_value = "/usr/local/bin/acme.sh" mock_run.return_value = MagicMock(returncode=0, stdout="success\n", stderr="") result = acme._run_acme(["--list"]) assert result == "success\n" @patch("lib.acme._find_acme") @patch("lib.acme.subprocess.run") def test_failure(self, mock_run, mock_find): mock_find.return_value = "/usr/local/bin/acme.sh" mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error\n") with pytest.raises(RuntimeError): acme._run_acme(["--list"]) @patch("lib.acme._find_acme") @patch("lib.acme.subprocess.run") def test_no_sudo_prefix(self, mock_run, mock_find): mock_find.return_value = "/usr/local/bin/acme.sh" mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") acme._run_acme(["--list"]) cmd = mock_run.call_args[0][0] assert cmd[0] == "/usr/local/bin/acme.sh" assert "sudo" not in cmd @patch("lib.acme._find_acme") @patch("lib.acme.subprocess.run") def test_log_flag_is_last(self, mock_run, mock_find): # --log must trail the subcommand args: acme.sh would otherwise # consume the first subcommand arg as its (optional) file argument. mock_find.return_value = "/usr/local/bin/acme.sh" mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") acme._run_acme(["--issue", "-d", "example.com"]) cmd = mock_run.call_args[0][0] assert cmd.count("--log") == 1 assert cmd[-1] == "--log" assert cmd.index("--issue") < cmd.index("--log") assert "example.com" in cmd class TestParseListOutput: def test_parses_single_entry(self): raw = "Main_Domain\tCA\nexample.com\tLetsEncrypt\n" result = acme._parse_list_output(raw) assert len(result) == 1 assert result[0]["main_domain"] == "example.com" assert result[0]["ca"] == "LetsEncrypt" def test_parses_multiple_entries(self): raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n" result = acme._parse_list_output(raw) assert len(result) == 2 def test_empty_input(self): result = acme._parse_list_output("") assert result == [] def test_skips_empty_lines(self): raw = "Main_Domain\tCA\nexample.com\tLE\n\n \nother.com\tLE\n" result = acme._parse_list_output(raw) assert len(result) == 2 def test_header_skipped(self): """Header row is not included in results.""" raw = "Main_Domain\tKeyLength\tCA\nexample.com\tec-256\tZeroSSL.com\n" result = acme._parse_list_output(raw) assert len(result) == 1 assert result[0]["main_domain"] == "example.com" def test_field_mapping(self): """Tab columns are mapped to lowercased header names as dict keys.""" raw = ( "Main_Domain\tKeyLength\tSAN_Domains\tProfile\tCA\tCreated\tRenew\n" 'example.com\t"ec-256"\twww.example.com\t\tZeroSSL.com\t2026-01-01\t2026-07-01\n' ) result = acme._parse_list_output(raw) assert len(result) == 1 entry = result[0] assert entry["main_domain"] == "example.com" assert entry["keylength"] == "ec-256" assert entry["san_domains"] == "www.example.com" assert entry["profile"] == "" assert entry["ca"] == "ZeroSSL.com" assert entry["created"] == "2026-01-01" assert entry["renew"] == "2026-07-01" def test_quoted_values_stripped(self): """Quoted values have quotes removed.""" raw = 'Main_Domain\tKeyLength\nexample.com\t"ec-256"\n' result = acme._parse_list_output(raw) assert result[0]["keylength"] == "ec-256" def test_header_only(self): """Header with no data rows returns empty list.""" raw = "Main_Domain\tKeyLength\tCA\n" result = acme._parse_list_output(raw) assert result == [] def test_pipe_separated_raw_format(self): """Pipe-separated output with empty fields (what --listraw produces).""" raw = ( "Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n" 'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n' ) result = acme._parse_list_output(raw) assert len(result) == 1 assert result[0]["main_domain"] == "example.com" assert result[0]["profile"] == "" assert result[0]["ca"] == "ZeroSSL.com" assert result[0]["created"] == "2026-01-01" assert result[0]["renew"] == "2026-07-01" class TestDaysUntil: def test_future_date(self): from datetime import UTC, timedelta future = datetime.now(UTC) + timedelta(days=365) result = acme._days_until(future.strftime("%Y-%m-%d")) assert result >= 364 def test_empty_string(self): assert acme._days_until("") is None assert acme._days_until(None) is None def test_invalid_format(self): assert acme._days_until("not-a-date") is None def test_expired_date(self): result = acme._days_until("2020-01-01") assert result is not None assert result < 0 class TestGetEmail: def test_returns_empty_when_no_account_conf(self): with patch.object(acme, "_ACME_HOME", Path("/tmp/no-acme-email")): result = acme.get_email() assert result == "" def test_parses_email_from_account_conf(self): tmpdir = tempfile.mkdtemp() acme_dir = Path(tmpdir) / "data" / "acme" acme_dir.mkdir(parents=True, exist_ok=True) conf = acme_dir / "account.conf" conf.write_text("ACME_LEEMAIL='test@example.com'\n") with patch.object(acme, "_ACME_HOME", acme_dir): result = acme.get_email() assert result == "test@example.com" class TestFindCertDir: def test_rsa_dir(self, tmp_path): rsa_dir = tmp_path / "example.com" rsa_dir.mkdir() result = acme.find_cert_dir("example.com", tmp_path) assert result == rsa_dir def test_ecc_dir(self, tmp_path): ecc_dir = tmp_path / "example.com_ecc" ecc_dir.mkdir() result = acme.find_cert_dir("example.com", tmp_path) assert result == ecc_dir def test_eccPreferred(self, tmp_path): rsa_dir = tmp_path / "example.com" rsa_dir.mkdir() ecc_dir = tmp_path / "example.com_ecc" ecc_dir.mkdir() result = acme.find_cert_dir("example.com", tmp_path) assert result == ecc_dir def test_fallback_when_neither(self, tmp_path): result = acme.find_cert_dir("example.com", tmp_path) assert result == tmp_path / "example.com" def test_resolves_ecc_only(self, tmp_path): """Only _ecc dir exists, no RSA dir — should resolve to _ecc.""" ecc_dir = tmp_path / "example.com_ecc" ecc_dir.mkdir() result = acme.find_cert_dir("example.com", tmp_path) assert result == ecc_dir class TestGetCertPaths: def test_returns_paths(self, tmp_path): with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"): paths = acme.get_cert_paths("example.com") assert paths["cert"].endswith("example.com/example.com.cert") assert paths["key"].endswith("example.com/example.com.key") assert paths["ca"].endswith("example.com/ca.cer") assert paths["fullchain"].endswith("example.com/fullchain.cer") def test_resolves_ecc_dir(self, tmp_path): acme_dir = tmp_path / "data" / "acme" ecc_dir = acme_dir / "example.com_ecc" ecc_dir.mkdir(parents=True) with patch.object(acme, "_ACME_HOME", acme_dir): paths = acme.get_cert_paths("example.com") assert paths["cert"].endswith("example.com_ecc/example.com.cert") assert paths["key"].endswith("example.com_ecc/example.com.key") assert paths["ca"].endswith("example.com_ecc/ca.cer") assert paths["fullchain"].endswith("example.com_ecc/fullchain.cer") class TestDeployHook: @patch("lib.acme._run_acme") def test_deploy_registers_hook(self, mock_run): acme.deploy("example.com") args = mock_run.call_args[0][0] assert "--deploy" in args assert "-d" in args assert "example.com" in args assert "--deploy-hook" in args assert any("acme-deploy.sh" in arg for arg in args) class TestHasAutoRenew: def test_true_when_conf_exists(self, tmp_path): acme_dir = tmp_path / "data" / "acme" acme_dir.mkdir(parents=True) conf = acme_dir / "example.com.conf" conf.touch() with patch.object(acme, "_ACME_HOME", acme_dir): result = acme._has_auto_renew("example.com") assert result is True conf.unlink() def test_false_when_conf_missing(self, tmp_path): acme_dir = tmp_path / "data" / "acme" acme_dir.mkdir(parents=True) with patch.object(acme, "_ACME_HOME", acme_dir): result = acme._has_auto_renew("nonexistent.com") assert result is False