feat: initialize user timezone and language from browser (#36170)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-05-15 08:12:52 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent ff02636a4b
commit a252fbddfa
24 changed files with 1050 additions and 87 deletions
@@ -143,7 +143,118 @@ class TestEmailRegisterResetApi:
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with("invitee@example.com", "ValidPass123!")
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone=None,
language=None,
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_reset_passes_timezone_to_new_account(
self,
mock_extract_ip,
mock_get_data,
mock_revoke_token,
mock_get_account,
mock_create_account,
mock_login,
mock_reset_login_rate,
app: Flask,
):
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
mock_create_account.return_value = MagicMock()
token_pair = MagicMock()
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"timezone": "Asia/Shanghai",
},
):
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone="Asia/Shanghai",
language=None,
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_reset_passes_language_to_new_account(
self,
mock_extract_ip,
mock_get_data,
mock_revoke_token,
mock_get_account,
mock_create_account,
mock_login,
mock_reset_login_rate,
app: Flask,
):
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
mock_create_account.return_value = MagicMock()
token_pair = MagicMock()
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"language": "zh-Hans",
},
):
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone=None,
language="zh-Hans",
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
@@ -14,7 +14,7 @@ from controllers.console.auth.oauth import (
_get_account_by_openid_or_email,
get_oauth_providers,
)
from libs.oauth import OAuthUserInfo
from libs.oauth import OAuthUserInfo, encode_oauth_state
from models.account import AccountStatus
from services.account_service import AccountService
from services.errors.account import AccountRegisterError
@@ -101,7 +101,55 @@ class TestOAuthLogin:
with app.test_request_context(f"/auth/oauth/github?{query_string}"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(invite_token=expected_token)
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=expected_token,
timezone=None,
language=None,
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth.redirect")
def test_should_pass_timezone_to_oauth_state(
self,
mock_redirect,
mock_get_providers,
resource,
app: Flask,
mock_oauth_provider,
):
mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None}
with app.test_request_context("/auth/oauth/github?timezone=Asia/Shanghai"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone="Asia/Shanghai",
language=None,
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth.redirect")
def test_should_pass_language_to_oauth_state(
self,
mock_redirect,
mock_get_providers,
resource,
app: Flask,
mock_oauth_provider,
):
mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None}
with app.test_request_context("/auth/oauth/github?language=zh-Hans"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone=None,
language="zh-Hans",
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@pytest.mark.parametrize(
@@ -229,7 +277,8 @@ class TestOAuthCallback:
mock_register_service.is_valid_invite_token.return_value = True
mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"}
with app.test_request_context("/auth/oauth/github/callback?code=test_code&state=invite123"):
state = encode_oauth_state(invite_token="invite123", timezone="Asia/Shanghai")
with app.test_request_context(f"/auth/oauth/github/callback?code=test_code&state={state}"):
resource.get("github")
mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123")
@@ -488,7 +537,13 @@ class TestAccountGeneration:
if should_create:
mock_register_service.register.assert_called_once_with(
email="test@example.com", name="Test User", password=None, open_id="123", provider="github"
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="en-US",
timezone=None,
)
else:
mock_register_service.register.assert_not_called()
@@ -515,7 +570,75 @@ class TestAccountGeneration:
_generate_account("github", user_info)
mock_register_service.register.assert_called_once_with(
email="upper@example.com", name="Test User", password=None, open_id="123", provider="github"
email="upper@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="en-US",
timezone=None,
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.AccountService")
@patch("controllers.console.auth.oauth.TenantService")
def test_should_register_with_browser_timezone(
self,
mock_tenant_service,
mock_account_service,
mock_register_service,
mock_feature_service,
mock_get_account,
app: Flask,
user_info,
):
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_register_service.register.return_value = MagicMock()
with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}):
_generate_account("github", user_info, timezone="Asia/Shanghai")
mock_register_service.register.assert_called_once_with(
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="zh-Hans",
timezone="Asia/Shanghai",
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.AccountService")
@patch("controllers.console.auth.oauth.TenantService")
def test_should_register_with_state_language(
self,
mock_tenant_service,
mock_account_service,
mock_register_service,
mock_feature_service,
mock_get_account,
app: Flask,
user_info,
):
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_register_service.register.return_value = MagicMock()
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
_generate_account("github", user_info, language="zh-Hans")
mock_register_service.register.assert_called_once_with(
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="zh-Hans",
timezone=None,
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email")
@@ -0,0 +1,40 @@
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload
@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant")
def test_create_new_account_uses_requested_language(mock_create_account):
account = MagicMock()
mock_create_account.return_value = account
result = EmailRegisterResetApi()._create_new_account(
"invitee@example.com",
"ValidPass123!",
timezone="Asia/Shanghai",
language="zh-Hans",
)
assert result is account
mock_create_account.assert_called_once_with(
email="invitee@example.com",
name="invitee@example.com",
password="ValidPass123!",
interface_language="zh-Hans",
timezone="Asia/Shanghai",
)
def test_reset_payload_rejects_invalid_timezone():
with pytest.raises(ValidationError):
EmailRegisterResetPayload.model_validate(
{
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"timezone": "",
}
)
@@ -13,9 +13,10 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError
from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi
from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginPayload, EmailCodeLoginSendEmailApi
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
@@ -31,6 +32,18 @@ def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode()
def test_email_code_login_payload_rejects_invalid_timezone():
with pytest.raises(ValidationError):
EmailCodeLoginPayload.model_validate(
{
"email": "newuser@example.com",
"code": "123456",
"token": "token-123",
"timezone": "",
}
)
class TestEmailCodeLoginSendEmailApi:
"""Test cases for sending email verification codes."""
@@ -342,6 +355,7 @@ class TestEmailCodeLoginApi:
"code": encode_code("123456"),
"token": "valid_token",
"language": "en-US",
"timezone": "Asia/Shanghai",
},
):
api = EmailCodeLoginApi()
@@ -349,7 +363,12 @@ class TestEmailCodeLoginApi:
# Assert
assert response.json["result"] == "success"
mock_create_account.assert_called_once()
mock_create_account.assert_called_once_with(
email="newuser@example.com",
name="newuser@example.com",
interface_language="en-US",
timezone="Asia/Shanghai",
)
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
@@ -0,0 +1,123 @@
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console.auth.oauth import OAuthLogin, _generate_account
from libs.oauth import OAuthUserInfo
from services.errors.account import AccountRegisterError
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
@patch("controllers.console.auth.oauth.redirect")
@patch("controllers.console.auth.oauth.get_oauth_providers")
def test_oauth_login_passes_language_and_timezone_to_authorization_url(
mock_get_oauth_providers,
mock_redirect,
app: Flask,
):
oauth_provider = MagicMock()
oauth_provider.get_authorization_url.return_value = "https://github.com/login/oauth/authorize?state=..."
mock_get_oauth_providers.return_value = {"github": oauth_provider}
with app.test_request_context("/oauth/login/github?language=zh-Hans&timezone=Asia/Shanghai"):
OAuthLogin().get("github")
oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone="Asia/Shanghai",
language="zh-Hans",
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?state=...")
@patch("controllers.console.auth.oauth.AccountService.link_account_integrate")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_registers_with_browser_timezone(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_link_account,
app: Flask,
):
account = MagicMock()
mock_register_service.register.return_value = account
mock_feature_service.get_system_features.return_value.is_allow_register = True
user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com")
with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}):
result, oauth_new_user = _generate_account("github", user_info, timezone="Asia/Shanghai")
assert result is account
assert oauth_new_user is True
mock_register_service.register.assert_called_once_with(
email="user@example.com",
name="Test User",
password=None,
open_id="github-123",
provider="github",
language="zh-Hans",
timezone="Asia/Shanghai",
)
mock_link_account.assert_called_once_with("github", "github-123", account)
@patch("controllers.console.auth.oauth.AccountService.link_account_integrate")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_prefers_state_language_over_accept_language(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_link_account,
app: Flask,
):
account = MagicMock()
mock_register_service.register.return_value = account
mock_feature_service.get_system_features.return_value.is_allow_register = True
user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com")
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
_generate_account("github", user_info, language="zh-Hans")
mock_register_service.register.assert_called_once_with(
email="user@example.com",
name="Test User",
password=None,
open_id="github-123",
provider="github",
language="zh-Hans",
timezone=None,
)
mock_link_account.assert_called_once_with("github", "github-123", account)
@patch("controllers.console.auth.oauth.dify_config")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_rejects_new_user_when_registration_disabled(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_config,
app: Flask,
):
mock_feature_service.get_system_features.return_value.is_allow_register = False
mock_config.BILLING_ENABLED = False
user_info = OAuthUserInfo(id="github-123", name="Test User", email="user@example.com")
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
with pytest.raises(AccountRegisterError):
_generate_account("github", user_info)
mock_register_service.register.assert_not_called()
+15 -1
View File
@@ -1,6 +1,6 @@
import pytest
from libs.oauth import OAuth
from libs.oauth import OAuth, decode_oauth_state, encode_oauth_state
def test_oauth_base_methods_raise_not_implemented():
@@ -17,3 +17,17 @@ def test_oauth_base_methods_raise_not_implemented():
with pytest.raises(NotImplementedError):
oauth._transform_user_info({})
def test_oauth_state_round_trips_invite_token_timezone_and_language():
state = encode_oauth_state(invite_token="invite-123", timezone="Asia/Shanghai", language="zh-Hans")
assert decode_oauth_state(state) == {
"invite_token": "invite-123",
"timezone": "Asia/Shanghai",
"language": "zh-Hans",
}
def test_oauth_state_returns_empty_payload_for_invalid_state():
assert decode_oauth_state("invalid-state") == {}
+35 -15
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo, decode_oauth_state
class BaseOAuthTest:
@@ -37,15 +37,25 @@ class TestGitHubOAuth(BaseOAuthTest):
return GitHubOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
@pytest.mark.parametrize(
("invite_token", "expected_state"),
("invite_token", "timezone", "language", "expected_state"),
[
(None, None),
("test_invite_token", "test_invite_token"),
("", None),
(None, None, None, None),
("test_invite_token", None, None, {"invite_token": "test_invite_token"}),
("", None, None, None),
(None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}),
(None, None, "zh-Hans", {"language": "zh-Hans"}),
(
"test_invite_token",
"Asia/Shanghai",
"zh-Hans",
{"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"},
),
],
)
def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
url = oauth.get_authorization_url(invite_token)
def test_should_generate_authorization_url_correctly(
self, oauth, oauth_config, invite_token, timezone, language, expected_state
):
url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language)
parsed, params = self.parse_auth_url(url)
assert parsed.scheme == "https"
@@ -56,7 +66,7 @@ class TestGitHubOAuth(BaseOAuthTest):
assert params["scope"][0] == "user:email"
if expected_state:
assert params["state"][0] == expected_state
assert decode_oauth_state(params["state"][0]) == expected_state
else:
assert "state" not in params
@@ -208,15 +218,25 @@ class TestGoogleOAuth(BaseOAuthTest):
return GoogleOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
@pytest.mark.parametrize(
("invite_token", "expected_state"),
("invite_token", "timezone", "language", "expected_state"),
[
(None, None),
("test_invite_token", "test_invite_token"),
("", None),
(None, None, None, None),
("test_invite_token", None, None, {"invite_token": "test_invite_token"}),
("", None, None, None),
(None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}),
(None, None, "zh-Hans", {"language": "zh-Hans"}),
(
"test_invite_token",
"Asia/Shanghai",
"zh-Hans",
{"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"},
),
],
)
def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
url = oauth.get_authorization_url(invite_token)
def test_should_generate_authorization_url_correctly(
self, oauth, oauth_config, invite_token, timezone, language, expected_state
):
url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language)
parsed, params = self.parse_auth_url(url)
assert parsed.scheme == "https"
@@ -228,7 +248,7 @@ class TestGoogleOAuth(BaseOAuthTest):
assert params["scope"][0] == "openid email"
if expected_state:
assert params["state"][0] == expected_state
assert decode_oauth_state(params["state"][0]) == expected_state
else:
assert "state" not in params
@@ -260,7 +260,7 @@ class TestAccountService:
assert result.interface_theme == "light"
assert result.password is not None
assert result.password_salt is not None
assert result.timezone is not None
assert result.timezone == "America/New_York"
# Verify database operations
mock_db_dependencies["db"].session.add.assert_called_once()
@@ -271,7 +271,28 @@ class TestAccountService:
assert added_account.interface_theme == "light"
assert added_account.password is not None
assert added_account.password_salt is not None
assert added_account.timezone is not None
assert added_account.timezone == "America/New_York"
self._assert_database_operations_called(mock_db_dependencies["db"])
def test_create_account_uses_explicit_timezone(
self, mock_db_dependencies, mock_password_dependencies, mock_external_service_dependencies
):
"""Test account creation prefers explicit browser timezone."""
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_password_dependencies["hash_password"].return_value = b"hashed_password"
result = AccountService.create_account(
email="test@example.com",
name="Test User",
interface_language="en-US",
password="password123",
timezone="Asia/Shanghai",
)
assert result.timezone == "Asia/Shanghai"
added_account = mock_db_dependencies["db"].session.add.call_args[0][0]
assert added_account.timezone == "Asia/Shanghai"
self._assert_database_operations_called(mock_db_dependencies["db"])
def test_create_account_registration_disabled(self, mock_external_service_dependencies):
@@ -1221,6 +1242,7 @@ class TestRegisterService:
interface_language="en-US",
password="password123",
is_setup=False,
timezone=None,
)
mock_create_tenant.assert_called_once_with("Test User's Workspace")
mock_create_member.assert_called_once_with(mock_tenant, mock_account, role="owner")