4d07d04c63
22 tests covering: - Helper functions (_domain_of, _check_domain_allowed, _default_tier, _is_admin_email) - POST /register (success, duplicate email, domain restriction) - POST /login (success, user not found, wrong password, inactive, admin auto-promotion) - POST /google (existing user, new user, unverified email, domain restriction) - GET /google/authorize-url (correct URL construction) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
488 lines
18 KiB
Python
488 lines
18 KiB
Python
"""
|
|
Unit tests for auth endpoints (backend/app/api/v1/endpoints/auth.py).
|
|
|
|
All tests mock the database session, security functions, and OAuth service
|
|
so no real PostgreSQL instance or external API is required.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from urllib.parse import quote as urlquote
|
|
|
|
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:
|
|
"""Return a MagicMock that behaves like a User ORM instance."""
|
|
defaults = dict(
|
|
id=1,
|
|
email="user@example.com",
|
|
hashed_password="hashedpw",
|
|
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)
|
|
user = MagicMock(spec=User)
|
|
for k, v in defaults.items():
|
|
setattr(user, k, v)
|
|
return user
|
|
|
|
|
|
def _scalar_one_or_none(value):
|
|
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
|
result = MagicMock()
|
|
result.scalar_one_or_none.return_value = value
|
|
return result
|
|
|
|
|
|
def _fake_tokens():
|
|
return {
|
|
"access_token": "fake-access-token",
|
|
"refresh_token": "fake-refresh-token",
|
|
"token_type": "bearer",
|
|
}
|
|
|
|
|
|
# ── fixtures ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_application()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
db = AsyncMock()
|
|
db.commit = AsyncMock()
|
|
db.refresh = AsyncMock()
|
|
db.delete = AsyncMock()
|
|
db.add = MagicMock()
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app, mock_db):
|
|
"""AsyncClient with only get_db overridden (no auth required)."""
|
|
|
|
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 c:
|
|
yield c
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helper functions
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestHelpers:
|
|
"""Unit tests for private helper functions in auth.py."""
|
|
|
|
def test_domain_of(self):
|
|
from app.api.v1.endpoints.auth import _domain_of
|
|
|
|
assert _domain_of("alice@Example.COM") == "example.com"
|
|
assert _domain_of("bob@sub.domain.org") == "sub.domain.org"
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_check_domain_allowed_no_restriction(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _check_domain_allowed
|
|
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
_check_domain_allowed("anyone@whatever.com") # should not raise
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_check_domain_allowed_passes(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _check_domain_allowed
|
|
|
|
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
|
_check_domain_allowed("alice@acme.com") # should not raise
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_check_domain_allowed_blocks(self, mock_settings):
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.v1.endpoints.auth import _check_domain_allowed
|
|
|
|
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_check_domain_allowed("alice@blocked.com")
|
|
assert exc_info.value.status_code == 403
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_default_tier_valid(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _default_tier
|
|
|
|
mock_settings.DEFAULT_USER_TIER = "pro"
|
|
assert _default_tier() == SubscriptionTier.PRO
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_default_tier_invalid_falls_back(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _default_tier
|
|
|
|
mock_settings.DEFAULT_USER_TIER = "invalid_tier"
|
|
assert _default_tier() == SubscriptionTier.FREE
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_is_admin_email_match(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _is_admin_email
|
|
|
|
mock_settings.ADMIN_EMAIL = "Admin@Example.com"
|
|
assert _is_admin_email("admin@example.com") is True
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_is_admin_email_no_match(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _is_admin_email
|
|
|
|
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
|
assert _is_admin_email("other@example.com") is False
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
def test_is_admin_email_none(self, mock_settings):
|
|
from app.api.v1.endpoints.auth import _is_admin_email
|
|
|
|
mock_settings.ADMIN_EMAIL = None
|
|
assert _is_admin_email("anyone@example.com") is False
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# POST /api/v1/auth/register
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestRegister:
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch("app.api.v1.endpoints.auth.get_password_hash", return_value="hashed123")
|
|
async def test_register_success(self, _mock_hash, mock_settings, client, mock_db):
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
mock_settings.DEFAULT_USER_TIER = "free"
|
|
mock_settings.ADMIN_EMAIL = None
|
|
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
# Simulate db.refresh assigning an id and required fields
|
|
def _set_id(obj):
|
|
obj.id = 42
|
|
obj.email = "new@example.com"
|
|
obj.full_name = "New User"
|
|
obj.is_active = True
|
|
obj.subscription_tier = SubscriptionTier.FREE
|
|
obj.subscription_status = "active"
|
|
obj.created_at = datetime.now(timezone.utc)
|
|
|
|
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "new@example.com",
|
|
"full_name": "New User",
|
|
"password": "secret",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
body = resp.json()
|
|
assert body["email"] == "new@example.com"
|
|
assert body["id"] == 42
|
|
mock_db.add.assert_called_once()
|
|
mock_db.commit.assert_awaited_once()
|
|
|
|
async def test_register_duplicate_email(self, client, mock_db):
|
|
existing = _make_user(email="dup@example.com")
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={"email": "dup@example.com", "full_name": "Dup", "password": "pw"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert "already registered" in resp.json()["detail"]
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
async def test_register_domain_restricted(self, mock_settings, client, mock_db):
|
|
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
|
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "user@blocked.com",
|
|
"full_name": "Blocked",
|
|
"password": "pw",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 403
|
|
assert "not authorised" in resp.json()["detail"]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# POST /api/v1/auth/login
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestLogin:
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch(
|
|
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
|
return_value=_fake_tokens(),
|
|
)
|
|
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
|
async def test_login_success(
|
|
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
|
):
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
mock_settings.ADMIN_EMAIL = None
|
|
|
|
user = _make_user(email="login@example.com")
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "login@example.com", "password": "correct"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["access_token"] == "fake-access-token"
|
|
assert body["token_type"] == "bearer"
|
|
mock_db.commit.assert_awaited_once()
|
|
|
|
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
|
async def test_login_user_not_found(self, _mock_verify, client, mock_db):
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "nobody@example.com", "password": "pw"},
|
|
)
|
|
|
|
assert resp.status_code == 401
|
|
assert "Incorrect email or password" in resp.json()["detail"]
|
|
|
|
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
|
async def test_login_wrong_password(self, _mock_verify, client, mock_db):
|
|
user = _make_user(email="login@example.com")
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "login@example.com", "password": "wrong"},
|
|
)
|
|
|
|
assert resp.status_code == 401
|
|
assert "Incorrect email or password" in resp.json()["detail"]
|
|
|
|
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
|
async def test_login_inactive_user(self, _mock_verify, client, mock_db):
|
|
user = _make_user(email="inactive@example.com", is_active=False)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "inactive@example.com", "password": "pw"},
|
|
)
|
|
|
|
assert resp.status_code == 403
|
|
assert "inactive" in resp.json()["detail"]
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch(
|
|
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
|
return_value=_fake_tokens(),
|
|
)
|
|
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
|
async def test_login_admin_auto_promotion(
|
|
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
|
):
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
|
|
|
user = _make_user(email="admin@example.com", is_superuser=False, is_active=True)
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "admin@example.com", "password": "pw"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
# The endpoint should have set is_superuser = True on the user mock
|
|
assert user.is_superuser is True
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# POST /api/v1/auth/google
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGoogleOAuth:
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch(
|
|
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
|
return_value=_fake_tokens(),
|
|
)
|
|
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
|
async def test_google_existing_user(
|
|
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
|
):
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
mock_settings.ADMIN_EMAIL = None
|
|
|
|
mock_google_info.return_value = {
|
|
"email": "existing@example.com",
|
|
"google_id": "g-123",
|
|
"full_name": "Existing User",
|
|
"verified_email": True,
|
|
}
|
|
|
|
user = _make_user(email="existing@example.com", google_id="g-123")
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/google",
|
|
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["access_token"] == "fake-access-token"
|
|
mock_db.commit.assert_awaited_once()
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch(
|
|
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
|
return_value=_fake_tokens(),
|
|
)
|
|
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
|
async def test_google_new_user(
|
|
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
|
):
|
|
mock_settings.ALLOWED_DOMAINS = []
|
|
mock_settings.DEFAULT_USER_TIER = "free"
|
|
mock_settings.ADMIN_EMAIL = None
|
|
|
|
mock_google_info.return_value = {
|
|
"email": "brand-new@example.com",
|
|
"google_id": "g-456",
|
|
"full_name": "Brand New",
|
|
"verified_email": True,
|
|
}
|
|
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
def _set_id(obj):
|
|
obj.id = 99
|
|
|
|
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/google",
|
|
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["access_token"] == "fake-access-token"
|
|
mock_db.add.assert_called_once()
|
|
|
|
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
|
async def test_google_email_not_verified(self, mock_google_info, client, mock_db):
|
|
mock_google_info.return_value = {
|
|
"email": "unverified@example.com",
|
|
"google_id": "g-789",
|
|
"verified_email": False,
|
|
}
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/google",
|
|
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert "not verified" in resp.json()["detail"]
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
|
async def test_google_domain_restricted_new_user(
|
|
self, mock_google_info, mock_settings, client, mock_db
|
|
):
|
|
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
|
mock_settings.DEFAULT_USER_TIER = "free"
|
|
mock_settings.ADMIN_EMAIL = None
|
|
|
|
mock_google_info.return_value = {
|
|
"email": "person@blocked.com",
|
|
"google_id": "g-block",
|
|
"full_name": "Blocked",
|
|
"verified_email": True,
|
|
}
|
|
|
|
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
|
|
|
resp = await client.post(
|
|
"/api/v1/auth/google",
|
|
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
|
)
|
|
|
|
assert resp.status_code == 403
|
|
assert "not authorised" in resp.json()["detail"]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# GET /api/v1/auth/google/authorize-url
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGoogleAuthorizeUrl:
|
|
|
|
@patch("app.api.v1.endpoints.auth.settings")
|
|
async def test_returns_correct_url(self, mock_settings, client):
|
|
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
|
|
|
|
resp = await client.get(
|
|
"/api/v1/auth/google/authorize-url",
|
|
params={"redirect_uri": "http://localhost:3000/callback"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
url = body["authorization_url"]
|
|
assert "accounts.google.com" in url
|
|
assert "client_id=test-client-id" in url
|
|
assert "redirect_uri=http://localhost:3000/callback" in url
|
|
expected_scope = urlquote("openid email profile")
|
|
assert f"scope={expected_scope}" in url
|
|
assert "prompt=select_account" in url
|