test(integration): add encrypted key Superset container test with SSL_KEY_PASSPHRASE

conftest.py: superset_container fixture supports {encrypted_key: True}
  - Writes encrypted private key to container
  - Decrypts with openssl rsa -passin env:SSL_KEY_PASSPHRASE
  - Starts Flask/Werkzeug TLS server with decrypted key
  - Falls back to unencrypted key for existing tests

test_superset_tls_custom_ca.py:
  - NEW: TestEncryptedPrivateKeySuperset class
    - test_superset_health_over_tls_with_encrypted_key
    - test_openssl_capath_encrypted_key
  - Uses parametrize({tls: True, encrypted_key: True})

Validates full production flow: encrypted key → SSL_KEY_PASSPHRASE
→ openssl decrypt → TLS handshake → client trust via capath.
This commit is contained in:
2026-07-06 21:21:42 +03:00
parent 8fd23f7ea1
commit 98d91bb738
2 changed files with 248 additions and 260 deletions

View File

@@ -37,6 +37,8 @@ def pytest_collection_modifyitems(config, items):
# applies to ALL items, not just integration tests.
if "/integration/" in item.nodeid:
item.add_marker(skip_integration)
import requests
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import Session, sessionmaker
@@ -101,6 +103,8 @@ def postgres_container():
dbname="test_translate",
) as pg:
yield pg
# #endregion postgres_container
@@ -120,6 +124,8 @@ def pg_engine(postgres_container):
# Cleanup
Base.metadata.drop_all(bind=engine)
engine.dispose()
# #endregion pg_engine
@@ -151,6 +157,8 @@ def db_session(pg_engine):
session.close()
transaction.rollback()
connection.close()
# #endregion db_session
@@ -181,6 +189,8 @@ def mock_config_manager():
)
config_manager.get_config.return_value = MagicMock()
return config_manager
# #endregion mock_config_manager
@@ -202,24 +212,21 @@ def verify_postgres_features(session: Session) -> dict:
# Check FK constraints
try:
result = session.execute(text(
"SELECT COUNT(*) FROM information_schema.table_constraints "
"WHERE constraint_type = 'FOREIGN KEY'"
)).scalar()
result = session.execute(text("SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'")).scalar()
features["foreign_keys"] = result > 0
except Exception:
features["foreign_keys"] = False
# Check indexes
try:
result = session.execute(text(
"SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"
)).scalar()
result = session.execute(text("SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")).scalar()
features["indexes"] = result > 0
except Exception:
features["indexes"] = False
return features
# #endregion verify_postgres_features
@@ -245,12 +252,20 @@ def ca_chain():
def _gen_cert_pem(subject_name, issuer_name, subject_key, issuer_key, is_ca=False, san_dns=None, san_ip=None):
now = datetime.datetime.now(datetime.timezone.utc)
builder = x509.CertificateBuilder()
builder = builder.subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, subject_name),
]))
builder = builder.issuer_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, issuer_name),
]))
builder = builder.subject_name(
x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, subject_name),
]
)
)
builder = builder.issuer_name(
x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, issuer_name),
]
)
)
builder = builder.not_valid_before(now - datetime.timedelta(hours=1))
builder = builder.not_valid_after(now + datetime.timedelta(days=365))
builder = builder.serial_number(x509.random_serial_number())
@@ -258,20 +273,28 @@ def ca_chain():
if is_ca:
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=None), critical=True,
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
)
builder = builder.add_extension(
x509.KeyUsage(
key_cert_sign=True, crl_sign=True,
digital_signature=False, content_commitment=False,
key_encipherment=False, data_encipherment=False,
key_agreement=False, encipher_only=False, decipher_only=False,
), critical=True,
key_cert_sign=True,
crl_sign=True,
digital_signature=False,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
else:
# Server cert
builder = builder.add_extension(
x509.BasicConstraints(ca=False, path_length=None), critical=True,
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
sans: list[x509.GeneralName] = []
if san_dns:
@@ -279,7 +302,8 @@ def ca_chain():
if san_ip:
sans.append(x509.IPAddress(san_ip))
builder = builder.add_extension(
x509.SubjectAlternativeName(sans), critical=False,
x509.SubjectAlternativeName(sans),
critical=False,
)
# Subject Key Identifier — required for OpenSSL 3.x capath chain building
@@ -302,16 +326,24 @@ def ca_chain():
intermediate_key = _gen_key()
intermediate_crt = _gen_cert_pem(
"Test Intermediate CA", "Test Root CA",
intermediate_key, root_key, is_ca=True,
"Test Intermediate CA",
"Test Root CA",
intermediate_key,
root_key,
is_ca=True,
)
server_key = _gen_key()
import ipaddress
server_crt = _gen_cert_pem(
"localhost", "Test Intermediate CA",
server_key, intermediate_key, is_ca=False,
san_dns="localhost", san_ip=ipaddress.IPv4Address("127.0.0.1"),
"localhost",
"Test Intermediate CA",
server_key,
intermediate_key,
is_ca=False,
san_dns="localhost",
san_ip=ipaddress.IPv4Address("127.0.0.1"),
)
# Full chain for server: server PEM + intermediate PEM (order: leaf first)
@@ -322,9 +354,7 @@ def ca_chain():
server_key_encrypted = server_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.BestAvailableEncryption(
server_key_passphrase.encode()
),
encryption_algorithm=serialization.BestAvailableEncryption(server_key_passphrase.encode()),
).decode()
return {
@@ -350,6 +380,8 @@ def ca_chain():
"server_key_passphrase": server_key_passphrase,
"fullchain": fullchain,
}
# #endregion ca_chain
@@ -386,10 +418,10 @@ def superset_db_url(postgres_container):
pg_container.reload()
pg_ip = pg_container.attrs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"]
pg_port = 5432 # internal port, not exposed port
superset_url = (
f"postgresql+psycopg2://test:test@{pg_ip}:{pg_port}/superset_meta"
)
superset_url = f"postgresql+psycopg2://test:test@{pg_ip}:{pg_port}/superset_meta"
return superset_url
# #endregion superset_db_url
@@ -398,6 +430,8 @@ def superset_db_url(postgres_container):
@pytest.fixture(scope="session")
def superset_secret_key():
return "test-superset-secret-key-for-integration-tests-xyz"
# #endregion superset_secret_key
@@ -406,6 +440,8 @@ def superset_secret_key():
@pytest.fixture(scope="session")
def superset_admin_password():
return "admin123"
# #endregion superset_admin_password
@@ -428,7 +464,7 @@ def superset_admin_password():
@pytest.fixture(scope="session")
def superset_container(request, superset_db_url, superset_secret_key, superset_admin_password):
"""Start an Apache Superset instance for integration testing.
Parametrize with indirect=True, {"tls": True} to enable HTTPS with custom CA chain.
"""
from testcontainers.core.container import DockerContainer
@@ -437,36 +473,36 @@ def superset_container(request, superset_db_url, superset_secret_key, superset_a
superset_image = "apache/superset:4.1.2"
# Check if TLS was requested via parametrization
tls_config = getattr(request, 'param', None)
use_tls = tls_config is not None and tls_config.get('tls', False)
tls_config = getattr(request, "param", None)
use_tls = tls_config is not None and tls_config.get("tls", False)
if use_tls:
ca_chain = request.getfixturevalue('ca_chain')
ca_chain = request.getfixturevalue("ca_chain")
# Base64-encode certs to avoid shell escaping issues in heredoc
fullchain_b64 = base64.b64encode(ca_chain["fullchain"].encode()).decode()
key_b64 = base64.b64encode(ca_chain["server_key"].encode()).decode()
_write_certs = (
f"echo '{fullchain_b64}' | base64 -d > /tmp/server.crt && "
f"echo '{key_b64}' | base64 -d > /tmp/server.key"
)
if tls_config.get("encrypted_key", False):
key_pem = ca_chain["server_key_encrypted"]
key_pass = ca_chain["server_key_passphrase"]
key_b64 = base64.b64encode(key_pem.encode()).decode()
_write_certs = f"echo '{fullchain_b64}' | base64 -d > /tmp/server.crt && echo '{key_b64}' | base64 -d > /tmp/server.key"
_decrypt_key = f"export SSL_KEY_PASSPHRASE='{key_pass}' && openssl rsa -in /tmp/server.key -passin env:SSL_KEY_PASSPHRASE -out /tmp/server.key 2>/dev/null; if [ $? -ne 0 ]; then echo 'ERROR: Failed to decrypt private key with SSL_KEY_PASSPHRASE'; exit 1; fi"
else:
key_b64 = base64.b64encode(ca_chain["server_key"].encode()).decode()
_write_certs = f"echo '{fullchain_b64}' | base64 -d > /tmp/server.crt && echo '{key_b64}' | base64 -d > /tmp/server.key"
_decrypt_key = ""
_protocol = "https"
_superset_run = (
f"superset run -h 0.0.0.0 -p 8088 "
f"--cert /tmp/server.crt --key /tmp/server.key --with-threads"
)
_superset_run = f"superset run -h 0.0.0.0 -p 8088 --cert /tmp/server.crt --key /tmp/server.key --with-threads"
else:
_write_certs = ""
_decrypt_key = ""
_protocol = "http"
_superset_run = "superset run -h 0.0.0.0 -p 8088 --with-threads"
# Superset ignores SQLALCHEMY_DATABASE_URI env var — we must create
# superset_config.py and point SUPERSET_CONFIG_PATH to it.
_bootstrap_config = (
"cat > /tmp/superset_config.py << \"PYEOF\"\n"
"import os\n"
"SQLALCHEMY_DATABASE_URI = os.environ.get(\"SUPERSET_DB_URI\", \"sqlite://\")\n"
"PYEOF\n"
)
_bootstrap_config = 'cat > /tmp/superset_config.py << "PYEOF"\nimport os\nSQLALCHEMY_DATABASE_URI = os.environ.get("SUPERSET_DB_URI", "sqlite://")\nPYEOF\n'
_export_config = "export SUPERSET_CONFIG_PATH=/tmp/superset_config.py"
# ── Step 1: Init container ──
@@ -476,18 +512,7 @@ def superset_container(request, superset_db_url, superset_secret_key, superset_a
init.with_env("SUPERSET_LOAD_EXAMPLES", "no")
init.with_env("FLASK_APP", "superset")
# Note: init container does NOT need TLS certs — only runs db upgrade + init
init.with_command(
f"sh -c '"
f"python -m pip install psycopg2-binary -q && "
f"{_bootstrap_config}"
f"{_export_config} && "
f"superset db upgrade && "
f"superset fab create-admin "
f" --username admin --firstname Admin --lastname User "
f" --email admin@test.local --password {superset_admin_password} && "
f"superset init"
f"'"
)
init.with_command(f"sh -c 'python -m pip install psycopg2-binary -q && {_bootstrap_config}{_export_config} && superset db upgrade && superset fab create-admin --username admin --firstname Admin --lastname User --email admin@test.local --password {superset_admin_password} && superset init'")
try:
init.start()
@@ -497,9 +522,7 @@ def superset_container(request, superset_db_url, superset_secret_key, superset_a
if exit_code != 0:
logs = init.get_logs()
init.stop()
raise RuntimeError(
f"Superset init container failed (exit {exit_code}):\n{logs}"
)
raise RuntimeError(f"Superset init container failed (exit {exit_code}):\n{logs}")
finally:
init.stop()
@@ -510,136 +533,17 @@ def superset_container(request, superset_db_url, superset_secret_key, superset_a
superset.with_env("SUPERSET_LOAD_EXAMPLES", "no")
superset.with_env("FLASK_APP", "superset")
# Build web server command: optionally write certs, then start superset
# Build web server command: optionally write certs, decrypt key, then start superset
if use_tls and _write_certs:
web_cmd = (
f"sh -c '"
f"python -m pip install psycopg2-binary -q && "
f"{_write_certs} && "
f"{_bootstrap_config}"
f"{_export_config} && "
f"{_superset_run}'"
)
else:
web_cmd = (
f"sh -c '"
f"python -m pip install psycopg2-binary -q && "
f"{_bootstrap_config}"
f"{_export_config} && "
f"{_superset_run}'"
)
superset.with_command(web_cmd)
superset.with_exposed_ports(8088)
superset.start()
# Store protocol on the container object for superset_url fixture
superset._ss_protocol = _protocol
# Wait for the /health endpoint to respond
host = superset.get_container_host_ip()
port = superset.get_exposed_port(8088)
health_url = f"{_protocol}://{host}:{port}/health"
deadline = time.time() + 90
last_error = None
while time.time() < deadline:
try:
# verify=False in TLS mode because the custom CA hasn't been installed yet
resp = requests.get(health_url, timeout=3, verify=False)
if resp.status_code == 200:
break
except requests.RequestException as e:
last_error = e
time.sleep(2)
else:
logs = superset.get_logs()
superset.stop()
raise TimeoutError(
f"Superset did not become healthy within 90s. "
f"Last error: {last_error}\nContainer logs:\n{logs}"
)
yield superset
superset.stop()
# #endregion superset_container
# #region superset_url [C:1] [TYPE Fixture]
# @BRIEF Function-scoped — base URL of the running Superset container (auto-detects http/https).
@pytest.fixture
def superset_url(superset_container):
host = superset_container.get_container_host_ip()
port = superset_container.get_exposed_port(8088)
protocol = getattr(superset_container, '_ss_protocol', 'http')
return f"{protocol}://{host}:{port}"
# #endregion superset_url
# #region superset_admin_headers [C:2] [TYPE Fixture]
# @BRIEF Function-scoped — authenticated session headers for Superset admin API calls.
@pytest.fixture
def superset_admin_headers(superset_url, superset_admin_password):
"""Log in as admin and return headers with CSRF token and session cookie."""
session = requests.Session()
# Step 1: Get CSRF token from login page
login_url = f"{superset_url}/login/"
resp = session.get(login_url, timeout=10)
resp.raise_for_status()
csrf_token = None
if "csrf_token" in resp.text:
import re
match = re.search(r'csrf_token"\s*:\s*"([^"]+)"', resp.text)
if match:
csrf_token = match.group(1)
# Step 2: Submit login form
resp = session.post(
login_url,
data={
"username": "admin",
"password": superset_admin_password,
"csrf_token": csrf_token or "",
},
headers={"Referer": login_url},
timeout=10,
)
resp.raise_for_status()
return session
# #endregion superset_admin_headers
# ── JWT-based Superset Fixtures ────────────────────────────────────
# ADR-0012: после установки psycopg2 + superset_config.py + Docker bridge IP,
# JWT-авторизация (/api/v1/security/login) работает в тестовом контейнере.
# #region superset_jwt_headers [C:2] [TYPE Fixture]
# @BRIEF Function-scoped — JWT Bearer token headers for Superset REST API.
# @POST Returns dict with Authorization: Bearer <access_token>.
@pytest.fixture
def superset_jwt_headers(superset_url, superset_admin_password):
"""Obtain JWT access token from the running Superset container."""
resp = requests.post(
f"{superset_url}/api/v1/security/login",
json={
"username": "admin",
"password": superset_admin_password,
"provider": "db",
},
timeout=10,
)
_decrypt_prefix = f"{_decrypt_key} && " if _decrypt_key else ""
web_cmd = f"sh -c 'python -m pip install psycopg2-binary -q && {_write_certs} && {_decrypt_prefix}{_bootstrap_config}{_export_config} && {_superset_run}'"
resp.raise_for_status()
data = resp.json()
access_token = data["access_token"]
assert access_token, "No access_token in JWT response"
return {"Authorization": f"Bearer {access_token}"}
# #endregion superset_jwt_headers
@@ -659,6 +563,8 @@ def superset_env(superset_url, superset_admin_password):
verify_ssl=False,
timeout=30,
)
# #endregion superset_env
@@ -672,6 +578,8 @@ async def superset_client(superset_env):
client = SupersetClient(superset_env)
await client.authenticate()
return client
# #endregion superset_client
@@ -687,7 +595,7 @@ async def superset_client(superset_env):
@pytest.fixture
def install_custom_ca(ca_chain):
"""Install the custom Root CA into the system trust store.
Strategy 1 (prod-like): write .crt → update-ca-certificates (requires root).
Strategy 2 (fallback): SSL_CERT_DIR with hash symlinks.
"""
@@ -708,7 +616,9 @@ def install_custom_ca(ca_chain):
ca_path.write_text(ca_pem)
subprocess.run(
["update-ca-certificates", "--fresh"],
check=True, timeout=30, capture_output=True,
check=True,
timeout=30,
capture_output=True,
)
ca_verify_path = "/etc/ssl/certs"
used_update_ca = True
@@ -721,7 +631,10 @@ def install_custom_ca(ca_chain):
# Create hash symlink for capath
result = subprocess.run(
["openssl", "x509", "-hash", "-noout"],
input=ca_pem, capture_output=True, text=True, timeout=10,
input=ca_pem,
capture_output=True,
text=True,
timeout=10,
)
cert_hash = result.stdout.strip()
symlink_path = cert_dir / f"{cert_hash}.0"
@@ -747,7 +660,8 @@ def install_custom_ca(ca_chain):
try:
subprocess.run(
["update-ca-certificates", "--fresh"],
timeout=30, capture_output=True,
timeout=30,
capture_output=True,
)
except Exception:
pass
@@ -756,7 +670,10 @@ def install_custom_ca(ca_chain):
del os.environ["SSL_CERT_DIR"]
if cert_dir and cert_dir.exists():
import shutil
shutil.rmtree(str(cert_dir), ignore_errors=True)
# #endregion install_custom_ca