From ca85ab541004551f45fec5670bc892379465853c Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:20:56 +0800 Subject: [PATCH] fix: harden onboarding permissions and concurrency --- .../com/mydreamjob/config/SecurityConfig.java | 2 + .../onboarding/CompanyKnowledgeService.java | 27 +- .../OnboardingChecklistService.java | 48 +- ...nboardingAuthorizationIntegrationTest.java | 211 +++++++++ .../OnboardingConcurrencyIntegrationTest.java | 424 ++++++++++++++++++ .../onboarding/OnboardingPrivacyTest.java | 227 ++++++++++ 6 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 0394c90..148f8c9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -126,6 +126,8 @@ public class SecurityConfig { .requestMatchers("/company/scoring/dashboard/**").authenticated() // 面试(需登录) .requestMatchers("/interviews/**").authenticated() + // 入职清单与公司知识(需登录;细粒度角色/所有权在 Service 校验) + .requestMatchers("/onboarding/**").authenticated() // API 信息入口 .requestMatchers("/").permitAll() .anyRequest().authenticated() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java index 7a113f9..a6350ce 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -1,5 +1,9 @@ package com.mydreamjob.onboarding; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.User; @@ -16,6 +20,7 @@ import com.mydreamjob.review.JobReviewDimensionScoreRepository; import com.mydreamjob.review.JobReviewRepository; import com.mydreamjob.review.ReviewDimension; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,6 +64,9 @@ public class CompanyKnowledgeService { private final JobReviewRepository jobReviewRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository; + @PersistenceContext + private EntityManager entityManager; + @Transactional(readOnly = true) public CompanyKnowledgeView getCompanyKnowledge(Long userId) { Company company = requireOwnedCompany(userId); @@ -76,6 +84,7 @@ public class CompanyKnowledgeService { CompanyKnowledgeUpdateRequest request) { Company company = requireOwnedCompany(userId); KnowledgeContent content = validateContent(request); + lockCompany(company); CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) .orElse(null); @@ -89,6 +98,9 @@ public class CompanyKnowledgeService { return toConfiguredView(saveKnowledge(created)); } + if (request.getVersion() == null && matchesContent(existing, content)) { + return toConfiguredView(existing); + } if (request.getVersion() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -123,12 +135,25 @@ public class CompanyKnowledgeService { private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { try { return companyKnowledgeRepository.saveAndFlush(knowledge); - } catch (ObjectOptimisticLockingFailureException ex) { + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { throw new BusinessException( ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); } } + private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) { + return Objects.equals(knowledge.getCulture(), content.culture()) + && Objects.equals(knowledge.getWorkPolicies(), content.workPolicies()) + && Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide()); + } + + private void lockCompany(Company company) { + if (entityManager == null || company == null || company.getId() == null) { + return; + } + entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); + } + private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { if (request == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index 01aa328..eac9f55 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -1,5 +1,9 @@ package com.mydreamjob.onboarding; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; + import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; @@ -14,6 +18,7 @@ import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,6 +57,9 @@ public class OnboardingChecklistService { private final OnboardingChecklistItemRepository checklistItemRepository; private final StudentOnboardingProgressRepository progressRepository; + @PersistenceContext + private EntityManager entityManager; + @Transactional(readOnly = true) public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { assertStudentRole(userId); @@ -71,6 +79,7 @@ public class OnboardingChecklistService { Long itemId, ProgressUpdateRequest request) { assertStudentRole(userId); + lockUser(userId); if (request == null || request.getCompleted() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -92,6 +101,13 @@ public class OnboardingChecklistService { progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); boolean desired = Boolean.TRUE.equals(request.getCompleted()); + if (current == null + && request.getVersion() != null + && request.getVersion() != 0L) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); + } + if (current != null) { boolean actual = Boolean.TRUE.equals(current.getCompleted()); if (actual != desired) { @@ -136,6 +152,7 @@ public class OnboardingChecklistService { CompanyChecklistItemCreateRequest request) { Company company = requireCompanyOwner(userId); validateCompanyItemCreateRequest(request); + lockCompany(company); String title = normalizeOptional(request.getTitle()); String description = normalizeOptional(request.getDescription()); @@ -183,7 +200,7 @@ public class OnboardingChecklistService { .idempotencyKey(idempotencyKey) .requestHash(requestHash) .build(); - return toCompanyChecklistItemView(saveCompanyItem(item)); + return toCompanyChecklistItemView(saveNewCompanyItem(item)); } @Transactional @@ -193,6 +210,7 @@ public class OnboardingChecklistService { CompanyChecklistItemUpdateRequest request) { Company company = requireCompanyOwner(userId); validateCompanyItemUpdateRequest(request); + lockCompany(company); OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); if (!Objects.equals(item.getVersion(), request.getVersion())) { throw new BusinessException( @@ -214,6 +232,7 @@ public class OnboardingChecklistService { Long itemId, Long version) { Company company = requireCompanyOwner(userId); + lockCompany(company); OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); if (Boolean.TRUE.equals(item.getActive())) { if (!Objects.equals(item.getVersion(), version)) { @@ -363,6 +382,31 @@ public class OnboardingChecklistService { .build(); } + private OnboardingChecklistItem saveNewCompanyItem(OnboardingChecklistItem item) { + try { + return checklistItemRepository.saveAndFlush(item); + } catch (DataIntegrityViolationException ex) { + throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ex); + } + } + + private void lockCompany(Company company) { + if (entityManager == null || company == null || company.getId() == null) { + return; + } + entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); + } + + private void lockUser(Long userId) { + if (entityManager == null) { + return; + } + User user = entityManager.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + } + private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) { try { return checklistItemRepository.saveAndFlush(item); @@ -507,7 +551,7 @@ public class OnboardingChecklistService { private void saveProgress(StudentOnboardingProgress progress) { try { progressRepository.saveAndFlush(progress); - } catch (ObjectOptimisticLockingFailureException ex) { + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { throw new BusinessException( ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java new file mode 100644 index 0000000..ab5a2b7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java @@ -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 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")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java new file mode 100644 index 0000000..5f52d0d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java @@ -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 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> 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> 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> 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> 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> calls = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + calls.add(() -> checklistService.updateCompletion( + student, owner.companyId(), item.getId(), progressRequest(true, 0L))); + } + + List> 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> 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> 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 seedCompanyItems(Long companyId, int count) { + List 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 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 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 List> runConcurrently(Callable... actions) throws Exception { + return runConcurrently(List.of(actions)); + } + + private List> runConcurrently(List> actions) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(actions.size()); + CountDownLatch ready = new CountDownLatch(actions.size()); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + try { + for (Callable 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> results = new ArrayList<>(); + for (Future> 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 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 get(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java new file mode 100644 index 0000000..8187124 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java @@ -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 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")); + } +} \ No newline at end of file