- Updated GitHub workflow to include Docker cache cleanup and image build steps. - Modified Swarm E2E test logic to skip Docker image build if it already exists, optimizing CI performance.
638 lines
No EOL
24 KiB
Python
638 lines
No EOL
24 KiB
Python
"""
|
|
Pytest test suite for EasyHAProxy Docker Swarm examples
|
|
|
|
These tests verify the functionality of Docker Swarm stack configurations.
|
|
Tests require Docker with Swarm support.
|
|
|
|
Test scenarios:
|
|
1. TestSwarmBasicServices: easyhaproxy.yml + services.yml
|
|
- HTTPS for host1.local and host2.local (embedded SSL cert)
|
|
- HTTP to HTTPS redirect
|
|
- HAProxy stats interface
|
|
- HAProxy config verification
|
|
|
|
2. TestSwarmPluginsCombined: plugins-combined.yml
|
|
- Public website (Cloudflare + deny_pages)
|
|
- Protected API (JWT validator + deny_pages)
|
|
- Admin panel (IP whitelist)
|
|
- HAProxy stats interface
|
|
|
|
Requirements:
|
|
- Docker with Swarm support
|
|
- pytest, requests, PyJWT, cryptography
|
|
|
|
Usage:
|
|
# Run all swarm tests
|
|
pytest tests_e2e/test_swarm.py -v
|
|
|
|
# Run specific test class
|
|
pytest tests_e2e/test_swarm.py::TestSwarmBasicServices -v
|
|
|
|
# Run with markers
|
|
pytest tests_e2e/test_swarm.py -m swarm -v
|
|
"""
|
|
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Generator
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
BASE_DIR = Path(__file__).parent.absolute()
|
|
SWARM_DIR = BASE_DIR / "swarm"
|
|
|
|
# Session-level init state (prevent redundant work within a session;
|
|
# swarm and network are left running after the session so subsequent runs skip setup)
|
|
_swarm_image_built = False
|
|
_swarm_initialized = False
|
|
_swarm_network_created = False
|
|
# Session-level cloudflare config state
|
|
_cloudflare_config_created = False
|
|
|
|
|
|
# =============================================================================
|
|
# Image and Swarm Infrastructure Helpers
|
|
# =============================================================================
|
|
|
|
def ensure_haproxy_image():
|
|
"""Build byjg/easy-haproxy:local from source at the start of the test session.
|
|
|
|
If the image already exists (e.g. pre-built by a CI step), the build is
|
|
skipped so that CI can control how/when the image is built without the
|
|
pytest session re-triggering a potentially slow or hanging `docker build`.
|
|
Subsequent calls within the same session are no-ops (image already built).
|
|
"""
|
|
global _swarm_image_built
|
|
if _swarm_image_built:
|
|
return
|
|
|
|
# Skip build if image already exists (e.g. pre-built in CI)
|
|
inspect = subprocess.run(
|
|
["docker", "image", "inspect", "byjg/easy-haproxy:local"],
|
|
capture_output=True,
|
|
)
|
|
if inspect.returncode == 0:
|
|
print("\n ✓ byjg/easy-haproxy:local already exists, skipping build")
|
|
_swarm_image_built = True
|
|
return
|
|
|
|
project_root = BASE_DIR.parent
|
|
print("\n → Building byjg/easy-haproxy:local from source...")
|
|
subprocess.run(
|
|
[
|
|
"docker", "build",
|
|
"-t", "byjg/easy-haproxy:local",
|
|
"-f", str(project_root / "deploy/docker/Dockerfile"),
|
|
str(project_root),
|
|
],
|
|
check=True,
|
|
)
|
|
print(" ✓ Image built as byjg/easy-haproxy:local")
|
|
_swarm_image_built = True
|
|
|
|
|
|
def init_swarm() -> None:
|
|
"""Initialize Docker Swarm if not already done this session.
|
|
|
|
Uses a session-level flag (like ensure_haproxy_image) so the check runs at most
|
|
once per pytest session regardless of how many fixtures call it.
|
|
Swarm is left running after the session so subsequent runs skip initialization.
|
|
"""
|
|
global _swarm_initialized
|
|
if _swarm_initialized:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
["docker", "info", "--format", "{{.Swarm.LocalNodeState}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
if result.stdout.strip() != "active":
|
|
print("\n → Initializing Docker Swarm...")
|
|
subprocess.run(["docker", "swarm", "init"], check=True, capture_output=True)
|
|
print(" ✓ Docker Swarm initialized")
|
|
else:
|
|
print("\n ✓ Docker Swarm already active")
|
|
|
|
_swarm_initialized = True
|
|
|
|
|
|
def create_overlay_network(network_name: str = "easyhaproxy") -> None:
|
|
"""Create an attachable overlay network if not already done this session.
|
|
|
|
Uses a session-level flag (like ensure_haproxy_image / init_swarm) so the check
|
|
runs at most once per pytest session. The network is left running after the
|
|
session so subsequent runs skip creation.
|
|
"""
|
|
global _swarm_network_created
|
|
if _swarm_network_created:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
["docker", "network", "ls", "--filter", f"name={network_name}", "--format", "{{.Name}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
existing = [n.strip() for n in result.stdout.strip().split("\n") if n.strip()]
|
|
if network_name not in existing:
|
|
print(f"\n → Creating overlay network '{network_name}'...")
|
|
subprocess.run(
|
|
["docker", "network", "create", "--driver", "overlay", "--attachable", network_name],
|
|
check=True, capture_output=True
|
|
)
|
|
print(f" ✓ Overlay network '{network_name}' created")
|
|
else:
|
|
print(f"\n ✓ Overlay network '{network_name}' already exists")
|
|
|
|
_swarm_network_created = True
|
|
|
|
|
|
def wait_for_swarm_services(stack_name: str, timeout: int = 120) -> bool:
|
|
"""Poll until all services in a stack have reached their target replica count."""
|
|
start_time = time.time()
|
|
print(f"\n → Waiting for stack '{stack_name}' services (timeout: {timeout}s)...")
|
|
while time.time() - start_time < timeout:
|
|
result = subprocess.run(
|
|
["docker", "stack", "services", stack_name, "--format", "{{.Replicas}}"],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
time.sleep(2)
|
|
continue
|
|
|
|
replicas = [r.strip() for r in result.stdout.strip().split("\n") if "/" in r]
|
|
if not replicas:
|
|
time.sleep(2)
|
|
continue
|
|
|
|
all_ready = all(r.split("/")[0] == r.split("/")[1] for r in replicas)
|
|
if all_ready:
|
|
elapsed = time.time() - start_time
|
|
print(f" ✓ All {len(replicas)} service(s) ready ({elapsed:.1f}s)")
|
|
return True
|
|
|
|
time.sleep(2)
|
|
|
|
return False
|
|
|
|
|
|
def wait_for_http(
|
|
url: str,
|
|
headers: dict = None,
|
|
expected_status: list = None,
|
|
timeout: int = 120,
|
|
verify_ssl: bool = False,
|
|
) -> bool:
|
|
"""Poll an HTTP endpoint until it responds with an expected status code."""
|
|
if expected_status is None:
|
|
expected_status = [200, 301, 302, 403, 401, 404]
|
|
|
|
start_time = time.time()
|
|
while time.time() - start_time < timeout:
|
|
try:
|
|
resp = requests.get(
|
|
url,
|
|
headers=headers or {},
|
|
verify=verify_ssl,
|
|
allow_redirects=False,
|
|
timeout=5,
|
|
)
|
|
if resp.status_code in expected_status:
|
|
return True
|
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
|
pass
|
|
|
|
time.sleep(1)
|
|
|
|
return False
|
|
|
|
|
|
def get_haproxy_container_id(stack_name: str, service_name: str = "haproxy") -> str:
|
|
"""Return the container ID for a swarm service (e.g., 'easyhaproxy_haproxy')."""
|
|
result = subprocess.run(
|
|
["docker", "ps", "--filter", f"name={stack_name}_{service_name}", "--format", "{{.ID}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
container_ids = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
|
|
if not container_ids:
|
|
raise RuntimeError(f"No container found for {stack_name}_{service_name}")
|
|
return container_ids[0]
|
|
|
|
|
|
def create_swarm_config(config_name: str, file_path: Path) -> None:
|
|
"""Create a Docker swarm config from a file, removing any existing one first."""
|
|
subprocess.run(["docker", "config", "rm", config_name], capture_output=True)
|
|
subprocess.run(
|
|
["docker", "config", "create", config_name, str(file_path)],
|
|
check=True, capture_output=True
|
|
)
|
|
print(f" ✓ Docker config '{config_name}' created")
|
|
|
|
|
|
def remove_swarm_config(config_name: str) -> None:
|
|
"""Remove a Docker swarm config, ignoring errors if it doesn't exist."""
|
|
subprocess.run(["docker", "config", "rm", config_name], capture_output=True)
|
|
|
|
|
|
def create_cloudflare_config():
|
|
"""Download Cloudflare IP ranges and create a Docker swarm config.
|
|
|
|
Adds the 10.0.0.0/8 range so that requests routed through Docker Swarm's
|
|
ingress network (typically 10.x.x.x) are treated as Cloudflare IPs in tests.
|
|
"""
|
|
global _cloudflare_config_created
|
|
if _cloudflare_config_created:
|
|
return
|
|
|
|
print("\n → Creating 'cloudflare_ips' Docker config...")
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as f:
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Add Docker ingress range so test requests appear to come from Cloudflare
|
|
with open(temp_path, "a") as f:
|
|
f.write("10.0.0.0/8\n")
|
|
|
|
create_swarm_config("cloudflare_ips", temp_path)
|
|
_cloudflare_config_created = True
|
|
finally:
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
# =============================================================================
|
|
# SwarmFixture: manages docker stack lifecycle
|
|
# =============================================================================
|
|
|
|
class SwarmFixture:
|
|
"""Helper class to manage Docker Swarm stack lifecycle."""
|
|
|
|
def __init__(self, stack_files, stack_name: str, timeout: int = 120):
|
|
self.stack_files = stack_files if isinstance(stack_files, list) else [stack_files]
|
|
self.stack_name = stack_name
|
|
self.timeout = timeout
|
|
|
|
def deploy(self):
|
|
"""Issue `docker stack deploy` for each file without waiting for replicas."""
|
|
for stack_file in self.stack_files:
|
|
name = Path(stack_file).name
|
|
print(f"\n → Deploying stack '{self.stack_name}' from {name}...")
|
|
result = subprocess.run(
|
|
[
|
|
"docker", "stack", "deploy",
|
|
"--resolve-image", "never", # use local image, never pull from registry
|
|
"-c", stack_file, self.stack_name,
|
|
],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
print(f" ✗ Deploy failed:\n stdout: {result.stdout}\n stderr: {result.stderr}")
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode, result.args, result.stdout, result.stderr
|
|
)
|
|
|
|
def wait_ready(self):
|
|
"""Wait until all services in this stack have reached their target replica count."""
|
|
if not wait_for_swarm_services(self.stack_name, self.timeout):
|
|
raise TimeoutError(
|
|
f"Stack '{self.stack_name}' services did not become ready within {self.timeout}s"
|
|
)
|
|
|
|
def up(self):
|
|
"""Deploy the stack and wait for all services to be running."""
|
|
self.deploy()
|
|
self.wait_ready()
|
|
|
|
def down(self):
|
|
"""Force-kill all stack containers, then remove the stack definition."""
|
|
print(f"\n → Removing stack '{self.stack_name}'...")
|
|
|
|
# Force-kill all running containers immediately (SIGKILL — no grace period)
|
|
result = subprocess.run(
|
|
["docker", "ps", "-q", "--filter", f"name={self.stack_name}_"],
|
|
capture_output=True, text=True
|
|
)
|
|
container_ids = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
|
|
if container_ids:
|
|
subprocess.run(["docker", "kill"] + container_ids, capture_output=True)
|
|
|
|
# Remove the stack definition (services, configs, secrets)
|
|
subprocess.run(
|
|
["docker", "stack", "rm", self.stack_name],
|
|
capture_output=True, text=True
|
|
)
|
|
|
|
# Containers are already dead from docker kill above; ports are freed immediately.
|
|
print(f" ✓ Stack '{self.stack_name}' removed")
|
|
|
|
|
|
# =============================================================================
|
|
# Session-level setup: swarm mode + overlay network + image build
|
|
# =============================================================================
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def swarm_setup():
|
|
"""Session fixture: build image, initialize Docker Swarm, and create overlay network.
|
|
|
|
Each step runs at most once per session (guarded by module-level flags).
|
|
Swarm mode and the overlay network are left running after the session so that
|
|
a subsequent test run can skip setup — the same pattern as the image build.
|
|
Only deployed stacks (SwarmFixture) are torn down between test classes.
|
|
"""
|
|
ensure_haproxy_image()
|
|
init_swarm()
|
|
create_overlay_network()
|
|
|
|
yield
|
|
# No teardown of swarm/network: they persist for subsequent runs (faster second run)
|
|
|
|
|
|
# =============================================================================
|
|
# Per-test fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture(scope="class")
|
|
def swarm_basic_services(generate_ssl_certificates) -> Generator[None, None, None]:
|
|
"""Fixture for easyhaproxy.yml + services.yml.
|
|
|
|
Deploys two stacks:
|
|
- easyhaproxy: HAProxy in swarm discovery mode
|
|
- services: Two backends (host1.local, host2.local) with embedded SSL
|
|
|
|
EasyHAProxy auto-attaches services to the easyhaproxy overlay network on
|
|
each refresh cycle (default: every 10 seconds), then regenerates HAProxy
|
|
config to include the discovered backends.
|
|
"""
|
|
easyhaproxy = SwarmFixture(str(SWARM_DIR / "easyhaproxy.yml"), "easyhaproxy", timeout=120)
|
|
services = SwarmFixture(str(SWARM_DIR / "services.yml"), "services", timeout=60)
|
|
|
|
# Deploy both stacks immediately so they start pulling/starting in parallel,
|
|
# then wait for each to reach its target replica count.
|
|
easyhaproxy.deploy()
|
|
services.deploy()
|
|
easyhaproxy.wait_ready()
|
|
services.wait_ready()
|
|
|
|
# Wait for EasyHAProxy to auto-attach services, regenerate config, and
|
|
# for HAProxy to start serving traffic (up to 2 refresh cycles = ~20s).
|
|
# Do NOT include 503 — that means HAProxy is up but the backend isn't ready yet.
|
|
print("\n → Waiting for HAProxy to discover and configure swarm services...")
|
|
ready = wait_for_http(
|
|
"https://127.0.0.1/",
|
|
headers={"Host": "host1.local"},
|
|
expected_status=[200, 301, 302],
|
|
timeout=120,
|
|
)
|
|
if not ready:
|
|
print(" ⚠ Warning: Services may not be fully configured yet")
|
|
else:
|
|
print(" ✓ Services are reachable through HAProxy")
|
|
|
|
yield
|
|
services.down()
|
|
easyhaproxy.down()
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def swarm_plugins_combined(generate_ssl_certificates) -> Generator[None, None, None]:
|
|
"""Fixture for plugins-combined.yml.
|
|
|
|
Creates required Docker swarm configs (jwt_api_pubkey, cloudflare_ips) and
|
|
deploys a self-contained stack containing:
|
|
- HAProxy with plugin support
|
|
- Public website (Cloudflare + deny_pages)
|
|
- Protected API (JWT validator + deny_pages)
|
|
- Admin panel (IP whitelist: 203.0.113.0/24, 10.0.0.0/8)
|
|
"""
|
|
certs = generate_ssl_certificates
|
|
|
|
print("\n → Setting up Docker configs for plugins-combined stack...")
|
|
create_swarm_config("jwt_api_pubkey", certs["jwt_pubkey"])
|
|
create_cloudflare_config()
|
|
|
|
stack = SwarmFixture(str(SWARM_DIR / "plugins-combined.yml"), "production", timeout=120)
|
|
stack.up()
|
|
|
|
# Wait for HAProxy to discover services and apply plugin configurations
|
|
print("\n → Waiting for HAProxy to configure plugin backends...")
|
|
ready = wait_for_http(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "website.example.com"},
|
|
expected_status=[200],
|
|
timeout=120,
|
|
)
|
|
if not ready:
|
|
print(" ⚠ Warning: Services may not be fully configured yet")
|
|
else:
|
|
print(" ✓ Services are reachable through HAProxy")
|
|
|
|
yield
|
|
stack.down()
|
|
|
|
print("\n → Cleaning up Docker configs...")
|
|
remove_swarm_config("jwt_api_pubkey")
|
|
remove_swarm_config("cloudflare_ips")
|
|
global _cloudflare_config_created
|
|
_cloudflare_config_created = False
|
|
|
|
|
|
# =============================================================================
|
|
# Tests: easyhaproxy.yml + services.yml - Basic Services with SSL in Swarm
|
|
# =============================================================================
|
|
|
|
@pytest.mark.swarm
|
|
@pytest.mark.ssl
|
|
class TestSwarmBasicServices:
|
|
"""Tests for Swarm mode with basic SSL services.
|
|
|
|
Uses easyhaproxy.yml (HAProxy) + services.yml (two SSL backends).
|
|
EasyHAProxy discovers services via Swarm API and auto-attaches them
|
|
to the shared overlay network.
|
|
"""
|
|
|
|
def test_services_running(self, swarm_basic_services):
|
|
"""Verify all expected swarm services are running."""
|
|
result = subprocess.run(
|
|
["docker", "service", "ls", "--format", "{{.Name}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
service_names = result.stdout
|
|
assert "easyhaproxy_haproxy" in service_names, "easyhaproxy_haproxy service not found"
|
|
assert "services_container" in service_names, "services_container service not found"
|
|
assert "services_container2" in service_names, "services_container2 service not found"
|
|
|
|
def test_haproxy_config(self, swarm_basic_services):
|
|
"""Verify HAProxy configuration contains backends for both swarm services."""
|
|
from utils import extract_backend_block
|
|
container_id = get_haproxy_container_id("easyhaproxy")
|
|
result = subprocess.run(
|
|
["docker", "exec", container_id, "cat", "/etc/easyhaproxy/haproxy/haproxy.cfg"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
config = result.stdout
|
|
|
|
# Both HTTPS backends must be present
|
|
assert "backend srv_host1_local_443" in config, "HTTPS backend for host1.local not found"
|
|
assert "backend srv_host2_local_443" in config, "HTTPS backend for host2.local not found"
|
|
|
|
# HTTP to HTTPS redirect must be configured
|
|
assert "redirect scheme https" in config or "redirect prefix https://" in config, \
|
|
"HTTP to HTTPS redirect not found in HAProxy config"
|
|
|
|
def test_https_host1(self, swarm_basic_services):
|
|
"""Test HTTPS access to host1.local through Swarm HAProxy."""
|
|
response = requests.get(
|
|
"https://127.0.0.1/",
|
|
headers={"Host": "host1.local"},
|
|
verify=False,
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_https_host2(self, swarm_basic_services):
|
|
"""Test HTTPS access to host2.local through Swarm HAProxy."""
|
|
response = requests.get(
|
|
"https://127.0.0.1/",
|
|
headers={"Host": "host2.local"},
|
|
verify=False,
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_http_redirect_host1(self, swarm_basic_services):
|
|
"""Test HTTP to HTTPS permanent redirect for host1.local."""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "host1.local"},
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 301
|
|
assert response.headers.get("location") == "https://host1.local/"
|
|
|
|
def test_http_redirect_host2(self, swarm_basic_services):
|
|
"""Test HTTP to HTTPS permanent redirect for host2.local."""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "host2.local"},
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 301
|
|
assert response.headers.get("location") == "https://host2.local/"
|
|
|
|
def test_haproxy_stats(self, swarm_basic_services):
|
|
"""Test HAProxy stats interface is accessible."""
|
|
from conftest import verify_haproxy_stats
|
|
verify_haproxy_stats()
|
|
|
|
|
|
# =============================================================================
|
|
# Tests: plugins-combined.yml - Multiple Security Plugins in Swarm
|
|
# =============================================================================
|
|
|
|
@pytest.mark.swarm
|
|
@pytest.mark.plugins
|
|
class TestSwarmPluginsCombined:
|
|
"""Tests for multiple combined security plugins in Swarm mode.
|
|
|
|
Uses plugins-combined.yml which is a self-contained stack:
|
|
- HAProxy with Cloudflare + JWT + IP-whitelist plugins
|
|
- website.example.com: Cloudflare IP restoration + deny_pages
|
|
- api.example.com: JWT validator + deny_pages
|
|
- admin.example.com: IP whitelist (203.0.113.0/24, 10.0.0.0/8)
|
|
"""
|
|
|
|
def test_services_running(self, swarm_plugins_combined):
|
|
"""Verify all four services are running in the production stack."""
|
|
result = subprocess.run(
|
|
["docker", "service", "ls", "--format", "{{.Name}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
service_names = result.stdout
|
|
assert "production_haproxy" in service_names, "production_haproxy service not found"
|
|
assert "production_website" in service_names, "production_website service not found"
|
|
assert "production_api" in service_names, "production_api service not found"
|
|
assert "production_admin" in service_names, "production_admin service not found"
|
|
|
|
def test_website_normal_access(self, swarm_plugins_combined):
|
|
"""Test normal GET request reaches the public website (Cloudflare + deny_pages)."""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "website.example.com"},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_website_blocked_paths(self, swarm_plugins_combined):
|
|
"""Test deny_pages plugin blocks sensitive paths with HTTP 404."""
|
|
blocked_paths = ["/admin", "/wp-admin", "/.env", "/config"]
|
|
for path in blocked_paths:
|
|
response = requests.get(
|
|
f"http://127.0.0.1{path}",
|
|
headers={"Host": "website.example.com"},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 404, \
|
|
f"Expected 404 for blocked path '{path}', got {response.status_code}"
|
|
|
|
def test_api_without_token(self, swarm_plugins_combined):
|
|
"""Test JWT validator rejects requests that have no Authorization header."""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "api.example.com"},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code in (401, 403), \
|
|
f"Expected 401/403 without JWT, got {response.status_code}"
|
|
assert "Missing Authorization HTTP header" in response.text
|
|
|
|
def test_api_with_valid_token(self, swarm_plugins_combined, jwt_token):
|
|
"""Test JWT validator allows requests with a valid RS256 JWT token."""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={
|
|
"Host": "api.example.com",
|
|
"Authorization": f"Bearer {jwt_token}",
|
|
},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_api_blocked_paths_with_token(self, swarm_plugins_combined, jwt_token):
|
|
"""Test deny_pages blocks internal API paths even with a valid JWT token."""
|
|
blocked_paths = ["/internal", "/debug", "/metrics"]
|
|
for path in blocked_paths:
|
|
response = requests.get(
|
|
f"http://127.0.0.1{path}",
|
|
headers={
|
|
"Host": "api.example.com",
|
|
"Authorization": f"Bearer {jwt_token}",
|
|
},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 403, \
|
|
f"Expected 403 for blocked API path '{path}', got {response.status_code}"
|
|
|
|
def test_admin_panel_ip_whitelist(self, swarm_plugins_combined):
|
|
"""Test IP whitelist plugin on admin panel.
|
|
|
|
The whitelist is '203.0.113.0/24,10.0.0.0/8'.
|
|
In Docker Swarm ingress mode, requests from the host arrive at HAProxy
|
|
with the Docker ingress router IP (typically in 10.0.0.0/8), so the
|
|
admin panel should be accessible.
|
|
"""
|
|
response = requests.get(
|
|
"http://127.0.0.1/",
|
|
headers={"Host": "admin.example.com"},
|
|
timeout=10,
|
|
)
|
|
# Docker Swarm ingress IPs (10.x.x.x) are in the 10.0.0.0/8 whitelist
|
|
assert response.status_code == 200, \
|
|
(f"Expected admin access from Docker ingress IP (in 10.0.0.0/8), "
|
|
f"got {response.status_code}")
|
|
|
|
def test_haproxy_stats(self, swarm_plugins_combined):
|
|
"""Test HAProxy stats interface is accessible."""
|
|
from conftest import verify_haproxy_stats
|
|
verify_haproxy_stats() |