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

test(S-cfc-186): RED contract test for teacher.family.overview

Add TeacherFamilyOverviewContractTest (pure Mockito, no Spring/DB) for capability
key teacher.family.overview (SC-T-16/17/21/27).

Covers endpoint anchors (GuideFamilyTaskController /{familyId}/overview,
/dashboard-stats, /{familyId}/children/{memberId}/tasks; GrowthPlanController
/child/{memberId}/active; AssessmentComparisonController /api/assessment/compare/*;
TianpanController /api/tianpan/*) plus role/binding guard controls.

RED gap asserted: GuideFamilyTaskController.getFamilyMemberTasks validates only
requireBound(guideId, familyId) but NOT memberId in familyId, allowing a teacher
bound to family A to read family B member tasks (IDOR). Cross-family memberId
should be rejected and never reach taskService.getTaskHistoryByFamilyMember.

Expected RED: 1 failure (cross-family IDOR), 6 pass (controls).
E2E Test Bot 8 часов назад
Родитель
Сommit
0e6f285da

+ 249 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherFamilyOverviewContractTest.java

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