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