# #region Test.Api.Auth [C:3] [TYPE Module] [SEMANTICS test,auth,api] # @BRIEF Unit tests for auth API routes — login, logout, me, ADFS. # @RELATION BINDS_TO -> [Api.Auth] # @TEST_EDGE: invalid_credentials -> 401 # @TEST_EDGE: locked_account -> 429 (rate limited) # @TEST_EDGE: missing_fields -> 422 # @TEST_EDGE: already_expired_token -> 200 (idempotent logout) import os # Set env BEFORE any source imports os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI, HTTPException, status from fastapi.testclient import TestClient _src = str(Path(__file__).resolve().parent.parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) @pytest.fixture(autouse=True) def _patch_rate_limiter(): """Ensure rate limiter is safe.""" with patch("src.api.auth.rate_limiter") as mock_rl: mock_rl.is_banned.return_value = False mock_rl.record_attempt = MagicMock() mock_rl.record_success = MagicMock() yield # ── Helper: build TestClient with auth dependencies overridden ── def _make_client() -> TestClient: """Build a TestClient for the auth router with all dependencies overridden.""" from src.api.auth import router from src.core.database import get_auth_db from src.dependencies import get_current_user from src.schemas.auth import User, RoleSchema app = FastAPI() app.include_router(router) mock_db = MagicMock() mock_user = User( id="user-1", username="testuser", email="test@example.com", auth_source="LOCAL", created_at=__import__("datetime").datetime.now(), roles=[RoleSchema(id="role-1", name="Admin", description="Admin role", permissions=[])], ) app.dependency_overrides[get_auth_db] = lambda: mock_db app.dependency_overrides[get_current_user] = lambda: mock_user return TestClient(app) # ── Tests ── class TestLogin: """POST /api/auth/login""" def test_login_success(self): """Happy path: valid credentials return 200 + Token.""" mock_user = MagicMock() mock_user.username = "testuser" mock_token = MagicMock() mock_token.access_token = "abc.def.ghi" mock_token.token_type = "bearer" with patch("src.api.auth.AuthService") as MockAuth: auth_instance = MockAuth.return_value auth_instance.authenticate_user.return_value = mock_user auth_instance.create_session.return_value = mock_token client = _make_client() resp = client.post("/api/auth/login", data={"username": "testuser", "password": "secret123"}) assert resp.status_code == 200 assert resp.json()["access_token"] == "abc.def.ghi" assert resp.json()["token_type"] == "bearer" def test_login_invalid_credentials(self): """Invalid credentials return 401.""" with patch("src.api.auth.AuthService") as MockAuth: auth_instance = MockAuth.return_value auth_instance.authenticate_user.return_value = None client = _make_client() resp = client.post("/api/auth/login", data={"username": "bad", "password": "wrong"}) assert resp.status_code == 401 assert "Incorrect username or password" in resp.text def test_login_rate_limited(self): """Banned IP returns 429.""" with patch("src.api.auth.rate_limiter") as mock_rl: mock_rl.is_banned.return_value = True client = _make_client() resp = client.post("/api/auth/login", data={"username": "test", "password": "test"}) assert resp.status_code == 429 assert "Too many login attempts" in resp.text def test_login_missing_fields(self): """Missing form data returns 422.""" client = _make_client() resp = client.post("/api/auth/login", data={}) assert resp.status_code == 422 class TestMe: """GET /api/auth/me""" def test_me_authenticated(self): """Authenticated user returns profile.""" client = _make_client() resp = client.get("/api/auth/me") assert resp.status_code == 200 data = resp.json() assert data["username"] == "testuser" assert data["email"] == "test@example.com" def test_me_unauthenticated(self): """Unauthenticated request returns 401.""" from src.core.database import get_auth_db from src.dependencies import get_current_user app = FastAPI() from src.api.auth import router app.include_router(router) app.dependency_overrides[get_current_user] = lambda: (_ for _ in ()).throw( HTTPException(status_code=401, detail="Not authenticated") ) app.dependency_overrides[get_auth_db] = lambda: MagicMock() client = TestClient(app) resp = client.get("/api/auth/me") assert resp.status_code == 401 class TestLogout: """POST /api/auth/logout""" def test_logout_success(self): """Valid token blacklisted, returns 200.""" with patch("src.api.auth.blacklist_token") as mock_blacklist: client = _make_client() resp = client.post( "/api/auth/logout", headers={"Authorization": "Bearer some.jwt.token"}, ) assert resp.status_code == 200 assert resp.json()["message"] == "Successfully logged out" mock_blacklist.assert_called_once() def test_logout_no_token_header(self): """No Authorization header still succeeds (no token to blacklist).""" with patch("src.api.auth.blacklist_token") as mock_blacklist: client = _make_client() resp = client.post("/api/auth/logout") assert resp.status_code == 200 mock_blacklist.assert_not_called() def test_logout_expired_token(self): """Expired token — still returns 200 (idempotent).""" with patch("src.api.auth.blacklist_token") as mock_blacklist: client = _make_client() resp = client.post( "/api/auth/logout", headers={"Authorization": "Bearer expired.token.here"}, ) assert resp.status_code == 200 mock_blacklist.assert_called_once() class TestLoginAdfs: """GET /api/auth/login/adfs""" def test_adfs_not_configured(self): """ADFS not configured returns 503.""" with patch("src.api.auth.is_adfs_configured", return_value=False): client = _make_client() resp = client.get("/api/auth/login/adfs") assert resp.status_code == 503 assert "ADFS is not configured" in resp.text def test_adfs_redirect(self): """ADFS configured redirects to provider.""" mock_oauth = MagicMock() mock_oauth.adfs.authorize_redirect = AsyncMock(return_value=None) with patch("src.api.auth.is_adfs_configured", return_value=True), \ patch("src.api.auth.oauth", mock_oauth): client = _make_client() resp = client.get("/api/auth/login/adfs") assert resp.status_code in (200, 307) class TestCallbackAdfs: """GET /api/auth/callback/adfs""" def test_callback_not_configured(self): """ADFS not configured returns 503.""" with patch("src.api.auth.is_adfs_configured", return_value=False): client = _make_client() resp = client.get("/api/auth/callback/adfs") assert resp.status_code == 503 def test_callback_no_userinfo(self): """ADFS token without userinfo returns 400.""" mock_oauth = MagicMock() mock_oauth.adfs.authorize_access_token = AsyncMock(return_value={}) with patch("src.api.auth.is_adfs_configured", return_value=True), \ patch("src.api.auth.oauth", mock_oauth): client = _make_client() resp = client.get("/api/auth/callback/adfs") assert resp.status_code == 400 assert "Failed to retrieve user info" in resp.text def test_callback_success(self): """ADFS callback provisions user and returns token.""" from types import SimpleNamespace mock_token = SimpleNamespace( access_token="adfs.token.xyz", token_type="bearer", ) mock_user_info = {"sub": "adfs-user", "email": "adfs@example.com"} mock_oauth = MagicMock() mock_oauth.adfs.authorize_access_token = AsyncMock( return_value={"userinfo": mock_user_info} ) with patch("src.api.auth.is_adfs_configured", return_value=True), \ patch("src.api.auth.oauth", mock_oauth), \ patch("src.api.auth.AuthService") as MockAuth: auth_instance = MockAuth.return_value auth_instance.provision_adfs_user.return_value = MagicMock() auth_instance.create_session.return_value = mock_token client = _make_client() resp = client.get("/api/auth/callback/adfs") assert resp.status_code == 200 assert resp.json()["access_token"] == "adfs.token.xyz" # #endregion Test.Api.Auth