forked from baskbuddy/zhangshun
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.
633 lines
28 KiB
633 lines
28 KiB
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 + 真实本地存储。
|
|
*
|
|
* <p>只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**;
|
|
* 授权链本身完全真实,未被 Mock。</p>
|
|
*/
|
|
@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<String> ALL_STATUSES =
|
|
List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
|
|
private static final List<String> 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<String, Object> 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<String, Object> 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<String>();
|
|
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<String, String> digestColumns() {
|
|
java.util.Map<String, String> 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;
|
|
}
|
|
|
|
/**
|
|
* 行级快照:按选定字段排序后取全部行,做深比较。
|
|
*
|
|
* <p>不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败,
|
|
* 不允许被吞掉后写入占位值。</p>
|
|
*/
|
|
private java.util.Map<String, java.util.List<java.util.Map<String, Object>>> snapshot() {
|
|
java.util.Map<String, java.util.List<java.util.Map<String, Object>>> out = new java.util.LinkedHashMap<>();
|
|
for (java.util.Map.Entry<String, String> 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;
|
|
}
|
|
}
|
|
}
|
|
|