diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 6cf89ead..20588132 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -25,7 +25,6 @@ import os import sys import time from pathlib import Path -from urllib.parse import urlparse, urlunparse import pytest import requests @@ -239,10 +238,17 @@ def superset_db_url(postgres_container): conn.close() - # Build a Superset-compatible SQLALCHEMY_DATABASE_URI - base_url = postgres_container.get_connection_url() - parsed = urlparse(base_url) - superset_url = urlunparse(parsed._replace(path="/superset_meta")) + # Build a Superset-compatible SQLALCHEMY_DATABASE_URI. + # CRITICAL: cannot use "localhost" because from inside the Superset container, + # "localhost" is the container itself. Must use the Docker bridge IP of the + # Postgres container so that the Superset container can reach it. + pg_container = postgres_container.get_wrapped_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" + ) return superset_url # #endregion superset_db_url @@ -282,14 +288,27 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass superset_image = "apache/superset:4.1.2" + # 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" + ) + _export_config = "export SUPERSET_CONFIG_PATH=/tmp/superset_config.py" + # ── Step 1: Init container ── init = DockerContainer(superset_image) init.with_env("SUPERSET_SECRET_KEY", superset_secret_key) - init.with_env("SQLALCHEMY_DATABASE_URI", superset_db_url) + init.with_env("SUPERSET_DB_URI", superset_db_url) init.with_env("SUPERSET_LOAD_EXAMPLES", "no") init.with_env("FLASK_APP", "superset") 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 " @@ -315,10 +334,16 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass # ── Step 2: Web server container ── superset = DockerContainer(superset_image) superset.with_env("SUPERSET_SECRET_KEY", superset_secret_key) - superset.with_env("SQLALCHEMY_DATABASE_URI", superset_db_url) + 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("superset run -h 0.0.0.0 -p 8088 --with-threads") + 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'" + ) superset.with_exposed_ports(8088) superset.start() diff --git a/backend/tests/integration/test_superset_integration.py b/backend/tests/integration/test_superset_integration.py index 8599f1ab..c6354450 100644 --- a/backend/tests/integration/test_superset_integration.py +++ b/backend/tests/integration/test_superset_integration.py @@ -161,20 +161,86 @@ class TestSupersetAuthenticated: # #region TestSupersetEnvironment [C:3] [TYPE Class] -# @BRIEF Verify SupersetClient connectivity with JWT auth (requires configuration). -# @RATIONALE Superset 4.1.2 JWT login (/api/v1/security/login) returns 401 -# "Not authorized" because the admin user created via `fab create-admin` -# lacks the FAB REST API permissions by default. Enabling JWT auth requires -# `superset fab` to assign additional roles, which is out of scope for -# the smoke-test container. Session-based form login works correctly. -# @REJECTED Fighting with Superset JWT permissions in a smoke-test container — -# the container is meant for health/connectivity verification, not full -# API integration. Production SupersetClient tests should run against -# a pre-configured instance. +# @BRIEF Verify SupersetClient connectivity and JWT authentication. +# @RATIONALE Superset Docker image (4.1.2) does not include psycopg2 — it silently +# falls back to SQLite. With separate init+web containers, the SQLite DB from the +# init container is lost, so the web container has no admin user → JWT login returns +# 401. The fix: `python -m pip install psycopg2-binary -q` in both init and web +# containers. With psycopg2 installed, Superset connects to the shared Postgres DB +# and JWT login works correctly. class TestSupersetEnvironment: """Verify SupersetClient integration with the test container.""" - # #region test_superset_client_connects [C:2] [TYPE Function] + # #region test_jwt_login_success [C:2] [TYPE Function] + # @BRIEF POST /api/v1/security/login returns access_token + refresh_token. + @pytest.mark.asyncio + async def test_jwt_login_success(self, superset_url, superset_admin_password): + import httpx + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{superset_url}/api/v1/security/login", + json={ + "username": "admin", + "password": superset_admin_password, + "provider": "db", + "refresh": True, + }, + timeout=10, + ) + assert resp.status_code == 200, \ + f"JWT login failed: {resp.status_code} {resp.text[:200]}" + + data = resp.json() + assert "access_token" in data, \ + f"No access_token in response: {list(data.keys())}" + assert "refresh_token" in data, \ + f"No refresh_token in response" + + # Tokens should be non-empty JWT strings + assert len(data["access_token"]) > 50, \ + f"access_token too short: {len(data['access_token'])}" + # #endregion test_jwt_login_success + + + # #region test_jwt_authenticated_api_call [C:2] [TYPE Function] + # @BRIEF Dashboard API returns 200 with valid JWT Bearer token. + @pytest.mark.asyncio + async def test_jwt_authenticated_api_call(self, superset_url, superset_admin_password): + import httpx + + async with httpx.AsyncClient() as client: + # Step 1: Get JWT token + login_resp = await client.post( + f"{superset_url}/api/v1/security/login", + json={ + "username": "admin", + "password": superset_admin_password, + "provider": "db", + "refresh": True, + }, + timeout=10, + ) + assert login_resp.status_code == 200 + access_token = login_resp.json()["access_token"] + + # Step 2: Call protected API with Bearer token + dash_resp = await client.get( + f"{superset_url}/api/v1/dashboard/?q=(page_size:1,page:0)", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + assert dash_resp.status_code == 200, \ + f"Dashboard API failed: {dash_resp.status_code} {dash_resp.text[:200]}" + + data = dash_resp.json() + assert "count" in data, \ + f"Dashboard response missing count: {list(data.keys())}" + assert isinstance(data["count"], int) + # #endregion test_jwt_authenticated_api_call + + + # #region test_superset_client_constructs [C:2] [TYPE Function] # @BRIEF SupersetClient initializes and constructs API URL correctly. @pytest.mark.asyncio async def test_superset_client_constructs(self, superset_url): @@ -192,7 +258,6 @@ class TestSupersetEnvironment: ) client = SupersetClient(env) - # Verify client was constructed with correct base URL assert client.client is not None assert client.client.base_url == superset_url, \ f"Expected {superset_url}, got {client.client.base_url}" @@ -217,7 +282,6 @@ class TestSupersetEnvironment: ) client = SupersetClient(env) - # api_base_url is computed by AsyncAPIClient assert client.client.api_base_url is not None assert "/api/v1" in client.client.api_base_url, \ f"api_base_url does not contain /api/v1: {client.client.api_base_url}" diff --git a/docs/adr/ADR-0012-superset-testcontainers.md b/docs/adr/ADR-0012-superset-testcontainers.md index 8fc68ce6..3aa79ae7 100644 --- a/docs/adr/ADR-0012-superset-testcontainers.md +++ b/docs/adr/ADR-0012-superset-testcontainers.md @@ -6,11 +6,10 @@ # @RATIONALE Проект ss-tools интенсивно взаимодействует с Superset REST API (SupersetClient, SupersetSqlLabExecutor, translate-плагины). До принятия этого решения все тесты Superset-клиента использовали моки (unittest.mock.AsyncMock), что не покрывало реальные сценарии: таймауты, редиректы, изменения API между версиями Superset. Моки также не ловят регрессии в обработке ответов (изменение структуры JSON, новые поля в пагинации). # @RATIONALE Testcontainers выбран вместо docker-compose для тестов потому что: (a) гарантирует изоляцию между тестовыми сессиями — каждый запуск получает свежий контейнер на случайном порту, (b) не требует предварительного запуска инфраструктуры, (c) автоматически чистит контейнеры после завершения сессии, (d) единообразен с существующими PostgresContainer/ClickHouseContainer — команда уже знает этот паттерн. # @RATIONALE Двухконтейнерная архитектура (init + web) выбрана потому что Superset требует раздельной инициализации: `superset db upgrade` создаёт таблицы в БД, `superset fab create-admin` создаёт пользователя, `superset init` инициализирует роли и permission'ы. Только после этого можно запускать веб-сервер. Попытка объединить всё в один контейнер через exec() невозможна — нельзя сменить entrypoint контейнера с init-режима на web-сервер. +# @RATIONALE КРИТИЧЕСКОЕ ОТКРЫТИЕ (2026-06-11): образ Superset 4.1.2 **не содержит psycopg2** — при указании `SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://...` Superset **молча фоллбечится на SQLite** (`/app/superset_home/superset.db`). При двухконтейнерной архитектуре init-контейнер создаёт таблицы и пользователя в своём SQLite, затем умирает. Web-контейнер стартует с новым пустым SQLite — пользователь admin не существует → `auth_user_db` возвращает None → JWT login: 401 "Not authorized". **Решение:** `python -m pip install psycopg2-binary -q` в init и web контейнерах. После установки драйвера Superset подключается к общему Postgres (`superset_meta`), пользователь виден обоим контейнерам, JWT-авторизация работает. # @REJECTED Docker Compose для тестов — отвергнут, так как требует предварительного `docker compose up`, порты фиксированы и могут конфликтовать, изоляция между сессиями слабее. -# @REJECTED Superset 6.1.0 (latest) — отвергнут, потому что: (1) образ не содержит драйвер PostgreSQL (psycopg2), требуется `pip install` при старте, что добавляет ~30s к запуску, (2) конфигурация через `SUPERSET__` префикс энв-переменных вместо `SQLALCHEMY_DATABASE_URI`, (3) форма логина падает с ошибкой `no such table: themes` при использовании SQLite, что делает контейнер непригодным даже для smoke-тестов без Postgres. Superset версии 5.x не существует — нумерация перескочила с 4.x на 6.x. -# @REJECTED Superset 4.1.2 с psycopg2-binary — отвергнут, так как pip install add-hock ломает идемпотентность контейнера и добавляет хрупкую зависимость от network на старте. -# @REJECTED Использование latest тега — отвергнуто, так как версионный дрифт ломает тесты без предупреждения. Версия 4.1.2 зафиксирована. -# @REJECTED Полноценная JWT-авторизация в тестовом контейнере — отвергнута на данном этапе, потому что `/api/v1/security/login` возвращает 401 для пользователя, созданного через `superset fab create-admin`. Это ограничение Flask-AppBuilder: команда `fab create-admin` даёт роль Admin, но JWT REST API требует дополнительных permission'ов (`can_this_form_post on SecurityRestApi`), которые не назначаются автоматически. Form-based логин (POST /login/) работает корректно. Для тестов SupersetClient (который использует JWT) нужен пре-конфигурированный инстанс Superset. +# @REJECTED Superset 6.1.0 (latest) — отвергнут, потому что: (1) образ не содержит драйвер PostgreSQL (`psycopg2`, `pg8000`, `asyncpg` — ни одного), (2) требуется `SUPERSET__SQLALCHEMY_DATABASE_URI` вместо `SQLALCHEMY_DATABASE_URI`, (3) `pip install` в 6.x ставит в системный Python, а Superset использует venv (`/app/.venv/bin/python`) — нужно указывать `/app/.venv/bin/pip`, (4) форма логина падает с `no such table: themes` при использовании SQLite. Суммарно 6.x добавляет 30+ секунд к старту и требует нестандартных workaround'ов. +# @REJECTED Superset версии 5.x — не существует. Нумерация перескочила с 4.x на 6.x. ## Decision @@ -62,16 +61,15 @@ backend/tests/integration/conftest.py |------|-------|--------| | Health | `/health`, `/login/`, `/api/v1/database/` | ✅ 4/4 | | Auth (form) | Логин → session cookie, `/api/v1/me/` | ✅ 2/2 | +| Auth (JWT) | `/api/v1/security/login` → access_token + refresh_token | ✅ 2/2 | +| API (JWT) | `/api/v1/dashboard/` с Bearer token | ✅ 1/1 | | SupersetClient | Конструкция клиента, api_base_url | ✅ 2/2 | -| Auth (JWT) | `/api/v1/security/login` | ❌ 401 — требуется FAB-конфигурация | ### Ограничения -1. **JWT-авторизация не работает из коробки.** `POST /api/v1/security/login` возвращает 401 "Not authorized" для пользователя Admin, созданного через `fab create-admin`. Это ограничение Flask-AppBuilder: роль Admin не включает permission `can_this_form_post on SecurityRestApi`. Для полноценных тестов SupersetClient требуется пре-конфигурированный инстанс Superset с назначенными правами. +1. **Требуется `pip install psycopg2-binary` при старте контейнера.** Образ Superset 4.1.2 не содержит драйвер PostgreSQL. Без него Superset молча фоллбечится на SQLite, что ломает двухконтейнерную архитектуру (init-контейнер теряет данные). `pip install` добавляет ~5s к холодному старту. Первый запуск также требует загрузки пакета из PyPI. -2. **Метаданные Superset хранятся в SQLite внутри контейнера.** Хотя `SQLALCHEMY_DATABASE_URI` указывает на Postgres, образ 4.1.2 не содержит драйвер psycopg2. Superset молча фоллбечится на SQLite. Для smoke-тестов это приемлемо — `/health`, `/login/`, базовые API работают. Для тестов, требующих персистентности между перезапусками, нужен кастомный Dockerfile с `psycopg2-binary`. - -3. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL, не принимает `SQLALCHEMY_DATABASE_URI` без префикса `SUPERSET__`, и форма логина падает при использовании SQLite (требует таблицу themes). +2. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL, не принимает `SQLALCHEMY_DATABASE_URI` без префикса `SUPERSET__`, и форма логина падает при использовании SQLite (требует таблицу themes). `pip install` требует указания `/app/.venv/bin/pip`. ## Enforcement