diff --git a/app/modules/auth/api_docs.md b/app/modules/auth/api_docs.md index 8721565..3401fe6 100644 --- a/app/modules/auth/api_docs.md +++ b/app/modules/auth/api_docs.md @@ -666,11 +666,17 @@ Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/api Initiate the Google OAuth 2.0 authorization flow by redirecting the user to Google's consent screen. +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|----------|----------|-----------------------------------------------------------------------------| +| `flow` | `string` | ❌ | The authorization flow, either `"login"` or `"signup"`. Default: `"login"`. | + **Response: `302 Found`** Redirects to Google's OAuth consent URL with: - `client_id`, `redirect_uri`, `scope: "openid email profile"` -- A cryptographically random `state` parameter stored in Redis for 10 minutes. +- A cryptographically random `state` parameter stored in Redis for 10 minutes, which maps to the chosen `flow` value. --- @@ -697,19 +703,24 @@ During the redirect callback, if an error occurs, it is returned as a JSON error |--------|----------------------------|----------------------------------------------------------| | `400` | `INVALID_OAUTH_STATE` | State token is invalid or expired | | `400` | `INVALID_OAUTH_PROFILE` | Google account does not provide a verified email address | +| `400` | `AUTH_METHOD_MISMATCH` | Account was created with email/password (login flow) | | `403` | `ACCOUNT_LOCKED` | Account is locked due to failed attempts | | `403` | `ACCOUNT_DEACTIVATED` | Account is deactivated or deleted | +| `404` | `ACCOUNT_NOT_FOUND` | No account found for this email (login flow) | +| `409` | `EMAIL_ALREADY_REGISTERED` | Account already exists (signup flow) | | `409` | `GOOGLE_ID_ALREADY_LINKED` | Google account is already linked to another user account | | `502` | `OAUTH_PROVIDER_ERROR` | Failed to communicate with Google | **User Resolution Logic:** -1. If a user with the `google_id` exists: - - If a user with the email exists and has a different user ID, raises `GOOGLE_ID_ALREADY_LINKED` (409 Conflict). - - Otherwise, resolve user. -2. If a user with the email exists but `google_id` is empty: - - Links the Google ID, updates avatar if missing, and marks email verified. -3. If no user exists: - - Creates a new verified user with a random hashed password, sets `google_id`, `full_name`, and `avatar_url`. + +Based on the `flow` query parameter saved during initiation: +- **`signup` flow:** + - If a user with the email or Google ID already exists, raises `EMAIL_ALREADY_REGISTERED` (409 Conflict). + - Otherwise, creates a new verified user with a random hashed password, sets `google_id`, `full_name`, and `avatar_url`, and returns `is_new_user = true`. +- **`login` flow:** + - If the user does not exist, raises `ACCOUNT_NOT_FOUND` (404 Not Found). + - If the user exists but `google_id` is empty (email/password user), raises `AUTH_METHOD_MISMATCH` (400 Bad Request). + - Otherwise, links the Google ID if missing, updates avatar, and returns `is_new_user = false`. --- @@ -736,7 +747,8 @@ Exchange the short-lived single-use exchange code received from the callback red "access_token": "eyJhbGciOiJIUzI1NiIs...", "user_id": "550e8400-e29b-41d4-a716-446655440000", "token_type": "bearer", - "expires_in": 3600 + "expires_in": 3600, + "is_new_user": false } ``` @@ -833,13 +845,14 @@ Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/ ### Response Schemas -| Schema | Used By | Fields | -|-------------------------|-----------------------|------------------------------------------------------------------------------------------------------------------------------| -| `SignupResponse` | `POST /signup` | `id`, `email`, `full_name`, `speaking_language`, `listening_language`, `user_role`, `is_active`, `is_verified`, `created_at` | -| `LoginResponse` | `POST /login` | `access_token`, `user_id`, `token_type`, `expires_in` | -| `VerifyEmailResponse` | `GET /verify-email` | `status` (= `"ok"`), `message` | -| `ActionAcknowledgement` | Multiple endpoints | `status` (= `"ok"`), `message` | -| `RefreshTokenResponse` | `POST /refresh-token` | `access_token`, `token_type`, `expires_in` | +| Schema | Used By | Fields | +|--------------------------|-------------------------|------------------------------------------------------------------------------------------------------------------------------| +| `SignupResponse` | `POST /signup` | `id`, `email`, `full_name`, `speaking_language`, `listening_language`, `user_role`, `is_active`, `is_verified`, `created_at` | +| `LoginResponse` | `POST /login` | `access_token`, `user_id`, `token_type`, `expires_in` | +| `GoogleExchangeResponse` | `POST /google/exchange` | `access_token`, `user_id`, `token_type`, `expires_in`, `is_new_user` | +| `VerifyEmailResponse` | `GET /verify-email` | `status` (= `"ok"`), `message` | +| `ActionAcknowledgement` | Multiple endpoints | `status` (= `"ok"`), `message` | +| `RefreshTokenResponse` | `POST /refresh-token` | `access_token`, `token_type`, `expires_in` | --- @@ -856,30 +869,32 @@ All errors follow a consistent JSON structure: ### Complete Error Code Table -| Code | HTTP Status | Endpoint(s) | Description | -|----------------------------|-------------|--------------------------------------------------|--------------------------------------------------| -| `EMAIL_ALREADY_REGISTERED` | 409 | `/signup` | Duplicate email at registration | -| `MISSING_CREDENTIALS` | 400 | `/login` | Empty request body on login | -| `INVALID_CREDENTIALS` | 401 | `/login`, auth guard | Wrong email/password or invalid JWT | -| `EMAIL_NOT_VERIFIED` | 403 | `/login` | Attempting login before email verification | -| `ACCOUNT_DELETED` | 403 | `/login`, auth guard | Account has been soft-deleted | -| `ACCOUNT_LOCKED` | 403 | `/login`, `/google/callback` | Locked after too many failed attempts | -| `ACCOUNT_DEACTIVATED` | 403 | `/refresh-token`, `/google/callback`, auth guard | Account deactivated or deleted | -| `MISSING_TOKEN` | 400/401 | `/verify-email`, auth guard | Token not provided | -| `INVALID_TOKEN` | 400 | `/verify-email` | Token is malformed or not found | -| `TOKEN_EXPIRED` | 400 | `/verify-email` | Verification token has expired | -| `TOKEN_REVOKED` | 401 | Auth guard | Access token has been blacklisted | -| `INVALID_RESET_TOKEN` | 400 | `/reset-password` | Reset token not found or user missing | -| `RESET_TOKEN_EXPIRED` | 400 | `/reset-password` | Password reset token has expired | -| `SAME_PASSWORD` | 400 | `/reset-password`, `/change-password` | New password matches the current one | -| `INCORRECT_PASSWORD` | 400 | `/change-password` | Current password verification failed | -| `MISSING_REFRESH_TOKEN` | 401 | `/refresh-token` | No refresh token cookie present | -| `INVALID_REFRESH_TOKEN` | 401 | `/refresh-token` | Refresh token JWT is invalid or expired | -| `REFRESH_TOKEN_REUSE` | 401 | `/refresh-token` | Revoked token was replayed — all sessions killed | -| `INVALID_OAUTH_STATE` | 400 | `/google/callback` | CSRF state token invalid or expired | -| `INVALID_OAUTH_PROFILE` | 400 | `/google/callback` | Google profile missing email address | -| `OAUTH_PROVIDER_ERROR` | 502 | `/google/callback` | Failed to communicate with Google APIs | -| `INVALID_EXCHANGE_CODE` | 400 | `/google/exchange` | The exchange code is invalid, reused, or expired | +| Code | HTTP Status | Endpoint(s) | Description | +|----------------------------|-------------|--------------------------------------------------|----------------------------------------------------------| +| `EMAIL_ALREADY_REGISTERED` | 409 | `/signup` | Duplicate email at registration | +| `MISSING_CREDENTIALS` | 400 | `/login` | Empty request body on login | +| `INVALID_CREDENTIALS` | 401 | `/login`, auth guard | Wrong email/password or invalid JWT | +| `EMAIL_NOT_VERIFIED` | 403 | `/login` | Attempting login before email verification | +| `ACCOUNT_DELETED` | 403 | `/login`, auth guard | Account has been soft-deleted | +| `ACCOUNT_LOCKED` | 403 | `/login`, `/google/callback` | Locked after too many failed attempts | +| `ACCOUNT_DEACTIVATED` | 403 | `/refresh-token`, `/google/callback`, auth guard | Account deactivated or deleted | +| `MISSING_TOKEN` | 400/401 | `/verify-email`, auth guard | Token not provided | +| `INVALID_TOKEN` | 400 | `/verify-email` | Token is malformed or not found | +| `TOKEN_EXPIRED` | 400 | `/verify-email` | Verification token has expired | +| `TOKEN_REVOKED` | 401 | Auth guard | Access token has been blacklisted | +| `INVALID_RESET_TOKEN` | 400 | `/reset-password` | Reset token not found or user missing | +| `RESET_TOKEN_EXPIRED` | 400 | `/reset-password` | Password reset token has expired | +| `SAME_PASSWORD` | 400 | `/reset-password`, `/change-password` | New password matches the current one | +| `INCORRECT_PASSWORD` | 400 | `/change-password` | Current password verification failed | +| `MISSING_REFRESH_TOKEN` | 401 | `/refresh-token` | No refresh token cookie present | +| `INVALID_REFRESH_TOKEN` | 401 | `/refresh-token` | Refresh token JWT is invalid or expired | +| `REFRESH_TOKEN_REUSE` | 401 | `/refresh-token` | Revoked token was replayed — all sessions killed | +| `INVALID_OAUTH_STATE` | 400 | `/google/callback` | CSRF state token invalid or expired | +| `INVALID_OAUTH_PROFILE` | 400 | `/google/callback` | Google profile missing email address | +| `AUTH_METHOD_MISMATCH` | 400 | `/google/callback` | Account was created with email/password | +| `ACCOUNT_NOT_FOUND` | 404 | `/google/callback` | No account found for this email (login flow) | +| `OAUTH_PROVIDER_ERROR` | 502 | `/google/callback` | Failed to communicate with Google APIs | +| `INVALID_EXCHANGE_CODE` | 400 | `/google/exchange` | The exchange code is invalid, reused, or expired | | `GOOGLE_ID_ALREADY_LINKED` | 409 | `/google/callback` | Google account is already linked to another user account | --- diff --git a/app/modules/auth/router.py b/app/modules/auth/router.py index 80cdb30..eba9737 100644 --- a/app/modules/auth/router.py +++ b/app/modules/auth/router.py @@ -27,6 +27,7 @@ ChangePasswordRequest, ForgotPasswordRequest, GoogleExchangeRequest, + GoogleExchangeResponse, LoginRequest, LoginResponse, RefreshTokenResponse, @@ -390,17 +391,28 @@ async def refresh_token( @limiter.limit("10/minute") async def google_login( request: Request, + flow: str = Query( + default="login", + description="The target flow (either 'login' or 'signup')", + ), google_oauth: GoogleOAuthService = Depends(get_google_oauth_service), ) -> RedirectResponse: import secrets + from app.core.exceptions import BadRequestException from app.modules.auth.token_store import _get_redis_client del request # consumed by slowapi + if flow not in ("login", "signup"): + raise BadRequestException( + code="INVALID_FLOW", + message="Flow parameter must be either 'login' or 'signup'.", + ) + state = secrets.token_urlsafe(32) redis = _get_redis_client() - await redis.set(f"oauth_state:{state}", "1", ex=600) # 10 minutes TTL + await redis.set(f"oauth_state:{state}", flow, ex=600) # 10 minutes TTL url = google_oauth.build_auth_url(state=state) return RedirectResponse(url=url, status_code=302) @@ -429,12 +441,14 @@ async def google_callback( redis = _get_redis_client() state_key = f"oauth_state:{state}" - # 1. State Validation - if not await redis.exists(state_key): + # 1. State Validation & Flow retrieval + flow_bytes = await redis.get(state_key) + if not flow_bytes: raise BadRequestException( code="INVALID_OAUTH_STATE", message="OAuth state is invalid or has expired.", ) + flow = flow_bytes.decode() if isinstance(flow_bytes, bytes) else flow_bytes await redis.delete(state_key) @@ -455,11 +469,17 @@ async def google_callback( avatar = user_info.get("picture") # 3. Resolve user - login_response, refresh_token, refresh_ttl = await auth_service.resolve_oauth_user( + ( + login_response, + refresh_token, + refresh_ttl, + is_new_user, + ) = await auth_service.resolve_oauth_user( email=cast(str, email), google_id=google_id, name=name, avatar_url=avatar, + flow=flow, ) # 4. Generate temporary exchange code and store tokens securely in Redis @@ -471,6 +491,7 @@ async def google_callback( "expires_in": login_response.expires_in, "refresh_token": refresh_token, "refresh_ttl": refresh_ttl, + "is_new_user": is_new_user, } await redis.set( f"oauth_exchange:{exchange_code}", @@ -485,7 +506,7 @@ async def google_callback( @router.post( "/google/exchange", - response_model=LoginResponse, + response_model=GoogleExchangeResponse, summary="Exchange temporary OAuth code for access token", description=( "Exchanges a short-lived authorization code retrieved from Google Callback " @@ -517,11 +538,12 @@ async def google_exchange( data = json.loads(data_str) - login_response = LoginResponse( + login_response = GoogleExchangeResponse( access_token=data["access_token"], user_id=data["user_id"], token_type=data["token_type"], expires_in=data["expires_in"], + is_new_user=data.get("is_new_user", False), ) res = JSONResponse( diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py index 804e6f3..d89670d 100644 --- a/app/modules/auth/schemas.py +++ b/app/modules/auth/schemas.py @@ -185,3 +185,12 @@ class GoogleExchangeRequest(BaseModel): """Payload submitted to ``POST /auth/google/exchange``.""" code: str = Field(..., min_length=1) + + +class GoogleExchangeResponse(LoginResponse): + """Payload returned on successful Google OAuth exchange. + + Extends LoginResponse with is_new_user field. + """ + + is_new_user: bool diff --git a/app/modules/auth/service.py b/app/modules/auth/service.py index 63b8573..7fd7684 100644 --- a/app/modules/auth/service.py +++ b/app/modules/auth/service.py @@ -16,6 +16,7 @@ BadRequestException, ConflictException, ForbiddenException, + NotFoundException, UnauthorizedException, ) from app.core.sanitize import sanitize_log_args @@ -89,7 +90,7 @@ async def signup(self, user_in: SignupRequest, frontend_base_url: str) -> User: hashed_password=self.security_service.hash_password(user_in.password), full_name=user_in.full_name, speaking_language=user_in.speaking_language.value, - listening_language=user_in.listening_language.value, + listening_language=user_in.speaking_language.value, is_active=True, is_verified=False, ) @@ -433,8 +434,13 @@ def _create_oauth_user( return user async def resolve_oauth_user( - self, email: str, google_id: str, name: str | None, avatar_url: str | None - ) -> tuple[LoginResponse, str, int]: + self, + email: str, + google_id: str, + name: str | None, + avatar_url: str | None, + flow: str, + ) -> tuple[LoginResponse, str, int, bool]: """Handle OAuth user resolution. Args: @@ -442,19 +448,40 @@ async def resolve_oauth_user( google_id (str): The Google ID. name (str | None): The user's name. avatar_url (str | None): The user's avatar URL. + flow (str): The authorization flow, either "login" or "signup". Returns: - tuple[LoginResponse, str, int]: The new access token, refresh token, - and TTL in seconds. + tuple[LoginResponse, str, int, bool]: The new access token, refresh token, + TTL in seconds, and is_new_user boolean. """ email = email.lower() user = self._find_oauth_user(email, google_id) - if user: + if flow == "signup": + if user: + raise ConflictException( + code="EMAIL_ALREADY_REGISTERED", + message="An account with this email already exists.", + ) + user = self._create_oauth_user(email, google_id, name, avatar_url) + is_new_user = True + else: # login flow + if not user: + raise NotFoundException( + code="ACCOUNT_NOT_FOUND", + message="No account found with this email. Please sign up first.", + ) + if not user.google_id: + raise BadRequestException( + code="AUTH_METHOD_MISMATCH", + message=( + "This account was created with email and password. " + "Please log in with your password." + ), + ) await self._check_oauth_user_status(user) self._update_oauth_user_profile(user, google_id, avatar_url) - else: - user = self._create_oauth_user(email, google_id, name, avatar_url) + is_new_user = False # Issue tokens for successful OAuth login (use user.email, not the # Google-provided email, in case user_by_google has a different stored email) @@ -476,7 +503,7 @@ async def resolve_oauth_user( expires_in=expires_in, ) - return login_response, refresh_token, refresh_ttl + return login_response, refresh_token, refresh_ttl, is_new_user # ------------------------------------------------------------------ # Logout diff --git a/app/modules/user/api_docs.md b/app/modules/user/api_docs.md index b6666d0..36cdf1f 100644 --- a/app/modules/user/api_docs.md +++ b/app/modules/user/api_docs.md @@ -110,6 +110,9 @@ Retrieve the current authenticated user's profile. Update the current user's profile properties. The update payload is partial; only supplied fields are modified. +> [!NOTE] +> To support unified language preference, updating either `speaking_language` or `listening_language` will automatically synchronize both fields to the same language value. + **🔒 Requires Authentication:** `Authorization: Bearer ` **Request Body:** @@ -117,15 +120,15 @@ Update the current user's profile properties. The update payload is partial; onl ```json { "full_name": "Jane H. Doe", - "listening_language": "es" + "speaking_language": "es" } ``` -| Field | Type | Required | Notes | -|----------------------|------------------|----------|--------------------------------------------| -| `full_name` | `string \| null` | ❌ | Max 255 chars | -| `speaking_language` | `string (enum)` | ❌ | Values: `en`, `fr`, `de`, `es`, `it`, `pt` | -| `listening_language` | `string (enum)` | ❌ | Values: `en`, `fr`, `de`, `es`, `it`, `pt` | +| Field | Type | Required | Notes | +|----------------------|------------------|----------|----------------------------------------------------------------------------| +| `full_name` | `string \| null` | ❌ | Max 255 chars | +| `speaking_language` | `string (enum)` | ❌ | Values: `en`, `fr`, `de`, `es`, `it`, `pt`. Synchronizes both preferences. | +| `listening_language` | `string (enum)` | ❌ | Values: `en`, `fr`, `de`, `es`, `it`, `pt`. Synchronizes both preferences. | **Response: `200 OK`** Returns a `ProfileApiResponse` enclosing the updated `UserProfileResponse`. diff --git a/app/modules/user/router.py b/app/modules/user/router.py index 0f6daa4..9f3c9b9 100644 --- a/app/modules/user/router.py +++ b/app/modules/user/router.py @@ -76,6 +76,24 @@ async def update_profile( update_data = payload.model_dump(exclude_unset=True) if update_data: + # Enforce that if speaking_language or listening_language is updated, + # both fields are synchronized to the same language value. + new_lang = None + if ( + "speaking_language" in update_data + and update_data["speaking_language"] is not None + ): + new_lang = update_data["speaking_language"] + elif ( + "listening_language" in update_data + and update_data["listening_language"] is not None + ): + new_lang = update_data["listening_language"] + + if new_lang is not None: + update_data["speaking_language"] = new_lang + update_data["listening_language"] = new_lang + # Convert SupportedLanguage enum values to plain strings for ORM. for lang_field in ("speaking_language", "listening_language"): if lang_field in update_data and update_data[lang_field] is not None: diff --git a/tests/test_auth/test_auth_signup.py b/tests/test_auth/test_auth_signup.py index 385b241..6edbdfa 100644 --- a/tests/test_auth/test_auth_signup.py +++ b/tests/test_auth/test_auth_signup.py @@ -86,7 +86,7 @@ def test_signup_success_creates_user_and_returns_public_profile( assert body["email"] == "user@example.com" assert body["full_name"] == "Ada Lovelace" assert body["speaking_language"] == "en" - assert body["listening_language"] == "fr" + assert body["listening_language"] == "en" assert body["is_active"] is True assert body["is_verified"] is False assert "password" not in body diff --git a/tests/test_auth/test_oauth_google.py b/tests/test_auth/test_oauth_google.py index c11b40d..3f10cb8 100644 --- a/tests/test_auth/test_oauth_google.py +++ b/tests/test_auth/test_oauth_google.py @@ -51,7 +51,7 @@ def test_google_callback_invalid_state( mock_exchange_code: AsyncMock, # noqa: ARG001 ) -> None: mock_redis_instance = AsyncMock() - mock_redis_instance.exists.return_value = False + mock_redis_instance.get.return_value = None mock_redis.return_value = mock_redis_instance response = client.get( @@ -70,7 +70,7 @@ def test_google_callback_unverified_email( mock_exchange_code: AsyncMock, ) -> None: mock_redis_instance = AsyncMock() - mock_redis_instance.exists.return_value = True + mock_redis_instance.get.return_value = b"login" mock_redis.return_value = mock_redis_instance mock_exchange_code.return_value = "mock_token" @@ -100,7 +100,7 @@ def test_google_callback_and_exchange_success( mock_exchange_code: AsyncMock, ) -> None: mock_redis_instance = AsyncMock() - mock_redis_instance.exists.return_value = True + mock_redis_instance.get.return_value = b"login" mock_redis.return_value = mock_redis_instance mock_exchange_code.return_value = "mock_token" @@ -122,6 +122,7 @@ def test_google_callback_and_exchange_success( ), "test_refresh_jwt", 86400, + False, ) app.dependency_overrides[get_auth_service] = lambda: mock_auth_svc @@ -158,6 +159,7 @@ def test_google_callback_and_exchange_success( "expires_in": 3600, "refresh_token": "test_refresh_jwt", "refresh_ttl": 86400, + "is_new_user": False, } ) ), @@ -173,6 +175,7 @@ def test_google_callback_and_exchange_success( "expires_in": 3600, "refresh_token": "test_refresh_jwt", "refresh_ttl": 86400, + "is_new_user": False, } ) @@ -187,6 +190,7 @@ def test_google_callback_and_exchange_success( assert exchange_response.status_code == 200 assert exchange_response.json()["access_token"] == "test_access_jwt" assert exchange_response.json()["token_type"] == "bearer" + assert exchange_response.json()["is_new_user"] is False assert "refresh_token" in exchange_response.cookies assert exchange_response.cookies["refresh_token"] == "test_refresh_jwt" @@ -194,3 +198,126 @@ def test_google_callback_and_exchange_success( mock_redis_instance.getdel.assert_called_once_with( f"oauth_exchange:{exchange_code}" ) + + +@patch("app.modules.auth.oauth_google.GoogleOAuthService.exchange_code") +@patch("app.modules.auth.oauth_google.GoogleOAuthService.get_user_info") +@patch("app.modules.auth.token_store._get_redis_client") +def test_google_callback_signup_conflict( + mock_redis, + mock_get_user_info: AsyncMock, + mock_exchange_code: AsyncMock, +) -> None: + mock_redis_instance = AsyncMock() + mock_redis_instance.get.return_value = b"signup" + mock_redis.return_value = mock_redis_instance + + mock_exchange_code.return_value = "mock_token" + mock_get_user_info.return_value = { + "email": "existing@google.com", + "email_verified": True, + "sub": "google123", + "name": "Google User", + "picture": "http://example.com/avatar.png", + } + + from app.core.exceptions import ConflictException + + mock_auth_svc = AsyncMock() + mock_auth_svc.resolve_oauth_user.side_effect = ConflictException( + code="EMAIL_ALREADY_REGISTERED", + message="An account with this email already exists.", + ) + app.dependency_overrides[get_auth_service] = lambda: mock_auth_svc + + response = client.get( + "/api/v1/auth/google/callback?code=mockcode&state=validstate", + follow_redirects=False, + ) + + app.dependency_overrides.clear() + assert response.status_code == 409 + assert response.json()["code"] == "EMAIL_ALREADY_REGISTERED" + + +@patch("app.modules.auth.oauth_google.GoogleOAuthService.exchange_code") +@patch("app.modules.auth.oauth_google.GoogleOAuthService.get_user_info") +@patch("app.modules.auth.token_store._get_redis_client") +def test_google_callback_login_nonexistent( + mock_redis, + mock_get_user_info: AsyncMock, + mock_exchange_code: AsyncMock, +) -> None: + mock_redis_instance = AsyncMock() + mock_redis_instance.get.return_value = b"login" + mock_redis.return_value = mock_redis_instance + + mock_exchange_code.return_value = "mock_token" + mock_get_user_info.return_value = { + "email": "new@google.com", + "email_verified": True, + "sub": "google123", + "name": "Google User", + "picture": "http://example.com/avatar.png", + } + + from app.core.exceptions import NotFoundException + + mock_auth_svc = AsyncMock() + mock_auth_svc.resolve_oauth_user.side_effect = NotFoundException( + code="ACCOUNT_NOT_FOUND", + message="No account found with this email. Please sign up first.", + ) + app.dependency_overrides[get_auth_service] = lambda: mock_auth_svc + + response = client.get( + "/api/v1/auth/google/callback?code=mockcode&state=validstate", + follow_redirects=False, + ) + + app.dependency_overrides.clear() + assert response.status_code == 404 + assert response.json()["code"] == "ACCOUNT_NOT_FOUND" + + +@patch("app.modules.auth.oauth_google.GoogleOAuthService.exchange_code") +@patch("app.modules.auth.oauth_google.GoogleOAuthService.get_user_info") +@patch("app.modules.auth.token_store._get_redis_client") +def test_google_callback_login_method_mismatch( + mock_redis, + mock_get_user_info: AsyncMock, + mock_exchange_code: AsyncMock, +) -> None: + mock_redis_instance = AsyncMock() + mock_redis_instance.get.return_value = b"login" + mock_redis.return_value = mock_redis_instance + + mock_exchange_code.return_value = "mock_token" + mock_get_user_info.return_value = { + "email": "emailpassword@google.com", + "email_verified": True, + "sub": "google123", + "name": "Google User", + "picture": "http://example.com/avatar.png", + } + + from app.core.exceptions import BadRequestException + + mock_auth_svc = AsyncMock() + mock_auth_svc.resolve_oauth_user.side_effect = BadRequestException( + code="AUTH_METHOD_MISMATCH", + message=( + "This account was created with email and password. " + "Please log in with your password." + ), + ) + app.dependency_overrides[get_auth_service] = lambda: mock_auth_svc + + response = client.get( + "/api/v1/auth/google/callback?code=mockcode&state=validstate", + follow_redirects=False, + ) + + app.dependency_overrides.clear() + assert response.status_code == 400 + assert response.json()["code"] == "AUTH_METHOD_MISMATCH" diff --git a/tests/test_auth/test_oauth_service.py b/tests/test_auth/test_oauth_service.py new file mode 100644 index 0000000..8b24803 --- /dev/null +++ b/tests/test_auth/test_oauth_service.py @@ -0,0 +1,164 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.exceptions import ( + BadRequestException, + ConflictException, + NotFoundException, +) +from app.core.security import SecurityService +from app.models.base import Base +from app.modules.auth.models import User +from app.modules.auth.service import AuthService + + +@pytest.fixture +def db_session(): + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + ) + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture +def auth_service(db_session): + sec_mock = MagicMock(spec=SecurityService) + sec_mock.hash_password.return_value = "hashed_pass" + sec_mock.create_access_token.return_value = ("access_tok", 3600) + sec_mock.create_refresh_token.return_value = ("refresh_tok", "jti_123", 86400) + + email_mock = MagicMock() + ver_mock = MagicMock() + lock_mock = AsyncMock() + lock_mock.is_locked.return_value = False + store_mock = AsyncMock() + + return AuthService( + db=db_session, + security_service=sec_mock, + email_producer=email_mock, + auth_verification_service=ver_mock, + lockout_svc=lock_mock, + token_store=store_mock, + ) + + +@pytest.mark.asyncio +async def test_resolve_oauth_user_signup_new_user(auth_service, db_session): + login_response, _rt, _rt_ttl, is_new = await auth_service.resolve_oauth_user( + email="newuser@example.com", + google_id="google_new", + name="New User", + avatar_url="http://avatar.png", + flow="signup", + ) + assert is_new is True + assert login_response.access_token == "access_tok" + + # Verify user exists in DB + user = db_session.query(User).filter_by(email="newuser@example.com").first() + assert user is not None + assert user.google_id == "google_new" + assert user.is_verified is True + + +@pytest.mark.asyncio +async def test_resolve_oauth_user_signup_existing_conflict(auth_service, db_session): + # Create existing user + user = User( + email="existing@example.com", + hashed_password="some_password", + is_active=True, + is_verified=True, + ) + db_session.add(user) + db_session.commit() + + with pytest.raises(ConflictException) as excinfo: + await auth_service.resolve_oauth_user( + email="existing@example.com", + google_id="google_id", + name="Existing", + avatar_url=None, + flow="signup", + ) + assert excinfo.value.code == "EMAIL_ALREADY_REGISTERED" + + +@pytest.mark.asyncio +async def test_resolve_oauth_user_login_success(auth_service, db_session): + # Create existing user with google_id + user = User( + email="googleuser@example.com", + hashed_password="some_password", + google_id="google_123", + is_active=True, + is_verified=True, + ) + db_session.add(user) + db_session.commit() + + login_response, _rt, _rt_ttl, is_new = await auth_service.resolve_oauth_user( + email="googleuser@example.com", + google_id="google_123", + name="Google User", + avatar_url="http://avatar.png", + flow="login", + ) + assert is_new is False + assert login_response.access_token == "access_tok" + + +@pytest.mark.asyncio +async def test_resolve_oauth_user_login_nonexistent(auth_service): + with pytest.raises(NotFoundException) as excinfo: + await auth_service.resolve_oauth_user( + email="nonexistent@example.com", + google_id="google_123", + name="Nonexistent", + avatar_url=None, + flow="login", + ) + assert excinfo.value.code == "ACCOUNT_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_resolve_oauth_user_login_method_mismatch(auth_service, db_session): + # User exists but google_id is NOT set (email/password user) + user = User( + email="emailpwd@example.com", + hashed_password="some_password", + google_id=None, + is_active=True, + is_verified=True, + ) + db_session.add(user) + db_session.commit() + + with pytest.raises(BadRequestException) as excinfo: + await auth_service.resolve_oauth_user( + email="emailpwd@example.com", + google_id="google_123", + name="Email Password User", + avatar_url=None, + flow="login", + ) + assert excinfo.value.code == "AUTH_METHOD_MISMATCH" diff --git a/tests/test_user/test_user_endpoints.py b/tests/test_user/test_user_endpoints.py index ace434f..5f6d51a 100644 --- a/tests/test_user/test_user_endpoints.py +++ b/tests/test_user/test_user_endpoints.py @@ -203,14 +203,25 @@ def test_update_full_name(self, client: TestClient) -> None: assert data["full_name"] == "Ada K. Lovelace" def test_update_languages(self, client: TestClient) -> None: + # If user updates speaking_language, both are updated to de response = client.patch( "/api/v1/users/me", - json={"speaking_language": "de", "listening_language": "es"}, + json={"speaking_language": "de"}, ) assert response.status_code == 200 data = response.json()["data"] assert data["speaking_language"] == "de" - assert data["listening_language"] == "es" + assert data["listening_language"] == "de" + + # If user updates listening_language, both are updated to es + response2 = client.patch( + "/api/v1/users/me", + json={"listening_language": "es"}, + ) + assert response2.status_code == 200 + data2 = response2.json()["data"] + assert data2["speaking_language"] == "es" + assert data2["listening_language"] == "es" def test_empty_body_no_change(self, client: TestClient) -> None: response = client.patch("/api/v1/users/me", json={})