package com.mydreamjob.application; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.support.TestAuthTokens; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobStatus; import com.mydreamjob.privacy.ResumeVisibility; import com.mydreamjob.privacy.StudentPrivacySettings; import com.mydreamjob.privacy.StudentPrivacySettingsRepository; import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfileRepository; import com.mydreamjob.profile.storage.ResumeStorageService; import org.junit.jupiter.api.AfterEach; 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.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** * S03-B 企业简历下载权限矩阵(设计 §4.3),真实 SecurityFilterChain + 真实 H2 + 真实本地存储。 * *

只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**; * 授权链本身完全真实,未被 Mock。

*/ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") @TestPropertySource(properties = "app.resume.storage-dir=target/s03-test-resumes") class CompanyResumeDownloadIntegrationTest { private static final AtomicLong PHONE_SEQ = new AtomicLong(8800); private static final List ALL_STATUSES = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); private static final List EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); /** 统计 read() 调用次数,用于断言拒绝路径不触碰文件。 */ static final AtomicInteger READ_CALLS = new AtomicInteger(); @TestConfiguration static class CountingStorageConfig { @Bean @Primary ResumeStorageService countingResumeStorageService( com.mydreamjob.profile.storage.LocalResumeStorageService delegate) { return new ResumeStorageService() { @Override public String store(Long userId, org.springframework.web.multipart.MultipartFile file) { return delegate.store(userId, file); } @Override public byte[] read(String storagePath) { READ_CALLS.incrementAndGet(); return delegate.read(storagePath); } @Override public void delete(String storagePath) { delegate.delete(storagePath); } }; } } @MockBean private RedisTemplate redisTemplate; @Autowired private MockMvc mockMvc; @Autowired private UserRepository userRepository; @Autowired private CompanyRepository companyRepository; @Autowired private JobRepository jobRepository; @Autowired private ApplicationRepository applicationRepository; @Autowired private StudentProfileRepository profileRepository; @Autowired private StudentPrivacySettingsRepository privacyRepository; @Autowired private ResumeStorageService resumeStorageService; @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; private Long companyUserId; private Long otherCompanyUserId; private Long companyId; private Long otherCompanyId; private Long studentUserId; private Long adminUserId; private Long jobId; private Long applicationId; private String storagePath; @BeforeEach @SuppressWarnings("unchecked") void setUp() { READ_CALLS.set(0); ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); companyUserId = createUser(User.Role.COMPANY, "S03 企业"); otherCompanyUserId = createUser(User.Role.COMPANY, "S03 他企"); studentUserId = createUser(User.Role.STUDENT, "S03 学生"); adminUserId = createUser(User.Role.ADMIN, "S03 管理员"); companyId = companyRepository.save(Company.builder() .ownerUserId(companyUserId).name("S03 甲企业").verified(false).build()).getId(); otherCompanyId = companyRepository.save(Company.builder() .ownerUserId(otherCompanyUserId).name("S03 乙企业").verified(true).build()).getId(); jobId = jobRepository.save(Job.builder() .companyId(companyId).title("S03 岗位").description("d").city("杭州") .employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId(); applicationId = applicationRepository.save(Application.builder() .jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()).getId(); // 真实写入一个 PDF 文件并挂到学生档案 MockMultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", "%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8)); storagePath = resumeStorageService.store(studentUserId, file); profileRepository.save(StudentProfile.builder() .userId(studentUserId) .resumeUrl(storagePath) .resumeOriginalName("我的简历.pdf") .resumeContentType("application/pdf") .resumeSize(file.getSize()) .build()); } @AfterEach void tearDown() { if (storagePath != null) { resumeStorageService.delete(storagePath); } profileRepository.findByUserId(studentUserId).ifPresent(profileRepository::delete); privacyRepository.findByStudentUserId(studentUserId).ifPresent(privacyRepository::delete); if (applicationId != null) applicationRepository.deleteById(applicationId); if (jobId != null) jobRepository.deleteById(jobId); if (companyId != null) companyRepository.deleteById(companyId); if (otherCompanyId != null) companyRepository.deleteById(otherCompanyId); List.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId) .forEach(id -> { if (id != null) userRepository.deleteById(id); }); } private Long createUser(User.Role role, String nickname) { return userRepository.save(User.builder() .phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) .nickname(nickname) .role(role) .roles(Set.of(role)) .active(true) .verified(true) .build()).getId(); } private void setVisibility(ResumeVisibility visibility) { privacyRepository.findByStudentUserId(studentUserId) .ifPresent(privacyRepository::delete); if (visibility != null) { privacyRepository.saveAndFlush(StudentPrivacySettings.builder() .studentUserId(studentUserId).resumeVisibility(visibility) .contactVisible(false).version(0L).build()); } } private void setStatus(String status) { Application app = applicationRepository.findById(applicationId).orElseThrow(); app.setStatus(status); applicationRepository.saveAndFlush(app); } private void certifyCompanyForResumePolicyTest() { Company company = companyRepository.findById(companyId).orElseThrow(); company.setVerified(true); companyRepository.saveAndFlush(company); } private String url() { return "/company/candidates/" + applicationId + "/resume"; } private String companyToken() { return TestAuthTokens.bearer(companyUserId, "COMPANY"); } // ── 决策矩阵:3 种可见性 × 6 种状态 ───────────────────────── @Test void privateIsDeniedForEveryStatusAndNeverReadsFile() { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.PRIVATE); for (String status : ALL_STATUSES) { setStatus(status); READ_CALLS.set(0); int code = perform(companyToken()); assertThat(code).as("PRIVATE 必须 403:" + status).isEqualTo(403); assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); } } @Test void missingSettingsIsDeniedAsPrivate() { certifyCompanyForResumePolicyTest(); setVisibility(null); for (String status : ALL_STATUSES) { setStatus(status); READ_CALLS.set(0); assertThat(perform(companyToken())).as("无设置按 PRIVATE 拒绝:" + status).isEqualTo(403); assertThat(READ_CALLS.get()).isZero(); } } @Test void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); for (String status : ALL_STATUSES) { setStatus(status); READ_CALLS.set(0); if (EFFECTIVE.contains(status)) { mockMvc.perform(get(url()).header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", "no-store")) .andExpect(content().contentType("application/pdf")); assertThat(READ_CALLS.get()).as("授权通过应读取一次:" + status).isEqualTo(1); } else { assertThat(perform(companyToken())).as("WITHDRAWN/REJECTED 应 403:" + status).isEqualTo(403); assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); } } } @Test void verifiedCompaniesAllowsAnyStatusOnlyForVerifiedOwningCompany() { // 甲企业未认证 → 全部拒绝 setVisibility(ResumeVisibility.VERIFIED_COMPANIES); for (String status : ALL_STATUSES) { setStatus(status); READ_CALLS.set(0); assertThat(perform(companyToken())).as("未认证企业应 403:" + status).isEqualTo(403); assertThat(READ_CALLS.get()).isZero(); } // 认证后 → 全部状态允许 Company company = companyRepository.findById(companyId).orElseThrow(); company.setVerified(true); companyRepository.saveAndFlush(company); for (String status : ALL_STATUSES) { setStatus(status); READ_CALLS.set(0); assertThat(perform(companyToken())).as("已认证且拥有岗位应 200:" + status).isEqualTo(200); assertThat(READ_CALLS.get()).isEqualTo(1); } } // ── APPLIED_COMPANIES 白名单:退化状态必须 403 且零读取 ────── @Test void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) { setStatus(unknown); READ_CALLS.set(0); var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) .andReturn().getResponse(); assertThat(result.getStatus()).as("未知/变体状态必须 403:" + unknown).isEqualTo(403); assertThat(result.getContentAsString()).as("业务码必须 2002:" + unknown).contains("\"code\":2002"); assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + unknown).isZero(); } } @Test void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception { // 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL; // NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。 certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); for (String blank : new String[]{"", " "}) { setStatus(blank); READ_CALLS.set(0); var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) .andReturn().getResponse(); assertThat(result.getStatus()).as("空白状态必须 403").isEqualTo(403); assertThat(result.getContentAsString()).as("业务码必须 2002").contains("\"code\":2002"); assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); } } // ── 跨企业 ───────────────────────────────────────────────── @Test void otherCompanyIsAlwaysDenied() { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); READ_CALLS.set(0); assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) .as("跨企业必须 403").isEqualTo(403); assertThat(READ_CALLS.get()).isZero(); setVisibility(ResumeVisibility.VERIFIED_COMPANIES); READ_CALLS.set(0); assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) .as("其他企业即使已认证也必须 403").isEqualTo(403); assertThat(READ_CALLS.get()).isZero(); } // ── 角色与非认证 ─────────────────────────────────────────── @Test void studentAdminAndAnonymousAreDenied() { READ_CALLS.set(0); setVisibility(ResumeVisibility.VERIFIED_COMPANIES); Company company = companyRepository.findById(companyId).orElseThrow(); company.setVerified(true); companyRepository.saveAndFlush(company); assertThat(perform(TestAuthTokens.bearer(studentUserId, "STUDENT"))).as("STUDENT 应 403").isEqualTo(403); assertThat(perform(TestAuthTokens.bearer(adminUserId, "ADMIN"))).as("ADMIN 应 403").isEqualTo(403); assertThat(performNoAuth()).as("匿名应 401").isEqualTo(401); assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); } @Test void nonexistentApplicationIsNotFound() { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); READ_CALLS.set(0); String token = companyToken(); int code; try { code = mockMvc.perform(get("/company/candidates/99999999/resume").header("Authorization", token)) .andReturn().getResponse().getStatus(); } catch (Exception e) { code = -1; } assertThat(code).as("不存在的投递应 404").isEqualTo(404); assertThat(READ_CALLS.get()).isZero(); } @Test void missingResumeIsProfileNotFound() { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow(); profile.setResumeUrl(null); profileRepository.saveAndFlush(profile); assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404); } // ── 响应脱敏 ─────────────────────────────────────────────── @Test void responseDoesNotLeakPathOrIdentity() throws Exception { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); String body = mockMvc.perform(get(url()).header("Authorization", companyToken())) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); assertThat(body).as("不得回显存储路径").doesNotContain(storagePath); assertThat(body).doesNotContain("resumeUrl"); assertThat(body).doesNotContain(String.valueOf(studentUserId)); assertThat(body).doesNotContain("studentUserId"); } @Test void contentDispositionUsesSanitizedOriginalName() throws Exception { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); mockMvc.perform(get(url()).header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("attachment"))) .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("../")))) .andExpect(header().string("Cache-Control", "no-store")); } // ── 企业候选人列表/详情仍不含 resumeUrl ───────────────────── @Test void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); String token = companyToken(); String detail = mockMvc.perform(get("/company/candidates/" + applicationId) .header("Authorization", token)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); assertThat(detail).doesNotContain("resumeUrl"); String list = mockMvc.perform(get("/company/candidates").header("Authorization", token)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); assertThat(list).doesNotContain("resumeUrl"); } // ── 运行时 OpenAPI 契约(S03 只新增一个企业下载 operation)── @Test void openApiContractForCompanyResumeDownload() throws Exception { String json = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); var paths = root.path("paths"); var schemas = root.path("components").path("schemas"); long pathCount = paths.size(); long opCount = 0; long modulePaths = 0; long moduleOperations = 0; var it = paths.fieldNames(); while (it.hasNext()) { String path = it.next(); boolean module = path.startsWith("/skills/") || path.startsWith("/onboarding/") || path.startsWith("/salary-calculator/"); if (module) modulePaths++; var ops = paths.path(path); var f = ops.fieldNames(); while (f.hasNext()) { if (java.util.Set.of("get", "post", "put", "delete", "patch") .contains(f.next())) { opCount++; if (module) moduleOperations++; } } } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(pathCount - modulePaths).as("既有 183 paths 保持不变").isEqualTo(183); assertThat(opCount - moduleOperations).as("既有 221 operations 保持不变").isEqualTo(221); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.has("requestBody")).as("下载接口不得有 requestBody").isFalse(); // 不得存在按 studentUserId / candidateKey 下载简历的入口 var names = new java.util.ArrayList(); paths.fieldNames().forEachRemaining(names::add); for (String p : names) { String lower = p.toLowerCase(); if (lower.contains("resume") || lower.contains("download")) { assertThat(lower).as("不得按 studentUserId 下载:" + p).doesNotContain("studentuserid"); assertThat(lower).as("不得按 candidateKey 下载:" + p).doesNotContain("candidatekey"); } } // 企业候选人 DTO 不得含 resumeUrl / studentUserId / phone / email / storagePath for (String dto : new String[]{"CandidateDetailView", "CandidateItemView"}) { var node = schemas.path(dto); if (node.isMissingNode()) { continue; } String text = node.toString(); for (String forbidden : new String[]{"resumeUrl", "studentUserId", "phone", "email", "storagePath"}) { assertThat(text).as(dto + " 不得包含 " + forbidden).doesNotContain(forbidden); } } // Actuator 不得进入业务 OpenAPI for (String p : names) { assertThat(p).as("Actuator 不得出现在业务契约").doesNotStartWith("/actuator"); } // 开发辅助:-Ds03.openapi.snapshot=<目录> 时导出快照 String snapshotDir = System.getProperty("s03.openapi.snapshot"); if (snapshotDir != null && !snapshotDir.isBlank()) { java.nio.file.Files.write( java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), json.getBytes(StandardCharsets.UTF_8)); } } // ── 副作用:企业下载只读 ─────────────────────────────────── /** * 表名**固定白名单**(不做任意外部表名拼接)+ 每张表明确选择的稳定业务字段。 * 字段名以真实表结构为准。 */ private static java.util.Map digestColumns() { java.util.Map m = new java.util.LinkedHashMap<>(); m.put("users", "id, phone, email, role, is_active, is_verified, updated_at"); m.put("user_roles", "user_id, role"); m.put("student_profiles", "id, user_id, resume_url, resume_original_name, resume_content_type, resume_size, updated_at"); m.put("resume_versions", "id, user_id, version_no, status, file_storage_path, file_name, content_type, file_size, updated_at"); m.put("applications", "id, job_id, student_user_id, status, updated_at"); m.put("jobs", "id, company_id, status, updated_at"); m.put("companies", "id, owner_user_id, verified, name, updated_at"); m.put("auth_tokens", "id, user_id, session_id, revoked, consumed_at, revoked_at, version, created_at"); m.put("audit_logs", "id, user_id, action, entity_type, entity_id, details, created_at"); m.put("candidate_dimension_scores", "id, student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version"); m.put("notifications", "id, user_id, type, title, is_read, read_at, created_at"); m.put("messages", "id, conversation_id, sender_user_id, content, status, recalled_at, version, created_at"); m.put("scoring_risk_events", "id, rule_config_id, subject_user_id, source_action, severity, occurrence_count, status, detected_at, version"); m.put("scoring_risk_actions", "id, risk_event_id, operator_user_id, action, reason_code, created_at"); m.put("talent_invitations", "id, company_id, job_id, student_user_id, status, expires_at, decided_at, version, created_at"); return m; } /** * 行级快照:按选定字段排序后取全部行,做深比较。 * *

不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败, * 不允许被吞掉后写入占位值。

*/ private java.util.Map>> snapshot() { java.util.Map>> out = new java.util.LinkedHashMap<>(); for (java.util.Map.Entry e : digestColumns().entrySet()) { String columns = e.getValue(); String sql = "SELECT " + columns + " FROM " + e.getKey() + " ORDER BY " + columns; out.put(e.getKey(), jdbcTemplate.queryForList(sql)); } return out; } @Test void companyDownloadHasNoSideEffectsOnBusinessTables() { certifyCompanyForResumePolicyTest(); setVisibility(ResumeVisibility.APPLIED_COMPANIES); // 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前, // 否则夹具自身的记录会被误判为「下载接口的副作用」。 String token = companyToken(); var before = snapshot(); assertThat(perform(token)).as("授权通过应 200").isEqualTo(200); var after = snapshot(); for (String table : before.keySet()) { assertThat(after.get(table)) .as(table + " 不得因企业下载而变化(逐行内容比较)") .isEqualTo(before.get(table)); } } /** * 反证:摘要必须能发现单行变化,否则前面的「零变化」断言没有说服力。 */ @Test void snapshotDetectsSingleRowChange() { var before = snapshot(); Application app = applicationRepository.findById(applicationId).orElseThrow(); String original = app.getStatus(); app.setStatus(original.equals("INTERVIEW") ? "HIRED" : "INTERVIEW"); applicationRepository.saveAndFlush(app); try { var after = snapshot(); assertThat(after.get("applications")) .as("单行 status 变化必须被摘要发现").isNotEqualTo(before.get("applications")); } finally { Application restore = applicationRepository.findById(applicationId).orElseThrow(); restore.setStatus(original); applicationRepository.saveAndFlush(restore); } } @Test void snapshotCoversEveryRequiredTableAndColumn() { var snap = snapshot(); for (String table : new String[]{ "users", "user_roles", "student_profiles", "resume_versions", "applications", "jobs", "companies", "auth_tokens", "audit_logs", "candidate_dimension_scores", "notifications", "messages", "scoring_risk_events", "scoring_risk_actions", "talent_invitations"}) { assertThat(snap).as("必须覆盖 " + table).containsKey(table); } // 抽查关键字段确实被取到(而非空 Map) assertThat(snap.get("applications").get(0)).containsKeys("status", "updated_at"); assertThat(snap.get("auth_tokens").get(0)).containsKeys("revoked", "version"); } // ── 工具 ─────────────────────────────────────────────────── private int perform(String token) { try { return mockMvc.perform(get(url()).header("Authorization", token)) .andReturn().getResponse().getStatus(); } catch (Exception e) { return -1; } } private int performNoAuth() { try { return mockMvc.perform(get(url())).andReturn().getResponse().getStatus(); } catch (Exception e) { return -1; } } }