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)
This commit is contained in:
jangrui 2026-05-16 05:33:23 +08:00
parent cbe675e98d
commit bd805c941a
4 changed files with 518 additions and 6 deletions

View file

@ -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");
}
}

View file

@ -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);
}
/**

View file

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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("赵六");
}
}

View file

@ -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<UserAccount> 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<IdentityBinding> 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");
}
}