|
|
@@ -0,0 +1,249 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.assessment.AssessmentComparisonController;
|
|
|
+import com.etotem.cfc.controller.growth.GrowthPlanController;
|
|
|
+import com.etotem.cfc.controller.guide.GuideFamilyTaskController;
|
|
|
+import com.etotem.cfc.controller.tianpan.TianpanController;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.Task;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
|
|
|
+import com.etotem.cfc.service.GuideFamilyService;
|
|
|
+import com.etotem.cfc.service.MembershipService;
|
|
|
+import com.etotem.cfc.service.RewardRuleService;
|
|
|
+import com.etotem.cfc.service.TaskService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code teacher.family.overview}(SC-T-16 客户健康总览 / SC-T-17 成长计划进度跟踪 /
|
|
|
+ * SC-T-21 报告对比分析 / SC-T-27 家庭天盘解读)。
|
|
|
+ *
|
|
|
+ * <p>契约溯源:
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-16(客户健康总览)/ SC-T-17(成长计划进度跟踪)/
|
|
|
+ * SC-T-21(报告对比分析)/ SC-T-27(家庭天盘解读·规划师);</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} EVD-10 §2 端点核验表 +
|
|
|
+ * §3 建议能力键映射({@code teacher.family.overview} ↔ T-16/17/21/27)+ §5 转测试侧重点 1(SC-T-13 数据隔离);</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域。</li>
|
|
|
+ * </ul></p>
|
|
|
+ *
|
|
|
+ * <p><b>端点锚点(origin/cfclub 现状核实,均已存在)</b>:
|
|
|
+ * <ul>
|
|
|
+ * <li>SC-T-16:{@code GuideFamilyTaskController /api/guide/families/{familyId}/overview} + {@code /dashboard-stats};</li>
|
|
|
+ * <li>SC-T-17:{@code GuideFamilyTaskController /{familyId}/children/{memberId}/tasks};
|
|
|
+ * {@code GrowthPlanController /api/growth/plan/child/{memberId}/active};</li>
|
|
|
+ * <li>SC-T-21:{@code AssessmentComparisonController /api/assessment/compare/*}(对比行为契约已由
|
|
|
+ * {@code AssessmentComparisonContractTest} 覆盖,本类仅锚定端点存在);</li>
|
|
|
+ * <li>SC-T-27:{@code TianpanController /api/tianpan/*}(天盘聚合/隔离行为契约已由
|
|
|
+ * {@code TianpanGuideContractTest} 覆盖,本类仅锚定端点存在)。</li>
|
|
|
+ * </ul></p>
|
|
|
+ *
|
|
|
+ * <p><b>本类聚焦本键新增契约缺口(TDD-RED)</b>:SC-T-17 成长计划进度跟踪端点
|
|
|
+ * {@code GuideFamilyTaskController#getFamilyMemberTasks} 仅校验「规划师与 familyId 绑定」(requireBound),
|
|
|
+ * <b>未校验路径 {@code memberId} 是否属于该 {@code familyId}</b>——已绑定家庭 A 的规划师传入属于家庭 B 的
|
|
|
+ * {@code memberId} 即可跨家庭越权读取成员任务列表(IDOR),违反 EVD-10 §5 重点 1(SC-T-13 数据隔离)
|
|
|
+ * 与本键「客户健康总览/成长进度仅见绑定家庭成员」的契约。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:跨家庭成员任务列表({@code memberId ∉ familyId})应被拒且不进入
|
|
|
+ * {@code taskService.getTaskHistoryByFamilyMember};当前实现缺成员归属校验 → 会调用 taskService →
|
|
|
+ * 断言失败(红)。GREEN 阶段补「memberId 属于 familyId」归属校验后转绿。</p>
|
|
|
+ *
|
|
|
+ * <p>纯 Mockito(无 Spring / 无 DB / 无外部服务),参照同批 RED 契约测试风格
|
|
|
+ * ({@code FamilyJoinValidationRulesContractTest}、{@code TeamProfitHookContractTest}、
|
|
|
+ * {@code GuideFamilyTaskReviewContractTest})。</p>
|
|
|
+ */
|
|
|
+class TeacherFamilyOverviewContractTest {
|
|
|
+
|
|
|
+ private static final Long GUIDE_ID = 1L;
|
|
|
+ /** 规划师已绑定的家庭 ID(requireBound 放行来源) */
|
|
|
+ private static final Long BOUND_FAMILY_ID = 100L;
|
|
|
+ /** 非绑定的其他家庭 ID(跨家庭越权来源) */
|
|
|
+ private static final Long OTHER_FAMILY_ID = 999L;
|
|
|
+ /** 属于 BOUND_FAMILY_ID 的成员 */
|
|
|
+ private static final Long OWN_MEMBER_ID = 11L;
|
|
|
+ /** 属于 OTHER_FAMILY_ID 的成员(越权读取目标) */
|
|
|
+ private static final Long CROSS_MEMBER_ID = 22L;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private TaskService taskService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private TaskMapper taskMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private MembershipService membershipService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private GuideFamilyService guideFamilyService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private RewardRuleService rewardRuleService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private GuideFamilyAccessGuard guideFamilyAccessGuard;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private GuideFamilyTaskController controller;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ // 规划师与 BOUND_FAMILY_ID 已绑定 → requireBound 放行(返回 null 表示放行)
|
|
|
+ when(guideFamilyAccessGuard.checkBinding(GUIDE_ID, BOUND_FAMILY_ID)).thenReturn(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:端点锚点存在(SC-T-16/17/21/27,对照应绿) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:SC-T-16/17 客户总览与成员任务端点应存在(GuideFamilyTaskController)")
|
|
|
+ void guideFamilyTaskController_锚点端点存在() {
|
|
|
+ assertEquals("/{familyId}/overview", postPath(GuideFamilyTaskController.class, "getFamilyTaskOverview"),
|
|
|
+ "[teacher.family.overview] SC-T-16 锚点 GET-style POST /{familyId}/overview 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/dashboard-stats", postPath(GuideFamilyTaskController.class, "getDashboardStats"),
|
|
|
+ "[teacher.family.overview] SC-T-16 锚点 POST /dashboard-stats 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/{familyId}/children/{memberId}/tasks", postPath(GuideFamilyTaskController.class, "getFamilyMemberTasks"),
|
|
|
+ "[teacher.family.overview] SC-T-17 锚点 POST /{familyId}/children/{memberId}/tasks 缺失(EVD-10 §2)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:SC-T-17 活跃成长方案端点应存在(GrowthPlanController)")
|
|
|
+ void growthPlanController_activePlan端点存在() {
|
|
|
+ assertEquals("/child/{memberId}/active", postPath(GrowthPlanController.class, "getActivePlan"),
|
|
|
+ "[teacher.family.overview] SC-T-17 锚点 POST /api/growth/plan/child/{memberId}/active 缺失(EVD-10 §2)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:SC-T-21 报告对比端点应存在(AssessmentComparisonController)")
|
|
|
+ void assessmentComparisonController_compare端点存在() {
|
|
|
+ assertEquals("/by-child", postPath(AssessmentComparisonController.class, "compareByChild"),
|
|
|
+ "[teacher.family.overview] SC-T-21 锚点 POST /api/assessment/compare/by-child 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/by-child-dimension", postPath(AssessmentComparisonController.class, "compareByChildDimension"),
|
|
|
+ "[teacher.family.overview] SC-T-21 锚点 POST /by-child-dimension 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/by-family", postPath(AssessmentComparisonController.class, "compareByFamily"),
|
|
|
+ "[teacher.family.overview] SC-T-21 锚点 POST /by-family 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/narrative", postPath(AssessmentComparisonController.class, "getNarrative"),
|
|
|
+ "[teacher.family.overview] SC-T-21 锚点 POST /narrative 缺失(EVD-10 §2)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4:SC-T-27 家庭天盘端点应存在(TianpanController)")
|
|
|
+ void tianpanController_dashboardMember端点存在() {
|
|
|
+ assertEquals("/dashboard", postPath(TianpanController.class, "dashboard"),
|
|
|
+ "[teacher.family.overview] SC-T-27 锚点 POST /api/tianpan/dashboard 缺失(EVD-10 §2)。");
|
|
|
+ assertEquals("/member/{memberId}", postPath(TianpanController.class, "memberDetail"),
|
|
|
+ "[teacher.family.overview] SC-T-27 锚点 POST /api/tianpan/member/{memberId} 缺失(EVD-10 §2)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:角色 / 绑定守卫(对照,应绿) ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 非 teacher/admin 角色访问规划师专属端点 → 应在 role 检查处短路返回 Access denied,不查询任务。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-5:非 teacher 角色访问成员任务端点应被拒且不查询")
|
|
|
+ void getFamilyMemberTasks_非teacher角色_拒绝且不查询() {
|
|
|
+ Result<Page<Task>> result = controller.getFamilyMemberTasks(
|
|
|
+ GUIDE_ID, BOUND_FAMILY_ID, OWN_MEMBER_ID, "parent", new HashMap<>(), null);
|
|
|
+
|
|
|
+ assertEquals("Access denied", result.getMessage());
|
|
|
+ verify(taskService, never()).getTaskHistoryByFamilyMember(eq(OWN_MEMBER_ID), anyInt(), anyInt(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 规划师访问非绑定家庭 {familyId} 端点 → requireBound 应拒(403),不查询任务(EVD-10 §5 重点 1 数据隔离)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-6:规划师访问非绑定家庭总览应被拒(requireBound 403)")
|
|
|
+ void getFamilyTaskOverview_非绑定家庭_拒绝() {
|
|
|
+ when(guideFamilyAccessGuard.checkBinding(GUIDE_ID, OTHER_FAMILY_ID))
|
|
|
+ .thenReturn(Result.error(403, "非绑定家庭"));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = controller.getFamilyTaskOverview(
|
|
|
+ GUIDE_ID, "teacher", OTHER_FAMILY_ID, null);
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals(403, result.getCode());
|
|
|
+ verify(taskService, never()).getTodayTasks(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(RED 契约缺口):跨家庭成员 IDOR ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SC-T-17 / EVD-10 §5 重点 1:规划师越权读取不属于已绑定家庭的成员任务 —— 接口应拒绝且不进入
|
|
|
+ * {@code taskService.getTaskHistoryByFamilyMember}。
|
|
|
+ *
|
|
|
+ * <p>缺口定位:{@code GuideFamilyTaskController#getFamilyMemberTasks} 当前仅校验 requireBound(guideId, familyId),
|
|
|
+ * 不校验 {@code memberId} 是否属于 {@code familyId},导致已绑定家庭 A 的规划师传入属于家庭 B 的 memberId
|
|
|
+ * 即可跨家庭越权读取成员任务(IDOR)。</p>
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1:跨家庭成员任务列表应被拒且不查询(RED:缺 memberId 归属校验)")
|
|
|
+ void getFamilyMemberTasks_跨家庭成员_应拒绝且不查询() {
|
|
|
+ // 成员存在但属于 OTHER_FAMILY_ID(跨家庭越权来源)
|
|
|
+ when(familyMemberMapper.selectById(CROSS_MEMBER_ID))
|
|
|
+ .thenReturn(member(CROSS_MEMBER_ID, OTHER_FAMILY_ID));
|
|
|
+ when(taskService.getTaskHistoryByFamilyMember(eq(CROSS_MEMBER_ID), anyInt(), anyInt(), any()))
|
|
|
+ .thenReturn(new Page<>());
|
|
|
+
|
|
|
+ Result<Page<Task>> result = controller.getFamilyMemberTasks(
|
|
|
+ GUIDE_ID, BOUND_FAMILY_ID, CROSS_MEMBER_ID, "teacher", new HashMap<>(), null);
|
|
|
+
|
|
|
+ // 契约:memberId 不属于路径 familyId → 不得进入任务查询(SC-T-13 数据隔离 / TC 权限流)
|
|
|
+ verify(taskService, never()).getTaskHistoryByFamilyMember(eq(CROSS_MEMBER_ID), anyInt(), anyInt(), any());
|
|
|
+ assertNotEquals(Integer.valueOf(200), result.getCode(), "跨家庭成员任务应被拒(非 200 成功响应)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助 ====================
|
|
|
+
|
|
|
+ private FamilyMember member(Long id, Long familyId) {
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(id);
|
|
|
+ m.setFamilyId(familyId);
|
|
|
+ m.setNickname("契约测试成员");
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反射读取方法上的 @PostMapping 路径;方法不存在或未标注返回 null。 */
|
|
|
+ private String postPath(Class<?> clazz, String methodName) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(methodName)) {
|
|
|
+ PostMapping pm = m.getAnnotation(PostMapping.class);
|
|
|
+ if (pm != null && pm.value().length > 0) {
|
|
|
+ return pm.value()[0];
|
|
|
+ }
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|