1
0
Fork 0

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:
Joao Gilberto Magalhaes 2026-02-14 16:59:22 -05:00
parent ece012a884
commit 2e45c3f2e5
2 changed files with 1179 additions and 0 deletions

View 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"