Просмотр исходного кода

Merge branch 'task/S-cfc-189' into cfclub

Sisyphus 1 час назад
Родитель
Сommit
05531929d6

+ 394 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherLoginSwitchContractTest.java

@@ -0,0 +1,394 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.controller.auth.AuthController;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.SwitchRoleDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:成长规划师登录/角色切换(能力键 {@code teacher.login.switch},SC-T-01 / SC-T-20)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/orchestration/sc-key-map.md} SC-T-01(规划师登录+切回家长)、
+ *       SC-T-20(退出/切换账号)</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-01/T-20 +
+ *       §3 能力键映射(teacher.login.switch → AuthController switch-role);
+ *       {@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域</li>
+ * </ul>
+ *
+ * <p><b>契约行为规范(SC-T-01/T-20)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:{@code POST /api/auth/switch-role} 接受 {@code SwitchRoleDTO.role}
+ *       ({"parent"/"child"/"teacher"}),对已持有该角色的用户更新 {@code user.role} 并返回
+ *       含新 JWT 的 {@link LoginResultDTO}。</li>
+ *   <li><b>权限流</b>:</li>
+ *   <ul>
+ *     <li>未登录(userId null/≤0)→ 401 "请先登录"</li>
+ *     <li>当前角色为 admin → "管理员角色不允许切换"</li>
+ *     <li>目标 role 非 parent/child/teacher → "不支持切换到该角色"</li>
+ *     <li>切换 teacher 但用户无 teacher 角色 → "您不是成长规划师,无法切换为规划师模式"</li>
+ *     <li>目标 role 不在 {@code user.roles} 中且不等当前角色 → "您没有该角色权限"</li>
+ *   </ul>
+ *   <li><b>结构流</b>:</li>
+ *   <ul>
+ *     <li>{@code AuthController} 含 {@code @PostMapping("/switch-role")} 方法</li>
+ *     <li>{@code JwtInterceptor#LOGIN_REQUIRED_PATHS} 包含 "/api/auth/switch-role"(必须鉴权,非公开)</li>
+ *   </ul>
+ * </ul>
+ *
+ * <p><b>实测现状(origin/cfclub @ 4e1497e16)</b>:</p>
+ * <ul>
+ *   <li>✅ {@code AuthController#switchRole}(POST /api/auth/switch-role)已落地(L332-L344)</li>
+ *   <li>✅ {@code UserService#switchRoleForLogin} 含 admin 守卫、login-role 白名单(parent/child/teacher)、
+ *       teacher 角色所有权校验、通用角色拥有性校验(L668-L706)</li>
+ *   <li>✅ {@code JwtInterceptor} L99 将 /api/auth/switch-role 列入 {@code LOGIN_REQUIRED_PATHS}</li>
+ *   <li>✅ {@code SwitchRoleDTO} 存在,字段 {@code role} 覆盖所有目标角色</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:若上述任一契约行为缺失/默认值未实现,对应断言失败(红);
+ * 若全部落地则全绿,测试作为契约证据留存。</p>
+ */
+class TeacherLoginSwitchContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== Controller & Service stubs ====================
+
+    private UserService userService;
+    private AuthController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        userService = mock(UserService.class);
+        controller = new AuthController();
+        inject(controller, "userService", userService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+    }
+
+    // ==================== 正常流:teacher↔parent 角色切换 ====================
+
+    @Test
+    @DisplayName("SC-T-01 正常-1:teacher 切回 parent → 200 含新 token(role=parent)")
+    void switchRole_teacherToParent_正常() {
+        Long userId = 7L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-new-token");
+        result.setUserId(userId);
+        result.setRole("parent");
+        when(userService.switchRoleForLogin(userId, "parent")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals("jwt-new-token", r.getData().getToken());
+        assertEquals("parent", r.getData().getRole());
+        verify(userService).switchRoleForLogin(userId, "parent");
+    }
+
+    @Test
+    @DisplayName("SC-T-01 正常-2:parent 切到 teacher(持有 teacher 角色)→ 200 含新 token(role=teacher)")
+    void switchRole_parentToTeacher_正常() {
+        Long userId = 8L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("teacher");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-teacher-token");
+        result.setUserId(userId);
+        result.setRole("teacher");
+        when(userService.switchRoleForLogin(userId, "teacher")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals("teacher", r.getData().getRole());
+        verify(userService).switchRoleForLogin(userId, "teacher");
+    }
+
+    // ==================== 权限流:未登录 → 401 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-1:未登录(userId=null)→ 401 请先登录")
+    void switchRole_unauthenticated_nullUserId_401() {
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, null);
+
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+        verify(userService, org.mockito.Mockito.never()).switchRoleForLogin(anyLong(), org.mockito.ArgumentMatchers.anyString());
+    }
+
+    @Test
+    @DisplayName("SC-T-20 权限-2:未登录(userId=0/负数)→ 401 请先登录")
+    void switchRole_unauthenticated_invalidUserId_401() {
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, 0L);
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+
+        r = controller.switchRole(dto, -1L);
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+
+        verify(userService, org.mockito.Mockito.never()).switchRoleForLogin(anyLong(), org.mockito.ArgumentMatchers.anyString());
+    }
+
+    // ==================== 权限流:admin 禁止切换 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-3:admin 角色禁止切换 → 500 '管理员角色不允许切换'")
+    void switchRole_adminBlocked_500() {
+        Long userId = 99L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        when(userService.switchRoleForLogin(userId, "parent"))
+                .thenThrow(new RuntimeException("管理员角色不允许切换"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("管理员角色不允许切换"));
+    }
+
+    // ==================== 权限流:目标角色白名单校验 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-4:目标 role 非 parent/child/teacher(admin/operator)→ 500 拒绝")
+    void switchRole_targetRoleNotInWhitelist_500() {
+        Long userId = 10L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("operator");
+
+        when(userService.switchRoleForLogin(userId, "operator"))
+                .thenThrow(new RuntimeException("不支持切换到该角色"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("不支持切换到该角色"));
+    }
+
+    // ==================== 权限流:teacher 角色拥有性校验 ====================
+
+    @Test
+    @DisplayName("SC-T-01 权限-5:无 teacher 角色的用户请求切到 teacher → 500 '您不是成长规划师'")
+    void switchRole_notTeacher_requestingTeacher_500() {
+        Long userId = 11L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("teacher");
+
+        when(userService.switchRoleForLogin(userId, "teacher"))
+                .thenThrow(new RuntimeException("您不是成长规划师,无法切换为规划师模式"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("成长规划师"));
+    }
+
+    @Test
+    @DisplayName("SC-T-01 权限-6:无 teacher 角色的用户请求切到 parent → 成功(普通 parent 可切回 parent)")
+    void switchRole_notTeacher_requestingParent_ok() {
+        Long userId = 12L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-parent-token");
+        result.setRole("parent");
+        when(userService.switchRoleForLogin(userId, "parent")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertEquals("parent", r.getData().getRole());
+    }
+
+    // ==================== 权限流:用户不持有目标角色 → 500 ====================
+
+    @Test
+    @DisplayName("SC-T-01 权限-7:用户 roles 不含 target role 且 target≠current → 500 无权限")
+    void switchRole_userMissingTargetRole_500() {
+        Long userId = 13L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("child");
+
+        when(userService.switchRoleForLogin(userId, "child"))
+                .thenThrow(new RuntimeException("您没有该角色权限"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("没有该角色权限"));
+    }
+
+    // ==================== 异常流:业务异常透传 ====================
+
+    @Test
+    @DisplayName("SC-T-20 异常-1:UserService 抛出运行时异常 → controller 返回 500 与消息透传")
+    void switchRole_serviceThrows_errorMessagePassedThrough() {
+        Long userId = 14L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        when(userService.switchRoleForLogin(userId, "parent"))
+                .thenThrow(new RuntimeException("系统内部错误,请稍后重试"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertEquals("系统内部错误,请稍后重试", r.getMessage());
+    }
+
+    // ==================== 结构流:端点映射 + JwtInterceptor 路径 ====================
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-1:AuthController 应含 POST /api/auth/switch-role 映射")
+    void structure_authControllerHasSwitchRoleMapping() throws Exception {
+        Method m = findSwitchRoleMethod(AuthController.class);
+        assertNotNull(m,
+                "[teacher.login.switch] AuthController 缺少 POST /api/auth/switch-role 端点映射:"
+                        + "契约要求 SC-T-01/T-20 的角色切换入口。");
+
+        org.springframework.web.bind.annotation.PostMapping post = m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        assertNotNull(post, "switchRole 应为 @PostMapping(AGENTS.md 禁止 GET/PUT/DELETE)");
+        assertTrue(Arrays.stream(post.value()).anyMatch(p -> p.equals("/switch-role")),
+                "switchRole 映射路径应为 /switch-role(完整路径 /api/auth/switch-role)");
+    }
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-2:JwtInterceptor#LOGIN_REQUIRED_PATHS 须包含 /api/auth/switch-role")
+    void structure_jwtInterceptorRequiresLoginForSwitchRole() throws Exception {
+        Field field = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        field.setAccessible(true);
+        String[] paths = (String[]) field.get(null);
+        assertNotNull(paths, "JwtInterceptor.LOGIN_REQUIRED_PATHS 不应为 null");
+
+        boolean hasSwitchRole = false;
+        for (String p : paths) {
+            if ("/api/auth/switch-role".equals(p)) {
+                hasSwitchRole = true;
+                break;
+            }
+        }
+        assertTrue(hasSwitchRole,
+                "[teacher.login.switch] JwtInterceptor.LOGIN_REQUIRED_PATHS 未包含 '/api/auth/switch-role':"
+                        + "契约要求角色切换端点必须登录(JWT Bearer Token),不得落入公开白名单。");
+    }
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-3:SwitchRoleDTO 应存在并含 role 字段(契约接口契约)")
+    void structure_switchRoleDto_existsWithRoleField() throws Exception {
+        Class<?> clazz = loadClass("com.etotem.cfc.dto.SwitchRoleDTO");
+        assertNotNull(clazz,
+                "[teacher.login.switch] SwitchRoleDTO 类不存在:契约要求 POST body 携带 SwitchRoleDTO(role) 入参。");
+
+        boolean hasRoleField = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if ("role".equals(f.getName())) {
+                hasRoleField = true;
+                break;
+            }
+        }
+        assertTrue(hasRoleField,
+                "[teacher.login.switch] SwitchRoleDTO 应含 role 字段,用于承载目标角色(parent/child/teacher)");
+    }
+
+    // ==================== 边界流 ====================
+
+    @Test
+    @DisplayName("SC-T-01 边界:切换为 child 角色(合法目标)→ 通过白名单校验")
+    void boundary_switchToChild_allowedByWhitelist() {
+        Long userId = 15L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("child");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-child-token");
+        result.setRole("child");
+        when(userService.switchRoleForLogin(userId, "child")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertEquals("child", r.getData().getRole());
+    }
+
+    @Test
+    @DisplayName("SC-T-01 边界:target role 为 null → 由 service 层守卫,controller 层透传 500")
+    void boundary_targetRoleNull_serviceThrows_500() {
+        Long userId = 16L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole(null);
+
+        when(userService.switchRoleForLogin(userId, null))
+                .thenThrow(new RuntimeException("不支持切换到该角色"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("不支持切换到该角色"));
+    }
+
+    @Test
+    @DisplayName("SC-T-20 观察:服务端无 logout 端点——JWT 无状态设计,前端删除 token 即退出(符合契约)")
+    void observation_noServerLogoutEndpoint_intentional() throws Exception {
+        boolean hasLogoutMethod = Arrays.stream(AuthController.class.getDeclaredMethods())
+                .anyMatch(m -> m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        && (m.getName().toLowerCase().contains("logout")
+                            || Arrays.stream(m.getAnnotationsByType(org.springframework.web.bind.annotation.PostMapping.class))
+                                    .anyMatch(a -> Arrays.asList(a.value()).stream().anyMatch(v -> v.toLowerCase().contains("logout")))));
+        assertFalse(hasLogoutMethod,
+                "[teacher.login.switch] AuthController 无独立/logout 端点——此为预期的 JWT 无状态设计(SC-T-20 观察项),"
+                        + "前端清理 token 即为退出,不属契约缺口。");
+    }
+
+    // ==================== helpers ====================
+
+    private Method findSwitchRoleMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("switchRole")) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+}