# [DEF:ADR-0012:ADR]
# @STATUS ACTIVE
# @PURPOSE Зафиксировать архитектурное решение об использовании Testcontainers для запуска Apache Superset в интеграционных тестах. Описать двухконтейнерную архитектуру (init + web), расследование проблем аутентификации, выбор версии 4.1.2, решение проблемы JWT-авторизации через psycopg2 + superset_config.py.
# @RELATION DEPENDS_ON -> [ADR-0005-auth-rbac]
# @RELATION DEPENDS_ON -> [ADR-0001-module-layout]
# @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-сервер.
# @REJECTED Docker Compose для тестов — отвергнут, так как требует предварительного `docker compose up`, порты фиксированы и могут конфликтовать, изоляция между сессиями слабее.
# @REJECTED Superset 6.1.0 (latest) — отвергнут. См. раздел «Расследование: почему не 6.x».
# @REJECTED Superset версии 5.x — не существует. Нумерация перескочила с 4.x на 6.x.

## Расследование: почему JWT-авторизация не работала

### Хронология отладки (2026-06-11)

**Шаг 1 — Симптом:** `POST /api/v1/security/login` → 401 `{"message":"Not authorized"}`
при правильных креденшелах (admin:admin123). Form-based логин (`POST /login/`) работал,
но `/api/v1/me/` с session cookie — 401.

**Шаг 2 — Ложная гипотеза (FAB permissions):** Проверено, что пользователь admin существует,
активен, пароль верен. `auth_user_db('admin', 'admin123')` возвращает пользователя.
Flask test client внутри контейнера успешно выполняет JWT login. Значит, проблема
не в permissions Flask-AppBuilder, а на уровне HTTP/сети.

**Шаг 3 — Ложная гипотеза (CSRF):** Проверен CSRF token из session cookie —
не помогает. Проверен `X-CSRFToken` header — не помогает.

**Шаг 4 — Ложная гипотеза (разные серверы):** Проверено, что `docker run -p`
(прямой Docker) работает, а testcontainers — нет. Разница оказалась в том,
что при `docker run -p` init и web — один контейнер, а у нас — два разных.

**Шаг 5 — Прорыв:** Проверка: существует ли пользователь admin в web-контейнере?
```
User found: False
NO USER
```
Web-контейнер НЕ ВИДИТ пользователя, созданного init-контейнером.

**Шаг 6 — Корневая причина:** Superset 4.1.2 **игнорирует** `SQLALCHEMY_DATABASE_URI`
из переменных окружения. Даже при установленном `SUPERSET__SQLALCHEMY_DATABASE_URI`
(с префиксом) конфиг всегда возвращает:
```
SQLALCHEMY_DATABASE_URI: sqlite:////app/superset_home/superset.db?check_same_thread=false
```
Superset загружает дефолтный конфиг из `superset/config.py`, который хардкодит SQLite.
Переменные окружения НЕ переопределяют этот параметр (ни с префиксом `SUPERSET__`, ни без).

**Шаг 7 — Следствие:** При двухконтейнерной архитектуре:
- Init-контейнер: `superset db upgrade` → создаёт таблицы в своём SQLite
- Init-контейнер: `superset fab create-admin` → создаёт admin в своём SQLite
- Init-контейнер: умирает → файловая система (включая `/app/superset_home/superset.db`) уничтожается
- Web-контейнер: стартует с новым пустым SQLite → пользователь admin не существует
- `auth_user_db('admin', 'admin123')` → `find_user` возвращает None → 401 "Not authorized"

**Шаг 8 — Решение (три компонента):**

1. **psycopg2-binary:** `python -m pip install psycopg2-binary -q` в init и web контейнерах.
   В Superset 4.1.2 Python — `/usr/local/bin/python`, pip install работает корректно.
   (В 6.1.0 Python в venv: `/app/.venv/bin/python`, требует `/app/.venv/bin/pip`.)

2. **superset_config.py:** Superset НЕ читает `SQLALCHEMY_DATABASE_URI` из env vars
   ни в какой форме. Единственный способ переопределить — создать `superset_config.py`
   и указать `SUPERSET_CONFIG_PATH`. Конфиг пишется через heredoc в `/tmp/superset_config.py`
   и читает `SUPERSET_DB_URI` из переменной окружения:
   ```python
   import os
   SQLALCHEMY_DATABASE_URI = os.environ.get("SUPERSET_DB_URI", "sqlite://")
   ```
   Альтернативы, которые НЕ работают: `SQLALCHEMY_DATABASE_URI` env var,
   `SUPERSET__SQLALCHEMY_DATABASE_URI` env var, `DATABASE_URL` env var.

3. **Docker bridge IP:** `postgres_container.get_connection_url()` возвращает
   `localhost:PORT`. Изнутри Superset-контейнера `localhost` — это сам контейнер,
   а не хост. Нужно использовать Docker bridge IP Postgres-контейнера (`172.17.0.x`),
   полученный через `get_wrapped_container().attrs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"]`.
   Порт — внутренний (5432), не exposed.

### Таблица: что работает, а что нет

| Подход | Результат | Причина |
|--------|-----------|---------|
| `SQLALCHEMY_DATABASE_URI` env var | ❌ Игнорируется | `superset/config.py` хардкодит SQLite |
| `SUPERSET__SQLALCHEMY_DATABASE_URI` env var | ❌ Игнорируется | Тоже не переопределяет дефолт |
| `DATABASE_URL` env var | ❌ Игнорируется | Superset не использует эту переменную |
| `superset_config.py` через `SUPERSET_CONFIG_PATH` | ✅ Работает | Единственный способ переопределить конфиг |
| `localhost` в URI изнутри контейнера | ❌ Connection refused | `localhost` внутри контейнера — сам контейнер |
| Docker bridge IP (`172.17.0.x`) | ✅ Работает | Контейнеры на одном bridge network |
| JWT login через Flask test client | ✅ 200 | Нет сетевого слоя, прямой вызов |
| JWT login через `curl` изнутри контейнера | ✅ 200 | `localhost` внутри контейнера — сам контейнер (и DB в одном контейнере) |
| JWT login через `curl` с хоста (docker run -p) | ✅ 200 | Один контейнер для init+web, DB не теряется |
| Form login (`POST /login/`) без DB | ✅ 200 | Работает даже с SQLite (пока не нужен themes) |
| `/api/v1/me/` с session cookie | ❌ 401 | REST API требует JWT Bearer, не session |

### Сравнение версий Superset

| Характеристика | 4.1.2 | 6.1.0 |
|---------------|-------|-------|
| Python | `/usr/local/bin/python` (3.10) | `/app/.venv/bin/python` (3.10) |
| psycopg2 | ❌ нет | ❌ нет |
| pip install работает | ✅ `python -m pip` | ⚠️ нужен `/app/.venv/bin/pip` |
| `SQLALCHEMY_DATABASE_URI` env | ❌ игнорируется | ❌ игнорируется |
| `SUPERSET__SQLALCHEMY_DATABASE_URI` env | ❌ игнорируется | ✅ работает |
| `superset_config.py` | ✅ работает | ✅ работает |
| SQLite fallback | Молча (без ошибок) | Падает: `no such table: themes` |
| `/health` на SQLite | ✅ "OK" | ✅ "OK" |
| `/login/` на SQLite | ✅ 200 | ❌ 500 (themes table) |
| Вердикт | **Выбрана** | Отвергнута |

## Decision

### Архитектура контейнеров

```
Test Session
├── PostgresContainer (postgres:16-alpine)
│   ├── DB: test_translate (для app-таблиц)
│   ├── DB: superset_meta (для метаданных Superset)
│   └── Docker bridge IP: 172.17.0.x (не localhost!)
│
├── Init Container (apache/superset:4.1.2)
│   ├── python -m pip install psycopg2-binary -q
│   ├── cat > /tmp/superset_config.py (читает SUPERSET_DB_URI)
│   ├── export SUPERSET_CONFIG_PATH=/tmp/superset_config.py
│   ├── superset db upgrade    → миграции в superset_meta (через psycopg2)
│   ├── superset fab create-admin → admin:admin123
│   ├── superset init          → роли, permission'ы
│   └── Статус: запускается → ждём exit code 0 → останавливается
│
└── Web Container (apache/superset:4.1.2)
    ├── python -m pip install psycopg2-binary -q
    ├── cat > /tmp/superset_config.py (читает SUPERSET_DB_URI)
    ├── export SUPERSET_CONFIG_PATH=/tmp/superset_config.py
    ├── superset run -h 0.0.0.0 -p 8088 --with-threads
    └── Статус: запускается → ждём /health → живёт до конца сессии
```

### Ключевые параметры

| Параметр | Значение | Обоснование |
|----------|----------|-------------|
| Версия Superset | `4.1.2` | Работает с psycopg2 + superset_config.py |
| PostgreSQL | `16-alpine` | Общий с app-тестами, отдельная БД `superset_meta` |
| psycopg2 | Установка при старте | `python -m pip install psycopg2-binary -q` |
| Конфиг | `/tmp/superset_config.py` | Через `SUPERSET_CONFIG_PATH` |
| DB host | Docker bridge IP | `172.17.0.x`, не `localhost` |
| SECRET_KEY | Фиксированный `test-...` | Детерминированное поведение |
| Admin password | `admin123` | Только для тестового контейнера |
| Startup timeout | 90s | Достаточно для cold-start pull + pip install + init |
| Health check | `GET /health` → 200 + `"OK"` | Самый лёгкий эндпоинт Superset |

### Fixtures в conftest

```
backend/tests/integration/conftest.py
├── superset_db_url       (session) — Docker bridge IP + порт 5432
├── superset_secret_key   (session) — SECRET_KEY
├── superset_admin_password (session) — admin123
├── superset_container    (session) — init + web контейнеры
├── superset_url          (function) — http://host:port
└── superset_admin_headers (function) — requests.Session с form-логином
```

### Что тестируется

| Слой | Тесты | Статус |
|------|-------|--------|
| 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 | ✅ 1/1 |
| API (JWT) | `/api/v1/dashboard/` с Bearer token | ✅ 1/1 |
| SupersetClient | Конструкция клиента, api_base_url | ✅ 2/2 |

### Ограничения

1. **Требуется `pip install psycopg2-binary` при старте контейнера.** Образ 4.1.2
   не содержит драйвер PostgreSQL. Без него Superset молча фоллбечится на SQLite,
   что ломает двухконтейнерную архитектуру (init теряет данные). `pip install`
   добавляет ~5s к холодному старту.

2. **Требуется `superset_config.py`.** Superset не читает `SQLALCHEMY_DATABASE_URI`
   из переменных окружения ни в какой форме — только через файл конфигурации.

3. **Требуется Docker bridge IP.** `localhost` недоступен изнутри контейнера.
   Нужно получать реальный IP через Docker API.

4. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL,
   требует `SUPERSET__` префикс для env vars (но не для `SQLALCHEMY_DATABASE_URI`
   — только `superset_config.py`), форма логина падает при использовании SQLite.

## Enforcement

- Все новые тесты, проверяющие взаимодействие с Superset REST API, должны использовать
  `superset_container` fixture из `tests/integration/conftest.py`.
- Версия образа (`4.1.2`) зафиксирована. Изменение версии требует нового ADR.
- Три компонента запуска (psycopg2, superset_config.py, Docker bridge IP) являются
  обязательными — удаление любого из них ломает JWT-авторизацию.

## Migration Path к Superset 6.x

При переходе на Superset 6.x потребуется:
1. Кастомный Dockerfile: `FROM apache/superset:6.x` + `RUN /app/.venv/bin/pip install psycopg2-binary`
2. `superset_config.py` через `SUPERSET_CONFIG_PATH` (обязательно — env vars не работают)
3. Docker bridge IP для Postgres (без изменений)
4. Пересмотр JWT-авторизации — API не изменился, но образ нестабилен на SQLite

# [/DEF:ADR-0012:ADR]
