# #region TestBackendContainer [C:3] [TYPE Module] [SEMANTICS test,integration,backend,container,docker,entrypoint,certs] # @BRIEF Integration tests for the superset-tools-backend Docker image: # verifies entrypoint + certs.sh work correctly inside the container. # @RELATION BINDS_TO -> [docker/backend.Dockerfile] # @RELATION BINDS_TO -> [docker/certs.sh] # @RELATION BINDS_TO -> [docker/backend.entrypoint.sh] # @RELATION CALLS -> [TestSupersetTlsCustomCA] # @RATIONALE In 0.5.0 release, backend entrypoint crashed with # "/app/entrypoint.sh: line 264: /app/certs.sh: No such file or directory" # because docker/backend.Dockerfile was missing COPY for certs.sh. # Existing testcontainers tests only exercise apache/superset containers, # not the superset-tools backend container. These tests close that gap. # @TEST_CONTRACT BackendImageCertPaths -> # /app/certs.sh exists and is readable # source /app/certs.sh defines install_all_certs, install_ca_to_nss, normalize_cert # /app/entrypoint.sh exists and is executable # @TEST_CONTRACT BackendContainerSmoke -> # docker compose up backend starts successfully # /api/health/summary responds 200 # Container logs do NOT contain "certs.sh: No such file or directory" # Container logs do NOT contain "Traceback" or unrecoverable startup error # @REQUIRES Docker daemon running. --run-integration flag. """Integration tests for superset-tools-backend Docker image. Two test suites: 1. **TestBackendImageCertsSh** — fast (no DB, no compose). Builds the backend Docker image, runs a container with --entrypoint bash, and verifies certs.sh is present, sourceable, and all certificate installation functions are defined. 2. **TestBackendContainerSmoke** — full stack (needs PostgreSQL). Starts PostgreSQL via testcontainers, deploys backend via docker compose, waits for the health endpoint, and checks container logs for startup errors. """ import os import re import subprocess import time from pathlib import Path import pytest # ── Project paths ──────────────────────────────────────────────────────── PROJECT_ROOT = Path(__file__).resolve().parents[3] BACKEND_IMAGE = "superset-tools-backend:test-integration" # ── Fixtures ────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") def _backend_image(): """Build superset-tools-backend:test-integration once per module.""" # Rebuild if Dockerfile, entrypoint, or cert logic changed. dockerfile = PROJECT_ROOT / "docker" / "backend.Dockerfile" certs_sh = PROJECT_ROOT / "docker" / "certs.sh" entrypoint = PROJECT_ROOT / "docker" / "backend.entrypoint.sh" image_marker = PROJECT_ROOT / "backend" / ".image-built" watched_mtime = max(p.stat().st_mtime for p in (dockerfile, certs_sh, entrypoint)) if not image_marker.exists() or image_marker.stat().st_mtime < watched_mtime: subprocess.run( ["docker", "build", "-f", str(dockerfile), "-t", BACKEND_IMAGE, str(PROJECT_ROOT)], check=True, capture_output=True, text=True, timeout=300, ) image_marker.touch() return BACKEND_IMAGE # ══════════════════════════════════════════════════════════════════════════ # TEST 1: Fast — verify certs.sh presence and basic functionality # ══════════════════════════════════════════════════════════════════════════ class TestBackendImageCertPaths: """Fast checks: certs.sh exists, is sourceable, defines all functions. These tests run in ~10s without PostgreSQL or docker compose. They catch the 'certs.sh: No such file or directory' failure mode. """ @pytest.fixture(autouse=True) def _image(self, _backend_image): pass # ── helpers ───────────────────────────────────────────────────── @staticmethod def _run_bash(command: str) -> subprocess.CompletedProcess: return subprocess.run( ["docker", "run", "--rm", "--entrypoint", "bash", BACKEND_IMAGE, "-c", command], capture_output=True, text=True, timeout=30, ) # ── tests ─────────────────────────────────────────────────────── # #region test_certs_sh_exists [C:1] [TYPE Function] [SEMANTICS test,integration,certs] # @BRIEF /app/certs.sh must exist and be readable. def test_certs_sh_exists(self): r = self._run_bash("test -f /app/certs.sh && test -r /app/certs.sh") assert r.returncode == 0, f"File exists: {r.returncode == 0}, stderr: {r.stderr[:200]}" # #endregion test_certs_sh_exists # #region test_entrypoint_sh_exists [C:1] [TYPE Function] [SEMANTICS test,integration,entrypoint] # @BRIEF /app/entrypoint.sh must exist and be executable. def test_entrypoint_sh_exists(self): r = self._run_bash("test -f /app/entrypoint.sh && test -x /app/entrypoint.sh") assert r.returncode == 0, f"File: /app/entrypoint.sh, stderr: {r.stderr[:200]}" # #endregion test_entrypoint_sh_exists # #region test_certs_sh_source_defines_functions [C:2] [TYPE Function] [SEMANTICS test,integration,certs] # @BRIEF sourcing /app/certs.sh defines install_all_certs, install_ca_to_nss, normalize_cert. def test_certs_sh_source_defines_functions(self): r = self._run_bash("source /app/certs.sh && declare -F install_all_certs >/dev/null 2>&1 && echo -n 'install_all_certs '; declare -F install_ca_to_nss >/dev/null 2>&1 && echo -n 'install_ca_to_nss '; declare -F normalize_cert >/dev/null 2>&1 && echo -n 'normalize_cert'") assert "install_all_certs" in r.stdout, f"install_all_certs not defined:\n{r.stderr}" assert "install_ca_to_nss" in r.stdout, f"install_ca_to_nss not defined:\n{r.stderr}" assert "normalize_cert" in r.stdout, f"normalize_cert not defined:\n{r.stderr}" # #endregion test_certs_sh_source_defines_functions # #region test_entrypoint_sources_certs_sh_ok [C:2] [TYPE Function] [SEMANTICS test,integration,entrypoint] # @BRIEF Running the real entrypoint script (without DB) must not crash on source certs.sh. # We pass a short DB wait timeout so it fails fast instead of retrying 30 times. # The certs.sh sourcing happens BEFORE the DB wait loop. def test_entrypoint_sources_certs_sh_ok(self): r = subprocess.run( [ "docker", "run", "--rm", "-e", "POSTGRES_HOST=does-not-exist", "-e", "POSTGRES_USER=test", "-e", "POSTGRES_PASSWORD=test", "-e", "POSTGRES_DB=test", # Reduce wait time — if certs.sh source succeeds, the error # will be about DB connection, not certs.sh "--entrypoint", "bash", BACKEND_IMAGE, "-c", "timeout 5 /app/entrypoint.sh 2>&1 || true", ], capture_output=True, text=True, timeout=15, ) # The certs.sh source comes BEFORE DB wait. # If certs.sh is missing, the output MUST contain "No such file or directory". # If certs.sh loads OK, the output should show DB connection error OR cert install. assert re.search(r"No such file or directory.*certs\.sh|certs\.sh.*No such file or directory", r.stdout + r.stderr) is None, f"certs.sh not found! Entrypoint tried to source missing file:\nSTDOUT:\n{r.stdout[:2000]}\nSTDERR:\n{r.stderr[:2000]}" # #endregion test_entrypoint_sources_certs_sh_ok # ══════════════════════════════════════════════════════════════════════════ # TEST 2: Full stack — docker compose up backend + health check # ══════════════════════════════════════════════════════════════════════════ @pytest.mark.skipif( not os.environ.get("E2E_BACKEND_CONTAINER"), reason="Skipped by default — set E2E_BACKEND_CONTAINER=1 to run full-stack test", ) @pytest.mark.usefixtures("_backend_image") class TestBackendContainerSmoke: """Full-stack smoke test: deploy backend container via docker compose with PostgreSQL, wait for health endpoint, verify container logs. Requires: set E2E_BACKEND_CONTAINER=1 (takes 30-60s, needs PostgreSQL). """ # #region test_backend_health [C:3] [TYPE Function] [SEMANTICS test,integration,smoke] # @BRIEF docker run (host network) backend with testcontainers PostgreSQL, # wait for health, check container logs for startup errors. # @PRE Docker daemon running. Port 8000 must be free on the host. # @POST Backend health endpoint responds 200. Container logs show no certs.sh errors. # @SIDE_EFFECT Starts/stops PostgreSQL and backend Docker containers. def test_backend_health(self): import time as time_module import urllib.request import urllib.error # ── Use host network mode so backend can reach PG on localhost ── from testcontainers.postgres import PostgresContainer pg = PostgresContainer( "postgres:16-alpine", dbname="ss_tools", username="postgres", password="postgres", ) pg.start() pg_port = pg.get_exposed_port(5432) # ── Start backend container (host network) ── container_name = f"ss-tools-integration-backend-{os.urandom(4).hex()}" backend_port = "8000" db_url = f"postgresql://postgres:postgres@127.0.0.1:{pg_port}/ss_tools" run_cmd = [ "docker", "run", "-d", "--name", container_name, "--network", "host", "-e", f"DATABASE_URL={db_url}", "-e", "INITIAL_ADMIN_CREATE=true", "-e", "INITIAL_ADMIN_USERNAME=admin", "-e", "INITIAL_ADMIN_PASSWORD=admin123", "-e", "INITIAL_ADMIN_EMAIL=admin@example.com", "-e", "ENABLE_BELIEF_STATE_LOGGING=true", "-e", "TASK_LOG_LEVEL=ERROR", "-e", "CERTS_PATH=/opt/certs", BACKEND_IMAGE, ] r = subprocess.run(run_cmd, capture_output=True, text=True, timeout=30) if r.returncode != 0: pg.stop() pytest.fail(f"docker run failed:\nSTDERR: {r.stderr[:2000]}") container_id = r.stdout.strip() container_removed = False def _cleanup(rm_container=True): nonlocal container_removed if rm_container and not container_removed: subprocess.run(["docker", "rm", "-f", container_id], capture_output=True, timeout=10) container_removed = True try: pg.stop() except Exception: pass try: # ── Wait for health ── health_url = f"http://127.0.0.1:{backend_port}/api/health/summary" deadline = time_module.time() + 60 last_error = "" while time_module.time() < deadline: try: hr = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "2", "--max-time", "4", health_url], capture_output=True, text=True, timeout=10, ) http_code = hr.stdout.strip() if http_code in ("200", "401", "403"): break last_error = f"HTTP {http_code}" except subprocess.TimeoutExpired: last_error = "curl timeout" except Exception as e: last_error = str(e) time_module.sleep(2) else: # Timeout — dump container logs logs_r = subprocess.run( ["docker", "logs", container_id, "--tail", "60"], capture_output=True, text=True, timeout=10, ) full_log = logs_r.stdout[:5000] + logs_r.stderr[:2000] _cleanup(rm_container=True) pytest.fail(f"Backend health check failed after 60s.\nLast error: {last_error}\nContainer logs:\n{full_log}") # ── Check container logs for startup errors ── logs_r = subprocess.run( ["docker", "logs", container_id], capture_output=True, text=True, timeout=10, ) combined_logs = logs_r.stdout + logs_r.stderr if re.search(r"No such file or directory.*certs\.sh|certs\.sh.*No such file", combined_logs): _cleanup(rm_container=True) pytest.fail(f"certs.sh not found! Entrypoint crashed:\n{combined_logs[:3000]}") if re.search(r"Traceback|Error.*certs\.sh", combined_logs): _cleanup(rm_container=True) pytest.fail(f"Entrypoint crash detected:\n{combined_logs[:3000]}") finally: _cleanup(rm_container=True) # #endregion test_backend_health # ══════════════════════════════════════════════════════════════════════════ # TEST 3: cert download — download_llm_ca_certs via HTTP # ══════════════════════════════════════════════════════════════════════════ class TestBackendImageCertDownload: """Verify download_llm_ca_certs downloads certs from HTTP URLs.""" @pytest.fixture(autouse=True) def _image(self, _backend_image): pass # #region _make_ca_cert [C:1] [TYPE Function] @staticmethod def _make_ca_cert(certs_dir: str, name: str = "test-download-ca") -> tuple[str, str]: ca_key = os.path.join(certs_dir, f"{name}.key") ca_crt = os.path.join(certs_dir, f"{name}.crt") subprocess.run( [ "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", ca_key, "-out", ca_crt, "-days", "1", "-subj", f"/CN={name}-for-container", ], check=True, capture_output=True, text=True, timeout=15, ) return ca_key, ca_crt # #endregion _make_ca_cert # #region _serve_dir [C:1] [TYPE Function] @staticmethod def _serve_dir(certs_dir: str): import http.server import socket import socketserver import threading with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) http_port = s.getsockname()[1] class _Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=certs_dir, **kwargs) httpd = socketserver.TCPServer(("127.0.0.1", http_port), _Handler) server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) server_thread.start() return httpd, http_port # #endregion _serve_dir # #region _run_download [C:1] [TYPE Function] @staticmethod def _run_download(urls: str, command_suffix: str = "") -> subprocess.CompletedProcess: command = "source /app/certs.sh && download_llm_ca_certs" if command_suffix: command = f"{command} && {command_suffix}" return subprocess.run( [ "docker", "run", "--rm", "--network", "host", "-e", f"LLM_CA_CERT_URLS={urls}", "--entrypoint", "bash", BACKEND_IMAGE, "-c", command, ], capture_output=True, text=True, timeout=45, ) # #endregion _run_download # #region test_download_ca_cert_over_http [C:3] [TYPE Function] [SEMANTICS test,integration,certs,download] # @BRIEF download_llm_ca_certs() fetches a PEM cert from an HTTP server # and installs it into /usr/local/share/ca-certificates/llm/. def test_download_ca_cert_over_http(self): import tempfile # ── Generate a test CA cert ── with tempfile.TemporaryDirectory() as certs_dir: self._make_ca_cert(certs_dir, "test-download-ca") httpd, http_port = self._serve_dir(certs_dir) cert_url = f"http://127.0.0.1:{http_port}/test-download-ca.crt" try: # ── Run download_llm_ca_certs in container (host network) ── r = self._run_download(cert_url, "echo 'DOWNLOAD_OK' && ls -la /usr/local/share/ca-certificates/llm/") assert r.returncode == 0, ( f"download_llm_ca_certs failed (exit {r.returncode}):\n" f"STDOUT: {r.stdout[:2000]}\nSTDERR: {r.stderr[:2000]}" ) # Verify download succeeded assert "DOWNLOAD_OK" in r.stdout, ( f"download_llm_ca_certs did not complete:\n{r.stdout[:2000]}" ) assert "Скачано и установлено: 1" in r.stdout, ( f"Expected 1 downloaded cert:\n{r.stdout[:2000]}" ) assert "test-download-ca.crt" in r.stdout, ( f"Cert file not found in llm/ dir:\n{r.stdout[:2000]}" ) finally: httpd.shutdown() # #endregion test_download_ca_cert_over_http # #region test_download_der_cert_converts_to_pem [C:2] [TYPE Function] [SEMANTICS test,integration,certs,download] # @BRIEF DER certificate downloaded over HTTP is converted to PEM .crt. def test_download_der_cert_converts_to_pem(self): import tempfile with tempfile.TemporaryDirectory() as certs_dir: _, ca_crt = self._make_ca_cert(certs_dir, "test-download-der") der_path = os.path.join(certs_dir, "test-download-der.der") subprocess.run( ["openssl", "x509", "-in", ca_crt, "-outform", "DER", "-out", der_path], check=True, capture_output=True, text=True, timeout=15, ) httpd, http_port = self._serve_dir(certs_dir) try: url = f"http://127.0.0.1:{http_port}/test-download-der.der" r = self._run_download(url, "grep -q 'BEGIN CERTIFICATE' /usr/local/share/ca-certificates/llm/test-download-der.crt && echo DER_OK") assert r.returncode == 0, f"DER download failed:\n{r.stdout}\n{r.stderr}" assert "DER_OK" in r.stdout assert "Формат: DER" in r.stdout finally: httpd.shutdown() # #endregion test_download_der_cert_converts_to_pem # #region test_download_skips_invalid_and_non_http [C:2] [TYPE Function] [SEMANTICS test,integration,certs,download] # @BRIEF Invalid content and HTTPS URLs are skipped without failing valid downloads. def test_download_skips_invalid_and_non_http(self): import tempfile with tempfile.TemporaryDirectory() as certs_dir: self._make_ca_cert(certs_dir, "valid-ca") Path(certs_dir, "not-a-cert.crt").write_text("not a certificate", encoding="utf-8") httpd, http_port = self._serve_dir(certs_dir) try: urls = " ".join([ f"http://127.0.0.1:{http_port}/valid-ca.crt", f"http://127.0.0.1:{http_port}/not-a-cert.crt", "https://example.invalid/blocked.crt", ]) r = self._run_download(urls, "ls -la /usr/local/share/ca-certificates/llm/") assert r.returncode == 0, f"Mixed download failed:\n{r.stdout}\n{r.stderr}" assert "Скачано и установлено: 1" in r.stdout assert "невалид" in r.stdout or "Неизвестный формат" in r.stdout assert "пропускаем не-HTTP URL" in r.stdout assert "valid-ca.crt" in r.stdout finally: httpd.shutdown() # #endregion test_download_skips_invalid_and_non_http # #region test_download_query_string_filename_and_nss_import [C:2] [TYPE Function] [SEMANTICS test,integration,certs,nss] # @BRIEF URL query strings are stripped from filenames and downloaded llm/ certs import into NSS. def test_download_query_string_filename_and_nss_import(self): import tempfile with tempfile.TemporaryDirectory() as certs_dir: self._make_ca_cert(certs_dir, "query-ca") httpd, http_port = self._serve_dir(certs_dir) try: url = f"http://127.0.0.1:{http_port}/query-ca.crt?token=abc" r = self._run_download( url, "install_all_certs && install_ca_to_nss && " "test -f /usr/local/share/ca-certificates/llm/query-ca.crt && " "certutil -L -d sql:${HOME}/.pki/nssdb | grep -q 'llm-query-ca' && echo QUERY_NSS_OK", ) assert r.returncode == 0, f"Query/NSS flow failed:\n{r.stdout}\n{r.stderr}" assert "QUERY_NSS_OK" in r.stdout finally: httpd.shutdown() # #endregion test_download_query_string_filename_and_nss_import # #region test_volume_certs_matrix_pem_der_cer_invalid_private [C:2] [TYPE Function] [SEMANTICS test,integration,certs,volume] # @BRIEF CERTS_PATH handles PEM/DER/CER and skips invalid/private/server files. def test_volume_certs_matrix_pem_der_cer_invalid_private(self): import tempfile with tempfile.TemporaryDirectory() as certs_dir: _, pem_crt = self._make_ca_cert(certs_dir, "volume-pem") _, der_src = self._make_ca_cert(certs_dir, "volume-der-src") der_cert = os.path.join(certs_dir, "volume-der.der") subprocess.run( ["openssl", "x509", "-in", der_src, "-outform", "DER", "-out", der_cert], check=True, capture_output=True, text=True, timeout=15, ) os.remove(der_src) _, cer_src = self._make_ca_cert(certs_dir, "volume-cer-src") os.rename(cer_src, os.path.join(certs_dir, "volume-cer.cer")) Path(certs_dir, "invalid.crt").write_text("not a certificate", encoding="utf-8") Path(certs_dir, "server.key").write_text("not a key", encoding="utf-8") Path(certs_dir, "server.crt").write_text(Path(pem_crt).read_text(encoding="utf-8"), encoding="utf-8") r = subprocess.run( [ "docker", "run", "--rm", "-v", f"{certs_dir}:/opt/certs:ro", "-e", "CERTS_PATH=/opt/certs", "--entrypoint", "bash", BACKEND_IMAGE, "-c", "source /app/certs.sh && install_all_certs && " "test -f /usr/local/share/ca-certificates/custom/volume-pem.crt && " "test -f /usr/local/share/ca-certificates/custom/volume-der.crt && " "test -f /usr/local/share/ca-certificates/custom/volume-cer.crt && " "test ! -f /usr/local/share/ca-certificates/custom/server.crt && " "grep -q 'BEGIN CERTIFICATE' /usr/local/share/ca-certificates/custom/volume-der.crt && " "echo VOLUME_MATRIX_OK", ], capture_output=True, text=True, timeout=45, ) assert r.returncode == 0, f"Volume matrix failed:\n{r.stdout}\n{r.stderr}" assert "VOLUME_MATRIX_OK" in r.stdout assert "Установлено: 3" in r.stdout assert "пропускаем: server.key" in r.stdout assert "пропускаем: server.crt" in r.stdout assert "невалидный сертификат: invalid.crt" in r.stdout # #endregion test_volume_certs_matrix_pem_der_cer_invalid_private # #region test_download_and_volume_combined_channels [C:2] [TYPE Function] [SEMANTICS test,integration,certs,combined] # @BRIEF LLM_CA_CERT_URLS and CERTS_PATH work together in one container startup flow. def test_download_and_volume_combined_channels(self): import tempfile with tempfile.TemporaryDirectory() as mount_dir, tempfile.TemporaryDirectory() as serve_dir: self._make_ca_cert(mount_dir, "mounted-ca") self._make_ca_cert(serve_dir, "downloaded-ca") httpd, http_port = self._serve_dir(serve_dir) try: url = f"http://127.0.0.1:{http_port}/downloaded-ca.crt" r = subprocess.run( [ "docker", "run", "--rm", "--network", "host", "-v", f"{mount_dir}:/opt/certs:ro", "-e", "CERTS_PATH=/opt/certs", "-e", f"LLM_CA_CERT_URLS={url}", "--entrypoint", "bash", BACKEND_IMAGE, "-c", "source /app/certs.sh && download_llm_ca_certs && install_all_certs && install_ca_to_nss && " "test -f /usr/local/share/ca-certificates/custom/mounted-ca.crt && " "test -f /usr/local/share/ca-certificates/llm/downloaded-ca.crt && " "certutil -L -d sql:${HOME}/.pki/nssdb | grep -q 'custom-mounted-ca' && " "certutil -L -d sql:${HOME}/.pki/nssdb | grep -q 'llm-downloaded-ca' && " "echo COMBINED_OK", ], capture_output=True, text=True, timeout=60, ) assert r.returncode == 0, f"Combined channels failed:\n{r.stdout}\n{r.stderr}" assert "COMBINED_OK" in r.stdout finally: httpd.shutdown() # #endregion test_download_and_volume_combined_channels # #region test_empty_inputs_graceful [C:2] [TYPE Function] [SEMANTICS test,integration,certs,empty] # @BRIEF Empty LLM_CA_CERT_URLS and missing CERTS_PATH are no-op success cases. def test_empty_inputs_graceful(self): r = subprocess.run( [ "docker", "run", "--rm", "-e", "LLM_CA_CERT_URLS=", "-e", "CERTS_PATH=/does-not-exist", "--entrypoint", "bash", BACKEND_IMAGE, "-c", "source /app/certs.sh && download_llm_ca_certs && install_all_certs && echo EMPTY_OK", ], capture_output=True, text=True, timeout=45, ) assert r.returncode == 0, f"Empty input path failed:\n{r.stdout}\n{r.stderr}" assert "EMPTY_OK" in r.stdout assert "CERTS_PATH=/does-not-exist не существует" in r.stdout # #endregion test_empty_inputs_graceful # #endregion TestBackendContainer