|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|