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.
319 lines
15 KiB
319 lines
15 KiB
package com.mydreamjob.privacy;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.mydreamjob.auth.JwtTokenProvider;
|
|
import com.mydreamjob.auth.User;
|
|
import com.mydreamjob.auth.UserRepository;
|
|
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
|
|
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.http.MediaType;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
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.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
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.request.MockMvcRequestBuilders.put;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
|
/**
|
|
* GET /api/v1/profile/me/privacy 与 PUT /api/v1/profile/me/privacy 契约集成测试。
|
|
*
|
|
* <p>覆盖:STUDENT 成功、GET 无记录返回默认且不写库、角色 403、未登录 401、
|
|
* userId 参数注入无效、非法枚举/缺参 400、version 冲突 409/9201、敏感字段不泄露、OpenAPI 一致性。
|
|
*/
|
|
@SpringBootTest
|
|
@AutoConfigureMockMvc
|
|
@ActiveProfiles("integration-test")
|
|
class PrivacySettingsControllerTest {
|
|
|
|
@Autowired private MockMvc mockMvc;
|
|
@Autowired private JwtTokenProvider jwtTokenProvider;
|
|
@Autowired private UserRepository userRepository;
|
|
@Autowired private StudentPrivacySettingsRepository privacyRepository;
|
|
|
|
@MockBean private org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate;
|
|
|
|
private final ObjectMapper mapper = new ObjectMapper();
|
|
private static final AtomicLong PHONE = new AtomicLong(910_000_000L);
|
|
|
|
@BeforeEach
|
|
@SuppressWarnings("unchecked")
|
|
void setUp() {
|
|
org.springframework.data.redis.core.ValueOperations<String, Object> ops =
|
|
mock(org.springframework.data.redis.core.ValueOperations.class);
|
|
when(redisTemplate.opsForValue()).thenReturn(ops);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
// 不得 deleteAll;通过自增 phone 保证用户唯一。
|
|
}
|
|
|
|
private long createUser(User.Role role) {
|
|
User u = userRepository.save(User.builder()
|
|
.phone(String.valueOf(PHONE.getAndIncrement()))
|
|
.roles(Set.of(role))
|
|
.role(role)
|
|
.active(true)
|
|
.build());
|
|
return u.getId();
|
|
}
|
|
|
|
private String studentToken(long userId) {
|
|
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
|
|
}
|
|
|
|
// ── GET:STUDENT 成功 + 无记录默认值 ──────────────────────
|
|
|
|
@Test
|
|
void getPrivacy_noRecord_returns200Default_andDoesNotWriteDb() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
long countBefore = privacyRepository.count();
|
|
|
|
String body = mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.code").value(200))
|
|
.andExpect(jsonPath("$.data.resumeVisibility").value("PRIVATE"))
|
|
.andExpect(jsonPath("$.data.contactVisible").value(false))
|
|
.andExpect(jsonPath("$.data.version").value(0))
|
|
.andExpect(jsonPath("$.data.studentUserId").value(uid))
|
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
JsonNode data = mapper.readTree(body).path("data");
|
|
assertFalse(data.hasNonNull("id"), "无记录时不应返回 id");
|
|
assertFalse(data.hasNonNull("createdAt"), "无记录时不应返回 createdAt");
|
|
assertEquals(privacyRepository.count(), countBefore, "查询不应写入 student_privacy_settings 记录");
|
|
}
|
|
|
|
@Test
|
|
void getPrivacy_afterPut_returnsStoredValues() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}"))
|
|
.andExpect(status().isOk());
|
|
|
|
mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.data.resumeVisibility").value("VERIFIED_COMPANIES"))
|
|
.andExpect(jsonPath("$.data.contactVisible").value(true));
|
|
}
|
|
|
|
// ── PUT:成功 + upsert 单行 ───────────────────────────────
|
|
|
|
@Test
|
|
void putPrivacy_firstUpdate_returns200() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"APPLIED_COMPANIES\",\"contactVisible\":true}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.code").value(200))
|
|
.andExpect(jsonPath("$.data.resumeVisibility").value("APPLIED_COMPANIES"))
|
|
.andExpect(jsonPath("$.data.contactVisible").value(true));
|
|
}
|
|
|
|
@Test
|
|
void putPrivacy_repeatedly_keepsSingleRow() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}"))
|
|
.andExpect(status().isOk());
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}"))
|
|
.andExpect(status().isOk());
|
|
|
|
long count = privacyRepository.findByStudentUserId(uid).map(s -> 1L).orElse(0L);
|
|
assertEquals(1L, count, "同一学生必须始终只有一行");
|
|
}
|
|
|
|
@Test
|
|
void putPrivacy_versionConflict_returns409_9201() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
// 先创建,当前 version=0
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}"))
|
|
.andExpect(status().isOk());
|
|
|
|
// 传不匹配的 version=5 → 409/9201
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true,\"version\":5}"))
|
|
.andExpect(status().isConflict())
|
|
.andExpect(jsonPath("$.code").value(9201));
|
|
}
|
|
|
|
// ── 校验失败 ─────────────────────────────────────────────
|
|
|
|
@Test
|
|
void putPrivacy_invalidEnum_returns400() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"INVALID_VALUE\",\"contactVisible\":true}"))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void putPrivacy_missingFields_returns400() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{}"))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
// ── 认证 / 角色 ──────────────────────────────────────────
|
|
|
|
@Test
|
|
void getPrivacy_noToken_returns401() throws Exception {
|
|
mockMvc.perform(get("/profile/me/privacy"))
|
|
.andExpect(status().isUnauthorized())
|
|
.andExpect(jsonPath("$.code").value(401));
|
|
}
|
|
|
|
@Test
|
|
void getPrivacy_invalidToken_returns401() throws Exception {
|
|
mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", "Bearer not-a-valid-token"))
|
|
.andExpect(status().isUnauthorized());
|
|
}
|
|
|
|
@Test
|
|
void getPrivacy_companyToken_returns403_2002() throws Exception {
|
|
long uid = createUser(User.Role.COMPANY);
|
|
mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid)))
|
|
.andExpect(status().isForbidden())
|
|
.andExpect(jsonPath("$.code").value(2002));
|
|
}
|
|
|
|
@Test
|
|
void getPrivacy_adminToken_returns403_2002() throws Exception {
|
|
long uid = createUser(User.Role.ADMIN);
|
|
mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid)))
|
|
.andExpect(status().isForbidden())
|
|
.andExpect(jsonPath("$.code").value(2002));
|
|
}
|
|
|
|
@Test
|
|
void putPrivacy_companyToken_returns403_2002() throws Exception {
|
|
long uid = createUser(User.Role.COMPANY);
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}"))
|
|
.andExpect(status().isForbidden())
|
|
.andExpect(jsonPath("$.code").value(2002));
|
|
}
|
|
|
|
// ── 参数注入防护 ─────────────────────────────────────────
|
|
|
|
@Test
|
|
void putPrivacy_ignoresUserIdParam_usesJwtIdentity() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
|
|
mockMvc.perform(put("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid))
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.param("userId", String.valueOf(uid * 999 + 1))
|
|
.content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.data.studentUserId").value(uid));
|
|
}
|
|
|
|
// ── 敏感字段不泄露(审计不写敏感值已在 ServiceTest 覆盖) ──
|
|
|
|
@Test
|
|
void getPrivacy_okResponse_forbidsSensitiveFields() throws Exception {
|
|
long uid = createUser(User.Role.STUDENT);
|
|
String body = mockMvc.perform(get("/profile/me/privacy")
|
|
.header("Authorization", studentToken(uid)))
|
|
.andExpect(status().isOk())
|
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
String normalized = body.toLowerCase();
|
|
assertFalse(normalized.contains("password"));
|
|
assertFalse(normalized.contains("phone"));
|
|
assertFalse(normalized.contains("email"));
|
|
assertFalse(normalized.contains("jwt"));
|
|
assertFalse(normalized.contains("resumeurl"));
|
|
}
|
|
|
|
// ── OpenAPI 一致性 ───────────────────────────────────────
|
|
|
|
@Test
|
|
void openApi_shouldDocumentPrivacyContract() throws Exception {
|
|
String json = mockMvc.perform(get("/v3/api-docs"))
|
|
.andExpect(status().isOk())
|
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
JsonNode root = mapper.readTree(json);
|
|
|
|
JsonNode get = root.path("paths").path("/profile/me/privacy").path("get");
|
|
JsonNode put = root.path("paths").path("/profile/me/privacy").path("put");
|
|
assertThat(get.isMissingNode()).isFalse();
|
|
assertThat(put.isMissingNode()).isFalse();
|
|
|
|
// 引用明确 DTO,而非 Map
|
|
assertTrue(get.toString().contains("PrivacySettingsResponse"));
|
|
assertTrue(put.toString().contains("PrivacySettingsResponse"));
|
|
// 安全要求
|
|
assertTrue(get.path("security").toString().contains("bearerAuth"));
|
|
assertTrue(put.path("security").toString().contains("bearerAuth"));
|
|
// 无请求参数
|
|
assertThat(put.path("parameters").isMissingNode()
|
|
|| (put.path("parameters").isArray() && put.path("parameters").isEmpty())).isTrue();
|
|
|
|
// 响应声明
|
|
assertThat(get.path("responses").has("200")).isTrue();
|
|
assertThat(get.path("responses").has("401")).isTrue();
|
|
assertThat(get.path("responses").has("403")).isTrue();
|
|
assertThat(put.path("responses").has("400")).isTrue();
|
|
assertThat(put.path("responses").has("409")).isTrue();
|
|
|
|
// 请求体 schema 引用 DTO
|
|
assertThat(put.path("requestBody").toString().contains("PrivacySettingsUpdateRequest")).isTrue();
|
|
|
|
// 无敏感字段
|
|
String responseSchema = get.path("responses").path("200").toString().toLowerCase();
|
|
assertThat(responseSchema.contains("\"password\"")).isFalse();
|
|
assertThat(responseSchema.contains("\"passwordhash\"")).isFalse();
|
|
assertThat(responseSchema.contains("\"token\"")).isFalse();
|
|
}
|
|
}
|
|
|