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:
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user