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 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()

View File

@@ -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}"

View File

@@ -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