Files
ss-tools/backend/tests/integration/test_superset_tls_custom_ca.py
busya 98d91bb738 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.
2026-07-06 21:21:42 +03:00

603 lines
26 KiB
Python

# #region TestSupersetTlsCustomCA [C:3] [TYPE Module] [SEMANTICS test,superset,tls,ssl,integration,ca]
# @BRIEF Integration tests validating the full corporate SSL certificate chain:
# custom CA generation → system store installation → HTTPS via superset-tools HTTP clients.
# @RELATION BINDS_TO -> [AsyncAPIClient]
# @RELATION BINDS_TO -> [SupersetClientBase]
# @RELATION BINDS_TO -> [SupersetClientRegistryModule]
# @RELATION BINDS_TO -> [install_certificates (entrypoint)]
# @RELATION BINDS_TO -> [ADR-0009]
# @RATIONALE Validates that ssl.create_default_context() (used by our patched HTTP clients)
# trusts custom CA certificates installed via update-ca-certificates or SSL_CERT_DIR.
# Uses 3-tier PKI (Root → Intermediate → Server) to test the OpenSSL 3.x capath vs cafile
# limitation documented in ADR-0009 Finding 7.
# @TEST_CONTRACT InstallCustomCA ->
# update-ca-certificates (or SSL_CERT_DIR fallback) makes custom Root CA discoverable
# ssl.create_default_context(capath=...) builds chain successfully
# ssl.create_default_context(cafile=...) fails with intermediate CA (OpenSSL 3.x bug)
# AsyncAPIClient(verify_ssl=True) authenticates against TLS Superset
# SupersetClient(verify_ssl=True) provides full API access over HTTPS
# @REQUIRES Docker daemon running. --run-integration flag.
import pytest
import re
import ssl
import subprocess
from urllib.parse import urlparse
# ── Test Class ──────────────────────────────────────────────────────────
# #region TestCustomCATrustChain [C:3] [TYPE Class]
# @BRIEF Validate that custom CA certificates installed into the system store
# are trusted by openssl, httpx, and superset-tools patched HTTP clients.
@pytest.mark.parametrize("superset_container", [{"tls": True}], indirect=True)
class TestCustomCATrustChain:
"""Full corporate CA trust chain validation against TLS-protected Superset."""
# ── openssl tests ─────────────────────────────────────────────────
# #region test_openssl_verify_capath_ok [C:2] [TYPE Function]
# @BRIEF openssl s_client with -CApath succeeds (hash symlinks).
# @POST Verify return code: 0 (ok)
def test_openssl_verify_capath_ok(self, superset_url, install_custom_ca):
"""openssl s_client -CApath must return code 0 (full chain)."""
parsed = urlparse(superset_url)
host = parsed.hostname
port = parsed.port or 443
ca_path = install_custom_ca["ca_verify_path"]
result = subprocess.run(
[
"openssl",
"s_client",
"-connect",
f"{host}:{port}",
"-servername",
host,
"-CApath",
ca_path,
"-verify_return_error",
],
input="",
capture_output=True,
text=True,
timeout=15,
)
# Parse Verify return code from output
match = re.search(r"Verify return code:\s*(\d+)", result.stderr + result.stdout)
assert match is not None, f"No Verify return code in openssl output.\nstdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}"
code = int(match.group(1))
assert code == 0, f"Expected verify code 0, got {code}.\nFull output:\n{result.stderr[:2000]}"
# #endregion test_openssl_verify_capath_ok
# #region test_openssl_verify_cafile_certifi_fails [C:2] [TYPE Function]
# @BRIEF openssl s_client -CAfile with certifi's bundle fails.
# This proves that certifi's CA bundle does NOT trust our custom Root CA.
# Before our patches, httpx used certifi by default (verify=True → certifi).
# After our patches, httpx uses ssl.create_default_context() (system CA store).
def test_openssl_verify_cafile_certifi_fails(self, superset_url):
"""openssl s_client -CAfile $(certifi) must return non-zero verify code.
This proves that our custom CA is NOT trusted by certifi, which is what
httpx used before our patches. After patches, we use system CA store."""
import certifi
parsed = urlparse(superset_url)
host = parsed.hostname
port = parsed.port or 443
result = subprocess.run(
[
"openssl",
"s_client",
"-connect",
f"{host}:{port}",
"-servername",
host,
"-CAfile",
certifi.where(),
"-verify_return_error",
],
input="",
capture_output=True,
text=True,
timeout=15,
)
match = re.search(r"Verify return code:\s*(\d+)", result.stderr + result.stdout)
assert match is not None, f"No Verify return code in openssl output.\nstdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}"
code = int(match.group(1))
assert code != 0, f"Expected non-zero verify code with certifi CA bundle, got 0.\nThis means certifi somehow trusts our generated CA, which should not happen.\nFull output:\n{result.stderr[:2000]}"
# #endregion test_openssl_verify_cafile_certifi_fails
# ── httpx SSL context tests ──────────────────────────────────────
# #region test_httpx_capath_works [C:2] [TYPE Function]
# @BRIEF httpx with ssl.create_default_context(capath=...) succeeds.
def test_httpx_capath_works(self, superset_url, install_custom_ca):
"""ssl.create_default_context(capath=...) must successfully connect.
This is the production code path used by all patched HTTP clients."""
import asyncio
import httpx
ca_path = install_custom_ca["ca_verify_path"]
ctx = ssl.create_default_context(capath=ca_path)
async def _test():
async with httpx.AsyncClient(verify=ctx, timeout=10) as client:
resp = await client.get(f"{superset_url}/health")
assert resp.status_code == 200
assert resp.text.strip() == "OK"
asyncio.run(_test())
# #endregion test_httpx_capath_works
# #region test_httpx_certifi_fails [C:2] [TYPE Function]
# @BRIEF httpx with certifi's CA bundle must fail.
# This proves the old behavior (verify=True → certifi) would fail with
# corporate CA certificates. After our patches, we use system CA store
# via ssl.create_default_context(capath=...).
def test_httpx_certifi_fails(self, superset_url):
"""ssl.create_default_context(cafile=certifi.where()) must raise SSLError.
Proves that certifi does NOT trust our custom Root CA, which is why
our patches replacing verify=True with ssl.create_default_context()
are necessary."""
import asyncio
import certifi
import httpx
ctx = ssl.create_default_context(cafile=certifi.where())
async def _test():
async with httpx.AsyncClient(verify=ctx, timeout=10) as client:
with pytest.raises((ssl.SSLCertVerificationError, httpx.RemoteProtocolError, httpx.ConnectError)):
await client.get(f"{superset_url}/health")
asyncio.run(_test())
# #endregion test_httpx_certifi_fails
# ── superset-tools client tests ────────────────────────────────────────
# #region test_asyncapiclient_verify_true [C:2] [TYPE Function]
# @BRIEF AsyncAPIClient(verify_ssl=True) authenticates against TLS Superset.
# Validates our patched async_network.py code path.
def test_asyncapiclient_verify_true(self, superset_url, superset_admin_password, install_custom_ca):
"""AsyncAPIClient with verify_ssl=True must authenticate over HTTPS.
This validates the core SSL fix in async_network.py."""
# install_custom_ca is used for its side effect — installing CA into system store
assert install_custom_ca is not None
import asyncio
from src.core.utils.async_network import AsyncAPIClient
parsed = urlparse(superset_url)
base_url = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
client = AsyncAPIClient(
config={
"base_url": base_url,
"auth": {
"username": "admin",
"password": superset_admin_password,
"provider": "db",
"refresh": "true",
},
},
verify_ssl=True,
timeout=30,
)
async def _test():
try:
tokens = await client.authenticate()
assert "access_token" in tokens, "No access_token in auth response"
assert "csrf_token" in tokens, "No csrf_token in auth response"
finally:
await client.aclose()
asyncio.run(_test())
# #endregion test_asyncapiclient_verify_true
# #region test_superset_client_full_auth [C:2] [TYPE Function]
# @BRIEF Full SupersetClient auth + API call over TLS with verify_ssl=True.
def test_superset_client_full_auth(self, superset_url, superset_admin_password, install_custom_ca):
"""SupersetClient with verify_ssl=True must authenticate and
execute API queries over HTTPS."""
# install_custom_ca is used for its side effect — installing CA into system store
assert install_custom_ca is not None
import asyncio
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
env = Environment(
id="test_tls",
name="Test TLS Superset",
url=superset_url,
username="admin",
password=superset_admin_password,
verify_ssl=True,
timeout=30,
)
async def _test():
client = SupersetClient(env)
try:
tokens = await client.authenticate()
assert "access_token" in tokens, "No access_token"
assert "csrf_token" in tokens, "No csrf_token"
# Fetch dashboards list via the authenticated client
result = await client.get_dashboards(query={"page": 0, "page_size": 10})
# get_dashboards returns (count, items) tuple
assert isinstance(result, (dict, tuple)), f"Expected dict or tuple, got {type(result)}"
if isinstance(result, tuple):
assert len(result) == 2, f"Expected (count, items), got {result}"
else:
assert "result" in result, f"No 'result' key: {list(result.keys())}"
finally:
await client.aclose()
asyncio.run(_test())
# #endregion test_superset_client_full_auth
# ── Regression tests ─────────────────────────────────────────────
# #region test_certifi_bundle_notrust [C:1] [TYPE Function]
# @BRIEF Prove that certifi's CA bundle does NOT contain our custom Root CA.
# This validates WHY the patches are needed: before our fix, httpx used
# certifi by default, which would NOT trust corporate CA certificates.
def test_certifi_bundle_notrust(self, superset_container, ca_chain):
"""certifi.where() bundle must NOT contain our custom Root CA.
This proves that default httpx.AsyncClient(verify=True) would reject
corporate certificates installed via update-ca-certificates.
Uses SHA256 fingerprint dedup, same as the entrypoint fallback logic."""
import certifi
# Load our Root CA and compute its SHA256 fingerprint
from cryptography import x509
from cryptography.hazmat.primitives import hashes
our_ca = x509.load_pem_x509_certificate(ca_chain["root_crt"].encode())
our_fingerprint = our_ca.fingerprint(hashes.SHA256()).hex()
# Load certifi's bundle and check if any cert matches our fingerprint
with open(certifi.where(), "rb") as f:
bundle_data = f.read()
assert our_fingerprint not in bundle_data.hex(), "Our custom Root CA was found in certifi bundle — this should not happen since the CA was just generated at test time."
# #endregion test_certifi_bundle_notrust
# #region test_verify_false_still_works [C:1] [TYPE Function]
# @BRIEF verify_ssl=False must still work (regression test).
# The SSL patches must not break the disabled-verification path.
def test_verify_false_still_works(self, superset_url):
"""AsyncAPIClient(verify_ssl=False) must work over HTTPS
(regression — the LLM_SSL_VERIFY=false escape hatch must not break)."""
import asyncio
import httpx
async def _test():
async with httpx.AsyncClient(verify=False, timeout=10) as client:
resp = await client.get(f"{superset_url}/health")
assert resp.status_code == 200
assert resp.text.strip() == "OK"
asyncio.run(_test())
# #endregion test_verify_false_still_works
# #endregion TestCustomCATrustChain
# #region TestEncryptedPrivateKeySuperset [C:3] [TYPE Class] [SEMANTICS test,tls,ssl,encrypted-key,passphrase,superset]
# @BRIEF Integration test for Superset container with passphrase-protected
# private key, decrypted via SSL_KEY_PASSPHRASE env var.
# Validates the production flow: encrypted key → openssl decrypt → TLS handshake.
# @RELATION BINDS_TO -> [superset_container]
# @RELATION BINDS_TO -> [ca_chain]
# @RELATION BINDS_TO -> [install_custom_ca]
# @RELATION BINDS_TO -> [ADR-0009]
# @RATIONALE Corporate PKI often distributes passphrase-protected keys.
# superset-tools entrypoint decrypts these with SSL_KEY_PASSPHRASE.
# This test validates the full pipeline: encrypted key in container →
# openssl decrypt via env var → Flask/Werkzeug TLS server → client trust.
@pytest.mark.parametrize("superset_container", [{"tls": True, "encrypted_key": True}], indirect=True)
class TestEncryptedPrivateKeySuperset:
"""Superset container with encrypted private key + SSL_KEY_PASSPHRASE."""
def test_superset_health_over_tls_with_encrypted_key(self, superset_url, install_custom_ca):
"""Health endpoint must respond 200 OK over HTTPS with encrypted key."""
import asyncio
import httpx
ca_verify_path = install_custom_ca["ca_verify_path"]
ctx = ssl.create_default_context(capath=ca_verify_path)
async def _test():
async with httpx.AsyncClient(verify=ctx, timeout=10) as client:
resp = await client.get(f"{superset_url}/health")
assert resp.status_code == 200
assert resp.text.strip() == "OK"
asyncio.run(_test())
def test_openssl_capath_encrypted_key(self, superset_url, install_custom_ca):
"""openssl s_client -CApath must succeed with encrypted key container."""
ca_verify_path = install_custom_ca["ca_verify_path"]
parsed = urlparse(superset_url)
host = parsed.hostname
port = parsed.port or 443
result = subprocess.run(
[
"openssl",
"s_client",
"-connect",
f"{host}:{port}",
"-servername",
host,
"-CApath",
ca_verify_path,
"-verify_return_error",
],
input="",
capture_output=True,
text=True,
timeout=15,
)
match = re.search(r"Verify return code:\s*(\d+)", result.stderr + result.stdout)
assert match is not None, "No Verify return code in openssl output"
assert int(match.group(1)) == 0, f"Expected verify code 0 with encrypted key container, got {match.group(1)}"
# #endregion TestEncryptedPrivateKeySuperset
# #region TestEncryptedPrivateKey [C:3] [TYPE Class] [SEMANTICS test,tls,ssl,encrypted-key,passphrase]
# @BRIEF Integration tests for passphrase-protected (encrypted) private keys.
# Uses openssl CLI + ssl.SSLContext instead of Superset container — lighter, faster,
# and exercises the same certificate decryption code path.
# @RELATION BINDS_TO -> [ca_chain]
# @RELATION BINDS_TO -> [install_custom_ca]
# @RELATION BINDS_TO -> [ADR-0009]
# @RATIONALE Corporate PKI often distributes passphrase-encrypted private keys
# (PEM with DEK-Info headers). These tests verify that:
# - ssl.SSLContext.load_cert_chain(password=...) supports encrypted keys
# - openssl s_server -pass successfully decrypts and serves TLS
# - full TLS handshake succeeds with encrypted key + correct passphrase
# - wrong/missing passphrase is correctly rejected
# @REQUIRES openssl CLI available in PATH.
class TestEncryptedPrivateKey:
"""Integration tests for passphrase-protected (encrypted) private keys."""
# #region test_ssl_context_loads_encrypted_key [C:2] [TYPE Function]
# @BRIEF ssl.SSLContext.load_cert_chain() with encrypted key + correct password succeeds.
# @POST SSL context loaded; no exception raised.
# @RELATION BINDS_TO -> [ca_chain]
def test_ssl_context_loads_encrypted_key(self, ca_chain, tmp_path):
"""Load an encrypted private key into ssl.SSLContext with correct passphrase."""
cert_file = tmp_path / "server.crt"
key_file = tmp_path / "server.key"
cert_file.write_text(ca_chain["server_crt"])
key_file.write_text(ca_chain["server_key_encrypted"])
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(
certfile=str(cert_file),
keyfile=str(key_file),
password=ca_chain["server_key_passphrase"],
)
# No exception raised — key loaded successfully
# #endregion test_ssl_context_loads_encrypted_key
# #region test_encrypted_key_wrong_passphrase_fails [C:2] [TYPE Function]
# @BRIEF SSLContext.load_cert_chain() with wrong passphrase raises SSLError.
# @POST ssl.SSLError raised.
# @RELATION BINDS_TO -> [ca_chain]
def test_encrypted_key_wrong_passphrase_fails(self, ca_chain, tmp_path):
"""Wrong passphrase must raise ssl.SSLError."""
cert_file = tmp_path / "server.crt"
key_file = tmp_path / "server.key"
cert_file.write_text(ca_chain["server_crt"])
key_file.write_text(ca_chain["server_key_encrypted"])
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
with pytest.raises(ssl.SSLError):
ctx.load_cert_chain(
certfile=str(cert_file),
keyfile=str(key_file),
password="wrong-passphrase-99999",
)
# #endregion test_encrypted_key_wrong_passphrase_fails
# #region test_encrypted_key_no_passphrase_fails [C:2] [TYPE Function]
# @BRIEF SSLContext.load_cert_chain() without password on encrypted key raises SSLError.
# @POST ssl.SSLError raised.
# @RELATION BINDS_TO -> [ca_chain]
def test_encrypted_key_no_passphrase_fails(self, ca_chain, tmp_path):
"""Encrypted key without password must raise ssl.SSLError."""
cert_file = tmp_path / "server.crt"
key_file = tmp_path / "server.key"
cert_file.write_text(ca_chain["server_crt"])
key_file.write_text(ca_chain["server_key_encrypted"])
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
# Note: Python raises OSError (not SSLError) when an encrypted key is
# loaded without a password and no interactive terminal is available.
with pytest.raises((ssl.SSLError, OSError)):
ctx.load_cert_chain(
certfile=str(cert_file),
keyfile=str(key_file),
# no password argument — should fail
)
# #endregion test_encrypted_key_no_passphrase_fails
# #region test_asyncio_server_encrypted_key_ok [C:3] [TYPE Function]
# @BRIEF Python asyncio SSL server with encrypted key + passphrase → TLS handshake succeeds.
# Uses ssl.SSLContext.load_cert_chain() — the same production code path.
# @POST Client successfully connects and receives HTTP 200 over TLS with full chain trust.
# @SIDE_EFFECT Starts/stops an asyncio TCP server on a local port; writes temp files.
# @RELATION BINDS_TO -> [ca_chain]
# @RELATION BINDS_TO -> [install_custom_ca]
# @RATIONALE Using asyncio.start_server with ssl.SSLContext instead of openssl s_server
# because openssl s_server does not reliably send intermediate CA certs in the chain
# on OpenSSL 3.x. Python's ssl.SSLContext.load_cert_chain() properly sends the full
# chain from a concatenated cert file, and this is also the production code path.
def test_asyncio_server_encrypted_key_ok(self, ca_chain, install_custom_ca):
"""Full TLS handshake with encrypted key + correct passphrase using Python asyncio server."""
import asyncio
import socket
import tempfile
from pathlib import Path
ca_verify_path = install_custom_ca["ca_verify_path"]
async def _test():
with tempfile.TemporaryDirectory() as tmpdir:
# Write fullchain (server cert + intermediate) and encrypted key
cert_path = Path(tmpdir) / "fullchain.pem"
key_path = Path(tmpdir) / "server.key"
cert_path.write_text(ca_chain["fullchain"])
key_path.write_text(ca_chain["server_key_encrypted"])
# Find a free port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
# Create SSL context with encrypted key + passphrase
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(
certfile=str(cert_path),
keyfile=str(key_path),
password=ca_chain["server_key_passphrase"],
)
# Simple responder handler
async def handle(reader, writer):
try:
await reader.read(1024)
writer.write(b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nOK")
await writer.drain()
finally:
writer.close()
# Start the server with async context manager
server = await asyncio.start_server(
handle,
host="127.0.0.1",
port=port,
ssl=server_ctx,
)
async with server:
# Create client SSL context with system CA (install_custom_ca)
client_ctx = ssl.create_default_context(capath=ca_verify_path)
# Connect and verify TLS handshake
reader, writer = await asyncio.open_connection(
"127.0.0.1",
port,
ssl=client_ctx,
)
writer.write(b"GET / HTTP/1.0\r\n\r\n")
await writer.drain()
response = await reader.read(2048)
assert b"200 OK" in response, f"Expected '200 OK' in response, got: {response[:200]}"
writer.close()
asyncio.run(_test())
# #endregion test_asyncio_server_encrypted_key_ok
# #region test_openssl_key_wrong_passphrase_fails [C:2] [TYPE Function]
# @BRIEF openssl rsa with wrong passphrase exits non-zero.
# @POST openssl exits with non-zero code.
# @RELATION BINDS_TO -> [ca_chain]
def test_openssl_key_wrong_passphrase_fails(self, ca_chain):
"""openssl rsa -check with wrong passphrase must exit non-zero."""
import subprocess
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmpdir:
key_path = Path(tmpdir) / "server.key"
key_path.write_text(ca_chain["server_key_encrypted"])
result = subprocess.run(
[
"openssl",
"rsa",
"-in",
str(key_path),
"-check",
"-passin",
"pass:wrong-passphrase-99999",
],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode != 0, f"Expected openssl rsa to fail with wrong passphrase, but it exited with code 0.\nstderr: {result.stderr[:2000]}"
# #endregion test_openssl_key_wrong_passphrase_fails
# #region test_openssl_key_no_passphrase_fails [C:2] [TYPE Function]
# @BRIEF openssl rsa without -passin on encrypted key exits non-zero.
# @POST openssl exits with non-zero code.
# @RELATION BINDS_TO -> [ca_chain]
def test_openssl_key_no_passphrase_fails(self, ca_chain):
"""openssl rsa -check without -passin on encrypted key must exit non-zero."""
import subprocess
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmpdir:
key_path = Path(tmpdir) / "server.key"
key_path.write_text(ca_chain["server_key_encrypted"])
# Without -passin, openssl prompts for password on stdin.
# Use stdin=DEVNULL to simulate EOF — openssl will fail.
result = subprocess.run(
[
"openssl",
"rsa",
"-in",
str(key_path),
"-check",
],
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
assert result.returncode != 0, f"Expected openssl rsa to fail without passphrase, but it exited with code 0.\nstderr: {result.stderr[:2000]}"
# #endregion test_openssl_key_no_passphrase_fails
# #endregion TestEncryptedPrivateKey
# #endregion TestSupersetTlsCustomCA