From bd805c941a0beda8ee2bc182d9555f38d05badf3 Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 16 May 2026 05:33:23 +0800 Subject: [PATCH] test(auth): add SSO unit tests (22 tests) and fix timeout configuration - Add SsoClientTest (7 tests): ticket validation, null/empty/missing field responses, custom response field mapping - Add SsoIdentityServiceTest (6 tests): auto-provisioning, display name update, disabled user rejection, role resolution - Add SsoLoginControllerTest (9 tests): enabled/disabled guard, redirect flow, returnTo preservation, error handling - Fix SsoClient: replace RestTemplateBuilder with SimpleClientHttpRequestFactory for timeout config (RestTemplateBuilder.connectTimeout() is a valid Spring Boot 3.x API but caused transient build failures in Docker buildx) --- .../controller/SsoLoginControllerTest.java | 188 ++++++++++++++++++ .../iflytek/skillhub/auth/sso/SsoClient.java | 12 +- .../skillhub/auth/sso/SsoClientTest.java | 149 ++++++++++++++ .../auth/sso/SsoIdentityServiceTest.java | 175 ++++++++++++++++ 4 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SsoLoginControllerTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoClientTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoIdentityServiceTest.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SsoLoginControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SsoLoginControllerTest.java new file mode 100644 index 000000000..f3929ce05 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SsoLoginControllerTest.java @@ -0,0 +1,188 @@ +package com.iflytek.skillhub.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.config.SsoProperties; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.auth.sso.SsoClient; +import com.iflytek.skillhub.auth.sso.SsoIdentityService; +import com.iflytek.skillhub.auth.sso.SsoUser; +import com.iflytek.skillhub.auth.sso.TicketValidationException; +import jakarta.servlet.http.HttpSession; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class SsoLoginControllerTest { + + private static final String BASE_URL = "https://sso.example.com"; + private static final String CLIENT_URL = "https://skillhub.example.com/api/v1/auth/sso/callback"; + + private SsoProperties properties; + + @Mock + private SsoClient ssoClient; + + @Mock + private SsoIdentityService ssoIdentityService; + + @Mock + private PlatformSessionService platformSessionService; + + private SsoLoginController controller; + + @BeforeEach + void setUp() { + properties = new SsoProperties(); + properties.setBaseUrl(BASE_URL); + properties.setClientUrl(CLIENT_URL); + controller = new SsoLoginController(properties, ssoClient, ssoIdentityService, + platformSessionService); + } + + @Test + void ssoLogin_enabled_redirectsToSsoServer() throws Exception { + properties.setEnabled(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoLogin(null, request, response); + + assertThat(response.getStatus()).isEqualTo(302); + assertThat(response.getRedirectedUrl()) + .startsWith("https://sso.example.com/login") + .contains("clientUrl=" + CLIENT_URL); + } + + @Test + void ssoLogin_disabled_returns403() throws Exception { + properties.setEnabled(false); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoLogin(null, request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void ssoLogin_withReturnTo_storesInSession() throws Exception { + properties.setEnabled(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("returnTo", "/skills/123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoLogin("/skills/123", request, response); + + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute("ssoReturnTo")).isEqualTo("/skills/123"); + } + + @Test + void ssoCallback_enabled_validatesTicketAndRedirects() throws Exception { + properties.setEnabled(true); + SsoUser ssoUser = new SsoUser("zhangsan", "EMP001", "张三"); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_001", "张三", null, null, "sso", Set.of("USER")); + + when(ssoClient.validateTicket("ST-valid")).thenReturn(ssoUser); + when(ssoIdentityService.resolveOrCreate(ssoUser)).thenReturn(principal); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoCallback("ST-valid", request, response); + + verify(platformSessionService).establishSession(principal, request); + assertThat(response.getStatus()).isEqualTo(302); + assertThat(response.getRedirectedUrl()).isEqualTo("/"); + } + + @Test + void ssoCallback_withReturnTo_redirectsToSavedUrl() throws Exception { + properties.setEnabled(true); + SsoUser ssoUser = new SsoUser("zhangsan", "EMP001", "张三"); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_001", "张三", null, null, "sso", Set.of("USER")); + + when(ssoClient.validateTicket("ST-valid")).thenReturn(ssoUser); + when(ssoIdentityService.resolveOrCreate(ssoUser)).thenReturn(principal); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute("ssoReturnTo", "/skills/456"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoCallback("ST-valid", request, response); + + assertThat(response.getRedirectedUrl()).isEqualTo("/skills/456"); + HttpSession session = request.getSession(false); + assertThat(session.getAttribute("ssoReturnTo")).isNull(); + } + + @Test + void ssoCallback_invalidTicket_redirectsToErrorPage() throws Exception { + properties.setEnabled(true); + when(ssoClient.validateTicket("ST-invalid")) + .thenThrow(new TicketValidationException("Invalid ticket")); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoCallback("ST-invalid", request, response); + + assertThat(response.getStatus()).isEqualTo(302); + assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=sso_auth_failed"); + } + + @Test + void ssoCallback_genericException_redirectsToErrorPage() throws Exception { + properties.setEnabled(true); + when(ssoClient.validateTicket("ST-error")) + .thenThrow(new RuntimeException("Unexpected error")); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoCallback("ST-error", request, response); + + assertThat(response.getStatus()).isEqualTo(302); + assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=sso_error"); + } + + @Test + void ssoCallback_disabled_returns403() throws Exception { + properties.setEnabled(false); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoCallback("ST-anything", request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void ssoLogin_ssoUrlContainsClientUrl() throws Exception { + properties.setEnabled(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.ssoLogin(null, request, response); + + String redirectUrl = response.getRedirectedUrl(); + assertThat(redirectUrl).contains("clientUrl=" + CLIENT_URL); + assertThat(redirectUrl).startsWith("https://sso.example.com/login"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java index 853f91996..fa8257f51 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java @@ -4,7 +4,7 @@ import java.time.Duration; import java.util.Map; import com.iflytek.skillhub.auth.config.SsoProperties; -import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -19,12 +19,12 @@ public class SsoClient { private final SsoProperties properties; private final RestTemplate restTemplate; - public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) { + public SsoClient(SsoProperties properties) { this.properties = properties; - this.restTemplate = restTemplateBuilder - .connectTimeout(Duration.ofSeconds(5)) - .readTimeout(Duration.ofSeconds(10)) - .build(); + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis()); + factory.setReadTimeout((int) Duration.ofSeconds(10).toMillis()); + this.restTemplate = new RestTemplate(factory); } /** diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoClientTest.java new file mode 100644 index 000000000..75f300957 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoClientTest.java @@ -0,0 +1,149 @@ +package com.iflytek.skillhub.auth.sso; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.config.SsoProperties; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class SsoClientTest { + + private static final String BASE_URL = "https://sso.example.com"; + private static final String VALIDATE_PATH = "/stvalidate"; + private static final String CLIENT_URL = "https://skillhub.example.com/api/v1/auth/sso/callback"; + private static final String CLIENT_TOKEN = "test-token-123"; + + private SsoProperties properties; + + @Mock + private RestTemplate restTemplate; + + private SsoClient client; + + @BeforeEach + void setUp() { + properties = new SsoProperties(); + properties.setEnabled(true); + properties.setBaseUrl(BASE_URL); + properties.setValidatePath(VALIDATE_PATH); + properties.setClientUrl(CLIENT_URL); + properties.setClientToken(CLIENT_TOKEN); + + client = new SsoClient(properties); + ReflectionTestUtils.setField(client, "restTemplate", restTemplate); + } + + @Test + void validateTicket_returnsSsoUserOnSuccess() { + Map response = Map.of( + "account", "zhangsan", + "id", "EMP001", + "name", "张三" + ); + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(response); + + SsoUser user = client.validateTicket("ST-valid-ticket"); + + assertThat(user.account()).isEqualTo("zhangsan"); + assertThat(user.id()).isEqualTo("EMP001"); + assertThat(user.name()).isEqualTo("张三"); + } + + @Test + void validateTicket_returnsSsoUserWithEmptyName() { + Map response = Map.of( + "account", "lisi", + "id", "EMP002", + "name", "" + ); + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(response); + + SsoUser user = client.validateTicket("ST-another-ticket"); + + assertThat(user.account()).isEqualTo("lisi"); + assertThat(user.id()).isEqualTo("EMP002"); + assertThat(user.name()).isEmpty(); + } + + @Test + void validateTicket_throwsOnNullResponse() { + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(null); + + assertThatThrownBy(() -> client.validateTicket("ST-null")) + .isInstanceOf(TicketValidationException.class) + .hasMessageContaining("Empty response"); + } + + @Test + void validateTicket_throwsOnEmptyResponse() { + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(Map.of()); + + assertThatThrownBy(() -> client.validateTicket("ST-empty")) + .isInstanceOf(TicketValidationException.class) + .hasMessageContaining("Empty response"); + } + + @Test + void validateTicket_throwsOnMissingRequiredFields() { + Map response = Map.of( + "name", "test" + ); + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(response); + + assertThatThrownBy(() -> client.validateTicket("ST-partial")) + .isInstanceOf(TicketValidationException.class) + .hasMessageContaining("missing required fields"); + } + + @Test + void validateTicket_throwsOnMissingIdField() { + Map response = Map.of( + "account", "wangwu" + ); + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(response); + + assertThatThrownBy(() -> client.validateTicket("ST-no-id")) + .isInstanceOf(TicketValidationException.class) + .hasMessageContaining("missing required fields"); + } + + @Test + void validateTicket_usesCustomResponseFields() { + properties.getResponse().setAccountField("login"); + properties.getResponse().setIdField("employeeId"); + properties.getResponse().setNameField("fullName"); + + Map response = Map.of( + "login", "zhaoliu", + "employeeId", "EMP006", + "fullName", "赵六" + ); + when(restTemplate.postForObject(anyString(), any(), eq(Map.class))) + .thenReturn(response); + + SsoUser user = client.validateTicket("ST-custom"); + + assertThat(user.account()).isEqualTo("zhaoliu"); + assertThat(user.id()).isEqualTo("EMP006"); + assertThat(user.name()).isEqualTo("赵六"); + } + +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoIdentityServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoIdentityServiceTest.java new file mode 100644 index 000000000..5d162df87 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/sso/SsoIdentityServiceTest.java @@ -0,0 +1,175 @@ +package com.iflytek.skillhub.auth.sso; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SsoIdentityServiceTest { + + private static final String PROVIDER_CODE = "sso"; + private static final String SSO_ACCOUNT = "zhangsan"; + private static final String SSO_ID = "EMP001"; + private static final String SSO_NAME = "张三"; + + @Mock + private IdentityBindingRepository bindingRepo; + + @Mock + private UserAccountRepository userRepo; + + @Mock + private UserRoleBindingRepository roleBindingRepo; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + private SsoIdentityService service; + + @BeforeEach + void setUp() { + service = new SsoIdentityService(bindingRepo, userRepo, roleBindingRepo, + globalNamespaceMembershipService); + } + + @Test + void resolveOrCreate_existingBinding_updatesDisplayName() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, "张三(更新)"); + IdentityBinding binding = new IdentityBinding("usr_001", PROVIDER_CODE, SSO_ID, SSO_ACCOUNT); + UserAccount existingUser = new UserAccount("usr_001", SSO_NAME, null, null); + existingUser.setStatus(UserStatus.ACTIVE); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_001")).thenReturn(Optional.of(existingUser)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId("usr_001")).thenReturn(List.of()); + + PlatformPrincipal principal = service.resolveOrCreate(ssoUser); + + assertThat(principal.displayName()).isEqualTo("张三(更新)"); + assertThat(principal.oauthProvider()).isEqualTo(PROVIDER_CODE); + verify(userRepo).save(existingUser); + verify(globalNamespaceMembershipService, never()).ensureMember(any()); + } + + @Test + void resolveOrCreate_newSsoUser_createsUserAndBinding() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, SSO_NAME); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.resolveOrCreate(ssoUser); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userRepo).save(userCaptor.capture()); + UserAccount createdUser = userCaptor.getValue(); + assertThat(createdUser.getId()).startsWith("usr_"); + assertThat(createdUser.getDisplayName()).isEqualTo(SSO_NAME); + assertThat(createdUser.getStatus()).isEqualTo(UserStatus.ACTIVE); + + ArgumentCaptor bindingCaptor = ArgumentCaptor.forClass(IdentityBinding.class); + verify(bindingRepo).save(bindingCaptor.capture()); + IdentityBinding createdBinding = bindingCaptor.getValue(); + assertThat(createdBinding.getProviderCode()).isEqualTo(PROVIDER_CODE); + assertThat(createdBinding.getSubject()).isEqualTo(SSO_ID); + assertThat(createdBinding.getLoginName()).isEqualTo(SSO_ACCOUNT); + + verify(globalNamespaceMembershipService).ensureMember(createdUser.getId()); + assertThat(principal.displayName()).isEqualTo(SSO_NAME); + assertThat(principal.oauthProvider()).isEqualTo(PROVIDER_CODE); + } + + @Test + void resolveOrCreate_existingUserWithExplicitRoles() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, SSO_NAME); + IdentityBinding binding = new IdentityBinding("usr_001", PROVIDER_CODE, SSO_ID, SSO_ACCOUNT); + UserAccount existingUser = new UserAccount("usr_001", SSO_NAME, null, null); + existingUser.setStatus(UserStatus.ACTIVE); + + Role role = new Role(); + ReflectionTestUtils.setField(role, "code", "AUDITOR"); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_001")).thenReturn(Optional.of(existingUser)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId("usr_001")) + .thenReturn(List.of(new UserRoleBinding("usr_001", role))); + + PlatformPrincipal principal = service.resolveOrCreate(ssoUser); + + assertThat(principal.platformRoles()).contains("AUDITOR"); + } + + @Test + void resolveOrCreate_defaultsToUserRole() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, SSO_NAME); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.resolveOrCreate(ssoUser); + + assertThat(principal.platformRoles()).contains("USER"); + } + + @Test + void resolveOrCreate_nonActiveUser_throwsException() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, SSO_NAME); + IdentityBinding binding = new IdentityBinding("usr_001", PROVIDER_CODE, SSO_ID, SSO_ACCOUNT); + UserAccount disabledUser = new UserAccount("usr_001", SSO_NAME, null, null); + disabledUser.setStatus(UserStatus.DISABLED); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_001")).thenReturn(Optional.of(disabledUser)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThatThrownBy(() -> service.resolveOrCreate(ssoUser)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not active"); + } + + @Test + void resolveOrCreate_bindingWithoutUser_throwsException() { + SsoUser ssoUser = new SsoUser(SSO_ACCOUNT, SSO_ID, SSO_NAME); + IdentityBinding binding = new IdentityBinding("usr_ghost", PROVIDER_CODE, SSO_ID, SSO_ACCOUNT); + + when(bindingRepo.findByProviderCodeAndSubject(PROVIDER_CODE, SSO_ID)) + .thenReturn(Optional.of(binding)); + when(userRepo.findById("usr_ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resolveOrCreate(ssoUser)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("User not found for binding"); + } +}