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 class TestParseListOutput: def test_parses_single_entry(self): raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No" 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:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n" "Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No" ) 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_lines_without_colons(self): raw = "some random line\nMain_Domain:a.com" result = acme._parse_list_output(raw) assert len(result) == 1 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 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") 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