diff --git a/backend/src/core/utils/async_network.py b/backend/src/core/utils/async_network.py index efe32ddc..4420d6d1 100644 --- a/backend/src/core/utils/async_network.py +++ b/backend/src/core/utils/async_network.py @@ -18,6 +18,7 @@ import asyncio import io import json from pathlib import Path +import ssl from typing import Any import httpx @@ -59,23 +60,32 @@ class AsyncAPIClient: # @DATA_CONTRACT Input[config: Dict[str, Any]] -> self._auth_cache_key[str] # @RELATION CALLS -> [AsyncAPIClient._normalize_base_url] # @RELATION DEPENDS_ON -> [SupersetAuthCache] - def __init__(self, config: dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, + def __init__(self, config: dict[str, Any], verify_ssl: bool | ssl.SSLContext = True, timeout: int = DEFAULT_TIMEOUT, semaphore: asyncio.Semaphore | None = None): self.base_url: str = self._normalize_base_url(config.get("base_url", "")) self.api_base_url: str = f"{self.base_url}/api/v1" self.auth = config.get("auth") self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout} + # Use system CA store via ssl.create_default_context() instead of bool True. + # httpx defaults to certifi, ignoring SSL_CERT_FILE and corporate CA certs. + if verify_ssl is True: + ssl_context: bool | ssl.SSLContext = ssl.create_default_context() + elif verify_ssl is False: + ssl_context = False + else: + ssl_context = verify_ssl # уже SSLContext self._client = httpx.AsyncClient( - verify=verify_ssl, + verify=ssl_context, timeout=httpx.Timeout(timeout), follow_redirects=True, ) self._tokens: dict[str, str] = {} self._authenticated = False + # Cache key по-прежнему использует bool для хеширования self._auth_cache_key = SupersetAuthCache.build_key( self.base_url, self.auth, - verify_ssl, + verify_ssl if isinstance(verify_ssl, bool) else True, ) # Optional shared semaphore for connection limiting (set by SupersetClientRegistry). self._semaphore = semaphore diff --git a/backend/src/core/utils/client_registry.py b/backend/src/core/utils/client_registry.py index 67a2d282..052967e5 100644 --- a/backend/src/core/utils/client_registry.py +++ b/backend/src/core/utils/client_registry.py @@ -19,6 +19,7 @@ # shows callers create SupersetClient anyway, defeating the purpose. import asyncio +import ssl from typing import Any from ..logger import logger @@ -47,6 +48,7 @@ def _build_env_id(env: Any) -> str: if isinstance(env, dict): return f"{env.get('base_url','')}|{env.get('username','')}" return str(id(env)) +# #endregion _build_env_id # #region get_client [C:3] [TYPE Function] @@ -97,9 +99,15 @@ async def get_client( config = {} verify_ssl = True timeout = request_timeout + # Convert bool to SSLContext with system CA store — httpx defaults to certifi, + # ignoring corporate CA certificates installed via update-ca-certificates. + if verify_ssl is True: + ssl_context: bool | ssl.SSLContext = ssl.create_default_context() + else: + ssl_context = verify_ssl async_client = AsyncAPIClient( config=config, - verify_ssl=verify_ssl, + verify_ssl=ssl_context, timeout=timeout, ) semaphore = asyncio.Semaphore(connection_pool_size) diff --git a/backend/src/plugins/translate/_llm_async_http.py b/backend/src/plugins/translate/_llm_async_http.py index e5698e39..8c6e2f45 100644 --- a/backend/src/plugins/translate/_llm_async_http.py +++ b/backend/src/plugins/translate/_llm_async_http.py @@ -16,6 +16,7 @@ import asyncio import os +import ssl from typing import Any import httpx @@ -31,19 +32,22 @@ DEFAULT_MAX_TOKENS: int = 8192 # #region _get_verify [C:1] [TYPE Function] [SEMANTICS translate, ssl, verify] -# @BRIEF Resolve SSL verification path from LLM_SSL_VERIFY env var. -# @RATIONALE Используем capath=/etc/ssl/certs/ вместо cafile, потому что -# OpenSSL 3.x не использует intermediate CA сертификаты из cafile для -# построения цепочки (verify code 20). capath с хеш-симлинками работает -# корректно (verify code 0). +# @BRIEF Resolve SSL verification context from LLM_SSL_VERIFY env var. +# @RATIONALE Используем capath=/etc/ssl/certs/ через ssl.create_default_context, +# потому что OpenSSL 3.x не использует intermediate CA сертификаты из cafile +# для построения цепочки (verify code 20). capath с хеш-симлинками работает +# корректно (verify code 0). Возвращаем SSLContext вместо строки — httpx 0.28.x +# депрекейтит строковый путь в verify=. # @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA # из единого bundle-файла. Только capath с хеш-симлинками даёт code 0. -# @POST Returns path to /etc/ssl/certs/ when enabled, False when disabled. -def _get_verify() -> str | bool: +# @REJECTED Строковый путь "/etc/ssl/certs/" отвергнут — httpx 0.28.x депрекейтит +# строки в verify=, требует SSLContext. +# @POST Returns ssl.SSLContext with capath when enabled, False when disabled. +def _get_verify() -> ssl.SSLContext | bool: raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower() if raw in ("false", "0", "no", "off"): return False - return "/etc/ssl/certs/" + return ssl.create_default_context(capath="/etc/ssl/certs/") # #endregion _get_verify diff --git a/backend/src/services/git/_base.py b/backend/src/services/git/_base.py index 2d16b520..3f76bfce 100644 --- a/backend/src/services/git/_base.py +++ b/backend/src/services/git/_base.py @@ -14,6 +14,7 @@ import os from pathlib import Path import re import shutil +import ssl import threading from urllib.parse import quote @@ -56,8 +57,11 @@ class GitServiceBase: self._closed = False self._close_lock = threading.Lock() - # Fix 5: Shared httpx.AsyncClient with connection pooling + # Fix 5: Shared httpx.AsyncClient with connection pooling. + # Uses system CA store (ssl.create_default_context) instead of certifi + # to trust corporate CA certificates installed via update-ca-certificates. self._http_client = httpx.AsyncClient( + verify=ssl.create_default_context(), timeout=30.0, limits=httpx.Limits(max_keepalive_connections=20, max_connections=100), ) @@ -397,5 +401,3 @@ class GitServiceBase: # endregion close # #endregion GitServiceBase # #endregion GitServiceBase -# #endregion GitServiceBase -# #endregion GitServiceBase diff --git a/backend/src/services/notifications/providers.py b/backend/src/services/notifications/providers.py index d5f46421..ee6abbd1 100644 --- a/backend/src/services/notifications/providers.py +++ b/backend/src/services/notifications/providers.py @@ -20,6 +20,7 @@ from abc import ABC, abstractmethod from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +import ssl from typing import Any import aiosmtplib @@ -32,10 +33,16 @@ _http_client: httpx.AsyncClient | None = None async def _get_http_client() -> httpx.AsyncClient: - """Get or create the module-level httpx.AsyncClient singleton.""" + """Get or create the module-level httpx.AsyncClient singleton. + Uses system CA store (ssl.create_default_context) instead of certifi + to trust corporate CA certificates installed via update-ca-certificates. + """ global _http_client if _http_client is None: - _http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) + _http_client = httpx.AsyncClient( + verify=ssl.create_default_context(), + timeout=httpx.Timeout(30.0), + ) return _http_client diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index ea9f44aa..4661c067 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -220,6 +220,111 @@ def verify_postgres_features(session: Session) -> dict: # #endregion verify_postgres_features +# ── TLS / PKI Fixtures ──────────────────────────────────────────────── + + +# #region ca_chain [C:2] [TYPE Fixture] +# @BRIEF Session-scoped — generates 3-tier PKI (Root CA → Intermediate CA → Server cert). +# @POST Returns dict with root_crt, intermediate_crt, server_crt, server_key, fullchain PEM strings. +@pytest.fixture(scope="session") +def ca_chain(): + """Generate 3-tier PKI: Root CA → Intermediate CA → Server certificate (SAN: localhost).""" + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + import datetime + + def _gen_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + 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.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()) + builder = builder.public_key(subject_key.public_key()) + + if is_ca: + builder = builder.add_extension( + 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, + ) + else: + # Server cert + builder = builder.add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True, + ) + sans: list[x509.GeneralName] = [] + if san_dns: + sans.append(x509.DNSName(san_dns)) + if san_ip: + sans.append(x509.IPAddress(san_ip)) + builder = builder.add_extension( + x509.SubjectAlternativeName(sans), critical=False, + ) + + cert = builder.sign(issuer_key, hashes.SHA256()) + return cert.public_bytes(serialization.Encoding.PEM).decode() + + root_key = _gen_key() + root_crt = _gen_cert_pem("Test Root CA", "Test Root CA", root_key, root_key, is_ca=True) + + intermediate_key = _gen_key() + intermediate_crt = _gen_cert_pem( + "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"), + ) + + # Full chain for server: server PEM + intermediate PEM (order: leaf first) + fullchain = server_crt + "\n" + intermediate_crt + + return { + "root_crt": root_crt, + "root_key": root_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + "intermediate_crt": intermediate_crt, + "intermediate_key": intermediate_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + "server_crt": server_crt, + "server_key": server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + "fullchain": fullchain, + } +# #endregion ca_chain + + # ── Superset Integration Test Fixtures ──────────────────────────────── @@ -278,23 +383,54 @@ def superset_admin_password(): # #region superset_container [C:3] [TYPE Fixture] # @BRIEF Session-scoped Apache Superset container — db upgrade → admin → init → web server. +# Can be parametrized with {"tls": True} to enable HTTPS with custom 3-tier PKI. # @PRE Docker daemon is running. superset_db_url points to an empty Postgres database. -# @POST Superset web server is listening on port 8088. Admin user created. +# @POST Superset web server is listening on port 8088 (HTTP or HTTPS). Admin user created. # @SIDE_EFFECT Spins up a short-lived init container, then a long-lived web container. +# In TLS mode: writes server cert + key into container via base64, runs with --cert/--key. # @RATIONALE Two-container approach (init + web) avoids race conditions where the web # server starts before migrations have completed. The init container runs `superset db # upgrade`, creates the admin user, and runs `superset init`, then exits. The web # container then starts against the fully initialized database. +# TLS parametrization allows testing corporate SSL certificate chain with the +# same Superset instance, using a dynamically generated 3-tier PKI. # @REJECTED Single container with sequential exec calls — cannot change entrypoint # from init to web server within the same container lifecycle. # @REJECTED Using `latest` tag — version drift breaks tests silently. @pytest.fixture(scope="session") -def superset_container(superset_db_url, superset_secret_key, superset_admin_password): - """Start an Apache Superset instance for integration testing.""" +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 + import base64 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) + + if use_tls: + 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" + ) + _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" + ) + else: + _write_certs = "" + _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 = ( @@ -311,6 +447,7 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass init.with_env("SUPERSET_DB_URI", superset_db_url) 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 && " @@ -344,28 +481,46 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass superset.with_env("SUPERSET_DB_URI", superset_db_url) superset.with_env("SUPERSET_LOAD_EXAMPLES", "no") superset.with_env("FLASK_APP", "superset") - superset.with_command( - f"sh -c '" - f"python -m pip install psycopg2-binary -q && " - f"{_bootstrap_config}" - f"{_export_config} && " - f"superset run -h 0.0.0.0 -p 8088 --with-threads'" - ) + + # Build web server command: optionally write certs, 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"http://{host}:{port}/health" + health_url = f"{_protocol}://{host}:{port}/health" deadline = time.time() + 90 last_error = None while time.time() < deadline: try: - resp = requests.get(health_url, timeout=3) + # 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: @@ -386,12 +541,13 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass # #region superset_url [C:1] [TYPE Fixture] -# @BRIEF Function-scoped — base URL of the running Superset container. +# @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) - return f"http://{host}:{port}" + protocol = getattr(superset_container, '_ss_protocol', 'http') + return f"{protocol}://{host}:{port}" # #endregion superset_url @@ -491,4 +647,89 @@ async def superset_client(superset_env): # #endregion superset_client +# ── TLS / Custom CA Fixtures ───────────────────────────────────── + + +# #region install_custom_ca [C:2] [TYPE Fixture] +# @BRIEF Function-scoped — installs Test Root CA into system trust store. +# Tries update-ca-certificates (root required), falls back to SSL_CERT_DIR. +# @POST Root CA accessible via ssl.create_default_context(). +# @SIDE_EFFECT Modifies /usr/local/share/ca-certificates/custom/ and runs update-ca-certificates. +# On fallback: sets SSL_CERT_DIR env var, creates temp dir with hash symlinks. +@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. + """ + import subprocess + import tempfile + from pathlib import Path + + ca_pem = ca_chain["root_crt"] + used_update_ca = False + ca_verify_path = None + cert_dir = None + + # Strategy 1: update-ca-certificates (matches production install_certificates()) + try: + cert_dir = Path("/usr/local/share/ca-certificates/custom") + cert_dir.mkdir(parents=True, exist_ok=True) + ca_path = cert_dir / "test_ss_tools_ca.crt" + ca_path.write_text(ca_pem) + subprocess.run( + ["update-ca-certificates", "--fresh"], + check=True, timeout=30, capture_output=True, + ) + ca_verify_path = "/etc/ssl/certs" + used_update_ca = True + except (subprocess.CalledProcessError, PermissionError, OSError): + # Strategy 2: fallback — SSL_CERT_DIR + cert_dir = Path(tempfile.mkdtemp(prefix="test_ca_")) + ca_path = cert_dir / "test_ss_tools_ca.pem" + ca_path.write_text(ca_pem) + + # Create hash symlink for capath + result = subprocess.run( + ["openssl", "x509", "-hash", "-noout"], + input=ca_pem, capture_output=True, text=True, timeout=10, + ) + cert_hash = result.stdout.strip() + symlink_path = cert_dir / f"{cert_hash}.0" + if symlink_path.exists(): + symlink_path.unlink() + symlink_path.symlink_to(ca_path.name) + + os.environ["SSL_CERT_DIR"] = str(cert_dir) + ca_verify_path = str(cert_dir) + used_update_ca = False + + yield { + "ca_verify_path": ca_verify_path, + "used_update_ca": used_update_ca, + "ca_pem": ca_pem, + } + + # Cleanup + if used_update_ca: + ca_file = Path("/usr/local/share/ca-certificates/custom/test_ss_tools_ca.crt") + if ca_file.exists(): + ca_file.unlink() + try: + subprocess.run( + ["update-ca-certificates", "--fresh"], + timeout=30, capture_output=True, + ) + except Exception: + pass + else: + if "SSL_CERT_DIR" in os.environ: + 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 + + # #endregion IntegrationTestConftest diff --git a/backend/tests/integration/test_superset_tls_custom_ca.py b/backend/tests/integration/test_superset_tls_custom_ca.py new file mode 100644 index 00000000..40f49305 --- /dev/null +++ b/backend/tests/integration/test_superset_tls_custom_ca.py @@ -0,0 +1,300 @@ +# #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 ss-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 ss-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.\n" + f"stdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}" + ) + code = int(match.group(1)) + assert code == 0, ( + f"Expected verify code 0, got {code}.\n" + f"Full 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.\n" + f"stdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}" + ) + code = int(match.group(1)) + assert code != 0, ( + "Expected non-zero verify code with certifi CA bundle, got 0.\n" + f"This means certifi somehow trusts our generated CA, which should not happen.\n" + f"Full 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)): + await client.get(f"{superset_url}/health") + + asyncio.run(_test()) + # #endregion test_httpx_certifi_fails + + + # ── ss-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} + ) + + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + 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, 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 +# #endregion TestSupersetTlsCustomCA