SFM/app/opencloud_token_store.py
Raykov-MS 0bdb83d2f3 Fix event-loop mismatch masking OpenCloud token as "not found" on first click
Each call into opencloud_token_store is wrapped in its own asyncio.run()
(app/app_config.py, main.py's token popover), so every call gets a fresh
event loop. The asyncpg connection pool is bound to the loop it was created
in, so the next asyncio.run() could try to reuse a connection from an
already-closed loop and fail — unrelated to actual DB availability, but the
broad except Exception in resolve_opencloud_access_token() silently
swallowed it and fell through to "token not found", masking the real cause.

- add_token/get_token/remove_token/list_usernames now call engine.dispose()
  in a finally block so each asyncio.run() starts with a clean pool.
- resolve_opencloud_access_token() now surfaces the real exception via
  st.error() before falling back to the .env token (kept only for local dev
  without a DB).
- Added tests/unit/test_opencloud_token_store.py covering that dispose() is
  called after every store operation, including when commit() raises.
2026-09-17 16:56:08 +03:00

108 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import select
from app.core.config import settings
from app.db.base import SessionLocal, engine
from app.db.models.opencloud_token import OpenCloudToken
class OpenCloudTokenStoreError(RuntimeError):
"""Ошибка при работе с таблицей токенов OpenCloud (шифрование/БД)."""
# engine.dispose() в finally каждой функции ниже — обязателен. Приложение
# синхронное (Streamlit), каждый вызов этого модуля оборачивается в
# отдельный asyncio.run() (см. app/app_config.py, main.py), который создаёт
# новый event loop и закрывает его по завершении. Пул соединений asyncpg
# привязан к loop'у, в котором был создан: без dispose() следующий
# asyncio.run() (уже в новом loop'е) попытается забрать из пула соединение,
# открытое в уже закрытом loop'е предыдущего вызова, и упадёт — эта ошибка
# не имеет отношения к реальной доступности БД, но выглядит как она.
def _get_fernet() -> Fernet:
if not settings.FERNET_KEY:
raise OpenCloudTokenStoreError(
"Не задан OPENBAO__SETTINGS__FERNET_KEY — шифрование токенов "
"OpenCloud недоступно."
)
try:
return Fernet(settings.FERNET_KEY.encode())
except (ValueError, TypeError) as exc:
raise OpenCloudTokenStoreError(
f"OPENBAO__SETTINGS__FERNET_KEY некорректен: {exc}"
) from exc
async def add_token(username: str, token: str, added_by: str | None = None) -> None:
"""Сохраняет (или перезаписывает) App Token заказчика, зашифровав его."""
if not username:
raise OpenCloudTokenStoreError("Не задан username для сохранения токена.")
if not token:
raise OpenCloudTokenStoreError("Не задан токен для сохранения.")
encrypted = _get_fernet().encrypt(token.encode()).decode()
try:
async with SessionLocal() as session:
obj = await session.get(OpenCloudToken, username)
if obj is not None:
obj.encrypted_token = encrypted
if added_by:
obj.added_by = added_by
else:
session.add(
OpenCloudToken(
username=username, encrypted_token=encrypted, added_by=added_by
)
)
await session.commit()
finally:
await engine.dispose()
async def get_token(username: str) -> str | None:
"""username -> расшифрованный App Token, либо None, если записи нет."""
if not username:
return None
try:
async with SessionLocal() as session:
obj = await session.get(OpenCloudToken, username)
finally:
await engine.dispose()
if obj is None:
return None
try:
return _get_fernet().decrypt(obj.encrypted_token.encode()).decode()
except InvalidToken as exc:
raise OpenCloudTokenStoreError(
f"Не удалось расшифровать токен для '{username}' — "
"проверьте OPENBAO__SETTINGS__FERNET_KEY."
) from exc
async def remove_token(username: str) -> bool:
"""Удаляет запись пользователя. Возвращает True, если запись была найдена."""
try:
async with SessionLocal() as session:
obj = await session.get(OpenCloudToken, username)
if obj is None:
return False
await session.delete(obj)
await session.commit()
return True
finally:
await engine.dispose()
async def list_usernames() -> list[str]:
"""Список username, для которых сохранён токен (без расшифровки)."""
try:
async with SessionLocal() as session:
result = await session.execute(select(OpenCloudToken.username))
return list(result.scalars().all())
finally:
await engine.dispose()