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