1
0
Fork 0

Refactor and consolidate E2E test utilities and fixtures

- Introduced `utils.py` for shared test utility functions (e.g., JWT generation, pod readiness checks, etc.).
- Added shared pytest fixtures in `conftest.py`, eliminating redundancy across Kubernetes and Docker Compose tests.
- Replaced inline test logic with reusable helper functions for Kubernetes configuration validation and resource readiness.
- Updated tests to leverage new consolidated functionality for better maintainability and clarity.
- Removed outdated, duplicate code in test files, aligning with new utilities and fixture structure.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-12 17:55:09 -05:00
parent d66c4f8595
commit f463020e3e
4 changed files with 398 additions and 432 deletions

85
tests_e2e/conftest.py Normal file
View file

@ -0,0 +1,85 @@
"""
Shared pytest fixtures for EasyHAProxy integration tests.
This module provides fixtures used by both Docker Compose and Kubernetes tests.
"""
import subprocess
from pathlib import Path
import pytest
from utils import generate_jwt_token
BASE_DIR = Path(__file__).parent.absolute()
@pytest.fixture(scope="session", autouse=True)
def generate_ssl_certificates():
"""
Generate SSL certificates once for all tests (Docker + Kubernetes).
Runs automatically at the start of the test session.
This fixture uses the working Docker approach (BASE_DIR) instead of the
broken K8s approach (BASE_DIR.parent) which was outdated after restructuring.
"""
script_path = BASE_DIR / "generate-keys.sh"
if not script_path.exists():
pytest.skip(f"SSL certificate generation script not found: {script_path}")
# Run from tests_e2e directory (Docker approach - WORKING)
result = subprocess.run(
["bash", str(script_path)],
cwd=BASE_DIR, # NOT BASE_DIR.parent (K8s bug)
capture_output=True,
text=True
)
if result.returncode != 0:
pytest.fail(f"Failed to generate SSL certificates:\n{result.stderr}")
# Return paths for K8s tests to use
yield {
"host1_local": BASE_DIR / "static" / "host1.local.pem",
"host2_local": BASE_DIR / "docker" / "host2.local.pem",
"jwt_private": BASE_DIR / "docker" / "jwt_private.pem",
"jwt_pubkey": BASE_DIR / "docker" / "jwt_pubkey.pem",
}
# No cleanup needed - certificates can be reused
@pytest.fixture
def jwt_token(generate_ssl_certificates) -> str:
"""
Generate a valid JWT token for Docker Compose tests.
Uses simple defaults suitable for docker-compose examples.
"""
certs = generate_ssl_certificates
return generate_jwt_token(
private_key_path=certs["jwt_private"],
issuer='https://auth.example.com/',
audience='https://api.example.com',
expired=False
)
def verify_haproxy_stats(port: int = 1936, username: str = "admin", password: str = "password"):
"""
Verify HAProxy stats interface is accessible.
This eliminates the duplicated test method that appears in 7 different
test classes in test_docker_compose.py.
Args:
port: HAProxy stats port
username: Basic auth username
password: Basic auth password
Raises:
AssertionError: If stats page not accessible or missing expected content
"""
import requests
response = requests.get(f"http://localhost:{port}", auth=(username, password))
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Statistics Report for HAProxy" in response.text, \
"HAProxy stats page content not found"

View file

@ -33,38 +33,12 @@ import pytest
import requests import requests
import jwt as jwt_lib import jwt as jwt_lib
from typing import Generator from typing import Generator
from utils import extract_backend_block
# Base directory for docker-compose files # Base directory for docker-compose files
BASE_DIR = Path(__file__).parent.absolute() BASE_DIR = Path(__file__).parent.absolute()
@pytest.fixture(scope="session", autouse=True)
def generate_ssl_certificates():
"""
Generate SSL certificates once for all tests that require them.
This runs automatically at the start of the test session.
"""
script_path = BASE_DIR / "generate-keys.sh"
# Check if script exists
if not script_path.exists():
pytest.skip(f"SSL certificate generation script not found: {script_path}")
# Run the script from the tests_e2e directory
result = subprocess.run(
["bash", str(script_path)],
cwd=BASE_DIR,
capture_output=True,
text=True
)
if result.returncode != 0:
pytest.fail(f"Failed to generate SSL certificates:\n{result.stderr}")
yield
# No cleanup needed - certificates can be reused
class DockerComposeFixture: class DockerComposeFixture:
"""Helper class to manage docker-compose lifecycle""" """Helper class to manage docker-compose lifecycle"""
@ -181,23 +155,6 @@ def docker_compose_cloudflare() -> Generator[None, None, None]:
fixture.down() fixture.down()
@pytest.fixture
def jwt_token() -> str:
"""Generate a valid JWT token for testing"""
private_key_path = BASE_DIR / "docker" / "jwt_private.pem"
with open(private_key_path, 'r') as f:
private_key = f.read()
payload = {
'iss': 'https://auth.example.com/',
'aud': 'https://api.example.com',
'exp': 9999999999
}
token = jwt_lib.encode(payload, private_key, algorithm='RS256')
return token
# ============================================================================= # =============================================================================
# Test: docker-compose.yml - Basic SSL Setup # Test: docker-compose.yml - Basic SSL Setup
# ============================================================================= # =============================================================================
@ -273,12 +230,8 @@ class TestBasicSSL:
def test_haproxy_stats(self, docker_compose_basic_ssl): def test_haproxy_stats(self, docker_compose_basic_ssl):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
@ -347,12 +300,8 @@ class TestJWTValidator:
def test_haproxy_stats(self, docker_compose_jwt_validator): def test_haproxy_stats(self, docker_compose_jwt_validator):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
@ -496,12 +445,8 @@ class TestPHPFPM:
def test_haproxy_stats(self, docker_compose_php_fpm): def test_haproxy_stats(self, docker_compose_php_fpm):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
@ -609,37 +554,14 @@ class TestPluginsCombined:
def test_haproxy_stats(self, docker_compose_plugins_combined): def test_haproxy_stats(self, docker_compose_plugins_combined):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
# Test: docker-compose-ip-whitelist.yml - IP Whitelist Plugin # Test: docker-compose-ip-whitelist.yml - IP Whitelist Plugin
# ============================================================================= # =============================================================================
def extract_backend_block(config: str, backend_name: str) -> str:
"""Extract a specific backend block from HAProxy configuration"""
lines = config.split('\n')
backend_lines = []
in_backend = False
for line in lines:
if line.startswith(f'backend {backend_name}'):
in_backend = True
backend_lines.append(line)
elif in_backend:
# Stop when we hit another backend, frontend, or global section
if line.startswith(('backend ', 'frontend ', 'global ', 'defaults ')):
break
backend_lines.append(line)
return '\n'.join(backend_lines)
@pytest.mark.security @pytest.mark.security
class TestIPWhitelist: class TestIPWhitelist:
"""Tests for IP whitelist plugin""" """Tests for IP whitelist plugin"""
@ -685,12 +607,8 @@ class TestIPWhitelist:
def test_haproxy_stats(self, docker_compose_ip_whitelist): def test_haproxy_stats(self, docker_compose_ip_whitelist):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
@ -790,12 +708,8 @@ class TestCloudflare:
def test_haproxy_stats(self, docker_compose_cloudflare): def test_haproxy_stats(self, docker_compose_cloudflare):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================
@ -886,12 +800,8 @@ class TestChangedLabel:
def test_haproxy_stats(self, docker_compose_changed_label): def test_haproxy_stats(self, docker_compose_changed_label):
"""Test HAProxy stats interface""" """Test HAProxy stats interface"""
response = requests.get( from conftest import verify_haproxy_stats
"http://localhost:1936", verify_haproxy_stats()
auth=("admin", "password")
)
assert response.status_code == 200
assert "Statistics Report for HAProxy" in response.text
# ============================================================================= # =============================================================================

View file

@ -27,6 +27,7 @@ from typing import Generator
import urllib.request import urllib.request
import pytest import pytest
import requests import requests
from utils import generate_jwt_token, wait_for_pods_ready, create_tls_secret_from_pem, extract_backend_block
# Import JWT libraries for token generation # Import JWT libraries for token generation
try: try:
@ -192,48 +193,6 @@ def ensure_helm_installed():
# Session Fixtures - kind Cluster Management # Session Fixtures - kind Cluster Management
# ============================================================================= # =============================================================================
@pytest.fixture(scope="session")
def generated_certs():
"""
Generate SSL certificates and JWT keys once for the entire test session.
This runs the generate-keys.sh script from the examples directory.
Returns:
dict: Paths to generated certificate files
"""
print("\n[Setup] Generating SSL certificates and JWT keys...")
# Path to generate-keys.sh
examples_dir = BASE_DIR.parent
generate_keys_script = examples_dir / "generate-keys.sh"
if not generate_keys_script.exists():
raise FileNotFoundError(f"generate-keys.sh not found at {generate_keys_script}")
# Run the script
result = subprocess.run(
["bash", str(generate_keys_script)],
cwd=str(examples_dir),
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
print(f"✗ Certificate generation failed: {result.stderr}")
raise RuntimeError(f"Failed to generate certificates: {result.stderr}")
print("✓ SSL certificates and JWT keys generated")
# Return paths to generated files
return {
"host1_local": examples_dir / "static" / "host1.local.pem",
"host2_local": examples_dir / "docker" / "host2.local.pem",
"jwt_private": examples_dir / "docker" / "jwt_private.pem",
"jwt_pubkey": examples_dir / "docker" / "jwt_pubkey.pem",
}
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def kubectl_cmd(): def kubectl_cmd():
"""Ensure kubectl is installed and return the command""" """Ensure kubectl is installed and return the command"""
@ -253,14 +212,16 @@ def helm_cmd():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def kind_cluster(kind_cmd, kubectl_cmd, helm_cmd, generated_certs, request): def kind_cluster(kind_cmd, kubectl_cmd, helm_cmd, generate_ssl_certificates, request):
""" """
Create a kind cluster for the entire test session. Create a kind cluster for the entire test session.
The cluster is shared across all tests for better performance. The cluster is shared across all tests for better performance.
Args: Args:
generated_certs: Fixture that ensures certificates are generated before cluster creation generate_ssl_certificates: Fixture that ensures certificates are generated before cluster creation
""" """
# Store certificate paths for later use
generated_certs = generate_ssl_certificates
cluster_name = "easyhaproxy-test" cluster_name = "easyhaproxy-test"
# Register cleanup to always run, even on failure # Register cleanup to always run, even on failure
@ -362,7 +323,7 @@ nodes:
# Build and load local EasyHAProxy image # Build and load local EasyHAProxy image
print("[4/9] Building local EasyHAProxy image (may take 30-60s)...") print("[4/9] Building local EasyHAProxy image (may take 30-60s)...")
project_root = BASE_DIR.parent.parent project_root = BASE_DIR.parent
subprocess.run( subprocess.run(
["docker", "build", "-t", "byjg/easy-haproxy:local", ["docker", "build", "-t", "byjg/easy-haproxy:local",
"-f", str(project_root / "build" / "Dockerfile"), "-f", str(project_root / "build" / "Dockerfile"),
@ -493,31 +454,8 @@ class KubernetesFixture:
time.sleep(self.wait_time) time.sleep(self.wait_time)
# Wait for all pods to be running # Wait for all pods to be running
max_wait = 60 if not wait_for_pods_ready(self.kubectl, self.namespace, timeout=60):
start_time = time.time() raise TimeoutError(f"Pods in namespace '{self.namespace}' did not become ready within 60 seconds")
while time.time() - start_time < max_wait:
result = subprocess.run(
[self.kubectl, "get", "pods", "-n", self.namespace, "-o", "json"],
check=True,
capture_output=True,
text=True
)
pods = json.loads(result.stdout)
if not pods['items']:
time.sleep(2)
continue
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods['items']
)
if all_running:
print(f"✓ All pods running in namespace '{self.namespace}'")
break
time.sleep(2)
def delete(self): def delete(self):
"""Delete Kubernetes resources""" """Delete Kubernetes resources"""
@ -537,74 +475,6 @@ class KubernetesFixture:
) )
def create_tls_secret_from_pem(kubectl_cmd: str, secret_name: str, namespace: str, pem_file: Path):
"""
Create a Kubernetes TLS secret from a PEM file.
Args:
kubectl_cmd: Path to kubectl command
secret_name: Name for the secret
namespace: Namespace to create the secret in
pem_file: Path to the PEM file containing both certificate and key
"""
print(f" → Creating TLS secret '{secret_name}' from {pem_file.name}...")
# Read the PEM file
with open(pem_file, 'r') as f:
pem_content = f.read()
# Split certificate and key (PEM file contains both)
cert_start = pem_content.find('-----BEGIN CERTIFICATE-----')
cert_end = pem_content.find('-----END CERTIFICATE-----') + len('-----END CERTIFICATE-----')
key_start = pem_content.find('-----BEGIN PRIVATE KEY-----')
key_end = pem_content.find('-----END PRIVATE KEY-----') + len('-----END PRIVATE KEY-----')
# Handle RSA PRIVATE KEY format (openssl genrsa format)
if key_start == -1:
key_start = pem_content.find('-----BEGIN RSA PRIVATE KEY-----')
key_end = pem_content.find('-----END RSA PRIVATE KEY-----') + len('-----END RSA PRIVATE KEY-----')
if cert_start == -1 or key_start == -1:
raise ValueError(f"Invalid PEM file format in {pem_file}")
cert = pem_content[cert_start:cert_end]
key = pem_content[key_start:key_end]
# Create temp files for cert and key
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.crt', delete=False) as cert_file:
cert_file.write(cert)
cert_path = cert_file.name
with tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False) as key_file:
key_file.write(key)
key_path = key_file.name
try:
# Delete secret if it exists
subprocess.run(
[kubectl_cmd, "delete", "secret", secret_name, "-n", namespace,
"--ignore-not-found=true"],
capture_output=True
)
# Create secret using kubectl
subprocess.run(
[kubectl_cmd, "create", "secret", "tls", secret_name,
f"--cert={cert_path}",
f"--key={key_path}",
"-n", namespace],
check=True,
capture_output=True
)
print(f" ✓ TLS secret '{secret_name}' created")
finally:
# Clean up temp files
os.unlink(cert_path)
os.unlink(key_path)
@pytest.fixture @pytest.fixture
def k8s_service(kind_cluster) -> Generator[str, None, None]: def k8s_service(kind_cluster) -> Generator[str, None, None]:
"""Fixture for service.yml""" """Fixture for service.yml"""
@ -647,7 +517,7 @@ def k8s_service_tls(kind_cluster) -> Generator[str, None, None]:
# Apply the manifest (without the embedded secret, we'll use ours) # Apply the manifest (without the embedded secret, we'll use ours)
# We need to filter out the Secret from service_tls.yml # We need to filter out the Secret from service_tls.yml
manifest_path = BASE_DIR / "service_tls.yml" manifest_path = BASE_DIR / "kubernetes" / "service_tls.yml"
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
manifest_content = f.read() manifest_content = f.read()
@ -677,31 +547,8 @@ def k8s_service_tls(kind_cluster) -> Generator[str, None, None]:
time.sleep(5) time.sleep(5)
# Wait for all pods to be running # Wait for all pods to be running
max_wait = 60 if not wait_for_pods_ready(kubectl_cmd, "default", label_selector="app=tls-example", timeout=60):
start_time = time.time() raise TimeoutError("TLS example pods did not become ready within 60 seconds")
while time.time() - start_time < max_wait:
result = subprocess.run(
[kubectl_cmd, "get", "pods", "-n", "default", "-l", "app=tls-example", "-o", "json"],
check=True,
capture_output=True,
text=True
)
pods = json.loads(result.stdout)
if not pods['items']:
time.sleep(2)
continue
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods['items']
)
if all_running:
print("✓ All TLS example pods running")
break
time.sleep(2)
yield kubectl_cmd yield kubectl_cmd
@ -774,7 +621,7 @@ def k8s_jwt_validator_secret(kind_cluster) -> Generator[dict, None, None]:
) )
# Apply manifest # Apply manifest
manifest_path = BASE_DIR / "jwt-validator-secret-example.yml" manifest_path = BASE_DIR / "kubernetes" / "jwt-validator-secret-example.yml"
subprocess.run( subprocess.run(
[kubectl_cmd, "apply", "-f", str(manifest_path), "-n", "default"], [kubectl_cmd, "apply", "-f", str(manifest_path), "-n", "default"],
check=True, check=True,
@ -785,31 +632,8 @@ def k8s_jwt_validator_secret(kind_cluster) -> Generator[dict, None, None]:
time.sleep(5) time.sleep(5)
# Wait for all pods to be running # Wait for all pods to be running
max_wait = 60 if not wait_for_pods_ready(kubectl_cmd, "default", label_selector="app=api", timeout=60):
start_time = time.time() raise TimeoutError("JWT API example pods did not become ready within 60 seconds")
while time.time() - start_time < max_wait:
result = subprocess.run(
[kubectl_cmd, "get", "pods", "-n", "default", "-l", "app=api", "-o", "json"],
check=True,
capture_output=True,
text=True
)
pods = json.loads(result.stdout)
if not pods['items']:
time.sleep(2)
continue
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods['items']
)
if all_running:
print("✓ All JWT API example pods running")
break
time.sleep(2)
# Return context with paths to JWT keys # Return context with paths to JWT keys
yield { yield {
@ -905,31 +729,8 @@ def k8s_cloudflare(kind_cluster, kind_cmd) -> Generator[str, None, None]:
time.sleep(5) time.sleep(5)
# Wait for all pods to be running # Wait for all pods to be running
max_wait = 60 if not wait_for_pods_ready(kubectl_cmd, "default", label_selector="app=webapp", timeout=60):
start_time = time.time() raise TimeoutError("Cloudflare webapp pods did not become ready within 60 seconds")
while time.time() - start_time < max_wait:
result = subprocess.run(
[kubectl_cmd, "get", "pods", "-n", "default", "-l", "app=webapp", "-o", "json"],
check=True,
capture_output=True,
text=True
)
pods = json.loads(result.stdout)
if not pods['items']:
time.sleep(2)
continue
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods['items']
)
if all_running:
print("✓ All cloudflare webapp pods running")
break
time.sleep(2)
yield kubectl_cmd yield kubectl_cmd
@ -995,22 +796,8 @@ def wait_for_easyhaproxy_discovery(kubectl_cmd: str, expected_host: str, timeout
break break
if ingress_namespace: if ingress_namespace:
# Check if pods in that namespace are running # Check if pods in that namespace are running using helper
result = subprocess.run( if wait_for_pods_ready(kubectl_cmd, ingress_namespace, timeout=5, verbose=False):
[kubectl_cmd, "get", "pods", "-n", ingress_namespace, "-o", "json"],
capture_output=True,
text=True,
timeout=5,
check=True
)
pods = json.loads(result.stdout)
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods.get('items', [])
)
if all_running and pods.get('items'):
print(f" ✓ Backend pods are Running") print(f" ✓ Backend pods are Running")
break break
except Exception: except Exception:
@ -1362,30 +1149,28 @@ class TestIPWhitelist:
) )
config = result.stdout config = result.stdout
# Find the backend for admin service # Extract the specific backend block for admin service
# The backend name should be something like srv_admin_example_local_80 or similar # Backend name format: srv_{hostname_with_underscores}_{port}
assert "admin" in config.lower(), "Admin service backend not found in HAProxy config" backend_block = extract_backend_block(config, "srv_admin_example_local_80")
assert backend_block, "Backend srv_admin_example_local_80 not found"
# Verify IP whitelist plugin comment # Verify IP whitelist plugin comment is in this backend
assert "# IP Whitelist - Only allow specific IPs" in config, \ assert "# IP Whitelist - Only allow specific IPs" in backend_block, \
"IP Whitelist plugin comment not found" "IP Whitelist plugin comment not found in admin backend"
# Verify ACL for whitelisted IPs # Verify ACL for whitelisted IPs is in this backend
assert "acl whitelisted_ip src" in config, \ assert "acl whitelisted_ip src" in backend_block, \
"IP whitelist ACL not found" "IP whitelist ACL not found in admin backend"
# Verify the IPs are in the configuration # Extract the ACL line to verify IPs
assert "127.0.0.1" in config, "Localhost not in allowed IPs" acl_line = [line for line in backend_block.split('\n') if 'acl whitelisted_ip src' in line][0]
assert "10.0.0.0/8" in config, "10.0.0.0/8 network not in allowed IPs" assert "127.0.0.1" in acl_line, "Localhost not in allowed IPs"
assert "172.16.0.0/12" in config, "172.16.0.0/12 network not in allowed IPs" assert "10.0.0.0/8" in acl_line, "10.0.0.0/8 network not in allowed IPs"
assert "172.16.0.0/12" in acl_line, "172.16.0.0/12 network not in allowed IPs"
# Verify deny rule for non-whitelisted IPs # Verify deny rule for non-whitelisted IPs is in this backend
assert "http-request deny" in config and "!whitelisted_ip" in config, \ assert "http-request deny deny_status 403 if !whitelisted_ip" in backend_block, \
"Deny rule for non-whitelisted IPs not found" "Deny rule for non-whitelisted IPs not found in admin backend"
# Verify status code 403
assert "deny_status 403" in config, \
"Status code 403 not configured for blocked IPs"
def test_access_from_localhost(self, k8s_ip_whitelist): def test_access_from_localhost(self, k8s_ip_whitelist):
"""Test that access from localhost is allowed""" """Test that access from localhost is allowed"""
@ -1416,46 +1201,6 @@ class TestIPWhitelist:
class TestJWTValidatorSecret: class TestJWTValidatorSecret:
"""Tests for jwt-validator-secret-example.yml - JWT validation using Kubernetes secrets""" """Tests for jwt-validator-secret-example.yml - JWT validation using Kubernetes secrets"""
def _generate_jwt_token(self, private_key_path: Path, issuer: str, audience: str, expired: bool = False) -> str:
"""
Generate a JWT token for testing
Args:
private_key_path: Path to RSA private key
issuer: JWT issuer
audience: JWT audience
expired: If True, generate an expired token
Returns:
JWT token string
"""
# Read private key
with open(private_key_path, 'rb') as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
# Set expiration time
if expired:
exp = int(time.time()) - 3600 # Expired 1 hour ago
else:
exp = int(time.time()) + 3600 # Valid for 1 hour
# Create JWT payload
payload = {
'iss': issuer,
'aud': audience,
'exp': exp,
'sub': 'test-user',
'iat': int(time.time())
}
# Generate token
token = jwt.encode(payload, private_key, algorithm='RS256')
return token
def test_resources_created(self, k8s_jwt_validator_secret): def test_resources_created(self, k8s_jwt_validator_secret):
"""Test that secrets, service, and ingresses are created""" """Test that secrets, service, and ingresses are created"""
kubectl = k8s_jwt_validator_secret["kubectl"] kubectl = k8s_jwt_validator_secret["kubectl"]
@ -1551,31 +1296,36 @@ class TestJWTValidatorSecret:
) )
config = result.stdout config = result.stdout
# Verify JWT Validator plugin comments # Extract the specific backend block for API service
assert "# JWT Validator - Validate JWT tokens" in config, \ # Backend name format: srv_{hostname_with_underscores}_{port}
"JWT Validator plugin comment not found" backend_block = extract_backend_block(config, "srv_api_example_local_80")
assert backend_block, "Backend srv_api_example_local_80 not found"
# Verify JWT extraction # Verify JWT Validator plugin comment is in this backend
assert "http_auth_bearer,jwt_header_query" in config, \ assert "# JWT Validator - Validate JWT tokens" in backend_block, \
"JWT header extraction not found" "JWT Validator plugin comment not found in API backend"
assert "http_auth_bearer,jwt_payload_query" in config, \
"JWT payload extraction not found"
# Verify JWT validation rules # Verify JWT extraction is in this backend
assert "jwt_verify" in config, \ assert "http_auth_bearer,jwt_header_query" in backend_block, \
"JWT signature verification not found" "JWT header extraction not found in API backend"
assert "http_auth_bearer,jwt_payload_query" in backend_block, \
"JWT payload extraction not found in API backend"
# Verify issuer validation # Verify JWT validation rules are in this backend
assert "https://auth.example.com/" in config, \ assert "jwt_verify" in backend_block, \
"JWT issuer validation not found" "JWT signature verification not found in API backend"
# Verify audience validation # Verify issuer validation is in this backend
assert "https://api.example.com" in config, \ assert "https://auth.example.com/" in backend_block, \
"JWT audience validation not found" "JWT issuer validation not found in API backend"
# Verify JWT keys directory is used # Verify audience validation is in this backend
assert "/etc/haproxy/jwt_keys/" in config, \ assert "https://api.example.com" in backend_block, \
"JWT keys directory not found in config" "JWT audience validation not found in API backend"
# Verify JWT keys directory is used in this backend
assert "/etc/haproxy/jwt_keys/" in backend_block, \
"JWT keys directory not found in API backend"
def test_access_without_token_denied(self, k8s_jwt_validator_secret): def test_access_without_token_denied(self, k8s_jwt_validator_secret):
"""Test that access without Authorization header is denied""" """Test that access without Authorization header is denied"""
@ -1614,7 +1364,7 @@ class TestJWTValidatorSecret:
"EasyHAProxy did not become ready for api.example.local within 30 seconds" "EasyHAProxy did not become ready for api.example.local within 30 seconds"
# Generate valid JWT token # Generate valid JWT token
token = self._generate_jwt_token( token = generate_jwt_token(
jwt_private_key, jwt_private_key,
issuer="https://auth.example.com/", issuer="https://auth.example.com/",
audience="https://api.example.com", audience="https://api.example.com",
@ -1649,7 +1399,7 @@ class TestJWTValidatorSecret:
"EasyHAProxy did not become ready for api.example.local within 30 seconds" "EasyHAProxy did not become ready for api.example.local within 30 seconds"
# Generate expired JWT token # Generate expired JWT token
token = self._generate_jwt_token( token = generate_jwt_token(
jwt_private_key, jwt_private_key,
issuer="https://auth.example.com/", issuer="https://auth.example.com/",
audience="https://api.example.com", audience="https://api.example.com",
@ -1686,7 +1436,7 @@ class TestJWTValidatorSecret:
"EasyHAProxy did not become ready for api.example.local within 30 seconds" "EasyHAProxy did not become ready for api.example.local within 30 seconds"
# Generate JWT token with wrong issuer # Generate JWT token with wrong issuer
token = self._generate_jwt_token( token = generate_jwt_token(
jwt_private_key, jwt_private_key,
issuer="https://wrong-issuer.example.com/", # Wrong issuer issuer="https://wrong-issuer.example.com/", # Wrong issuer
audience="https://api.example.com", audience="https://api.example.com",
@ -1723,7 +1473,7 @@ class TestJWTValidatorSecret:
"EasyHAProxy did not discover api-custom.example.local within 30 seconds" "EasyHAProxy did not discover api-custom.example.local within 30 seconds"
# Generate valid JWT token # Generate valid JWT token
token = self._generate_jwt_token( token = generate_jwt_token(
jwt_private_key, jwt_private_key,
issuer="https://auth.example.com/", issuer="https://auth.example.com/",
audience="https://api.example.com", audience="https://api.example.com",
@ -1843,21 +1593,26 @@ class TestCloudflare:
) )
config = result.stdout config = result.stdout
# Verify Cloudflare plugin comment # Extract the specific backend block for myapp service
assert "# Cloudflare - Restore original visitor IP" in config, \ # Backend name format: srv_{hostname_with_underscores}_{port}
"Cloudflare plugin comment not found in HAProxy config" backend_block = extract_backend_block(config, "srv_myapp_example_local_80")
assert backend_block, "Backend srv_myapp_example_local_80 not found"
# Verify ACL for Cloudflare IPs # Verify Cloudflare plugin comment is in this backend
assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in config, \ assert "# Cloudflare - Restore original visitor IP" in backend_block, \
"Cloudflare IP ACL not found in HAProxy config" "Cloudflare plugin comment not found in myapp backend"
# Verify real IP extraction from CF-Connecting-IP header # Verify ACL for Cloudflare IPs is in this backend
assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare" in config, \ assert "acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst" in backend_block, \
"CF-Connecting-IP header extraction not found" "Cloudflare IP ACL not found in myapp backend"
# Verify X-Forwarded-For header update # Verify real IP extraction from CF-Connecting-IP header is in this backend
assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare" in config, \ assert "http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare" in backend_block, \
"X-Forwarded-For header update not found" "CF-Connecting-IP header extraction not found in myapp backend"
# Verify X-Forwarded-For header update is in this backend
assert "http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare" in backend_block, \
"X-Forwarded-For header update not found in myapp backend"
def test_cloudflare_ip_file_contains_custom_ips(self, k8s_cloudflare): def test_cloudflare_ip_file_contains_custom_ips(self, k8s_cloudflare):
"""Test that custom base64-encoded IP list was written to the IP file""" """Test that custom base64-encoded IP list was written to the IP file"""

216
tests_e2e/utils.py Normal file
View file

@ -0,0 +1,216 @@
"""
Utility functions for EasyHAProxy integration tests.
This module provides non-fixture helper functions used across test files.
"""
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
import jwt as jwt_lib
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
def generate_jwt_token(
private_key_path: Path,
issuer: str,
audience: str,
expired: bool = False,
expiration_seconds: int = 3600
) -> str:
"""
Generate a JWT token for testing.
This uses the sophisticated K8s implementation with proper RSA key loading
and expiration handling.
Args:
private_key_path: Path to RSA private key (PEM format)
issuer: JWT issuer claim (iss)
audience: JWT audience claim (aud)
expired: If True, generate an already-expired token
expiration_seconds: Token validity duration in seconds (default 1 hour)
Returns:
JWT token string
"""
# Read and parse private key
with open(private_key_path, 'rb') as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
# Set expiration
if expired:
exp = int(time.time()) - 3600 # Expired 1 hour ago
else:
exp = int(time.time()) + expiration_seconds
# Create JWT payload
payload = {
'iss': issuer,
'aud': audience,
'exp': exp,
'sub': 'test-user',
'iat': int(time.time())
}
return jwt_lib.encode(payload, private_key, algorithm='RS256')
def wait_for_pods_ready(
kubectl_cmd: str,
namespace: str,
label_selector: str = None,
timeout: int = 60,
verbose: bool = True
) -> bool:
"""
Wait for all pods in a namespace to be Running.
This eliminates the duplicated wait pattern that appears 5+ times
in the Kubernetes test file.
Args:
kubectl_cmd: Path to kubectl command
namespace: Kubernetes namespace
label_selector: Optional label selector (e.g., "app=api")
timeout: Maximum seconds to wait
verbose: Print status messages
Returns:
True if all pods running, False if timeout
"""
start_time = time.time()
while time.time() - start_time < timeout:
cmd = [kubectl_cmd, "get", "pods", "-n", namespace, "-o", "json"]
if label_selector:
cmd.extend(["-l", label_selector])
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
pods = json.loads(result.stdout)
if not pods['items']:
time.sleep(2)
continue
all_running = all(
pod['status']['phase'] == 'Running'
for pod in pods['items']
)
if all_running:
if verbose:
label_info = f" (label: {label_selector})" if label_selector else ""
print(f"✓ All pods running in '{namespace}'{label_info}")
return True
time.sleep(2)
return False
def extract_backend_block(config: str, backend_name: str) -> str:
"""
Extract a specific backend block from HAProxy configuration.
Used by Docker Compose tests to verify HAProxy config contains expected rules.
Args:
config: Full HAProxy configuration content
backend_name: Name of backend to extract (e.g., "srv_host1_local_443")
Returns:
Backend block as string, or empty string if not found
"""
lines = config.split('\n')
backend_lines = []
in_backend = False
for line in lines:
if line.startswith(f'backend {backend_name}'):
in_backend = True
backend_lines.append(line)
elif in_backend:
# Stop when we hit another backend, frontend, or global section
if line.startswith(('backend ', 'frontend ', 'global ', 'defaults ')):
break
backend_lines.append(line)
return '\n'.join(backend_lines)
def create_tls_secret_from_pem(kubectl_cmd: str, secret_name: str, namespace: str, pem_file: Path):
"""
Create a Kubernetes TLS secret from a PEM file.
Used by Kubernetes tests to create TLS secrets from generated certificates.
Args:
kubectl_cmd: Path to kubectl command
secret_name: Name for the secret
namespace: Namespace to create the secret in
pem_file: Path to the PEM file containing both certificate and key
"""
print(f" → Creating TLS secret '{secret_name}' from {pem_file.name}...")
# Read the PEM file
with open(pem_file, 'r') as f:
pem_content = f.read()
# Split certificate and key (PEM file contains both)
cert_start = pem_content.find('-----BEGIN CERTIFICATE-----')
cert_end = pem_content.find('-----END CERTIFICATE-----') + len('-----END CERTIFICATE-----')
key_start = pem_content.find('-----BEGIN PRIVATE KEY-----')
key_end = pem_content.find('-----END PRIVATE KEY-----') + len('-----END PRIVATE KEY-----')
# Handle RSA PRIVATE KEY format (openssl genrsa format)
if key_start == -1:
key_start = pem_content.find('-----BEGIN RSA PRIVATE KEY-----')
key_end = pem_content.find('-----END RSA PRIVATE KEY-----') + len('-----END RSA PRIVATE KEY-----')
if cert_start == -1 or key_start == -1:
raise ValueError(f"Invalid PEM file format in {pem_file}")
cert = pem_content[cert_start:cert_end]
key = pem_content[key_start:key_end]
# Create temp files for cert and key
with tempfile.NamedTemporaryFile(mode='w', suffix='.crt', delete=False) as cert_file:
cert_file.write(cert)
cert_path = cert_file.name
with tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False) as key_file:
key_file.write(key)
key_path = key_file.name
try:
# Delete secret if it exists
subprocess.run(
[kubectl_cmd, "delete", "secret", secret_name, "-n", namespace,
"--ignore-not-found=true"],
capture_output=True
)
# Create secret using kubectl
subprocess.run(
[kubectl_cmd, "create", "secret", "tls", secret_name,
f"--cert={cert_path}",
f"--key={key_path}",
"-n", namespace],
check=True,
capture_output=True
)
print(f" ✓ TLS secret '{secret_name}' created")
finally:
# Clean up temp files
os.unlink(cert_path)
os.unlink(key_path)