Browse Source

fix: harden onboarding permissions and concurrency

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
ca85ab5410
  1. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  2. 27
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java
  3. 48
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  4. 211
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java
  5. 424
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java
  6. 227
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java

2
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()

27
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);

48
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);
}

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

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

227
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<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…
Cancel
Save