Subagents delivered tests across all uncovered backend modules: Schemas (100%): agent, auth, health, profile, settings, validation Services (98-100%): auth, profile, health, llm, mapping, resource, security, git, superset_lookup, sql_table_extractor, rbac API routes (new): auth, admin, health, environments, plugins, dashboards (helpers, projection, actions, listing), git (config, deps, env, helpers) Clean Release (100%): DTO, facade, policy_engine, stages, repos, preparation, source_isolation, compliance Git services: base, remote_providers Agent module: app, run, middleware, langgraph_setup Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching Reports: normalizer, report_service, type_profiles Notifications: service, providers Also: - .gitignore: add .coverage, *.cover, coverage-* dirs - src/schemas/auth.py: fix AD group DN regex (comma in CN=...) - Remove co-located src/services/__tests__/ (caused pytest module collision)
253 lines
9.1 KiB
Python
253 lines
9.1 KiB
Python
# #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."""
|
|
mock_token = MagicMock()
|
|
mock_token.access_token = "adfs.token.xyz"
|
|
mock_token.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
|