package com.mydreamjob.common.r01b; import com.mydreamjob.auth.support.TestAuthTokens; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.risk.RiskGovernanceService; import com.mydreamjob.security.UserRoleGuard; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * R01-B 独立复验:**接线变异检测**。 * *

用 {@code @MockBean} 替换公共 guard,使"角色决策"完全由测试掌控。 * 由于端点自身的角色判定已被收束到该 guard,两个方向都可被观测:

* *

第二条是关键的变异证明:若端点仍保留自有的角色比较(未委托 guard), * STUDENT 无论 guard 怎么桩都不可能通过 ADMIN 端点,本测试必然失败。

*/ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class R01BWiringMutationTest { private static final long STUDENT_ID = 9_340_001L; private static final long ADMIN_ID = 9_340_002L; private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; private static final String COMPANY_CANDIDATE_ENDPOINT = "/company/candidates/999999999"; @Autowired private MockMvc mockMvc; @Autowired private JdbcTemplate jdbcTemplate; @MockBean private UserRoleGuard userRoleGuard; @MockBean private RiskGovernanceService riskGovernanceService; @MockBean private RedisTemplate redisTemplate; @BeforeEach void provision() { cleanup(); insertUser(STUDENT_ID, "r01bwm-student", "STUDENT"); insertUser(ADMIN_ID, "r01bwm-admin", "ADMIN"); } @AfterEach void cleanup() { for (long id : new long[]{STUDENT_ID, ADMIN_ID}) { jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); } } private void insertUser(long id, String phone, String role) { jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01bwm-" + role, role); jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); } // ── guard 拒绝 → 业务不继续 ─────────────────────────────── @Test @DisplayName("变异检测:guard 抛 FORBIDDEN 时端点 403,且下游业务方法零调用") void guardDenyStopsBusinessFlow() throws Exception { doThrow(new BusinessException(ErrorCode.FORBIDDEN)) .when(userRoleGuard).requireAdmin(anyLong()); mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) .andExpect(status().isForbidden()); verify(riskGovernanceService, never()).overview(any(), any()); verify(userRoleGuard).requireAdmin(ADMIN_ID); } // ── guard 放行 → 业务继续;且证明端点不自带角色比较 ──────── @Test @DisplayName("变异检测:guard 放行时业务继续;STUDENT 亦可通过 ADMIN 端点(证明判定只来自 guard)") void guardAllowLetsFlowContinueEvenForStudent() throws Exception { // guard 为 mock,void 方法默认放行 mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) .andExpect(status().isOk()); verify(riskGovernanceService).overview(any(), any()); // 端点把**真实调用方身份**交给 guard;若端点自带角色比较,STUDENT 不可能走到这里 verify(userRoleGuard).requireAdmin(STUDENT_ID); } @Test @DisplayName("变异检测:guard 收到的 userId 与 Token 主体一致(无身份替换)") void guardReceivesCallerIdentity() throws Exception { mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) .andExpect(status().isOk()); verify(userRoleGuard).requireAdmin(ADMIN_ID); verify(userRoleGuard, never()).requireAdmin(STUDENT_ID); } // ── 资源归属仍由业务层负责,且错误码未被角色层吞掉 ───────── @Test @DisplayName("变异检测:guard 放行后,归属/存在性失败仍由业务层返回原错误码(404 而非 403)") void ownershipFailureStillComesFromBusinessLayer() throws Exception { // 该 STUDENT 用户没有企业实体;guard(mock)放行后由业务层给出 404/COMPANY_NOT_FOUND 语义 mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) .header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) .andExpect(status().isNotFound()); // 该端点路径上 Company guard 会被调用多次(Controller 直调 + 下游 Service 委托), // 故只断言"至少一次且身份正确",不臆造次数。 verify(userRoleGuard, org.mockito.Mockito.atLeastOnce()).requireCompany(STUDENT_ID); } @Test @DisplayName("变异检测:guard 在 COMPANY 端点上同样按调用方身份被调用") void companyEndpointAlsoDelegatesToGuard() throws Exception { doThrow(new BusinessException(ErrorCode.FORBIDDEN)) .when(userRoleGuard).requireCompany(anyLong()); mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) .header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) .andExpect(status().isForbidden()); verify(userRoleGuard).requireCompany(ADMIN_ID); } }