diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index 956832f52..d50cdc1f0 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -45,7 +45,8 @@ async def signup(request: UserSignUpRequest): try: user_data = await signup_user_with_invitation(email=request.email, password=request.password, - invite_code=request.invite_code) + invite_code=request.invite_code, + auto_login=request.auto_login) success_message = "🎉 User account registered successfully! Please start experiencing the AI assistant service." return JSONResponse(status_code=HTTPStatus.OK, content={"message":success_message, "data":user_data}) diff --git a/backend/consts/model.py b/backend/consts/model.py index 7b7c55e8b..6aea42fa9 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -31,6 +31,7 @@ class UserSignUpRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=6) invite_code: Optional[str] = None + auto_login: Optional[bool] = True # Whether to return session after signup class UserSignInRequest(BaseModel): diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 3499d3170..39ea8cfbe 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -129,11 +129,12 @@ async def check_auth_service_health(): async def signup_user_with_invitation(email: EmailStr, password: str, - invite_code: Optional[str] = None): + invite_code: Optional[str] = None, + auto_login: Optional[bool] = True): """User registration with invitation code support""" client = get_supabase_client() logging.info( - f"Receive registration request: email={email}, invite_code={'provided' if invite_code else 'not provided'}") + f"Receive registration request: email={email}, invite_code={'provided' if invite_code else 'not provided'}, auto_login={auto_login}") # Default user role is USER user_role = "USER" @@ -228,7 +229,7 @@ async def signup_user_with_invitation(email: EmailStr, f"Failed to use invitation code {invite_code} for user {user_id}: {str(e)}") logging.info( - f"User {email} registered successfully, role: {user_role}, tenant: {tenant_id}") + f"User {email} registered successfully, role: {user_role}, tenant: {tenant_id}, auto_login={auto_login}") if user_role == "ADMIN": await generate_tts_stt_4_admin(tenant_id, user_id) @@ -236,7 +237,7 @@ async def signup_user_with_invitation(email: EmailStr, # Initialize tool list for the new tenant (only once per tenant) await init_tool_list_for_tenant(tenant_id, user_id) - return await parse_supabase_response(False, response, user_role) + return await parse_supabase_response(False, response, user_role, auto_login) else: logging.error( "Supabase registration request returned no user object") @@ -244,7 +245,7 @@ async def signup_user_with_invitation(email: EmailStr, "Registration service is temporarily unavailable, please try again later") -async def parse_supabase_response(is_admin, response, user_role): +async def parse_supabase_response(is_admin, response, user_role, auto_login: bool = True): """Parse Supabase response and build standardized user registration response""" user_data = { "id": response.user.id, @@ -253,7 +254,7 @@ async def parse_supabase_response(is_admin, response, user_role): } session_data = None - if response.session: + if response.session and auto_login: session_data = { "access_token": response.session.access_token, "refresh_token": response.session.refresh_token, diff --git a/frontend/services/authService.ts b/frontend/services/authService.ts index 8d2bac942..fa5281989 100644 --- a/frontend/services/authService.ts +++ b/frontend/services/authService.ts @@ -173,6 +173,7 @@ export const authService = { email, password, invite_code: inviteCode || null, + auto_login: autoLogin, }), }); diff --git a/test/backend/app/test_user_management_app.py b/test/backend/app/test_user_management_app.py index cfb22dd15..30e8479dc 100644 --- a/test/backend/app/test_user_management_app.py +++ b/test/backend/app/test_user_management_app.py @@ -117,7 +117,34 @@ def test_signup_success_regular_user(self): mock_signup.assert_called_once_with( email="test@example.com", password="password123", - invite_code=None + invite_code=None, + auto_login=True + ) + + def test_signup_success_regular_user_with_auto_login_false(self): + """Test successful regular user registration with auto_login=false""" + with patch('apps.user_management_app.signup_user_with_invitation') as mock_signup: + mock_signup.return_value = {"user_id": "123", "email": "test@example.com"} + + response = client.post( + "/user/signup", + json={ + "email": "test@example.com", + "password": "password123", + "invite_code": None, + "auto_login": False + } + ) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert "registered successfully" in data["message"] + assert "data" in data + mock_signup.assert_called_once_with( + email="test@example.com", + password="password123", + invite_code=None, + auto_login=False ) def test_signup_success_admin_user(self): @@ -141,7 +168,33 @@ def test_signup_success_admin_user(self): mock_signup.assert_called_once_with( email="admin@example.com", password="password123", - invite_code="admin_code" + invite_code="admin_code", + auto_login=True + ) + + def test_signup_success_admin_user_with_auto_login_false(self): + """Test successful admin user registration with auto_login=false (tenant management scenario)""" + with patch('apps.user_management_app.signup_user_with_invitation') as mock_signup: + mock_signup.return_value = {"user_id": "123", "email": "admin@example.com"} + + response = client.post( + "/user/signup", + json={ + "email": "admin@example.com", + "password": "password123", + "invite_code": "admin_code", + "auto_login": False + } + ) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert "registered successfully" in data["message"] + mock_signup.assert_called_once_with( + email="admin@example.com", + password="password123", + invite_code="admin_code", + auto_login=False ) def test_signup_no_invite_code_exception(self): diff --git a/test/backend/services/test_user_management_service.py b/test/backend/services/test_user_management_service.py index 335cf0a64..ac5deba80 100644 --- a/test/backend/services/test_user_management_service.py +++ b/test/backend/services/test_user_management_service.py @@ -586,7 +586,7 @@ async def test_signup_user_with_admin_invite_code(self, mock_get_client, mock_us mock_insert_tenant.assert_called_once_with(user_id="user-123", tenant_id="tenant_id", user_role="ADMIN", user_email="admin@example.com") mock_use_invite.assert_called_once_with("ADMIN123", "user-123") mock_add_groups.assert_called_once_with("user-123", [1, 2, 3], "user-123") - mock_parse_response.assert_called_once_with(False, mock_response, "ADMIN") + mock_parse_response.assert_called_once_with(False, mock_response, "ADMIN", True) # Verify init_tool_list_for_tenant was called mock_init_tools.assert_called_once_with("tenant_id", "user-123") @@ -637,7 +637,7 @@ async def test_signup_user_with_dev_invite_code(self, mock_get_client, mock_use_ mock_insert_tenant.assert_called_once_with(user_id="user-456", tenant_id="tenant_id", user_role="DEV", user_email="dev@example.com") mock_use_invite.assert_called_once_with("DEV456", "user-456") mock_add_groups.assert_called_once_with("user-456", [4, 5], "user-456") - mock_parse_response.assert_called_once_with(False, mock_response, "DEV") + mock_parse_response.assert_called_once_with(False, mock_response, "DEV", True) # Verify init_tool_list_for_tenant was called mock_init_tools.assert_called_once_with("tenant_id", "user-456") @@ -738,7 +738,7 @@ async def test_signup_user_with_admin_invite_role_assignment(self, mock_check_av # Verify ADMIN role was assigned and TTS/STT generation was called mock_insert_tenant.assert_called_with(user_id="user-123", tenant_id="tenant_id", user_role="ADMIN", user_email="admin@example.com") mock_generate_tts.assert_called_once_with("tenant_id", "user-123") - mock_parse.assert_called_with(False, mock_response, "ADMIN") + mock_parse.assert_called_with(False, mock_response, "ADMIN", True) # Verify init_tool_list_for_tenant was called mock_init_tools.assert_called_once_with("tenant_id", "user-123") @@ -774,7 +774,7 @@ async def test_signup_user_with_dev_invite_role_assignment(self, mock_check_avai # Verify DEV role was assigned and TTS/STT generation was NOT called mock_insert_tenant.assert_called_with(user_id="user-123", tenant_id="tenant_id", user_role="DEV", user_email="dev@example.com") - mock_parse.assert_called_with(False, mock_response, "DEV") + mock_parse.assert_called_with(False, mock_response, "DEV", True) # Verify init_tool_list_for_tenant was called mock_init_tools.assert_called_once_with("tenant_id", "user-123") @@ -789,6 +789,97 @@ async def test_signup_user_with_invite_code_validation_exception_conversion(self self.assertIn("Invalid invitation code: Database connection failed", str(context.exception)) + @patch('backend.services.user_management_service.add_user_to_groups') + @patch('backend.services.user_management_service.parse_supabase_response') + @patch('backend.services.user_management_service.generate_tts_stt_4_admin') + @patch('backend.services.user_management_service.insert_user_tenant') + @patch('backend.services.user_management_service.get_invitation_by_code') + @patch('backend.services.user_management_service.check_invitation_available') + @patch('backend.services.user_management_service.use_invitation_code') + @patch('backend.services.user_management_service.get_supabase_client') + async def test_signup_user_with_auto_login_false(self, mock_get_client, mock_use_invite, + mock_check_available, mock_get_invite_code, + mock_insert_tenant, mock_generate_tts, mock_parse_response, mock_add_groups): + """Test user signup with auto_login=False (tenant admin creation scenario)""" + # Setup mocks + mock_client = MagicMock() + mock_user = MagicMock() + mock_user.id = "user-123" + mock_response = MagicMock() + mock_response.user = mock_user + mock_client.auth.sign_up.return_value = mock_response + mock_get_client.return_value = mock_client + + # Mock invitation code validation + mock_check_available.return_value = True + mock_get_invite_code.return_value = { + "invitation_id": 1, + "code_type": "ADMIN_INVITE", + "group_ids": [], + "tenant_id": "tenant_id" + } + mock_use_invite.return_value = {"invitation_id": 1, "code_type": "ADMIN_INVITE", "group_ids": []} + mock_parse_response.return_value = {"user": "admin_data", "session": None} + mock_add_groups.return_value = [] + + # Call with auto_login=False + with patch('backend.services.user_management_service.init_tool_list_for_tenant', new_callable=AsyncMock) as mock_init_tools: + result = await signup_user_with_invitation( + "admin@example.com", + "password123", + invite_code="ADMIN123", + auto_login=False + ) + + # Verify parse_supabase_response was called with auto_login=False + mock_parse_response.assert_called_once_with(False, mock_response, "ADMIN", False) + # Verify init_tool_list_for_tenant was called + mock_init_tools.assert_called_once_with("tenant_id", "user-123") + + @patch('backend.services.user_management_service.add_user_to_groups') + @patch('backend.services.user_management_service.parse_supabase_response') + @patch('backend.services.user_management_service.generate_tts_stt_4_admin') + @patch('backend.services.user_management_service.insert_user_tenant') + @patch('backend.services.user_management_service.get_invitation_by_code') + @patch('backend.services.user_management_service.check_invitation_available') + @patch('backend.services.user_management_service.use_invitation_code') + @patch('backend.services.user_management_service.get_supabase_client') + async def test_signup_user_with_auto_login_default(self, mock_get_client, mock_use_invite, + mock_check_available, mock_get_invite_code, + mock_insert_tenant, mock_generate_tts, mock_parse_response, mock_add_groups): + """Test user signup with default auto_login (True)""" + # Setup mocks + mock_client = MagicMock() + mock_user = MagicMock() + mock_user.id = "user-123" + mock_response = MagicMock() + mock_response.user = mock_user + mock_client.auth.sign_up.return_value = mock_response + mock_get_client.return_value = mock_client + + # Mock invitation code validation + mock_check_available.return_value = True + mock_get_invite_code.return_value = { + "invitation_id": 1, + "code_type": "ADMIN_INVITE", + "group_ids": [], + "tenant_id": "tenant_id" + } + mock_use_invite.return_value = {"invitation_id": 1, "code_type": "ADMIN_INVITE", "group_ids": []} + mock_parse_response.return_value = {"user": "admin_data", "session": "session_data"} + mock_add_groups.return_value = [] + + # Call without auto_login parameter (should default to True) + with patch('backend.services.user_management_service.init_tool_list_for_tenant', new_callable=AsyncMock) as mock_init_tools: + result = await signup_user_with_invitation( + "admin@example.com", + "password123", + invite_code="ADMIN123" + ) + + # Verify parse_supabase_response was called with default auto_login=True + mock_parse_response.assert_called_once_with(False, mock_response, "ADMIN", True) + class TestParseSupabaseResponse(unittest.IsolatedAsyncioTestCase): """Test parse_supabase_response""" @@ -853,6 +944,93 @@ async def test_parse_response_without_session(self): } self.assertEqual(result, expected) + @patch('backend.services.user_management_service.get_jwt_expiry_seconds') + @patch('backend.services.user_management_service.calculate_expires_at') + async def test_parse_response_with_session_but_auto_login_false(self, mock_calc_expires, mock_get_expiry): + """Test parsing response with session but auto_login=False (tenant admin creation scenario)""" + mock_user = MagicMock() + mock_user.id = "user-123" + mock_user.email = "admin@example.com" + + mock_session = MagicMock() + mock_session.access_token = "access-token" + mock_session.refresh_token = "refresh-token" + + mock_response = MagicMock() + mock_response.user = mock_user + mock_response.session = mock_session + + mock_calc_expires.return_value = "2024-01-01T00:00:00Z" + mock_get_expiry.return_value = 3600 + + # When auto_login=False, session should be None even if Supabase returns session + result = await parse_supabase_response(False, mock_response, "ADMIN", auto_login=False) + + expected = { + "user": { + "id": "user-123", + "email": "admin@example.com", + "role": "ADMIN" + }, + "session": None, # Session should be suppressed when auto_login=False + "registration_type": "user" + } + self.assertEqual(result, expected) + + @patch('backend.services.user_management_service.get_jwt_expiry_seconds') + @patch('backend.services.user_management_service.calculate_expires_at') + async def test_parse_response_with_session_and_auto_login_true(self, mock_calc_expires, mock_get_expiry): + """Test parsing response with session and auto_login=True (normal signup scenario)""" + mock_user = MagicMock() + mock_user.id = "user-123" + mock_user.email = "test@example.com" + + mock_session = MagicMock() + mock_session.access_token = "access-token" + mock_session.refresh_token = "refresh-token" + + mock_response = MagicMock() + mock_response.user = mock_user + mock_response.session = mock_session + + mock_calc_expires.return_value = "2024-01-01T00:00:00Z" + mock_get_expiry.return_value = 3600 + + # When auto_login=True, session should be included + result = await parse_supabase_response(False, mock_response, "USER", auto_login=True) + + expected = { + "user": { + "id": "user-123", + "email": "test@example.com", + "role": "USER" + }, + "session": { + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": "2024-01-01T00:00:00Z", + "expires_in_seconds": 3600 + }, + "registration_type": "user" + } + self.assertEqual(result, expected) + + async def test_parse_response_default_auto_login_true(self): + """Test that auto_login defaults to True when not specified""" + mock_user = MagicMock() + mock_user.id = "user-123" + mock_user.email = "test@example.com" + + mock_response = MagicMock() + mock_response.user = mock_user + mock_response.session = None # No session from Supabase + + # Call without auto_login parameter (should default to True) + result = await parse_supabase_response(False, mock_response, "user") + + # Session should be None because Supabase didn't return it + self.assertIsNone(result["session"]) + class TestGenerateTtsStt4Admin(unittest.IsolatedAsyncioTestCase): """Test generate_tts_stt_4_admin"""