Skip to content

Commit 3a1c9c8

Browse files
committed
fix(*): update test coverage
1 parent 6bb875c commit 3a1c9c8

File tree

3 files changed

+450
-0
lines changed

3 files changed

+450
-0
lines changed

backend/app/tests/api/test_deps.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.tests.utils.auth import TestAuthContext
1414
from app.tests.utils.user import authentication_token_from_email, create_random_user
1515
from app.core.config import settings
16+
from app.core.security import create_access_token, create_refresh_token
1617
from app.tests.utils.test_data import create_test_api_key
1718

1819

@@ -187,3 +188,83 @@ def test_get_auth_context_with_inactive_project(
187188

188189
assert exc_info.value.status_code == 403
189190
assert exc_info.value.detail == "Inactive Project"
191+
192+
def test_get_auth_context_with_cookie_token(
193+
self, db: Session, normal_user_token_headers: dict[str, str]
194+
) -> None:
195+
"""Test successful authentication via access_token cookie"""
196+
token = normal_user_token_headers["Authorization"].replace("Bearer ", "")
197+
auth_context = get_auth_context(
198+
request=_mock_request(cookies={"access_token": token}),
199+
session=db,
200+
token=None,
201+
api_key=None,
202+
)
203+
204+
assert isinstance(auth_context, AuthContext)
205+
assert auth_context.user.email == settings.EMAIL_TEST_USER
206+
207+
def test_get_auth_context_with_expired_token(self, db: Session) -> None:
208+
"""Test authentication fails with expired token"""
209+
from datetime import timedelta
210+
211+
expired_token = create_access_token(
212+
subject="1", expires_delta=timedelta(minutes=-1)
213+
)
214+
215+
with pytest.raises(HTTPException) as exc_info:
216+
get_auth_context(
217+
request=_mock_request(),
218+
session=db,
219+
token=expired_token,
220+
api_key=None,
221+
)
222+
223+
assert exc_info.value.status_code == 401
224+
assert exc_info.value.detail == "Token has expired"
225+
226+
def test_get_auth_context_rejects_refresh_token(self, db: Session) -> None:
227+
"""Test that refresh tokens are rejected for API access"""
228+
from datetime import timedelta
229+
230+
refresh_token = create_refresh_token(
231+
subject="1", expires_delta=timedelta(minutes=60)
232+
)
233+
234+
with pytest.raises(HTTPException) as exc_info:
235+
get_auth_context(
236+
request=_mock_request(),
237+
session=db,
238+
token=refresh_token,
239+
api_key=None,
240+
)
241+
242+
assert exc_info.value.status_code == 401
243+
assert exc_info.value.detail == "Refresh tokens cannot be used for API access"
244+
245+
def test_get_auth_context_jwt_with_org_and_project(
246+
self, db: Session, user_api_key: TestAuthContext
247+
) -> None:
248+
"""Test JWT token with org_id and project_id populates AuthContext"""
249+
from datetime import timedelta
250+
251+
token = create_access_token(
252+
subject=str(user_api_key.user.id),
253+
expires_delta=timedelta(minutes=60),
254+
organization_id=user_api_key.organization.id,
255+
project_id=user_api_key.project.id,
256+
)
257+
258+
auth_context = get_auth_context(
259+
request=_mock_request(),
260+
session=db,
261+
token=token,
262+
api_key=None,
263+
)
264+
265+
assert isinstance(auth_context, AuthContext)
266+
assert auth_context.user.id == user_api_key.user.id
267+
assert auth_context.organization is not None
268+
assert auth_context.organization.id == user_api_key.organization.id
269+
assert auth_context.project is not None
270+
assert auth_context.project.id == user_api_key.project.id
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
from datetime import timedelta
2+
from unittest.mock import patch
3+
4+
from fastapi.testclient import TestClient
5+
from sqlmodel import Session
6+
7+
from app.core.config import settings
8+
from app.core.security import create_access_token, create_refresh_token
9+
from app.tests.utils.auth import TestAuthContext
10+
from app.tests.utils.user import create_random_user
11+
12+
GOOGLE_AUTH_URL = f"{settings.API_V1_STR}/auth/google"
13+
SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project"
14+
REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh"
15+
LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout"
16+
17+
MOCK_GOOGLE_PROFILE = {
18+
"email": None, # set per test
19+
"email_verified": True,
20+
"name": "Test User",
21+
"picture": "https://example.com/photo.jpg",
22+
"given_name": "Test",
23+
"family_name": "User",
24+
}
25+
26+
27+
def _mock_idinfo(email: str, email_verified: bool = True) -> dict:
28+
return {**MOCK_GOOGLE_PROFILE, "email": email, "email_verified": email_verified}
29+
30+
31+
class TestGoogleAuth:
32+
"""Test suite for POST /auth/google endpoint."""
33+
34+
@patch("app.api.routes.google_auth.settings")
35+
def test_google_auth_not_configured(self, mock_settings, client: TestClient):
36+
"""Test returns 500 when GOOGLE_CLIENT_ID is not set."""
37+
mock_settings.GOOGLE_CLIENT_ID = ""
38+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
39+
assert resp.status_code == 500
40+
assert "not configured" in resp.json()["detail"]
41+
42+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
43+
@patch("app.api.routes.google_auth.settings")
44+
def test_google_auth_invalid_token(
45+
self, mock_settings, mock_verify, client: TestClient
46+
):
47+
"""Test returns 400 for invalid Google token."""
48+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
49+
mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440
50+
mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080
51+
mock_settings.ENVIRONMENT = "testing"
52+
mock_settings.API_V1_STR = settings.API_V1_STR
53+
mock_verify.side_effect = ValueError("Invalid token")
54+
55+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "bad-token"})
56+
assert resp.status_code == 400
57+
assert "Invalid or expired" in resp.json()["detail"]
58+
59+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
60+
@patch("app.api.routes.google_auth.settings")
61+
def test_google_auth_unverified_email(
62+
self, mock_settings, mock_verify, client: TestClient
63+
):
64+
"""Test returns 400 when Google email is not verified."""
65+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
66+
mock_verify.return_value = _mock_idinfo(
67+
"test@example.com", email_verified=False
68+
)
69+
70+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
71+
assert resp.status_code == 400
72+
assert "not verified" in resp.json()["detail"]
73+
74+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
75+
@patch("app.api.routes.google_auth.settings")
76+
def test_google_auth_user_not_found(
77+
self, mock_settings, mock_verify, client: TestClient
78+
):
79+
"""Test returns 401 when no user exists for the email."""
80+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
81+
mock_verify.return_value = _mock_idinfo("nonexistent@example.com")
82+
83+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
84+
assert resp.status_code == 401
85+
assert "No account found" in resp.json()["detail"]
86+
87+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
88+
@patch("app.api.routes.google_auth.settings")
89+
def test_google_auth_activates_inactive_user(
90+
self, mock_settings, mock_verify, db: Session, client: TestClient
91+
):
92+
"""Test that inactive user is activated on first Google login."""
93+
user = create_random_user(db)
94+
user.is_active = False
95+
db.add(user)
96+
db.commit()
97+
db.refresh(user)
98+
99+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
100+
mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440
101+
mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080
102+
mock_settings.ENVIRONMENT = "testing"
103+
mock_settings.API_V1_STR = settings.API_V1_STR
104+
mock_settings.SECRET_KEY = settings.SECRET_KEY
105+
mock_verify.return_value = _mock_idinfo(user.email)
106+
107+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
108+
assert resp.status_code == 200
109+
110+
db.refresh(user)
111+
assert user.is_active is True
112+
113+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
114+
@patch("app.api.routes.google_auth.settings")
115+
def test_google_auth_success_no_projects(
116+
self, mock_settings, mock_verify, db: Session, client: TestClient
117+
):
118+
"""Test successful login for user with no projects."""
119+
user = create_random_user(db)
120+
121+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
122+
mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440
123+
mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080
124+
mock_settings.ENVIRONMENT = "testing"
125+
mock_settings.API_V1_STR = settings.API_V1_STR
126+
mock_settings.SECRET_KEY = settings.SECRET_KEY
127+
mock_verify.return_value = _mock_idinfo(user.email)
128+
129+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
130+
assert resp.status_code == 200
131+
132+
data = resp.json()
133+
assert "access_token" in data
134+
assert data["requires_project_selection"] is False
135+
assert data["available_projects"] == []
136+
assert "access_token" in resp.cookies
137+
138+
@patch("app.api.routes.google_auth.id_token.verify_oauth2_token")
139+
@patch("app.api.routes.google_auth.settings")
140+
def test_google_auth_success_single_project_via_api_key(
141+
self,
142+
mock_settings,
143+
mock_verify,
144+
db: Session,
145+
client: TestClient,
146+
user_api_key: TestAuthContext,
147+
):
148+
"""Test successful login auto-selects single project from API key."""
149+
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
150+
mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440
151+
mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080
152+
mock_settings.ENVIRONMENT = "testing"
153+
mock_settings.API_V1_STR = settings.API_V1_STR
154+
mock_settings.SECRET_KEY = settings.SECRET_KEY
155+
mock_verify.return_value = _mock_idinfo(user_api_key.user.email)
156+
157+
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
158+
assert resp.status_code == 200
159+
160+
data = resp.json()
161+
assert data["requires_project_selection"] is False
162+
assert len(data["available_projects"]) == 1
163+
164+
165+
class TestSelectProject:
166+
"""Test suite for POST /auth/select-project endpoint."""
167+
168+
def test_select_project_unauthenticated(self, client: TestClient):
169+
"""Test returns 401 when not authenticated."""
170+
resp = client.post(SELECT_PROJECT_URL, json={"project_id": 1})
171+
assert resp.status_code == 401
172+
173+
def test_select_project_no_access(
174+
self, client: TestClient, normal_user_token_headers: dict[str, str]
175+
):
176+
"""Test returns 403 when user has no access to the project."""
177+
resp = client.post(
178+
SELECT_PROJECT_URL,
179+
json={"project_id": 99999},
180+
headers=normal_user_token_headers,
181+
)
182+
assert resp.status_code == 403
183+
assert "do not have access" in resp.json()["detail"]
184+
185+
def test_select_project_success(
186+
self,
187+
db: Session,
188+
client: TestClient,
189+
user_api_key: TestAuthContext,
190+
normal_user_token_headers: dict[str, str],
191+
):
192+
"""Test successful project selection returns new token with cookies."""
193+
resp = client.post(
194+
SELECT_PROJECT_URL,
195+
json={"project_id": user_api_key.project.id},
196+
headers=normal_user_token_headers,
197+
)
198+
assert resp.status_code == 200
199+
200+
data = resp.json()
201+
assert "access_token" in data
202+
assert "access_token" in resp.cookies
203+
204+
205+
class TestRefreshToken:
206+
"""Test suite for POST /auth/refresh endpoint."""
207+
208+
def test_refresh_no_cookie(self, client: TestClient):
209+
"""Test returns 401 when no refresh token cookie is present."""
210+
resp = client.post(REFRESH_URL)
211+
assert resp.status_code == 401
212+
assert "not found" in resp.json()["detail"]
213+
214+
def test_refresh_with_access_token_instead(self, db: Session, client: TestClient):
215+
"""Test returns 401 when access token is used instead of refresh token."""
216+
user = create_random_user(db)
217+
access_token = create_access_token(
218+
subject=str(user.id), expires_delta=timedelta(minutes=30)
219+
)
220+
client.cookies.set("refresh_token", access_token)
221+
222+
resp = client.post(REFRESH_URL)
223+
assert resp.status_code == 401
224+
assert "Invalid token type" in resp.json()["detail"]
225+
226+
def test_refresh_with_expired_token(self, db: Session, client: TestClient):
227+
"""Test returns 401 when refresh token is expired."""
228+
user = create_random_user(db)
229+
expired_refresh = create_refresh_token(
230+
subject=str(user.id), expires_delta=timedelta(minutes=-1)
231+
)
232+
client.cookies.set("refresh_token", expired_refresh)
233+
234+
resp = client.post(REFRESH_URL)
235+
assert resp.status_code == 401
236+
assert "expired" in resp.json()["detail"]
237+
238+
def test_refresh_success(self, db: Session, client: TestClient):
239+
"""Test successful refresh returns new tokens."""
240+
user = create_random_user(db)
241+
refresh_token = create_refresh_token(
242+
subject=str(user.id), expires_delta=timedelta(days=7)
243+
)
244+
client.cookies.set("refresh_token", refresh_token)
245+
246+
resp = client.post(REFRESH_URL)
247+
assert resp.status_code == 200
248+
249+
data = resp.json()
250+
assert "access_token" in data
251+
assert "access_token" in resp.cookies
252+
253+
def test_refresh_with_org_project(
254+
self, db: Session, client: TestClient, user_api_key: TestAuthContext
255+
):
256+
"""Test refresh preserves org/project claims."""
257+
refresh_token = create_refresh_token(
258+
subject=str(user_api_key.user.id),
259+
expires_delta=timedelta(days=7),
260+
organization_id=user_api_key.organization.id,
261+
project_id=user_api_key.project.id,
262+
)
263+
client.cookies.set("refresh_token", refresh_token)
264+
265+
resp = client.post(REFRESH_URL)
266+
assert resp.status_code == 200
267+
assert "access_token" in resp.json()
268+
269+
def test_refresh_inactive_user(self, db: Session, client: TestClient):
270+
"""Test returns 403 when user is inactive."""
271+
user = create_random_user(db)
272+
refresh_token = create_refresh_token(
273+
subject=str(user.id), expires_delta=timedelta(days=7)
274+
)
275+
276+
user.is_active = False
277+
db.add(user)
278+
db.commit()
279+
280+
client.cookies.set("refresh_token", refresh_token)
281+
282+
resp = client.post(REFRESH_URL)
283+
assert resp.status_code == 403
284+
285+
286+
class TestLogout:
287+
"""Test suite for POST /auth/logout endpoint."""
288+
289+
def test_logout_clears_cookies(self, client: TestClient):
290+
"""Test logout clears auth cookies."""
291+
resp = client.post(LOGOUT_URL)
292+
assert resp.status_code == 200
293+
assert resp.json()["message"] == "Logged out successfully"

0 commit comments

Comments
 (0)