You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

217 lines
15 KiB

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