package com.mydreamjob.auth.identity; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import java.time.LocalDateTime; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest(properties = {"app.company-registration.enabled=true", "app.auth.password.enabled=true", "app.auth.password.rate-key-secret=identity-integration-test-rate-secret-only-0001", "spring.datasource.url=jdbc:h2:mem:unified_identity;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"}) @AutoConfigureMockMvc @ActiveProfiles("integration-test") @Transactional class UnifiedRegistrationIntegrationTest { @Autowired MockMvc mvc; @Autowired ObjectMapper mapper; @Autowired SmsVerificationCodeRepository codes; @Autowired UserRepository users; @Autowired JdbcTemplate jdbc; @Autowired org.springframework.security.crypto.password.PasswordEncoder passwords; @Autowired com.mydreamjob.auth.company.CompanyRegistrationProperties registration; @MockBean RedisTemplate redis; @org.springframework.boot.test.mock.mockito.SpyBean IdentitySelectionService identitySelection; private JsonNode login(String phone) throws Exception { codes.saveAndFlush(SmsVerificationCode.builder().phone(phone).code("123456").purpose("login") .attempts(0).maxAttempts(5).used(false).createdAt(LocalDateTime.now()) .expiredAt(LocalDateTime.now().plusMinutes(5)).build()); return mapper.readTree(mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON) .content("{\"phone\":\""+phone+"\",\"code\":\"123456\",\"purpose\":\"login\"}")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); } @Test void newAccountHasNoBusinessRole() throws Exception { JsonNode data = login("19900009401"); assertThat(data.path("role").asText()).isEqualTo("UNASSIGNED"); assertThat(data.path("identitySelectionRequired").asBoolean()).isTrue(); assertThat(jdbc.queryForList("select role from user_roles where user_id=?", data.path("userId").asLong())).isEmpty(); } @Test void sessionRevokedAfterFilterValidationReturnsSafe401() throws Exception { String token=login("19900009411").path("accessToken").asText(); // 模拟通过过滤器后、取得用户锁前被并发请求撤销会话;真实并发另由 MySQL/JAR 覆盖。 org.mockito.Mockito.doThrow(new InvalidTokenException()).when(identitySelection).select( org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(IdentitySelectionRequest.class), org.mockito.ArgumentMatchers.any(com.mydreamjob.audit.AuditContext.class)); String body=mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+token) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) .andExpect(status().isUnauthorized()).andExpect(jsonPath("$.code").value(401)) .andReturn().getResponse().getContentAsString(); assertThat(body).doesNotContain("Exception","jdbc:","auth_tokens",token); } @Test void pendingIdentityCannotUseAuthenticatedOnlyBusinessEndpoints() throws Exception { String token = login("19900009402").path("accessToken").asText(); for (String path : new String[]{"/assessments/types","/abilities/types","/notifications", "/companies/me/onboarding","/profile/me","/messages","/admin/unknown"}) { mvc.perform(get(path).header("Authorization", "Bearer "+token)).andExpect(status().isForbidden()); } mvc.perform(get("/auth/me").header("Authorization", "Bearer "+token)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(true)); mvc.perform(get("/jobs").header("Authorization", "Bearer "+token)).andExpect(status().isOk()); } @Test void studentSelectionRotatesCredentials() throws Exception { JsonNode before = login("19900009403"); JsonNode selected = mapper.readTree(mvc.perform(post("/auth/identity/select") .header("Authorization", "Bearer "+before.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("STUDENT")) .andReturn().getResponse().getContentAsString()).path("data"); mvc.perform(get("/auth/me").header("Authorization", "Bearer "+before.path("accessToken").asText())) .andExpect(status().isUnauthorized()); mvc.perform(get("/auth/me").header("Authorization", "Bearer "+selected.path("accessToken").asText())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(false)); mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(java.util.Map.of("refreshToken", before.path("refreshToken").asText())))) .andExpect(status().isUnauthorized()); } @Test void companySelectionCreatesUnverifiedCompany() throws Exception { JsonNode before = login("19900009404"); mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+before.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"身份选择测试企业\"}}")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("COMPANY")) .andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED")); assertThat(jdbc.queryForList("select role from user_roles where user_id=?", String.class, before.path("userId").asLong())) .containsExactly("COMPANY"); assertThat(jdbc.queryForObject("select verified from companies where owner_user_id=?", Boolean.class, before.path("userId").asLong())).isFalse(); } @Test void forbiddenClientFieldsAndAdminIdentityAreRejected() throws Exception { String token = login("19900009405").path("accessToken").asText(); for (String body : new String[]{"{\"identity\":\"ADMIN\"}","{\"identity\":\"STUDENT\",\"userId\":1}", "{\"identity\":\"COMPANY\"}","{\"identity\":\"STUDENT\",\"company\":{\"name\":\"bad\"}}", "{\"identity\":\"COMPANY\",\"company\":{\"name\":\"bad\",\"verified\":true}}"}) { mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+token) .contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest()); } } @Test void existingStudentIsNotReset() throws Exception { users.saveAndFlush(User.builder().phone("19900009406").role(User.Role.STUDENT) .roles(Set.of(User.Role.STUDENT)).active(true).build()); JsonNode data = login("19900009406"); assertThat(data.path("role").asText()).isEqualTo("STUDENT"); assertThat(data.has("identitySelectionRequired")).isTrue(); assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse(); } @ParameterizedTest @ValueSource(strings={"COMPANY","ADMIN"}) void otherExistingRolesAreNeverReset(String role) throws Exception { String phone=role.equals("COMPANY")?"19900009411":"19900009412"; User.Role existing=User.Role.valueOf(role); users.saveAndFlush(User.builder().phone(phone).role(existing).roles(Set.of(existing)).active(true).build()); JsonNode data=login(phone); assertThat(data.path("role").asText()).isEqualTo(role); assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse(); } @Test void passwordLoginRetainsPendingState() throws Exception { org.mockito.Mockito.when(redis.execute(org.mockito.ArgumentMatchers.any(org.springframework.data.redis.core.script.RedisScript.class), org.mockito.ArgumentMatchers.anyList(),org.mockito.ArgumentMatchers.any(Object[].class))).thenReturn(1L); users.saveAndFlush(User.builder().phone("19900009407").role(User.Role.UNASSIGNED).roles(Set.of()) .active(true).passwordHash(passwords.encode("IdentityTest!2026#A")).build()); mvc.perform(post("/auth/password/login").contentType(MediaType.APPLICATION_JSON) .content("{\"phone\":\"19900009407\",\"password\":\"IdentityTest!2026#A\"}")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("UNASSIGNED")) .andExpect(jsonPath("$.data.identitySelectionRequired").value(true)); } @Test void companySwitchOffDoesNotPreventStudentSelection() throws Exception { JsonNode before=login("19900009408"); String previous=registration.getEnabled(); try { registration.setEnabled("false"); mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}")) .andExpect(status().isServiceUnavailable()); assertThat(users.findById(before.path("userId").asLong()).orElseThrow().getRole()).isEqualTo(User.Role.UNASSIGNED); mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) .andExpect(status().isOk()); } finally {registration.setEnabled(previous);} } @Test void repeatSelectionIsReadOnlyAndChangingIdentityConflicts() throws Exception { JsonNode before=login("19900009409"); JsonNode result=mapper.readTree(mvc.perform(post("/auth/identity/select") .header("Authorization","Bearer "+before.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); var tokens=jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong()); var audits=jdbc.queryForList("select * from audit_logs order by id"); mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.accessToken").doesNotExist()) .andExpect(jsonPath("$.data.reauthenticationRequired").value(true)); mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText()) .contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}")) .andExpect(status().isConflict()); assertThat(jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong())).isEqualTo(tokens); assertThat(jdbc.queryForList("select * from audit_logs order by id")).isEqualTo(audits); } @Test void runtimeInventoryHasNoPendingIdentityBusinessBypass() throws Exception { JsonNode doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) .andReturn().getResponse().getContentAsString()); // 生成产物只用于本地结构对账,不提交运行日志或令牌。 java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-openapi-runtime.json"),mapper.writeValueAsString(doc)); String token=login("19900009410").path("accessToken").asText(); // 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。 Set publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters", "/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}", "/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs", "/job-recommendation-taxonomies","/auth/me"); Set accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set", "/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset", "/auth/refresh","/auth/sms/send","/auth/company/register"); int denied=0; int moduleDenied=0; var paths=doc.path("paths").fields(); while(paths.hasNext()) { var path=paths.next(); String uri=path.getKey().replaceAll("\\{[^}]+}","1"); var methods=path.getValue().fieldNames(); while(methods.hasNext()) { String method=methods.next().toUpperCase(java.util.Locale.ROOT); if((method.equals("GET") && publicGet.contains(path.getKey())) || (method.equals("POST") && accountPost.contains(path.getKey()))) continue; mvc.perform(request(org.springframework.http.HttpMethod.valueOf(method),uri) .header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401)); denied++; if (path.getKey().startsWith("/skills/") || path.getKey().startsWith("/onboarding/") || path.getKey().startsWith("/salary-calculator/")) moduleDenied++; } } assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); assertThat(denied - moduleDenied).as("既有身份门禁范围不减少").isEqualTo(197); java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); } }