1
0
Fork 0

Add ACME/Certbot E2E tests with Pebble integration and update dependencies in CI workflows

- Introduced `docker-compose-acme-e2e.yml` for end-to-end testing with Pebble test server.
- Added tests to validate ACME challenge routing, certificate issuance, HTTPS functionality, and HAProxy configuration.
- Implemented CA certificate download fixture (`create_pebble_ca_file`) for test session initialization.
- Updated `.gitignore` to exclude Pebble-related files.
- Modified CI workflows to include `needs: [Test]` dependencies for E2E jobs, ensuring proper sequencing.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-14 17:40:40 -05:00
parent 2e45c3f2e5
commit 3e963228f3
4 changed files with 337 additions and 1 deletions

View file

@ -41,6 +41,40 @@ DOCKER_DIR = BASE_DIR / "docker"
# Track if cloudflare_ips.lst has been created in this test session
_cloudflare_ips_created = False
# Track if pebble CA cert has been downloaded in this test session
_pebble_ca_downloaded = False
def create_pebble_ca_file():
"""
Download Pebble's test CA certificate.
This file is required for docker-compose-acme-e2e.yml to trust Pebble's HTTPS endpoint.
Downloads from Pebble's GitHub repository.
Strategy:
- First call: Always download (fresh certificate)
- Subsequent calls: Skip if file exists (reuse from first call)
"""
global _pebble_ca_downloaded
pebble_ca_path = DOCKER_DIR / "pebble-ca.pem"
# On subsequent calls, skip if file exists
if _pebble_ca_downloaded and pebble_ca_path.exists() and pebble_ca_path.is_file():
return
# Download Pebble's test CA certificate
subprocess.run(
[
"curl", "-sL", "-o", str(pebble_ca_path),
"https://raw.githubusercontent.com/letsencrypt/pebble/main/test/certs/pebble.minica.pem"
],
check=True
)
# Mark as downloaded for this test session
_pebble_ca_downloaded = True
def create_cloudflare_ips_file():
@ -805,6 +839,179 @@ class TestChangedLabel:
verify_haproxy_stats()
# =============================================================================
# Test: docker-compose-acme-e2e.yml - ACME/Certbot with Pebble
# =============================================================================
@pytest.fixture
def docker_compose_acme() -> Generator[None, None, None]:
"""Fixture for docker-compose-acme-e2e.yml - ACME/Certbot E2E test"""
volume_name = "docker_certbot-certs"
# Download Pebble CA certificate (only once per test session)
create_pebble_ca_file()
# Clean up volume from previous test runs (ensures fresh start)
subprocess.run(
["docker", "volume", "rm", volume_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL # Ignore error if volume doesn't exist
)
fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-acme-e2e.yml"), startup_wait=15)
fixture.up()
yield
fixture.down()
# Clean up volume after test
subprocess.run(
["docker", "volume", "rm", volume_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
@pytest.mark.acme
class TestACME:
"""Tests for docker-compose-acme-e2e.yml - ACME/Certbot with Pebble test server"""
def test_haproxy_config(self, docker_compose_acme):
"""Test HAProxy configuration has ACME challenge routing"""
result = subprocess.run(
["docker", "exec", "docker-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
capture_output=True,
text=True,
check=True
)
config = result.stdout
# Verify ACME challenge ACL exists
assert 'acl is_certbot_test_local_80 path_beg /.well-known/acme-challenge/' in config, \
"ACME challenge ACL not found in HAProxy config"
# Verify routing to certbot_backend
assert 'use_backend certbot_backend if is_certbot_test_local_80' in config, \
"ACME challenge routing rule not found"
# Verify certbot_backend definition
assert 'backend certbot_backend' in config, \
"certbot_backend not defined"
assert 'server certbot 127.0.0.1:2080' in config, \
"certbot backend server not configured correctly"
# Verify SSL redirect bypasses ACME challenges
# Find redirect rule and verify it excludes certbot ACL
lines = config.split('\n')
for line in lines:
if 'http-request redirect scheme https' in line and 'test_local' in line:
assert '!is_certbot_test_local_80' in line, \
"SSL redirect should bypass ACME challenges"
break
def test_acme_challenge_routing(self, docker_compose_acme):
"""Test that HTTP requests to /.well-known/acme-challenge/ route to certbot backend"""
# Request to ACME challenge path
# We expect a 404 from certbot standalone server (no actual challenge file)
# This confirms routing works - backend server would return different response
response = requests.get(
'http://localhost/.well-known/acme-challenge/test-token-12345',
headers={'Host': 'test.local'},
allow_redirects=False
)
# Should NOT redirect to HTTPS (ACME challenges must be HTTP)
assert response.status_code != 301 and response.status_code != 302, \
"ACME challenge path should not redirect to HTTPS"
# Expected: 404 or connection error from certbot (not running during challenge)
# What we're verifying is that it doesn't return backend's response
assert response.status_code in [404, 502, 503], \
f"Expected 404/502/503 from certbot backend, got {response.status_code}"
def test_certificate_issuance(self, docker_compose_acme):
"""Test that Pebble successfully issues a certificate"""
# Check HAProxy logs for certificate issuance
result = subprocess.run(
["docker", "logs", "docker-haproxy-1"],
capture_output=True,
text=True
)
logs = result.stdout + result.stderr
# Look for certbot success messages
# Certbot outputs: "Successfully received certificate"
has_success = "Successfully received certificate" in logs or \
"Certificate not yet due for renewal" in logs or \
"Cert not yet due for renewal" in logs
# If not successful, check for Pebble connection
if not has_success:
# Check if we can at least connect to Pebble
has_pebble_connection = "pebble:14000/dir" in logs or "pebble:14000" in logs
assert has_pebble_connection, \
f"HAProxy cannot connect to Pebble ACME server. Check docker network.\nLogs:\n{logs[-2000:]}"
# Verify merged certificate file exists
# EasyHAProxy merges cert+key from /etc/letsencrypt/live/ to /certs/certbot/{domain}.pem
merged_cert_path = "/certs/certbot/test.local.pem"
result = subprocess.run(
["docker", "exec", "docker-haproxy-1", "test", "-f", merged_cert_path],
capture_output=True
)
assert result.returncode == 0, \
f"Merged certificate file not found at {merged_cert_path}. " \
f"Certificate issuance or merging may have failed. Check logs: docker logs docker-haproxy-1"
# Verify merged certificate is valid (contains both cert and key)
result = subprocess.run(
["docker", "exec", "docker-haproxy-1", "cat", merged_cert_path],
capture_output=True,
text=True,
check=True
)
cert_content = result.stdout
assert '-----BEGIN CERTIFICATE-----' in cert_content, \
f"{merged_cert_path} does not contain a certificate"
assert '-----END CERTIFICATE-----' in cert_content, \
f"{merged_cert_path} certificate is incomplete"
assert '-----BEGIN PRIVATE KEY-----' in cert_content or '-----BEGIN RSA PRIVATE KEY-----' in cert_content, \
f"{merged_cert_path} does not contain a private key"
def test_https_with_issued_cert(self, docker_compose_acme):
"""Test HTTPS works with Pebble-issued certificate"""
# Pebble issues real certificates, but from a test CA
# Browsers won't trust them, but the TLS handshake should work
response = requests.get(
'https://localhost/',
headers={'Host': 'test.local'},
verify=False # Pebble uses test CA not trusted by system
)
# Should get 200 from backend server
assert response.status_code == 200, \
f"Expected 200 OK, got {response.status_code}"
# Verify it's the backend server responding (static-httpserver)
assert "soon" in response.text.lower() or "coming" in response.text.lower(), \
"Response doesn't match expected backend server content"
def test_http_to_https_redirect_with_acme_bypass(self, docker_compose_acme):
"""Test HTTP redirects to HTTPS but ACME challenges bypass redirect"""
# Regular HTTP request (not ACME challenge) should redirect
response = requests.get(
'http://localhost/',
headers={'Host': 'test.local'},
allow_redirects=False
)
assert response.status_code == 301, \
f"Expected HTTP 301 redirect, got {response.status_code}"
assert response.headers['Location'].startswith('https://'), \
f"Expected redirect to HTTPS, got {response.headers['Location']}"
# ACME challenge path should NOT redirect (tested in test_acme_challenge_routing)
# =============================================================================
# Helper functions for manual testing
# =============================================================================
@ -841,4 +1048,5 @@ if __name__ == "__main__":
print(" - TestPluginsCombined: Combined plugins tests")
print(" - TestIPWhitelist: IP whitelist plugin tests")
print(" - TestCloudflare: Cloudflare IP restoration plugin tests")
print(" - TestChangedLabel: Custom label prefix tests")
print(" - TestChangedLabel: Custom label prefix tests")
print(" - TestACME: ACME/Certbot certificate issuance with Pebble test server")