fix(superset): resolve JWT auth — install psycopg2 + superset_config.py

Root cause: Superset 4.1.2 ignores SQLALCHEMY_DATABASE_URI env var —
always falls back to SQLite. With separate init+web containers, the
init container's SQLite DB is lost → web container has no admin user
→ JWT login returns 401.

Fix:
- Install psycopg2-binary at container start (python -m pip install)
- Create /tmp/superset_config.py via heredoc that reads SUPERSET_DB_URI
- Set SUPERSET_CONFIG_PATH=/tmp/superset_config.py
- Use Docker bridge IP (not localhost) for Postgres from inside container

Now all 10 integration tests pass, including:
- test_jwt_login_success (access_token + refresh_token)
- test_jwt_authenticated_api_call (Bearer token → /api/v1/dashboard/)
- 4 health checks + 2 form-auth + 2 client construct
This commit is contained in:
2026-06-11 15:23:12 +03:00
parent 1c9c2c298e
commit d24745a01e
3 changed files with 117 additions and 30 deletions

View File

@@ -25,7 +25,6 @@ import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse, urlunparse
import pytest import pytest
import requests import requests
@@ -239,10 +238,17 @@ def superset_db_url(postgres_container):
conn.close() conn.close()
# Build a Superset-compatible SQLALCHEMY_DATABASE_URI # Build a Superset-compatible SQLALCHEMY_DATABASE_URI.
base_url = postgres_container.get_connection_url() # CRITICAL: cannot use "localhost" because from inside the Superset container,
parsed = urlparse(base_url) # "localhost" is the container itself. Must use the Docker bridge IP of the
superset_url = urlunparse(parsed._replace(path="/superset_meta")) # 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 return superset_url
# #endregion superset_db_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_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 ── # ── Step 1: Init container ──
init = DockerContainer(superset_image) init = DockerContainer(superset_image)
init.with_env("SUPERSET_SECRET_KEY", superset_secret_key) 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("SUPERSET_LOAD_EXAMPLES", "no")
init.with_env("FLASK_APP", "superset") init.with_env("FLASK_APP", "superset")
init.with_command( init.with_command(
f"sh -c '" f"sh -c '"
f"python -m pip install psycopg2-binary -q && "
f"{_bootstrap_config}"
f"{_export_config} && "
f"superset db upgrade && " f"superset db upgrade && "
f"superset fab create-admin " f"superset fab create-admin "
f" --username admin --firstname Admin --lastname User " 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 ── # ── Step 2: Web server container ──
superset = DockerContainer(superset_image) superset = DockerContainer(superset_image)
superset.with_env("SUPERSET_SECRET_KEY", superset_secret_key) 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("SUPERSET_LOAD_EXAMPLES", "no")
superset.with_env("FLASK_APP", "superset") 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.with_exposed_ports(8088)
superset.start() superset.start()

View File

@@ -161,20 +161,86 @@ class TestSupersetAuthenticated:
# #region TestSupersetEnvironment [C:3] [TYPE Class] # #region TestSupersetEnvironment [C:3] [TYPE Class]
# @BRIEF Verify SupersetClient connectivity with JWT auth (requires configuration). # @BRIEF Verify SupersetClient connectivity and JWT authentication.
# @RATIONALE Superset 4.1.2 JWT login (/api/v1/security/login) returns 401 # @RATIONALE Superset Docker image (4.1.2) does not include psycopg2 — it silently
# "Not authorized" because the admin user created via `fab create-admin` # falls back to SQLite. With separate init+web containers, the SQLite DB from the
# lacks the FAB REST API permissions by default. Enabling JWT auth requires # init container is lost, so the web container has no admin user → JWT login returns
# `superset fab` to assign additional roles, which is out of scope for # 401. The fix: `python -m pip install psycopg2-binary -q` in both init and web
# the smoke-test container. Session-based form login works correctly. # containers. With psycopg2 installed, Superset connects to the shared Postgres DB
# @REJECTED Fighting with Superset JWT permissions in a smoke-test container — # and JWT login works correctly.
# the container is meant for health/connectivity verification, not full
# API integration. Production SupersetClient tests should run against
# a pre-configured instance.
class TestSupersetEnvironment: class TestSupersetEnvironment:
"""Verify SupersetClient integration with the test container.""" """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. # @BRIEF SupersetClient initializes and constructs API URL correctly.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_superset_client_constructs(self, superset_url): async def test_superset_client_constructs(self, superset_url):
@@ -192,7 +258,6 @@ class TestSupersetEnvironment:
) )
client = SupersetClient(env) client = SupersetClient(env)
# Verify client was constructed with correct base URL
assert client.client is not None assert client.client is not None
assert client.client.base_url == superset_url, \ assert client.client.base_url == superset_url, \
f"Expected {superset_url}, got {client.client.base_url}" f"Expected {superset_url}, got {client.client.base_url}"
@@ -217,7 +282,6 @@ class TestSupersetEnvironment:
) )
client = SupersetClient(env) client = SupersetClient(env)
# api_base_url is computed by AsyncAPIClient
assert client.client.api_base_url is not None assert client.client.api_base_url is not None
assert "/api/v1" in client.client.api_base_url, \ assert "/api/v1" in client.client.api_base_url, \
f"api_base_url does not contain /api/v1: {client.client.api_base_url}" f"api_base_url does not contain /api/v1: {client.client.api_base_url}"

View File

@@ -6,11 +6,10 @@
# @RATIONALE Проект ss-tools интенсивно взаимодействует с Superset REST API (SupersetClient, SupersetSqlLabExecutor, translate-плагины). До принятия этого решения все тесты Superset-клиента использовали моки (unittest.mock.AsyncMock), что не покрывало реальные сценарии: таймауты, редиректы, изменения API между версиями Superset. Моки также не ловят регрессии в обработке ответов (изменение структуры JSON, новые поля в пагинации). # @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 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 Двухконтейнерная архитектура (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 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 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 4.1.2 с psycopg2-binary — отвергнут, так как pip install add-hock ломает идемпотентность контейнера и добавляет хрупкую зависимость от network на старте. # @REJECTED Superset версии 5.x — не существует. Нумерация перескочила с 4.x на 6.x.
# @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.
## Decision ## Decision
@@ -62,16 +61,15 @@ backend/tests/integration/conftest.py
|------|-------|--------| |------|-------|--------|
| Health | `/health`, `/login/`, `/api/v1/database/` | ✅ 4/4 | | Health | `/health`, `/login/`, `/api/v1/database/` | ✅ 4/4 |
| Auth (form) | Логин → session cookie, `/api/v1/me/` | ✅ 2/2 | | 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 | | 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`. 2. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL, не принимает `SQLALCHEMY_DATABASE_URI` без префикса `SUPERSET__`, и форма логина падает при использовании SQLite (требует таблицу themes). `pip install` требует указания `/app/.venv/bin/pip`.
3. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL, не принимает `SQLALCHEMY_DATABASE_URI` без префикса `SUPERSET__`, и форма логина падает при использовании SQLite (требует таблицу themes).
## Enforcement ## Enforcement