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. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
5.1 KiB
Python
162 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
|
|
from app import opencloud_token_store as store
|
|
from app.opencloud_token_store import OpenCloudTokenStoreError
|
|
|
|
|
|
class FakeResult:
|
|
def __init__(self, usernames: list[str]) -> None:
|
|
self._usernames = usernames
|
|
|
|
def scalars(self):
|
|
return self
|
|
|
|
def all(self):
|
|
return self._usernames
|
|
|
|
|
|
class FakeRow:
|
|
def __init__(self, username: str, encrypted_token: str, added_by=None) -> None:
|
|
self.username = username
|
|
self.encrypted_token = encrypted_token
|
|
self.added_by = added_by
|
|
|
|
|
|
class FakeSession:
|
|
"""Мини-модель БД: {username: FakeRow}, без реального движка."""
|
|
|
|
def __init__(self, table: dict[str, FakeRow]) -> None:
|
|
self._table = table
|
|
|
|
async def get(self, model, username): # noqa: ANN001
|
|
_ = model
|
|
return self._table.get(username)
|
|
|
|
def add(self, obj: FakeRow) -> None:
|
|
self._table[obj.username] = obj
|
|
|
|
async def delete(self, obj: FakeRow) -> None:
|
|
self._table.pop(obj.username, None)
|
|
|
|
async def commit(self) -> None:
|
|
pass
|
|
|
|
async def execute(self, stmt): # noqa: ANN001
|
|
_ = stmt
|
|
return FakeResult(sorted(self._table.keys()))
|
|
|
|
async def __aenter__(self) -> FakeSession:
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
|
|
return False
|
|
|
|
|
|
class FakeSessionLocal:
|
|
def __init__(self, table: dict[str, FakeRow]) -> None:
|
|
self._table = table
|
|
|
|
def __call__(self) -> FakeSession:
|
|
return FakeSession(self._table)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_store(monkeypatch):
|
|
"""Подменяет SessionLocal/engine и FERNET_KEY на in-memory заглушки.
|
|
|
|
Каждый вызов add_token/get_token/remove_token/list_usernames в проде
|
|
оборачивается ОТДЕЛЬНЫМ asyncio.run() (см. app/app_config.py) — новый
|
|
event loop на каждый вызов. Тесты ниже воспроизводят это же (несколько
|
|
независимых asyncio.run() подряд в одном тесте) и проверяют, что
|
|
engine.dispose() вызывается после каждой операции.
|
|
"""
|
|
table: dict[str, FakeRow] = {}
|
|
fake_engine = AsyncMock()
|
|
monkeypatch.setattr(store, "SessionLocal", FakeSessionLocal(table))
|
|
monkeypatch.setattr(store, "engine", fake_engine)
|
|
monkeypatch.setattr(store.settings, "FERNET_KEY", Fernet.generate_key().decode())
|
|
return table, fake_engine
|
|
|
|
|
|
def test_add_token_then_get_token_across_separate_event_loops(_fake_store) -> None:
|
|
table, fake_engine = _fake_store
|
|
|
|
asyncio.run(store.add_token("raykovmse", "coherent effects plausibly apple"))
|
|
assert fake_engine.dispose.await_count == 1
|
|
|
|
token = asyncio.run(store.get_token("raykovmse"))
|
|
assert fake_engine.dispose.await_count == 2
|
|
assert token == "coherent effects plausibly apple"
|
|
|
|
|
|
def test_add_token_updates_existing_row(_fake_store) -> None:
|
|
table, fake_engine = _fake_store
|
|
|
|
asyncio.run(store.add_token("raykovmse", "first token", added_by="admin"))
|
|
asyncio.run(store.add_token("raykovmse", "second token", added_by="raykovmse"))
|
|
|
|
assert fake_engine.dispose.await_count == 2
|
|
assert len(table) == 1
|
|
assert asyncio.run(store.get_token("raykovmse")) == "second token"
|
|
|
|
|
|
def test_get_token_missing_username_disposes_engine(_fake_store) -> None:
|
|
_, fake_engine = _fake_store
|
|
|
|
result = asyncio.run(store.get_token("no-such-user"))
|
|
|
|
assert result is None
|
|
assert fake_engine.dispose.await_count == 1
|
|
|
|
|
|
def test_remove_token_disposes_engine(_fake_store) -> None:
|
|
table, fake_engine = _fake_store
|
|
asyncio.run(store.add_token("raykovmse", "token-123"))
|
|
fake_engine.dispose.reset_mock()
|
|
|
|
removed = asyncio.run(store.remove_token("raykovmse"))
|
|
|
|
assert removed is True
|
|
assert "raykovmse" not in table
|
|
assert fake_engine.dispose.await_count == 1
|
|
|
|
|
|
def test_list_usernames_disposes_engine(_fake_store) -> None:
|
|
_, fake_engine = _fake_store
|
|
asyncio.run(store.add_token("raykovmse", "token-123"))
|
|
fake_engine.dispose.reset_mock()
|
|
|
|
usernames = asyncio.run(store.list_usernames())
|
|
|
|
assert usernames == ["raykovmse"]
|
|
assert fake_engine.dispose.await_count == 1
|
|
|
|
|
|
def test_add_token_disposes_engine_even_when_commit_fails(
|
|
_fake_store, monkeypatch
|
|
) -> None:
|
|
_, fake_engine = _fake_store
|
|
|
|
async def _failing_commit(self) -> None: # noqa: ANN001
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(FakeSession, "commit", _failing_commit)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
asyncio.run(store.add_token("raykovmse", "token-123"))
|
|
|
|
assert fake_engine.dispose.await_count == 1
|
|
|
|
|
|
def test_add_token_requires_username_and_token(_fake_store) -> None:
|
|
with pytest.raises(OpenCloudTokenStoreError):
|
|
asyncio.run(store.add_token("", "token-123"))
|
|
with pytest.raises(OpenCloudTokenStoreError):
|
|
asyncio.run(store.add_token("raykovmse", ""))
|