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