Add unit and integration tests for Certbot and HAProxy configuration
- Added integration tests to validate HAProxy configuration generation for Certbot's HTTP-01 challenges. - Verified handling of ACLs, SSL redirects, multi-domain setups, and backend behaviors. - Introduced unit tests for the Certbot class to ensure proper ACME server handling, certificate status checks, and configuration validation. - Improved test coverage for edge cases, including EAB credentials, manual hooks, and custom ports. - Enhanced certbot-related environment variable parsing and error scenarios.
This commit is contained in:
parent
ece012a884
commit
2e45c3f2e5
2 changed files with 1179 additions and 0 deletions
765
tests/test_certbot.py
Normal file
765
tests/test_certbot.py
Normal file
|
|
@ -0,0 +1,765 @@
|
||||||
|
"""
|
||||||
|
Unit tests for Certbot/ACME functionality
|
||||||
|
|
||||||
|
Tests the Certbot class without requiring internet access or third-party providers.
|
||||||
|
Verifies command generation, certificate status checking, and configuration handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock, Mock, mock_open, patch
|
||||||
|
|
||||||
|
from OpenSSL import crypto
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from functions import Certbot, ContainerEnv, Functions
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotStaticMethods:
|
||||||
|
"""Test Certbot static helper methods"""
|
||||||
|
|
||||||
|
def test_set_acme_server_empty(self):
|
||||||
|
"""Test ACME server with empty string"""
|
||||||
|
assert Certbot.set_acme_server("") == ""
|
||||||
|
assert Certbot.set_acme_server(None) == ""
|
||||||
|
assert Certbot.set_acme_server(False) == ""
|
||||||
|
|
||||||
|
def test_set_acme_server_staging(self):
|
||||||
|
"""Test ACME server with staging flag"""
|
||||||
|
assert Certbot.set_acme_server("staging") == "--staging"
|
||||||
|
assert Certbot.set_acme_server("STAGING") == "--staging"
|
||||||
|
assert Certbot.set_acme_server("Staging") == "--staging"
|
||||||
|
|
||||||
|
def test_set_acme_server_custom_url(self):
|
||||||
|
"""Test ACME server with custom URL"""
|
||||||
|
url = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
assert Certbot.set_acme_server(url) == f"--server {url}"
|
||||||
|
|
||||||
|
url2 = "https://acme.ssl.com/sslcom-dv-rsa"
|
||||||
|
assert Certbot.set_acme_server(url2) == f"--server {url2}"
|
||||||
|
|
||||||
|
# HTTP URLs should also work
|
||||||
|
url3 = "http://localhost:14000/dir"
|
||||||
|
assert Certbot.set_acme_server(url3) == f"--server {url3}"
|
||||||
|
|
||||||
|
def test_set_acme_server_invalid(self):
|
||||||
|
"""Test ACME server with invalid values"""
|
||||||
|
assert Certbot.set_acme_server("production") == ""
|
||||||
|
assert Certbot.set_acme_server("invalid") == ""
|
||||||
|
assert Certbot.set_acme_server("test") == ""
|
||||||
|
|
||||||
|
def test_set_eab_kid_empty(self):
|
||||||
|
"""Test EAB KID with empty string"""
|
||||||
|
assert Certbot.set_eab_kid("") == ""
|
||||||
|
|
||||||
|
def test_set_eab_kid_with_value(self):
|
||||||
|
"""Test EAB KID with valid value"""
|
||||||
|
kid = "test-kid-12345"
|
||||||
|
assert Certbot.set_eab_kid(kid) == f'--eab-kid "{kid}"'
|
||||||
|
|
||||||
|
def test_set_eab_hmac_key_empty(self):
|
||||||
|
"""Test EAB HMAC key with empty string"""
|
||||||
|
assert Certbot.set_eab_hmac_key("") == ""
|
||||||
|
|
||||||
|
def test_set_eab_hmac_key_with_value(self):
|
||||||
|
"""Test EAB HMAC key with valid value"""
|
||||||
|
hmac = "test-hmac-key-abcdef"
|
||||||
|
assert Certbot.set_eab_hmac_key(hmac) == f'--eab-hmac-key "{hmac}"'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotInitialization:
|
||||||
|
"""Test Certbot class initialization"""
|
||||||
|
|
||||||
|
def test_certbot_init_basic(self):
|
||||||
|
"""Test Certbot initialization with basic configuration"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER': 'staging',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
assert certbot.certs == "/tmp/certs"
|
||||||
|
assert certbot.email == "test@example.com"
|
||||||
|
assert certbot.acme_server == "--staging"
|
||||||
|
assert certbot.eab_kid == ""
|
||||||
|
assert certbot.eab_hmac_key == ""
|
||||||
|
assert certbot.freeze_issue == {}
|
||||||
|
assert certbot.retry_count == 60 # default
|
||||||
|
assert certbot.certbot_preferred_challenges == "http" # default
|
||||||
|
assert certbot.certbot_manual_auth_hook == False # default
|
||||||
|
|
||||||
|
def test_certbot_init_with_eab(self):
|
||||||
|
"""Test Certbot initialization with EAB credentials"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER': 'https://acme.ssl.com/sslcom-dv-rsa',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_KID': 'my-eab-kid',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_HMAC_KEY': 'my-hmac-key',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
assert certbot.email == "test@example.com"
|
||||||
|
assert certbot.acme_server == "--server https://acme.ssl.com/sslcom-dv-rsa"
|
||||||
|
assert certbot.eab_kid == '--eab-kid "my-eab-kid"'
|
||||||
|
assert certbot.eab_hmac_key == '--eab-hmac-key "my-hmac-key"'
|
||||||
|
|
||||||
|
def test_certbot_init_with_custom_retry_count(self):
|
||||||
|
"""Test Certbot initialization with custom retry count"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_RETRY_COUNT': '120',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
assert certbot.retry_count == 120
|
||||||
|
|
||||||
|
def test_certbot_init_with_dns_challenge(self):
|
||||||
|
"""Test Certbot initialization with DNS challenge"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
assert certbot.certbot_preferred_challenges == "dns"
|
||||||
|
|
||||||
|
def test_certbot_init_with_manual_auth_hook(self):
|
||||||
|
"""Test Certbot initialization with manual auth hook"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/path/to/auth-hook.sh',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
assert certbot.certbot_manual_auth_hook == "/path/to/auth-hook.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotCertificateStatus:
|
||||||
|
"""Test certificate status checking"""
|
||||||
|
|
||||||
|
def create_test_certificate(self, days_valid=30):
|
||||||
|
"""Helper to create a test certificate valid for specified days"""
|
||||||
|
# Create key pair
|
||||||
|
key = crypto.PKey()
|
||||||
|
key.generate_key(crypto.TYPE_RSA, 2048)
|
||||||
|
|
||||||
|
# Create certificate
|
||||||
|
cert = crypto.X509()
|
||||||
|
cert.get_subject().CN = "test.example.com"
|
||||||
|
cert.set_serial_number(1000)
|
||||||
|
cert.gmtime_adj_notBefore(0)
|
||||||
|
cert.gmtime_adj_notAfter(days_valid * 24 * 60 * 60)
|
||||||
|
cert.set_issuer(cert.get_subject())
|
||||||
|
cert.set_pubkey(key)
|
||||||
|
cert.sign(key, 'sha256')
|
||||||
|
|
||||||
|
# Combine cert and key
|
||||||
|
cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
|
||||||
|
key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key)
|
||||||
|
|
||||||
|
return cert_pem.decode() + key_pem.decode()
|
||||||
|
|
||||||
|
def test_get_certificate_status_not_found(self):
|
||||||
|
"""Test certificate status when file doesn't exist"""
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/nonexistent")
|
||||||
|
status = certbot.get_certificate_status("example.com")
|
||||||
|
assert status == "not_found"
|
||||||
|
|
||||||
|
def test_get_certificate_status_ok(self):
|
||||||
|
"""Test certificate status when valid and not expiring soon"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as f:
|
||||||
|
cert_content = self.create_test_certificate(days_valid=90)
|
||||||
|
f.write(cert_content)
|
||||||
|
cert_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot(os.path.dirname(cert_file))
|
||||||
|
# Mock the filename pattern
|
||||||
|
with patch.object(certbot, 'certs', os.path.dirname(cert_file)):
|
||||||
|
status = certbot.get_certificate_status(os.path.basename(cert_file).replace('.pem', ''))
|
||||||
|
assert status == "ok"
|
||||||
|
finally:
|
||||||
|
os.unlink(cert_file)
|
||||||
|
|
||||||
|
def test_get_certificate_status_expiring(self):
|
||||||
|
"""Test certificate status when expiring within 15 days"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as f:
|
||||||
|
cert_content = self.create_test_certificate(days_valid=10)
|
||||||
|
f.write(cert_content)
|
||||||
|
cert_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot(os.path.dirname(cert_file))
|
||||||
|
with patch.object(certbot, 'certs', os.path.dirname(cert_file)):
|
||||||
|
status = certbot.get_certificate_status(os.path.basename(cert_file).replace('.pem', ''))
|
||||||
|
assert status == "expiring"
|
||||||
|
finally:
|
||||||
|
os.unlink(cert_file)
|
||||||
|
|
||||||
|
def test_get_certificate_status_expired(self):
|
||||||
|
"""Test certificate status when already expired"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as f:
|
||||||
|
cert_content = self.create_test_certificate(days_valid=-1)
|
||||||
|
f.write(cert_content)
|
||||||
|
cert_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot(os.path.dirname(cert_file))
|
||||||
|
with patch.object(certbot, 'certs', os.path.dirname(cert_file)):
|
||||||
|
status = certbot.get_certificate_status(os.path.basename(cert_file).replace('.pem', ''))
|
||||||
|
assert status == "expired"
|
||||||
|
finally:
|
||||||
|
os.unlink(cert_file)
|
||||||
|
|
||||||
|
def test_get_certificate_status_error(self):
|
||||||
|
"""Test certificate status with invalid/corrupted certificate"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as f:
|
||||||
|
f.write("Invalid certificate content\n")
|
||||||
|
cert_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot(os.path.dirname(cert_file))
|
||||||
|
with patch.object(certbot, 'certs', os.path.dirname(cert_file)):
|
||||||
|
status = certbot.get_certificate_status(os.path.basename(cert_file).replace('.pem', ''))
|
||||||
|
assert status == "error"
|
||||||
|
finally:
|
||||||
|
os.unlink(cert_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotMergeCertificate:
|
||||||
|
"""Test certificate merging functionality"""
|
||||||
|
|
||||||
|
def test_merge_certificate(self):
|
||||||
|
"""Test merging certificate and key into single file"""
|
||||||
|
cert = "-----BEGIN CERTIFICATE-----\nCERT_DATA\n-----END CERTIFICATE-----\n"
|
||||||
|
key = "-----BEGIN PRIVATE KEY-----\nKEY_DATA\n-----END PRIVATE KEY-----\n"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
filename = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
Certbot.merge_certificate(cert, key, filename)
|
||||||
|
|
||||||
|
with open(filename, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert content == cert + key
|
||||||
|
assert "BEGIN CERTIFICATE" in content
|
||||||
|
assert "BEGIN PRIVATE KEY" in content
|
||||||
|
finally:
|
||||||
|
os.unlink(filename)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotCheckCertificates:
|
||||||
|
"""Test check_certificates method and command generation"""
|
||||||
|
|
||||||
|
def test_check_certificates_no_email(self):
|
||||||
|
"""Test that no certificates are requested without email"""
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': ''}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
result = certbot.check_certificates(["example.com"])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_check_certificates_no_hosts(self):
|
||||||
|
"""Test that no certificates are requested without hosts"""
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
result = certbot.check_certificates([])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_request_new(self, mock_run_bash):
|
||||||
|
"""Test requesting new certificates (not_found status)"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER': 'staging',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# Mock get_certificate_status to return not_found
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com', 'test.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run_bash.called
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# Verify command structure
|
||||||
|
assert '/usr/bin/certbot certonly' in command
|
||||||
|
assert '--staging' in command
|
||||||
|
assert '--preferred-challenges http' in command
|
||||||
|
assert '--agree-tos' in command
|
||||||
|
assert '--issuance-timeout 90' in command
|
||||||
|
assert '--no-eff-email' in command
|
||||||
|
assert '--non-interactive' in command
|
||||||
|
assert '--max-log-backups=0' in command
|
||||||
|
assert '-d example.com' in command
|
||||||
|
assert '-d test.com' in command
|
||||||
|
assert '--email test@example.com' in command
|
||||||
|
assert '--http-01-port 2080' in command
|
||||||
|
assert '--standalone' in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_with_eab(self, mock_run_bash):
|
||||||
|
"""Test certificate request with EAB credentials"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER': 'https://acme.ssl.com/sslcom-dv-rsa',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_KID': 'my-kid',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_HMAC_KEY': 'my-hmac',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
assert '--eab-kid "my-kid"' in command
|
||||||
|
assert '--eab-hmac-key "my-hmac"' in command
|
||||||
|
assert '--server https://acme.ssl.com/sslcom-dv-rsa' in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_with_dns_challenge(self, mock_run_bash):
|
||||||
|
"""Test certificate request with DNS challenge"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
assert '--preferred-challenges dns' in command
|
||||||
|
# DNS challenge should NOT include --http-01-port or --standalone
|
||||||
|
assert '--http-01-port' not in command
|
||||||
|
assert '--standalone' not in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_with_manual_auth_hook(self, mock_run_bash):
|
||||||
|
"""Test certificate request with manual auth hook"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/path/to/hook.sh',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
assert "--manual --manual-auth-hook '/path/to/hook.sh'" in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_renew(self, mock_run_bash):
|
||||||
|
"""Test renewing expiring certificates"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='expiring'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run_bash.called
|
||||||
|
|
||||||
|
# Should call certbot renew
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
assert '/usr/bin/certbot renew' in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_mixed_statuses(self, mock_run_bash):
|
||||||
|
"""Test with mixed certificate statuses (new, renew, ok)"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# Mock different statuses for different hosts
|
||||||
|
def mock_status(host):
|
||||||
|
statuses = {
|
||||||
|
'new.com': 'not_found',
|
||||||
|
'renew.com': 'expiring',
|
||||||
|
'ok.com': 'ok',
|
||||||
|
'error.com': 'error',
|
||||||
|
}
|
||||||
|
return statuses.get(host, 'ok')
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', side_effect=mock_status):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['new.com', 'renew.com', 'ok.com', 'error.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
# Should be called twice: once for certonly (new.com), once for renew (renew.com)
|
||||||
|
assert mock_run_bash.call_count == 2
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_freeze_mechanism(self, mock_run_bash):
|
||||||
|
"""Test freeze mechanism when certificate issuance fails"""
|
||||||
|
mock_run_bash.return_value = (1, []) # Return error code
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_RETRY_COUNT': '5',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
with patch.object(certbot, 'find_missing_certificates') as mock_find_missing:
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True # Still returns True (reload needed)
|
||||||
|
assert mock_find_missing.called
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_check_certificates_debug_mode(self, mock_run_bash):
|
||||||
|
"""Test that verbose flag is added in debug mode"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'CERTBOT_LOG_LEVEL': 'DEBUG',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# Set logger to DEBUG
|
||||||
|
from functions import logger_certbot
|
||||||
|
with patch.object(logger_certbot, 'level', logging.DEBUG):
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# Should include -v for verbose output
|
||||||
|
assert ' -v' in command
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotFindLiveCertificates:
|
||||||
|
"""Test finding and merging live certificates"""
|
||||||
|
|
||||||
|
def test_find_live_certificates_no_directory(self):
|
||||||
|
"""Test when /etc/letsencrypt/live doesn't exist"""
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch('os.path.exists', return_value=False):
|
||||||
|
certbot.find_live_certificates()
|
||||||
|
# Should not crash
|
||||||
|
|
||||||
|
def test_find_live_certificates_with_certs(self):
|
||||||
|
"""Test finding and merging certificates from live directory"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create mock directory structure
|
||||||
|
live_dir = os.path.join(tmpdir, "live")
|
||||||
|
os.makedirs(live_dir)
|
||||||
|
|
||||||
|
# Create example.com certificate
|
||||||
|
example_dir = os.path.join(live_dir, "example.com")
|
||||||
|
os.makedirs(example_dir)
|
||||||
|
|
||||||
|
cert_content = "-----BEGIN CERTIFICATE-----\nCERT\n-----END CERTIFICATE-----\n"
|
||||||
|
key_content = "-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
|
||||||
|
|
||||||
|
with open(os.path.join(example_dir, "cert.pem"), 'w') as f:
|
||||||
|
f.write(cert_content)
|
||||||
|
with open(os.path.join(example_dir, "privkey.pem"), 'w') as f:
|
||||||
|
f.write(key_content)
|
||||||
|
|
||||||
|
output_dir = os.path.join(tmpdir, "output")
|
||||||
|
os.makedirs(output_dir)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com'}, clear=False):
|
||||||
|
certbot = Certbot(output_dir)
|
||||||
|
|
||||||
|
with patch('os.path.exists', return_value=True):
|
||||||
|
with patch('os.listdir', return_value=['example.com']):
|
||||||
|
with patch('os.path.isdir', return_value=True):
|
||||||
|
with patch.object(Functions, 'load', side_effect=[cert_content, key_content]):
|
||||||
|
certbot.find_live_certificates()
|
||||||
|
|
||||||
|
# Verify merged certificate was created
|
||||||
|
merged_file = os.path.join(output_dir, "example.com.pem")
|
||||||
|
if os.path.exists(merged_file):
|
||||||
|
with open(merged_file, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert content == cert_content + key_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotFindMissingCertificates:
|
||||||
|
"""Test freeze mechanism for failed certificates"""
|
||||||
|
|
||||||
|
def test_find_missing_certificates_sets_freeze(self):
|
||||||
|
"""Test that missing certificates are frozen for retry"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_RETRY_COUNT': '10',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
certbot.find_missing_certificates(['-d example.com', '-d test.com'])
|
||||||
|
|
||||||
|
assert 'example.com' in certbot.freeze_issue
|
||||||
|
assert 'test.com' in certbot.freeze_issue
|
||||||
|
assert certbot.freeze_issue['example.com'] == 10
|
||||||
|
assert certbot.freeze_issue['test.com'] == 10
|
||||||
|
|
||||||
|
def test_find_missing_certificates_skips_ok(self):
|
||||||
|
"""Test that OK certificates are not frozen"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_RETRY_COUNT': '10',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='ok'):
|
||||||
|
certbot.find_missing_certificates(['-d example.com'])
|
||||||
|
|
||||||
|
assert 'example.com' not in certbot.freeze_issue
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_frozen_host_is_skipped(self, mock_run_bash):
|
||||||
|
"""Test that frozen hosts are skipped during retry period"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_RETRY_COUNT': '2',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# Manually set freeze
|
||||||
|
certbot.freeze_issue['frozen.com'] = 2
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
result = certbot.check_certificates(['frozen.com', 'normal.com'])
|
||||||
|
|
||||||
|
# Should only request certificate for normal.com
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
assert '-d normal.com' in command
|
||||||
|
assert '-d frozen.com' not in command
|
||||||
|
# Freeze count should decrement
|
||||||
|
assert certbot.freeze_issue['frozen.com'] == 1
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_frozen_host_unfreezes_after_countdown(self, mock_run_bash):
|
||||||
|
"""Test that frozen hosts are unfrozen after countdown reaches 0"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# Set freeze to 1 (will decrement to 0)
|
||||||
|
certbot.freeze_issue['example.com'] = 1
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
# First call: still frozen (count = 1 -> 0)
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
assert certbot.freeze_issue['example.com'] == 0
|
||||||
|
|
||||||
|
# Second call: should be unfrozen and removed from dict
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
assert 'example.com' not in certbot.freeze_issue
|
||||||
|
|
||||||
|
# Third call: should request certificate
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
# On third call, certificate should be requested
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
assert '-d example.com' in command
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotWebhookDNS:
|
||||||
|
"""Test manual auth hook (webhook) for DNS challenges"""
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_dns_challenge_without_webhook(self, mock_run_bash):
|
||||||
|
"""Test DNS challenge command generation without webhook (will fail in practice)"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# Should use DNS challenge
|
||||||
|
assert '--preferred-challenges dns' in command
|
||||||
|
# Should NOT include HTTP-specific flags
|
||||||
|
assert '--http-01-port' not in command
|
||||||
|
assert '--standalone' not in command
|
||||||
|
# Should NOT include manual flags (no webhook configured)
|
||||||
|
assert '--manual' not in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_dns_challenge_with_webhook(self, mock_run_bash):
|
||||||
|
"""Test DNS challenge with webhook for wildcard certificates"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/usr/local/bin/cloudflare-dns.sh',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
# DNS is required for wildcard certificates
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
certbot.check_certificates(['*.example.com', 'example.com'])
|
||||||
|
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# Verify DNS challenge with webhook
|
||||||
|
assert '--preferred-challenges dns' in command
|
||||||
|
assert '--manual' in command
|
||||||
|
assert "--manual-auth-hook '/usr/local/bin/cloudflare-dns.sh'" in command
|
||||||
|
# Verify both wildcard and apex domain
|
||||||
|
assert '-d *.example.com' in command
|
||||||
|
assert '-d example.com' in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_http_challenge_with_webhook(self, mock_run_bash):
|
||||||
|
"""Test HTTP challenge can also use webhook (less common)"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'http',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/hooks/http-webroot.sh',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# Both HTTP flags and webhook should be present
|
||||||
|
assert '--preferred-challenges http' in command
|
||||||
|
assert '--http-01-port 2080' in command
|
||||||
|
assert '--standalone' in command
|
||||||
|
assert '--manual' in command
|
||||||
|
assert "--manual-auth-hook '/hooks/http-webroot.sh'" in command
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_webhook_environment_variables_documented(self, mock_run_bash):
|
||||||
|
"""Document environment variables passed to webhook by certbot"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/hooks/dns-hook.sh',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
# Certbot automatically passes these to the webhook script:
|
||||||
|
# CERTBOT_DOMAIN - Domain being authenticated (e.g., "example.com")
|
||||||
|
# CERTBOT_VALIDATION - Validation string to add to DNS TXT record
|
||||||
|
# CERTBOT_TOKEN - Challenge token (for HTTP challenges)
|
||||||
|
#
|
||||||
|
# Example webhook script:
|
||||||
|
# #!/bin/bash
|
||||||
|
# # Add TXT record: _acme-challenge.$CERTBOT_DOMAIN -> $CERTBOT_VALIDATION
|
||||||
|
# curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
|
||||||
|
# -H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
# -d '{"type":"TXT","name":"_acme-challenge.'$CERTBOT_DOMAIN'","content":"'$CERTBOT_VALIDATION'"}'
|
||||||
|
|
||||||
|
assert mock_run_bash.called
|
||||||
|
|
||||||
|
@patch('functions.Functions.run_bash')
|
||||||
|
def test_webhook_with_multiple_providers(self, mock_run_bash):
|
||||||
|
"""Test webhook works with different ACME providers"""
|
||||||
|
mock_run_bash.return_value = (0, [])
|
||||||
|
|
||||||
|
# ZeroSSL with DNS challenge and webhook
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
'EASYHAPROXY_CERTBOT_SERVER': 'https://acme.zerossl.com/v2/DV90',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_KID': 'zerossl-kid',
|
||||||
|
'EASYHAPROXY_CERTBOT_EAB_HMAC_KEY': 'zerossl-hmac',
|
||||||
|
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES': 'dns',
|
||||||
|
'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK': '/hooks/route53-dns.py',
|
||||||
|
}, clear=False):
|
||||||
|
certbot = Certbot("/tmp/certs")
|
||||||
|
|
||||||
|
with patch.object(certbot, 'get_certificate_status', return_value='not_found'):
|
||||||
|
with patch.object(certbot, 'find_live_certificates'):
|
||||||
|
certbot.check_certificates(['example.com'])
|
||||||
|
|
||||||
|
call_args = mock_run_bash.call_args[0]
|
||||||
|
command = call_args[1]
|
||||||
|
|
||||||
|
# All components should be present
|
||||||
|
assert '--server https://acme.zerossl.com/v2/DV90' in command
|
||||||
|
assert '--eab-kid "zerossl-kid"' in command
|
||||||
|
assert '--eab-hmac-key "zerossl-hmac"' in command
|
||||||
|
assert '--preferred-challenges dns' in command
|
||||||
|
assert "--manual-auth-hook '/hooks/route53-dns.py'" in command
|
||||||
414
tests/test_certbot_haproxy_config.py
Normal file
414
tests/test_certbot_haproxy_config.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
"""
|
||||||
|
Integration tests for Certbot/ACME HAProxy configuration generation
|
||||||
|
|
||||||
|
Tests that verify the HAProxy configuration is correctly generated for HTTP-01 challenges:
|
||||||
|
- ACLs for /.well-known/acme-challenge/ paths
|
||||||
|
- certbot_backend routing to 127.0.0.1:2080
|
||||||
|
- ACME challenges bypass SSL redirect
|
||||||
|
- Multiple domains with certbot enabled
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from easymapping import HaproxyConfigGenerator
|
||||||
|
from functions import ContainerEnv
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotHAProxyConfig:
|
||||||
|
"""Test HAProxy configuration generation for ACME/certbot"""
|
||||||
|
|
||||||
|
def test_certbot_backend_always_created(self):
|
||||||
|
"""Test that certbot_backend is always present in HAProxy config"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
# Empty config should still have certbot_backend
|
||||||
|
haproxy_config = cfg.generate({})
|
||||||
|
|
||||||
|
assert 'backend certbot_backend' in haproxy_config
|
||||||
|
assert 'server certbot 127.0.0.1:2080' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_acl_for_single_domain(self):
|
||||||
|
"""Test ACME challenge ACL for single domain with certbot enabled"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Verify ACME challenge ACL
|
||||||
|
assert 'acl is_certbot_example_com_80 path_beg /.well-known/acme-challenge/' in haproxy_config
|
||||||
|
|
||||||
|
# Verify routing to certbot_backend
|
||||||
|
assert 'use_backend certbot_backend if is_certbot_example_com_80' in haproxy_config
|
||||||
|
|
||||||
|
# Verify certbot_backend exists
|
||||||
|
assert 'backend certbot_backend' in haproxy_config
|
||||||
|
assert 'server certbot 127.0.0.1:2080' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_acl_for_multiple_domains(self):
|
||||||
|
"""Test ACME challenge ACLs for multiple domains with certbot enabled"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
},
|
||||||
|
'container2': {
|
||||||
|
'easyhaproxy.http.host': 'test.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '4000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
},
|
||||||
|
'container3': {
|
||||||
|
'easyhaproxy.http.host': 'nocert.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '5000',
|
||||||
|
# certbot not enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Verify ACLs for domains with certbot=true
|
||||||
|
assert 'acl is_certbot_example_com_80 path_beg /.well-known/acme-challenge/' in haproxy_config
|
||||||
|
assert 'acl is_certbot_test_com_80 path_beg /.well-known/acme-challenge/' in haproxy_config
|
||||||
|
|
||||||
|
# Verify NO ACL for domain without certbot
|
||||||
|
assert 'acl is_certbot_nocert_com_80' not in haproxy_config
|
||||||
|
|
||||||
|
# Verify routing for each certbot-enabled domain
|
||||||
|
assert 'use_backend certbot_backend if is_certbot_example_com_80' in haproxy_config
|
||||||
|
assert 'use_backend certbot_backend if is_certbot_test_com_80' in haproxy_config
|
||||||
|
|
||||||
|
# Verify certbot_backend definition exists (only once)
|
||||||
|
# Count lines starting with "backend certbot_backend" (not use_backend lines)
|
||||||
|
backend_lines = [line for line in haproxy_config.split('\n') if line.startswith('backend certbot_backend')]
|
||||||
|
assert len(backend_lines) == 1
|
||||||
|
assert haproxy_config.count('server certbot 127.0.0.1:2080') == 1
|
||||||
|
|
||||||
|
def test_certbot_bypasses_ssl_redirect(self):
|
||||||
|
"""Test that ACME challenges bypass SSL redirect"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
'easyhaproxy.http.redirect_ssl': 'true', # Force HTTPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Find the redirect rule
|
||||||
|
lines = haproxy_config.split('\n')
|
||||||
|
redirect_line = None
|
||||||
|
for line in lines:
|
||||||
|
if 'http-request redirect scheme https' in line and 'example_com' in line:
|
||||||
|
redirect_line = line
|
||||||
|
break
|
||||||
|
|
||||||
|
assert redirect_line is not None, "SSL redirect rule not found"
|
||||||
|
|
||||||
|
# Verify ACME challenge is excluded from redirect
|
||||||
|
# Should contain: if !is_certbot_example_com_80 is_rule_...
|
||||||
|
assert '!is_certbot_example_com_80' in redirect_line
|
||||||
|
|
||||||
|
def test_certbot_with_ssl_clone(self):
|
||||||
|
"""Test certbot with clone_to_ssl (auto-create port 443)"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
'easyhaproxy.http.clone_to_ssl': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Should have HTTP frontend (port 80) with certbot ACL
|
||||||
|
assert 'frontend http_in_80' in haproxy_config
|
||||||
|
assert 'acl is_certbot_example_com_80 path_beg /.well-known/acme-challenge/' in haproxy_config
|
||||||
|
|
||||||
|
# Should have HTTPS frontend (port 443) without certbot ACL
|
||||||
|
# (ACME challenges only happen on HTTP port 80)
|
||||||
|
assert 'frontend http_in_443' in haproxy_config or 'frontend https_in_443' in haproxy_config
|
||||||
|
|
||||||
|
# Port 443 should NOT have certbot ACL
|
||||||
|
lines = haproxy_config.split('\n')
|
||||||
|
in_443_frontend = False
|
||||||
|
for line in lines:
|
||||||
|
if 'frontend http_in_443' in line or 'frontend https_in_443' in line:
|
||||||
|
in_443_frontend = True
|
||||||
|
if in_443_frontend and 'frontend' in line and '443' not in line:
|
||||||
|
break # Moved to next frontend
|
||||||
|
if in_443_frontend and 'is_certbot' in line:
|
||||||
|
assert False, "ACME challenge ACL should not be in port 443 frontend"
|
||||||
|
|
||||||
|
def test_certbot_without_email_no_acl(self):
|
||||||
|
"""Test that no ACME ACLs are created when email is not configured"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': '', # No email
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true', # Set but won't work without email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Should NOT create ACME ACL without email
|
||||||
|
assert 'acl is_certbot_example_com_80' not in haproxy_config
|
||||||
|
assert 'use_backend certbot_backend' not in haproxy_config
|
||||||
|
|
||||||
|
# certbot_backend should still exist (always created)
|
||||||
|
assert 'backend certbot_backend' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_acl_naming_special_chars(self):
|
||||||
|
"""Test ACME ACL naming with domains containing special characters"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'sub-domain.example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Domain with dots should have them replaced with underscores in ACL name
|
||||||
|
assert 'acl is_certbot_sub-domain_example_com_80' in haproxy_config
|
||||||
|
assert 'use_backend certbot_backend if is_certbot_sub-domain_example_com_80' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_get_certbot_hosts(self):
|
||||||
|
"""Test that get_certbot_hosts returns list of domains with certbot enabled"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
},
|
||||||
|
'container2': {
|
||||||
|
'easyhaproxy.http.host': 'test.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '4000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
},
|
||||||
|
'container3': {
|
||||||
|
'easyhaproxy.http.host': 'nocert.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '5000',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Should return list of hosts with certbot=true
|
||||||
|
certbot_hosts = cfg.certbot_hosts
|
||||||
|
assert 'example.com' in certbot_hosts
|
||||||
|
assert 'test.com' in certbot_hosts
|
||||||
|
assert 'nocert.com' not in certbot_hosts
|
||||||
|
|
||||||
|
def test_certbot_port_must_be_80(self):
|
||||||
|
"""Test that certbot only works on port 80 (HTTP-01 requirement)"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
# Try certbot on port 8080 (not standard HTTP port)
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '8080', # Non-standard port
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# ACME ACL should still be created (up to user to ensure proper routing)
|
||||||
|
# Note: The actual ACME validation will fail if port 80 isn't accessible
|
||||||
|
assert 'acl is_certbot_example_com_8080' in haproxy_config
|
||||||
|
|
||||||
|
|
||||||
|
class TestCertbotHAProxyConfigEdgeCases:
|
||||||
|
"""Test edge cases and error conditions"""
|
||||||
|
|
||||||
|
def test_multiple_containers_same_domain_with_certbot(self):
|
||||||
|
"""Test multiple containers serving the same domain with certbot"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
},
|
||||||
|
'container2': {
|
||||||
|
'easyhaproxy.http.host': 'example.com', # Same domain
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '4000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Should only create ACL once (not duplicate)
|
||||||
|
assert haproxy_config.count('acl is_certbot_example_com_80') == 1
|
||||||
|
|
||||||
|
# Should route to certbot_backend
|
||||||
|
assert 'use_backend certbot_backend if is_certbot_example_com_80' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_with_custom_ports(self):
|
||||||
|
"""Test certbot behavior with custom frontend ports"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.custom.host': 'example.com',
|
||||||
|
'easyhaproxy.custom.port': '8080',
|
||||||
|
'easyhaproxy.custom.localport': '3000',
|
||||||
|
'easyhaproxy.custom.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Should create frontend on port 8080 with certbot ACL
|
||||||
|
assert 'frontend http_in_8080' in haproxy_config
|
||||||
|
assert 'acl is_certbot_example_com_8080' in haproxy_config
|
||||||
|
|
||||||
|
def test_certbot_backend_format(self):
|
||||||
|
"""Test exact format of certbot_backend"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate({})
|
||||||
|
|
||||||
|
# Verify exact backend format
|
||||||
|
assert 'backend certbot_backend' in haproxy_config
|
||||||
|
assert 'mode http' in haproxy_config
|
||||||
|
assert 'server certbot 127.0.0.1:2080' in haproxy_config
|
||||||
|
|
||||||
|
# Should NOT have any load balancing, health checks, etc.
|
||||||
|
# (it's a simple pass-through to localhost)
|
||||||
|
lines = haproxy_config.split('\n')
|
||||||
|
in_certbot_backend = False
|
||||||
|
certbot_backend_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if 'backend certbot_backend' in line:
|
||||||
|
in_certbot_backend = True
|
||||||
|
elif in_certbot_backend and line.strip() and not line.startswith(' '):
|
||||||
|
break # End of backend section
|
||||||
|
elif in_certbot_backend:
|
||||||
|
certbot_backend_lines.append(line.strip())
|
||||||
|
|
||||||
|
# Should only have mode and server lines
|
||||||
|
assert 'mode http' in certbot_backend_lines
|
||||||
|
assert 'server certbot 127.0.0.1:2080' in certbot_backend_lines
|
||||||
|
assert len([l for l in certbot_backend_lines if l]) == 2 # Only 2 non-empty lines
|
||||||
|
|
||||||
|
def test_certbot_acl_order_before_use_backend(self):
|
||||||
|
"""Test that ACL definitions come before use_backend rules"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'EASYHAPROXY_CERTBOT_EMAIL': 'test@example.com',
|
||||||
|
}, clear=False):
|
||||||
|
mapping = ContainerEnv.read()
|
||||||
|
cfg = HaproxyConfigGenerator(mapping)
|
||||||
|
|
||||||
|
parsed_object = {
|
||||||
|
'container1': {
|
||||||
|
'easyhaproxy.http.host': 'example.com',
|
||||||
|
'easyhaproxy.http.port': '80',
|
||||||
|
'easyhaproxy.http.localport': '3000',
|
||||||
|
'easyhaproxy.http.certbot': 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haproxy_config = cfg.generate(parsed_object)
|
||||||
|
|
||||||
|
# Find positions
|
||||||
|
acl_pos = haproxy_config.find('acl is_certbot_example_com_80')
|
||||||
|
use_backend_pos = haproxy_config.find('use_backend certbot_backend if is_certbot_example_com_80')
|
||||||
|
|
||||||
|
assert acl_pos > 0, "ACL not found"
|
||||||
|
assert use_backend_pos > 0, "use_backend not found"
|
||||||
|
assert acl_pos < use_backend_pos, "ACL must be defined before use_backend"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue