You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

227 lines
9.7 KiB

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