Реализовано без authlib, на голом httpx (AsyncClient — синхронный httpx
блокировал бы event loop API на время внешнего запроса), по образцу двух
провайдеров:
- Миграция 004: hashed_password → nullable (OAuth-юзеры без пароля),
oauth_provider/oauth_id + уникальный индекс на пару.
- app/core/security.py: verify_password защищён от hashed=None (иначе TypeError
при попытке OAuth-юзера войти по паролю — нашёл при ревью, не баг-репорт).
- app/core/oauth.py: get_authorize_url()/exchange_code() — единый интерфейс для
google/yandex. Redirect URI: <APP_URL>/api/auth/<provider>/callback.
- app/api/auth.py: GET /auth/{provider}/login (редирект на согласие, state в
httponly-cookie от CSRF) и /callback (обмен code, find-or-create юзера по
oauth_id → по email для привязки существующего аккаунта → новый без пароля,
is_verified=email_verified от провайдера). Токен фронту — через URL-фрагмент
#token=..., не query (не уходит в логи/Referer).
- Фронтенд: OAuthButtons (Login/Register), страница /oauth/callback (читает
фрагмент → GET /auth/me → setAuth → редирект в кабинет).
- 6 юнит-тестов чистой логики сборки ссылок (app/core/oauth.py) — первый тест-
контур для api/ в этой сессии (pytest.ini/conftest/requirements-test по
образцу остальных сервисов), добавлен в общий run_tests.sh + mypy-гейт.
GOOGLE_CLIENT_ID/SECRET уже в .env (юзер создал OAuth-клиент), YANDEX_* пусты —
эндпоинты в этом случае отвечают 503, не падают. .env.example документирует обе
пары. Тестов всего: 118 (было 112).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""Юнит-тесты сборки OAuth-ссылок (app.core.oauth) — чистая логика, без сети."""
|
||
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
import pytest
|
||
from app.config import settings
|
||
from app.core.oauth import OAuthNotConfigured, _redirect_uri, get_authorize_url
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_oauth_settings(monkeypatch):
|
||
"""По умолчанию оба провайдера не настроены — как на чистой инсталляции."""
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "")
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "")
|
||
monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "")
|
||
monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "")
|
||
|
||
|
||
def test_redirect_uri_matches_registered_pattern():
|
||
assert _redirect_uri("google") == f"{settings.APP_URL}/api/auth/google/callback"
|
||
assert _redirect_uri("yandex") == f"{settings.APP_URL}/api/auth/yandex/callback"
|
||
|
||
|
||
def test_not_configured_raises(monkeypatch):
|
||
with pytest.raises(OAuthNotConfigured):
|
||
get_authorize_url("google", "state123")
|
||
with pytest.raises(OAuthNotConfigured):
|
||
get_authorize_url("yandex", "state123")
|
||
|
||
|
||
def test_unknown_provider_raises_value_error(monkeypatch):
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id")
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret")
|
||
with pytest.raises(ValueError):
|
||
get_authorize_url("facebook", "state123")
|
||
|
||
|
||
def test_google_authorize_url_structure(monkeypatch):
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "my-client-id")
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "my-secret")
|
||
|
||
url = get_authorize_url("google", "the-state")
|
||
parsed = urlparse(url)
|
||
qs = parse_qs(parsed.query)
|
||
|
||
assert parsed.netloc == "accounts.google.com"
|
||
assert qs["client_id"] == ["my-client-id"]
|
||
assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/google/callback"]
|
||
assert qs["response_type"] == ["code"]
|
||
assert qs["state"] == ["the-state"]
|
||
assert "email" in qs["scope"][0]
|
||
|
||
|
||
def test_yandex_authorize_url_structure(monkeypatch):
|
||
monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "my-yandex-id")
|
||
monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "my-yandex-secret")
|
||
|
||
url = get_authorize_url("yandex", "the-state")
|
||
parsed = urlparse(url)
|
||
qs = parse_qs(parsed.query)
|
||
|
||
assert parsed.netloc == "oauth.yandex.ru"
|
||
assert qs["client_id"] == ["my-yandex-id"]
|
||
assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/yandex/callback"]
|
||
assert qs["state"] == ["the-state"]
|
||
|
||
|
||
def test_state_is_url_encoded_safely(monkeypatch):
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id")
|
||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret")
|
||
|
||
# state со спецсимволами не должен ломать ссылку
|
||
url = get_authorize_url("google", "abc&def=1")
|
||
qs = parse_qs(urlparse(url).query)
|
||
assert qs["state"] == ["abc&def=1"]
|