From 0ad985515b1eb2826bbdad71006b2fa85258764e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 23 Jul 2026 12:16:06 +0900 Subject: [PATCH] test: move password reset coverage to unit tests (#38936) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/auth/test_password_reset.py | 187 +++++++++--------- 1 file changed, 95 insertions(+), 92 deletions(-) rename api/tests/{test_containers_integration_tests => unit_tests}/controllers/console/auth/test_password_reset.py (77%) diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py similarity index 77% rename from api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py rename to api/tests/unit_tests/controllers/console/auth/test_password_reset.py index 5fc3b3084a9..0ee9cf5248b 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -1,12 +1,14 @@ -"""Testcontainers integration tests for password reset authentication flows.""" +"""Unit tests for password reset controller flows.""" from __future__ import annotations +from collections.abc import Generator +from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest from flask import Flask -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session, sessionmaker from controllers.console.auth.error import ( EmailCodeError, @@ -21,47 +23,58 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError -from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup +from models.account import Account, Tenant, TenantAccountJoin +from services.feature_service import SystemFeatureModel + +SQLITE_MODELS = (Account, Tenant, TenantAccountJoin) + + +@contextmanager +def _bind_database_session(session: Session) -> Generator[scoped_session[Session]]: + """Bind the controller's session proxy to the SQLite test engine.""" + + database_session = scoped_session(sessionmaker(bind=session.get_bind(), expire_on_commit=False)) + try: + with patch("controllers.console.auth.forgot_password.db.session", database_session): + yield database_session + finally: + database_session.remove() + + +@pytest.fixture(autouse=True) +def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep endpoint decorators deterministic without requiring the configured app database.""" + + monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD") + monkeypatch.setattr( + "controllers.console.wraps.FeatureService.get_system_features", + lambda: SystemFeatureModel(enable_email_password_login=True), + ) class TestForgotPasswordSendEmailApi: """Test cases for sending password reset emails.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - - @pytest.fixture - def mock_account(self): - """Create mock account object.""" - account = MagicMock() - account.email = "test@example.com" - account.name = "Test User" - return account - + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email") - @patch("controllers.console.auth.forgot_password.FeatureService.get_system_features") def test_send_reset_email_success( self, - mock_get_features, mock_send_email, - mock_get_account, mock_is_ip_limit, app: Flask, - mock_account, + sqlite_session: Session, ): # Arrange mock_is_ip_limit.return_value = False - mock_get_account.return_value = mock_account mock_send_email.return_value = "reset_token_123" - mock_get_features.return_value.is_allow_register = True # Act - with app.test_request_context( - "/forgot-password", method="POST", json={"email": "test@example.com", "language": "en-US"} + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password", method="POST", json={"email": "test@example.com", "language": "en-US"} + ), ): api = ForgotPasswordSendEmailApi() response = api.post() @@ -98,20 +111,17 @@ class TestForgotPasswordSendEmailApi: (None, "en-US"), # Defaults to en-US when not provided ], ) + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email") - @patch("controllers.console.auth.forgot_password.FeatureService.get_system_features") def test_send_reset_email_language_handling( self, - mock_get_features, mock_send_email, - mock_get_account, mock_is_ip_limit, - app: Flask, - mock_account, language_input, expected_language, + app: Flask, + sqlite_session: Session, ): """ Test password reset email with different language preferences. @@ -122,13 +132,14 @@ class TestForgotPasswordSendEmailApi: """ # Arrange mock_is_ip_limit.return_value = False - mock_get_account.return_value = mock_account mock_send_email.return_value = "token" - mock_get_features.return_value.is_allow_register = True # Act - with app.test_request_context( - "/forgot-password", method="POST", json={"email": "test@example.com", "language": language_input} + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password", method="POST", json={"email": "test@example.com", "language": language_input} + ), ): api = ForgotPasswordSendEmailApi() api.post() @@ -141,11 +152,6 @@ class TestForgotPasswordSendEmailApi: class TestForgotPasswordCheckApi: """Test cases for verifying password reset codes.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") @@ -153,10 +159,10 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit") def test_verify_code_success( self, - mock_reset_rate_limit, - mock_generate_token, - mock_revoke_token, - mock_get_data, + mock_reset_rate_limit: MagicMock, + mock_generate_token: MagicMock, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, mock_is_rate_limit, app: Flask, ): @@ -200,10 +206,10 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit") def test_verify_code_preserves_token_email_case( self, - mock_reset_rate_limit, - mock_generate_token, - mock_revoke_token, - mock_get_data, + mock_reset_rate_limit: MagicMock, + mock_generate_token: MagicMock, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, mock_is_rate_limit, app: Flask, ): @@ -325,33 +331,15 @@ class TestForgotPasswordCheckApi: class TestForgotPasswordResetApi: """Test cases for resetting password with verified token.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - - @pytest.fixture - def mock_account(self): - """Create mock account object.""" - account = MagicMock() - account.email = "test@example.com" - account.name = "Test User" - return account - + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.forgot_password.db") - @patch("controllers.console.auth.forgot_password.TenantService.get_join_tenants") def test_reset_password_success( self, - mock_get_tenants, - mock_db, - mock_get_account, - mock_revoke_token, - mock_get_data, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, app: Flask, - mock_account, + sqlite_session: Session, ): """ Test successful password reset. @@ -363,25 +351,39 @@ class TestForgotPasswordResetApi: """ # Arrange mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"} - mock_get_account.return_value = mock_account - mock_db.session.merge.return_value = mock_account - mock_get_tenants.return_value = [MagicMock()] # Act - with app.test_request_context( - "/forgot-password/resets", - method="POST", - json={"token": "valid_token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, - ): - api = ForgotPasswordResetApi() - response = api.post() + with _bind_database_session(sqlite_session) as database_session: + account = Account(name="Test User", email="test@example.com") + tenant = Tenant(name="Test Workspace") + database_session.add_all([account, tenant]) + database_session.flush() + database_session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=account.id)) + database_session.commit() + account_id = account.id + + with app.test_request_context( + "/forgot-password/resets", + method="POST", + json={ + "token": "valid_token", + "new_password": "NewPass123!", + "password_confirm": "NewPass123!", + }, + ): + api = ForgotPasswordResetApi() + response = api.post() + + updated_account = database_session.get(Account, account_id) # Assert assert response["result"] == "success" mock_revoke_token.assert_called_once_with("valid_token") + assert updated_account is not None + assert updated_account.password is not None + assert updated_account.password_salt is not None - @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_reset_password_mismatch(self, mock_get_data, app: Flask): + def test_reset_password_mismatch(self, app: Flask): """ Test password reset with mismatched passwords. @@ -389,9 +391,6 @@ class TestForgotPasswordResetApi: - PasswordMismatchError is raised when passwords don't match - No password update occurs """ - # Arrange - mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"} - # Act & Assert with app.test_request_context( "/forgot-password/resets", @@ -445,10 +444,12 @@ class TestForgotPasswordResetApi: with pytest.raises(InvalidTokenError): api.post() + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") - def test_reset_password_account_not_found(self, mock_get_account, mock_revoke_token, mock_get_data, app: Flask): + def test_reset_password_account_not_found( + self, mock_revoke_token, mock_get_data, app: Flask, sqlite_session: Session + ): """ Test password reset for non-existent account. @@ -457,13 +458,15 @@ class TestForgotPasswordResetApi: """ # Arrange mock_get_data.return_value = {"email": "nonexistent@example.com", "phase": "reset"} - mock_get_account.return_value = None # Act & Assert - with app.test_request_context( - "/forgot-password/resets", - method="POST", - json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password/resets", + method="POST", + json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, + ), ): api = ForgotPasswordResetApi() with pytest.raises(AccountNotFound):