6 changed files with 936 additions and 3 deletions
@ -0,0 +1,211 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
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.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = |
|||
"spring.datasource.url=jdbc:h2:mem:onboarding_authorization;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class OnboardingAuthorizationIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(971_000_000L); |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private OnboardingChecklistItemRepository checklistItemRepository; |
|||
@Autowired private StudentOnboardingProgressRepository progressRepository; |
|||
@Autowired private CompanyKnowledgeRepository knowledgeRepository; |
|||
@Autowired private OnboardingChecklistService checklistService; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
void clearData() { |
|||
progressRepository.deleteAll(); |
|||
knowledgeRepository.deleteAll(); |
|||
checklistItemRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
userRepository.deleteAll(); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousOnboardingRequestsAreUnauthorized() throws Exception { |
|||
mockMvc.perform(get("/onboarding/companies/1/checklist")) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(jsonPath("$.code").value(401)); |
|||
mockMvc.perform(get("/onboarding/company/checklist-items")) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(jsonPath("$.code").value(401)); |
|||
mockMvc.perform(get("/onboarding/company/knowledge")) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(jsonPath("$.code").value(401)); |
|||
} |
|||
|
|||
@Test |
|||
void onboardingEndpointsRejectWrongRolesWithForbidden() throws Exception { |
|||
Long companyUser = createUser(User.Role.COMPANY); |
|||
createCompany(companyUser, "权限企业"); |
|||
Long student = createUser(User.Role.STUDENT); |
|||
Long admin = createUser(User.Role.ADMIN); |
|||
|
|||
mockMvc.perform(get("/onboarding/companies/1/checklist") |
|||
.header("Authorization", bearer(companyUser))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); |
|||
|
|||
for (Long userId : java.util.List.of(student, admin)) { |
|||
mockMvc.perform(get("/onboarding/company/checklist-items") |
|||
.header("Authorization", bearer(userId))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void companyCannotUpdateOrDeleteAnotherCompanysItem() { |
|||
Long companyAUser = createUser(User.Role.COMPANY); |
|||
Long companyA = createCompany(companyAUser, "企业A"); |
|||
Long companyBUser = createUser(User.Role.COMPANY); |
|||
createCompany(companyBUser, "企业B"); |
|||
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() |
|||
.scopeType("COMPANY") |
|||
.scopeId(companyA) |
|||
.companyId(companyA) |
|||
.itemKey("company-a-secret") |
|||
.title("A 企业专属标题") |
|||
.category("ADMIN") |
|||
.sortOrder(100) |
|||
.required(false) |
|||
.active(true) |
|||
.build()); |
|||
|
|||
CompanyChecklistItemUpdateRequest update = new CompanyChecklistItemUpdateRequest(); |
|||
update.setTitle("越权修改"); |
|||
update.setCategory("ADMIN"); |
|||
update.setSortOrder(100); |
|||
update.setRequired(false); |
|||
update.setActive(true); |
|||
update.setVersion(0L); |
|||
|
|||
assertThatThrownBy(() -> checklistService.updateCompanyChecklistItem( |
|||
companyBUser, item.getId(), update)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); |
|||
assertThatThrownBy(() -> checklistService.deleteCompanyChecklistItem( |
|||
companyBUser, item.getId(), 0L)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); |
|||
|
|||
OnboardingChecklistItem unchanged = checklistItemRepository.findById(item.getId()).orElseThrow(); |
|||
assertThat(unchanged.getTitle()).isEqualTo("A 企业专属标题"); |
|||
assertThat(unchanged.getActive()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void httpCrossCompanyUpdateReturnsNotFoundWithoutChangingTarget() throws Exception { |
|||
Long companyAUser = createUser(User.Role.COMPANY); |
|||
Long companyA = createCompany(companyAUser, "HTTP 企业A"); |
|||
Long companyBUser = createUser(User.Role.COMPANY); |
|||
createCompany(companyBUser, "HTTP 企业B"); |
|||
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() |
|||
.scopeType("COMPANY") |
|||
.scopeId(companyA) |
|||
.companyId(companyA) |
|||
.itemKey("http-company-a") |
|||
.title("HTTP 企业专属") |
|||
.category("ADMIN") |
|||
.sortOrder(100) |
|||
.required(false) |
|||
.active(true) |
|||
.build()); |
|||
|
|||
mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId()) |
|||
.header("Authorization", bearer(companyBUser)) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(""" |
|||
{ |
|||
"title": "越权修改", |
|||
"category": "ADMIN", |
|||
"sortOrder": 100, |
|||
"required": false, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
""")) |
|||
.andExpect(status().isNotFound()) |
|||
.andExpect(jsonPath("$.code") |
|||
.value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode())); |
|||
|
|||
assertThat(checklistItemRepository.findById(item.getId()).orElseThrow().getTitle()) |
|||
.isEqualTo("HTTP 企业专属"); |
|||
} |
|||
|
|||
@Test |
|||
void companyWithoutProfileReturnsCompanyNotFound() throws Exception { |
|||
Long companyUser = createUser(User.Role.COMPANY); |
|||
|
|||
mockMvc.perform(get("/onboarding/company/checklist-items") |
|||
.header("Authorization", bearer(companyUser))) |
|||
.andExpect(status().isNotFound()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); |
|||
} |
|||
|
|||
private Long createCompany(Long ownerUserId, String name) { |
|||
return companyRepository.save(Company.builder() |
|||
.ownerUserId(ownerUserId) |
|||
.name(name) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
User user = userRepository.save(User.builder() |
|||
.phone(String.valueOf(PHONE_SEQ.getAndIncrement())) |
|||
.nickname(role.name()) |
|||
.role(role) |
|||
.roles(Set.of(role)) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()); |
|||
return user.getId(); |
|||
} |
|||
|
|||
private String bearer(Long userId) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken( |
|||
userId, String.valueOf(userId), Map.of("roles", "STUDENT")); |
|||
} |
|||
} |
|||
@ -0,0 +1,424 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
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.context.TestPropertySource; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.Callable; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.Future; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = |
|||
"spring.datasource.url=jdbc:h2:mem:onboarding_concurrency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class OnboardingConcurrencyIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(972_000_000L); |
|||
private static final AtomicLong ITEM_SEQ = new AtomicLong(1L); |
|||
|
|||
@Autowired private OnboardingChecklistService checklistService; |
|||
@Autowired private CompanyKnowledgeService knowledgeService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private OnboardingChecklistItemRepository checklistItemRepository; |
|||
@Autowired private StudentOnboardingProgressRepository progressRepository; |
|||
@Autowired private CompanyKnowledgeRepository knowledgeRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
void clearData() { |
|||
progressRepository.deleteAll(); |
|||
knowledgeRepository.deleteAll(); |
|||
checklistItemRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
userRepository.deleteAll(); |
|||
} |
|||
|
|||
@Test |
|||
void identicalIdempotencyKeyConcurrentCreatesReplaySameRow() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("幂等企业"); |
|||
CompanyChecklistItemCreateRequest request = createRequest("重复请求", "same-key"); |
|||
|
|||
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently( |
|||
() -> checklistService.createCompanyChecklistItem(owner.userId(), request), |
|||
() -> checklistService.createCompanyChecklistItem(owner.userId(), request)); |
|||
|
|||
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); |
|||
assertThat(attempts).extracting(attempt -> attempt.value().getItemId()) |
|||
.containsOnly(attempts.get(0).value().getItemId()); |
|||
assertThat(companyRows(owner.companyId()).stream() |
|||
.filter(item -> "same-key".equals(item.getIdempotencyKey()))) |
|||
.hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void idempotencyKeyRaceWithDifferentBodiesReturns9704() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("幂等冲突企业"); |
|||
|
|||
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently( |
|||
() -> checklistService.createCompanyChecklistItem( |
|||
owner.userId(), createRequest("请求A", "conflict-key")), |
|||
() -> checklistService.createCompanyChecklistItem( |
|||
owner.userId(), createRequest("请求B", "conflict-key"))); |
|||
|
|||
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); |
|||
assertThat(attempts).filteredOn(Attempt::failedWithIdempotencyConflict).hasSize(1); |
|||
assertThat(companyRows(owner.companyId()).stream() |
|||
.filter(item -> "conflict-key".equals(item.getIdempotencyKey()))) |
|||
.hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("上限企业"); |
|||
seedCompanyItems(owner.companyId(), 49); |
|||
|
|||
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently( |
|||
() -> checklistService.createCompanyChecklistItem( |
|||
owner.userId(), createRequest("边界A", "limit-a")), |
|||
() -> checklistService.createCompanyChecklistItem( |
|||
owner.userId(), createRequest("边界B", "limit-b"))); |
|||
|
|||
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); |
|||
assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1); |
|||
assertThat(companyRows(owner.companyId())) |
|||
.filteredOn(item -> Boolean.TRUE.equals(item.getActive())) |
|||
.hasSize(50); |
|||
} |
|||
|
|||
@Test |
|||
void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("停用项企业"); |
|||
seedCompanyItems(owner.companyId(), 49); |
|||
CompanyChecklistItemCreateRequest fiftieth = createRequest("第50条", "replay-key"); |
|||
CompanyChecklistItemView created = checklistService.createCompanyChecklistItem( |
|||
owner.userId(), fiftieth); |
|||
seedInactiveCompanyItems(owner.companyId(), 3); |
|||
|
|||
CompanyChecklistItemCreateRequest newRequest = createRequest("第51条", "new-over-limit"); |
|||
assertThat(failedWith(() -> checklistService.createCompanyChecklistItem( |
|||
owner.userId(), newRequest))).isEqualTo( |
|||
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()); |
|||
|
|||
CompanyChecklistItemView replay = checklistService.createCompanyChecklistItem( |
|||
owner.userId(), fiftieth); |
|||
assertThat(replay.getItemId()).isEqualTo(created.getItemId()); |
|||
assertThat(companyRows(owner.companyId()).stream() |
|||
.filter(item -> Boolean.TRUE.equals(item.getActive()))) |
|||
.hasSize(50); |
|||
} |
|||
|
|||
@Test |
|||
void sameIdempotencyKeyIsIsolatedByCompany() throws Exception { |
|||
CompanyOwner first = createCompanyOwner("企业甲"); |
|||
CompanyOwner second = createCompanyOwner("企业乙"); |
|||
CompanyChecklistItemCreateRequest request = createRequest("独立企业请求", "shared-key"); |
|||
|
|||
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently( |
|||
() -> checklistService.createCompanyChecklistItem(first.userId(), request), |
|||
() -> checklistService.createCompanyChecklistItem(second.userId(), request)); |
|||
|
|||
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); |
|||
assertThat(companyRows(first.companyId())).hasSize(1); |
|||
assertThat(companyRows(second.companyId())).hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void firstProgressCreateWithNonZeroVersionReturns9705() { |
|||
CompanyOwner owner = createCompanyOwner("进度版本企业"); |
|||
Long student = createUser(User.Role.STUDENT); |
|||
OnboardingChecklistItem item = createStandardItem("进度版本项"); |
|||
|
|||
ProgressUpdateRequest request = progressRequest(true, 1L); |
|||
|
|||
assertThat(failedWith(() -> checklistService.updateCompletion( |
|||
student, owner.companyId(), item.getId(), request))) |
|||
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()); |
|||
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId())) |
|||
.isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentFirstProgressCreatesProduceSingleRow() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("首次进度企业"); |
|||
Long student = createUser(User.Role.STUDENT); |
|||
OnboardingChecklistItem item = createStandardItem("首次进度项"); |
|||
|
|||
List<Callable<ProgressUpdateResponse>> calls = new ArrayList<>(); |
|||
for (int i = 0; i < 4; i++) { |
|||
calls.add(() -> checklistService.updateCompletion( |
|||
student, owner.companyId(), item.getId(), progressRequest(true, 0L))); |
|||
} |
|||
|
|||
List<Attempt<ProgressUpdateResponse>> attempts = runConcurrently(calls); |
|||
|
|||
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); |
|||
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId())) |
|||
.isPresent(); |
|||
assertThat(progressRepository.findAll().stream() |
|||
.filter(row -> row.getStudentUserId().equals(student) |
|||
&& row.getItemId().equals(item.getId()))) |
|||
.hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void simultaneousFirstKnowledgeUpsertsAreDeterministic() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("知识并发企业"); |
|||
CompanyKnowledgeUpdateRequest request = knowledgeRequest("共同文化", null, null, null); |
|||
|
|||
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently( |
|||
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request), |
|||
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request)); |
|||
|
|||
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); |
|||
assertThat(knowledgeRepository.findByCompanyId(owner.companyId())).isPresent(); |
|||
assertThat(knowledgeRepository.findAll().stream() |
|||
.filter(row -> row.getCompanyId().equals(owner.companyId()))) |
|||
.hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void identicalKnowledgeCreateReplayWithoutVersionIsIdempotent() { |
|||
CompanyOwner owner = createCompanyOwner("知识重放企业"); |
|||
CompanyKnowledgeUpdateRequest request = knowledgeRequest("重放文化", "制度", null, null); |
|||
|
|||
knowledgeService.upsertCompanyKnowledge(owner.userId(), request); |
|||
CompanyKnowledgeView replay = knowledgeService.upsertCompanyKnowledge(owner.userId(), request); |
|||
|
|||
assertThat(replay.getConfigured()).isTrue(); |
|||
assertThat(replay.getVersion()).isZero(); |
|||
assertThat(knowledgeRepository.findAll().stream() |
|||
.filter(row -> row.getCompanyId().equals(owner.companyId()))) |
|||
.hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentKnowledgeUpdatesWithSameVersionReturn9706() throws Exception { |
|||
CompanyOwner owner = createCompanyOwner("知识版本企业"); |
|||
knowledgeRepository.save(CompanyKnowledge.builder() |
|||
.companyId(owner.companyId()) |
|||
.culture("初始文化") |
|||
.build()); |
|||
|
|||
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently( |
|||
() -> knowledgeService.upsertCompanyKnowledge( |
|||
owner.userId(), knowledgeRequest("更新A", null, null, 0L)), |
|||
() -> knowledgeService.upsertCompanyKnowledge( |
|||
owner.userId(), knowledgeRequest("更新B", null, null, 0L))); |
|||
|
|||
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); |
|||
assertThat(attempts).filteredOn(Attempt::failedWithKnowledgeConflict).hasSize(1); |
|||
assertThat(knowledgeRepository.findByCompanyId(owner.companyId()).orElseThrow().getVersion()) |
|||
.isEqualTo(1L); |
|||
} |
|||
|
|||
private int failedWith(ThrowingSupplier<?> supplier) { |
|||
try { |
|||
supplier.get(); |
|||
return -1; |
|||
} catch (BusinessException ex) { |
|||
return ex.getErrorCodeInt(); |
|||
} |
|||
} |
|||
|
|||
private CompanyChecklistItemCreateRequest createRequest(String title, String idempotencyKey) { |
|||
CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest(); |
|||
request.setTitle(title); |
|||
request.setDescription(title + "说明"); |
|||
request.setCategory("ADMIN"); |
|||
request.setSortOrder(200); |
|||
request.setRequired(false); |
|||
request.setIdempotencyKey(idempotencyKey); |
|||
return request; |
|||
} |
|||
|
|||
private ProgressUpdateRequest progressRequest(boolean completed, Long version) { |
|||
ProgressUpdateRequest request = new ProgressUpdateRequest(); |
|||
request.setCompleted(completed); |
|||
request.setVersion(version); |
|||
return request; |
|||
} |
|||
|
|||
private CompanyKnowledgeUpdateRequest knowledgeRequest( |
|||
String culture, String workPolicies, String onboardingGuide, Long version) { |
|||
CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest(); |
|||
request.setCulture(culture); |
|||
request.setWorkPolicies(workPolicies); |
|||
request.setOnboardingGuide(onboardingGuide); |
|||
request.setVersion(version); |
|||
return request; |
|||
} |
|||
|
|||
private List<OnboardingChecklistItem> seedCompanyItems(Long companyId, int count) { |
|||
List<OnboardingChecklistItem> items = new ArrayList<>(); |
|||
for (int i = 0; i < count; i++) { |
|||
items.add(OnboardingChecklistItem.builder() |
|||
.scopeType("COMPANY") |
|||
.scopeId(companyId) |
|||
.companyId(companyId) |
|||
.itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement()) |
|||
.title("种子项-" + i) |
|||
.description("种子说明") |
|||
.category("OTHER") |
|||
.sortOrder(1000 + i) |
|||
.required(false) |
|||
.active(true) |
|||
.idempotencyKey("seed-key-" + companyId + "-" + i) |
|||
.requestHash("a".repeat(64)) |
|||
.build()); |
|||
} |
|||
return checklistItemRepository.saveAllAndFlush(items); |
|||
} |
|||
|
|||
private void seedInactiveCompanyItems(Long companyId, int count) { |
|||
List<OnboardingChecklistItem> items = new ArrayList<>(); |
|||
for (int i = 0; i < count; i++) { |
|||
items.add(OnboardingChecklistItem.builder() |
|||
.scopeType("COMPANY") |
|||
.scopeId(companyId) |
|||
.companyId(companyId) |
|||
.itemKey("inactive-" + companyId + "-" + ITEM_SEQ.getAndIncrement()) |
|||
.title("停用项-" + i) |
|||
.category("OTHER") |
|||
.sortOrder(2000 + i) |
|||
.required(false) |
|||
.active(false) |
|||
.build()); |
|||
} |
|||
checklistItemRepository.saveAllAndFlush(items); |
|||
} |
|||
|
|||
private OnboardingChecklistItem createStandardItem(String title) { |
|||
return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder() |
|||
.scopeType("STANDARD") |
|||
.scopeId(0L) |
|||
.companyId(null) |
|||
.itemKey("standard-" + ITEM_SEQ.getAndIncrement()) |
|||
.title(title) |
|||
.description("通用说明") |
|||
.category("DOCUMENT") |
|||
.sortOrder(10) |
|||
.required(true) |
|||
.active(true) |
|||
.build()); |
|||
} |
|||
|
|||
private List<OnboardingChecklistItem> companyRows(Long companyId) { |
|||
return checklistItemRepository.findAll().stream() |
|||
.filter(item -> companyId.equals(item.getCompanyId())) |
|||
.toList(); |
|||
} |
|||
|
|||
private CompanyOwner createCompanyOwner(String name) { |
|||
Long userId = createUser(User.Role.COMPANY); |
|||
Long companyId = companyRepository.save(Company.builder() |
|||
.ownerUserId(userId) |
|||
.name(name) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
return new CompanyOwner(userId, companyId); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
return userRepository.save(User.builder() |
|||
.phone(String.valueOf(PHONE_SEQ.getAndIncrement())) |
|||
.nickname(role.name()) |
|||
.role(role) |
|||
.roles(Set.of(role)) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
@SafeVarargs |
|||
private final <T> List<Attempt<T>> runConcurrently(Callable<T>... actions) throws Exception { |
|||
return runConcurrently(List.of(actions)); |
|||
} |
|||
|
|||
private <T> List<Attempt<T>> runConcurrently(List<Callable<T>> actions) throws Exception { |
|||
ExecutorService pool = Executors.newFixedThreadPool(actions.size()); |
|||
CountDownLatch ready = new CountDownLatch(actions.size()); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
List<Future<Attempt<T>>> futures = new ArrayList<>(); |
|||
try { |
|||
for (Callable<T> action : actions) { |
|||
futures.add(pool.submit(() -> { |
|||
ready.countDown(); |
|||
if (!start.await(10, TimeUnit.SECONDS)) { |
|||
throw new IllegalStateException("concurrent test start timed out"); |
|||
} |
|||
try { |
|||
return new Attempt<>(action.call(), null); |
|||
} catch (Throwable ex) { |
|||
return new Attempt<>(null, ex); |
|||
} |
|||
})); |
|||
} |
|||
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); |
|||
start.countDown(); |
|||
|
|||
List<Attempt<T>> results = new ArrayList<>(); |
|||
for (Future<Attempt<T>> future : futures) { |
|||
results.add(future.get(20, TimeUnit.SECONDS)); |
|||
} |
|||
return results; |
|||
} finally { |
|||
pool.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
private record CompanyOwner(Long userId, Long companyId) { |
|||
} |
|||
|
|||
private record Attempt<T>(T value, Throwable error) { |
|||
boolean failedWithIdempotencyConflict() { |
|||
return error instanceof BusinessException ex |
|||
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode(); |
|||
} |
|||
|
|||
boolean failedWithItemLimit() { |
|||
return error instanceof BusinessException ex |
|||
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode(); |
|||
} |
|||
|
|||
boolean failedWithKnowledgeConflict() { |
|||
return error instanceof BusinessException ex |
|||
&& ex.getErrorCodeInt() |
|||
== ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode(); |
|||
} |
|||
} |
|||
|
|||
@FunctionalInterface |
|||
private interface ThrowingSupplier<T> { |
|||
T get(); |
|||
} |
|||
} |
|||
@ -0,0 +1,227 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
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.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = |
|||
"spring.datasource.url=jdbc:h2:mem:onboarding_privacy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class OnboardingPrivacyTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(973_000_000L); |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private OnboardingChecklistItemRepository checklistItemRepository; |
|||
@Autowired private StudentOnboardingProgressRepository progressRepository; |
|||
@Autowired private CompanyKnowledgeRepository knowledgeRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
void clearData() { |
|||
progressRepository.deleteAll(); |
|||
knowledgeRepository.deleteAll(); |
|||
checklistItemRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
userRepository.deleteAll(); |
|||
} |
|||
|
|||
@Test |
|||
void companyChecklistResponseDoesNotLeakPersistenceOrIdempotencyFields() throws Exception { |
|||
Long companyUser = createUser(User.Role.COMPANY); |
|||
createCompany(companyUser, "隐私企业"); |
|||
|
|||
String body = mockMvc.perform(post("/onboarding/company/checklist-items") |
|||
.header("Authorization", bearer(companyUser)) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(""" |
|||
{ |
|||
"title": "隐私测试清单项", |
|||
"description": "仅返回契约字段", |
|||
"category": "ADMIN", |
|||
"sortOrder": 100, |
|||
"required": false, |
|||
"idempotencyKey": "private-key" |
|||
} |
|||
""")) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
assertThat(body).contains("itemId", "source", "itemKey", "title", "category"); |
|||
assertSensitiveFieldsAbsent(body); |
|||
assertThat(body).doesNotContain("\"idempotencyKey\":", "\"requestHash\":", |
|||
"\"scopeId\":", "\"scopeType\":", "\"companyId\":"); |
|||
} |
|||
|
|||
@Test |
|||
void studentChecklistResponseDoesNotLeakStudentOrPersistenceIdentifiers() throws Exception { |
|||
Long companyUser = createUser(User.Role.COMPANY); |
|||
Long companyId = createCompany(companyUser, "学生隐私企业"); |
|||
Long student = createUser(User.Role.STUDENT); |
|||
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() |
|||
.scopeType("STANDARD") |
|||
.scopeId(0L) |
|||
.companyId(null) |
|||
.itemKey("privacy-standard") |
|||
.title("隐私通用项") |
|||
.description("说明") |
|||
.category("DOCUMENT") |
|||
.sortOrder(10) |
|||
.required(true) |
|||
.active(true) |
|||
.build()); |
|||
progressRepository.save(StudentOnboardingProgress.builder() |
|||
.studentUserId(student) |
|||
.itemId(item.getId()) |
|||
.completed(true) |
|||
.completedAt(LocalDateTime.now()) |
|||
.build()); |
|||
|
|||
String body = mockMvc.perform(get("/onboarding/companies/{companyId}/checklist", companyId) |
|||
.header("Authorization", bearer(student))) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
assertThat(body).contains("companyId", "companyName", "items", "progressVersion"); |
|||
assertSensitiveFieldsAbsent(body); |
|||
assertThat(body).doesNotContain("\"studentUserId\":", "\"passwordHash\":", |
|||
"\"requestHash\":", "\"idempotencyKey\":"); |
|||
} |
|||
|
|||
@Test |
|||
void studentKnowledgeOverviewDoesNotLeakOwnerOrModerationInternals() throws Exception { |
|||
Long companyUser = createUser(User.Role.COMPANY); |
|||
Long companyId = createCompany(companyUser, "知识隐私企业"); |
|||
Long student = createUser(User.Role.STUDENT); |
|||
knowledgeRepository.save(CompanyKnowledge.builder() |
|||
.companyId(companyId) |
|||
.culture("文化") |
|||
.workPolicies("制度") |
|||
.onboardingGuide("指南") |
|||
.build()); |
|||
|
|||
String body = mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", companyId) |
|||
.header("Authorization", bearer(student))) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
assertThat(body).contains("knowledgeState", "company", "reviewSummary"); |
|||
assertSensitiveFieldsAbsent(body); |
|||
assertThat(body).doesNotContain("\"companyId\":", "\"ownerUserId\":", |
|||
"\"moderationReason\":", "\"reviewHash\":"); |
|||
} |
|||
|
|||
@Test |
|||
void crossCompanyErrorDoesNotEchoTargetContentOrHashes() throws Exception { |
|||
Long ownerA = createUser(User.Role.COMPANY); |
|||
Long companyA = createCompany(ownerA, "内容企业A"); |
|||
Long ownerB = createUser(User.Role.COMPANY); |
|||
createCompany(ownerB, "内容企业B"); |
|||
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() |
|||
.scopeType("COMPANY") |
|||
.scopeId(companyA) |
|||
.companyId(companyA) |
|||
.itemKey("private-target-item") |
|||
.title("不可泄露的目标标题") |
|||
.description("不可泄露的说明") |
|||
.category("ADMIN") |
|||
.sortOrder(1) |
|||
.required(false) |
|||
.active(true) |
|||
.idempotencyKey("private-target-key") |
|||
.requestHash("b".repeat(64)) |
|||
.build()); |
|||
|
|||
String body = mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId()) |
|||
.header("Authorization", bearer(ownerB)) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(""" |
|||
{ |
|||
"title": "越权请求", |
|||
"category": "ADMIN", |
|||
"sortOrder": 1, |
|||
"required": false, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
""")) |
|||
.andExpect(status().isNotFound()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
assertThat(body).doesNotContain("不可泄露的目标标题", "不可泄露的说明", |
|||
"private-target-key", "b".repeat(64), "private-target-item"); |
|||
assertSensitiveFieldsAbsent(body); |
|||
} |
|||
|
|||
private void assertSensitiveFieldsAbsent(String body) { |
|||
assertThat(body).doesNotContain( |
|||
"ownerUserId", |
|||
"studentUserId", |
|||
"requestHash", |
|||
"idempotencyKey", |
|||
"moderationReason", |
|||
"passwordHash", |
|||
"ipAddress", |
|||
"userAgent", |
|||
"\"token\":", |
|||
"\"accessToken\":"); |
|||
} |
|||
|
|||
private Long createCompany(Long ownerUserId, String name) { |
|||
return companyRepository.save(Company.builder() |
|||
.ownerUserId(ownerUserId) |
|||
.name(name) |
|||
.description("公司描述") |
|||
.industry("软件") |
|||
.website("https://example.com") |
|||
.address("上海") |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
return userRepository.save(User.builder() |
|||
.phone(String.valueOf(PHONE_SEQ.getAndIncrement())) |
|||
.nickname(role.name()) |
|||
.role(role) |
|||
.roles(Set.of(role)) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private String bearer(Long userId) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken( |
|||
userId, String.valueOf(userId), Map.of("roles", "STUDENT")); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue