Files
gh-christianlouis-inboxconv…/backend/tests/unit/test_auth_endpoints.py
T

372 lines
14 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.
"""
Unit tests for authentication endpoints (api/v1/endpoints/auth.py).
All database interactions and the oauth_service are mocked so no real
PostgreSQL instance or Google credentials are needed.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.models.database_models import User, SubscriptionTier
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
hashed_password=None,
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
return db
@pytest.fixture
async def anon_client(app, mock_db):
"""Client with no auth (db mocked)."""
async def _override_db():
yield mock_db
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── /register ─────────────────────────────────────────────────────────────────
class TestRegisterEndpoint:
async def test_register_new_user_201(self, anon_client, mock_db):
# DB returns no existing user
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
# db.refresh will be called with the new User object; we simulate it
# by setting the required response fields on that object.
from datetime import datetime, timezone
from app.models.database_models import SubscriptionTier
async def _refresh(obj):
obj.id = 99
obj.email = "new@example.com"
obj.full_name = None
obj.is_active = True
obj.subscription_tier = SubscriptionTier.FREE
obj.subscription_status = "active"
obj.created_at = datetime.now(timezone.utc)
obj.updated_at = datetime.now(timezone.utc)
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await anon_client.post(
"/api/v1/auth/register",
json={"email": "new@example.com", "password": "secretpassword"},
)
assert response.status_code == 201
async def test_register_existing_user_400(self, anon_client, mock_db):
existing = _make_user(email="taken@example.com")
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
response = await anon_client.post(
"/api/v1/auth/register",
json={"email": "taken@example.com", "password": "pass"},
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"]
async def test_register_blocked_domain_403(self, app, mock_db):
"""When ALLOWED_DOMAINS is set, unknown domains should get 403."""
async def _override_db():
yield mock_db
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
with patch("app.api.v1.endpoints.auth.settings") as mock_settings:
mock_settings.ALLOWED_DOMAINS = ["allowed.com"]
mock_settings.DEFAULT_USER_TIER = "free"
mock_settings.ADMIN_EMAIL = None
async with AsyncClient(
transport=transport, base_url="http://test"
) as client:
response = await client.post(
"/api/v1/auth/register",
json={"email": "user@blocked.com", "password": "pass"},
)
app.dependency_overrides.clear()
assert response.status_code == 403
# ── /login ─────────────────────────────────────────────────────────────────────
class TestLoginEndpoint:
async def test_login_success_returns_tokens(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("correctpassword")
user = _make_user(email="user@example.com", hashed_password=hashed)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "correctpassword"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password_401(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("correctpassword")
user = _make_user(email="user@example.com", hashed_password=hashed)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "wrongpassword"},
)
assert response.status_code == 401
async def test_login_user_not_found_401(self, anon_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "unknown@example.com", "password": "pass"},
)
assert response.status_code == 401
async def test_login_inactive_user_403(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("password")
user = _make_user(
email="user@example.com", hashed_password=hashed, is_active=False
)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "password"},
)
assert response.status_code == 403
async def test_login_no_password_hash_401(self, anon_client, mock_db):
"""OAuth-only users have no hashed_password login should fail."""
user = _make_user(hashed_password=None)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "pass"},
)
assert response.status_code == 401
# ── /google ────────────────────────────────────────────────────────────────────
class TestGoogleOAuthEndpoint:
async def test_google_oauth_unverified_email_400(self, anon_client, mock_db):
with patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "user@gmail.com",
"google_id": "g123",
"full_name": "Test",
"verified_email": False,
}
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 400
async def test_google_oauth_new_user_created(self, anon_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
with (
patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "google@example.com",
"google_id": "g123",
"full_name": "Google User",
"verified_email": True,
}
),
),
patch(
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
return_value={
"access_token": "tok",
"refresh_token": "ref",
"token_type": "bearer",
},
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 200
assert response.json()["access_token"] == "tok"
async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db):
existing = _make_user(email="google@example.com", google_id="g123")
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
with (
patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "google@example.com",
"google_id": "g123",
"full_name": "Google User",
"verified_email": True,
}
),
),
patch(
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
return_value={
"access_token": "tok2",
"refresh_token": "ref2",
"token_type": "bearer",
},
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 200
# ── /google/authorize-url ──────────────────────────────────────────────────────
class TestGoogleAuthorizeUrl:
async def test_returns_authorization_url(self, anon_client):
response = await anon_client.get(
"/api/v1/auth/google/authorize-url",
params={"redirect_uri": "http://localhost/callback"},
)
assert response.status_code == 200
data = response.json()
assert "authorization_url" in data
assert data["authorization_url"].startswith(
"https://accounts.google.com/o/oauth2/v2/auth"
)
async def test_url_contains_redirect_uri(self, anon_client):
redirect = "http://myapp.example.com/callback"
response = await anon_client.get(
"/api/v1/auth/google/authorize-url",
params={"redirect_uri": redirect},
)
assert response.status_code == 200
# ── helper functions (domain checks, tier, admin email) ───────────────────────
class TestAuthHelpers:
def test_domain_of(self):
from app.api.v1.endpoints.auth import _domain_of
assert _domain_of("user@Example.COM") == "example.com"
assert _domain_of("a@b.de") == "b.de"
def test_default_tier_fallback(self):
from app.api.v1.endpoints.auth import _default_tier
from app.models.database_models import SubscriptionTier
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.DEFAULT_USER_TIER = "invalid_tier"
tier = _default_tier()
assert tier == SubscriptionTier.FREE
def test_default_tier_valid(self):
from app.api.v1.endpoints.auth import _default_tier
from app.models.database_models import SubscriptionTier
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.DEFAULT_USER_TIER = "pro"
tier = _default_tier()
assert tier == SubscriptionTier.PRO
def test_is_admin_email_match(self):
from app.api.v1.endpoints.auth import _is_admin_email
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.ADMIN_EMAIL = "admin@example.com"
assert _is_admin_email("ADMIN@EXAMPLE.COM") is True
assert _is_admin_email("other@example.com") is False
def test_is_admin_email_none_config(self):
from app.api.v1.endpoints.auth import _is_admin_email
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.ADMIN_EMAIL = None
assert _is_admin_email("admin@example.com") is False