|
|
@@ -0,0 +1,249 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.guide.PlanReviewController;
|
|
|
+import com.etotem.cfc.entity.TaskPlanInstance;
|
|
|
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
|
|
|
+import com.etotem.cfc.service.GuideFamilyService;
|
|
|
+import com.etotem.cfc.service.PlanActivationService;
|
|
|
+import com.etotem.cfc.service.PlannerCertificationGuard;
|
|
|
+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.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:规划师视角数据隔离(能力键 {@code teacher.data.isolation},SC-T-13)。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-13(规划师视角数据隔离——仅见绑定家庭;
|
|
|
+ * 边界:非绑定家庭 403;模块:控制器内 role 检查)</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}(EVD-10 §2 SC-T-13 端点核验表 +
|
|
|
+ * §5.1 权限流建议:teacher 越权访问非绑定家庭 {@code {familyId}} 端点应 403/拒绝)</li>
|
|
|
+ * <li>API:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-13——授权源
|
|
|
+ * {@code guide_families.status='binding'}({@link GuideFamilyService#isBound})+ 角色隔离;
|
|
|
+ * {@code PlanReviewController family-list}(teacher 按 JWT familyId 过滤)</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>实测缺口(RED 依据,origin/cfclub @ e03ec423a 工作树)</b></p>
|
|
|
+ * <p>{@code PlanReviewController#listFamilyPlans}({@code POST /api/guide/plans/family-list})对
|
|
|
+ * teacher 的处理是「body.familyId 优先、JWT familyId 兜底」,<b>无任何 isBound 校验</b>——
|
|
|
+ * 控制器仅注入 {@code planMapper/userMapper/planActivationService/plannerCertificationGuard},
|
|
|
+ * 未注入全项目唯一授权判定点 {@link GuideFamilyAccessGuard}(其 javadoc 自述「全项目唯一判定点,
|
|
|
+ * 避免各控制器散写导致口径漂移」)。因此 teacher 在 body 中传任意非绑定 familyId 即可列出该家庭全部方案
|
|
|
+ * (IDOR 数据泄漏),与契约「teacher 按 JWT familyId 过滤 / 非绑定家庭 403」相悖。SC-T-13 其余三个载体
|
|
|
+ * ({@code GuideFamilyQueryController /my-families}、{@code GuideFamilyTaskController /bound-families +
|
|
|
+ * /{familyId}/*} 的 isBound 校验)已由 {@code GuideBindingAccessContractTest} / {@code PlanReviewTeacherContractTest}
|
|
|
+ * 覆盖,本类专补 family-list 隔离缺口。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:权限流(越权 403 / 不泄漏非绑定家庭方案 / 越权不查询 planMapper)与
|
|
|
+ * 结构流(注入统一授权守卫)在实现补齐前应失败(红);正常流/异常流/观察为基线钉住(绿)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code PlanReviewController} 注入
|
|
|
+ * {@code GuideFamilyAccessGuard}(或 {@code GuideFamilyService}),在 {@code listFamilyPlans} 的
|
|
|
+ * teacher 分支先做 {@code checkFamilyAccess(userId, filterFamilyId)} 校验(非绑定 → 403),
|
|
|
+ * 并将家族过滤收敛到绑定/JWT 家庭集合后,本类自动转绿。</p>
|
|
|
+ */
|
|
|
+class TeacherDataIsolationScT13ContractTest extends AssessmentPlanContractTestBase {
|
|
|
+
|
|
|
+ private static final Long TEACHER = 7L;
|
|
|
+ /** JWT familyId(默认家庭上下文,按契约即 teacher 的绑定/JWT 家庭) */
|
|
|
+ private static final Long OWN_FAMILY = 7L;
|
|
|
+ /** 非绑定家庭:teacher 越权目标(契约要求 403/拒绝) */
|
|
|
+ private static final Long FOREIGN_FAMILY = 99L;
|
|
|
+
|
|
|
+ private TaskPlanInstanceMapper planMapper;
|
|
|
+ private PlanReviewController controller;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ // 无 Spring 上下文:预注册 TableInfo,使 listFamilyPlans 内 LambdaQueryWrapper 可用
|
|
|
+ initTable(TaskPlanInstance.class);
|
|
|
+ planMapper = mock(TaskPlanInstanceMapper.class);
|
|
|
+ controller = new PlanReviewController();
|
|
|
+ inject(controller, "planMapper", planMapper);
|
|
|
+ inject(controller, "userMapper", mock(UserMapper.class));
|
|
|
+ inject(controller, "planActivationService", mock(PlanActivationService.class));
|
|
|
+ inject(controller, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 / 异常流(基线钉住,当前实现已符合契约) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 正常流-1:admin 指定任务家庭查方案列表 → 200 放行(运营兜底)")
|
|
|
+ void familyList_正常_admin放行() {
|
|
|
+ when(planMapper.selectList(any())).thenReturn(foreignPlans());
|
|
|
+
|
|
|
+ Result<List<TaskPlanInstance>> r =
|
|
|
+ controller.listFamilyPlans(bodyWithFamily(FOREIGN_FAMILY), 1L, "admin", 2L, null);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertNotNull(r.getData());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 异常流-1:teacher 无 familyId 且无 JWT 家庭 → 5001 请先创建或加入家庭")
|
|
|
+ void familyList_异常_teacher无家庭上下文() {
|
|
|
+ Result<List<TaskPlanInstance>> r =
|
|
|
+ controller.listFamilyPlans(new HashMap<String, Object>(), TEACHER, "teacher", null, null);
|
|
|
+
|
|
|
+ assertEquals(5001, r.getCode());
|
|
|
+ assertTrue(r.getMessage().contains("请先创建或加入家庭"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 异常流-2:非 teacher/admin 角色 → 拒绝(无权限)")
|
|
|
+ void familyList_异常_非teacher角色拒绝() {
|
|
|
+ Result<List<TaskPlanInstance>> r =
|
|
|
+ controller.listFamilyPlans(bodyWithFamily(OWN_FAMILY), 1L, "parent", 7L, null);
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertTrue(r.getMessage().contains("无权限"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(RED:family-list 数据隔离缺口,当前实现越权可及) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 权限流-1:teacher 越权访问非绑定家庭(99L) family-list → 应 403 拒绝")
|
|
|
+ void familyList_权限_teacher越权非绑定家庭_403() {
|
|
|
+ when(planMapper.selectList(any())).thenReturn(foreignPlans());
|
|
|
+
|
|
|
+ Result<List<TaskPlanInstance>> r =
|
|
|
+ controller.listFamilyPlans(bodyWithFamily(FOREIGN_FAMILY), TEACHER, "teacher", OWN_FAMILY, null);
|
|
|
+
|
|
|
+ // 契约(EVD-10 §5.1 / API_REFERENCE §4.57 SC-T-13):teacher 越权访问非绑定家庭应 403/拒绝。
|
|
|
+ // 当前实现无 isBound 校验,直接以 body.familyId 查询并返回该家庭方案(200 + data)→ 断言失败(红)。
|
|
|
+ assertEquals(403, r.getCode(),
|
|
|
+ "[teacher.data.isolation] family-list 应拒绝 teacher 越权访问非绑定家庭(99L):"
|
|
|
+ + "需按 guide_families(status='binding') isBound 校验(SC-T-13)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 权限流-2:teacher 携带 body.familyId 不得返回非绑定家庭方案(按 JWT 家庭隔离)")
|
|
|
+ void familyList_权限_不泄漏跨家庭方案() {
|
|
|
+ when(planMapper.selectList(any())).thenReturn(foreignPlans());
|
|
|
+
|
|
|
+ Map<String, Object> body = bodyWithFamily(FOREIGN_FAMILY);
|
|
|
+ Result<List<TaskPlanInstance>> r =
|
|
|
+ controller.listFamilyPlans(body, TEACHER, "teacher", OWN_FAMILY, null);
|
|
|
+
|
|
|
+ // 契约:teacher 查询范围 = 绑定/JWT 家庭;99L 非绑定 → 结果不得含 99L 方案。
|
|
|
+ // 当前实现 body.familyId 直接透传 → 返回 foreignPlans(含 99L)→ 断言失败(红)。
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertNotNull(r.getData());
|
|
|
+ for (TaskPlanInstance plan : r.getData()) {
|
|
|
+ assertTrue(!FOREIGN_FAMILY.equals(plan.getFamilyId()),
|
|
|
+ "[teacher.data.isolation] family-list 泄漏非绑定家庭(99L)方案:"
|
|
|
+ + "teacher 应按绑定/JWT 家庭过滤,禁止跨家庭返回(SC-T-13)。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 权限流-3:teacher 越权非绑定家庭时 planMapper.selectList 不得被查询(拒绝先行)")
|
|
|
+ void familyList_权限_越权不查询planMapper() {
|
|
|
+ when(planMapper.selectList(any())).thenReturn(foreignPlans());
|
|
|
+
|
|
|
+ Map<String, Object> body = bodyWithFamily(FOREIGN_FAMILY);
|
|
|
+ controller.listFamilyPlans(body, TEACHER, "teacher", OWN_FAMILY, null);
|
|
|
+
|
|
|
+ // 契约(GuideFamilyAccessGuard 全项目唯一判定点语义):隔离校验先行,
|
|
|
+ // 非绑定家庭不触达方案查询层。当前实现无校验、直接 selectList → verify(never) 失败(红)。
|
|
|
+ verify(planMapper, never()).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 结构流(RED:family-list 未接入统一授权判定点) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 结构流-1:PlanReviewController 应注入 GuideFamilyAccessGuard/GuideFamilyService(统一判定点)")
|
|
|
+ void familyList_结构_应注入统一授权守卫() {
|
|
|
+ boolean hasGuard = false;
|
|
|
+ for (Field f : PlanReviewController.class.getDeclaredFields()) {
|
|
|
+ String type = f.getType().getName();
|
|
|
+ if (GuideFamilyAccessGuard.class.getName().equals(type)
|
|
|
+ || GuideFamilyService.class.getName().equals(type)) {
|
|
|
+ hasGuard = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasGuard,
|
|
|
+ "[teacher.data.isolation] PlanReviewController 未注入 GuideFamilyAccessGuard/GuideFamilyService:"
|
|
|
+ + "契约要求 family-list 对 teacher 复用全项目唯一授权判定点(guide_families status='binding' "
|
|
|
+ + "isBound 校验,SC-T-13 / EVD-10 §5.1),越权访问非绑定家庭应 403。当前未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 结构流-2(基线):授权源 guide_families 表 + status='binding' 语义应存在于 schema")
|
|
|
+ void familyList_结构_授权源表存在() {
|
|
|
+ String schema = readSchemaSql();
|
|
|
+ assertTrue(schema.contains("guide_families"),
|
|
|
+ "[teacher.data.isolation] schema.sql 应含 guide_families 表(授权源,SC-T-13 契约 §2)。"
|
|
|
+ + "GREEN 实现依赖该表 status='binding' 做 isBound 判定。");
|
|
|
+ assertTrue(schema.contains("binding"),
|
|
|
+ "[teacher.data.isolation] schema.sql 的 guide_families 应含 status='binding' 状态语义"
|
|
|
+ + "(GuideFamilyService.isBound 授权源,SC-T-13)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 观察(钉住现状,不修实现) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-13 观察-1:planDetail 无角色/归属校验入参(观察钉住,SC-T-13 契约面为 family-list)")
|
|
|
+ void pinsObserved_planDetail无隔离校验() throws Exception {
|
|
|
+ Method m = PlanReviewController.class.getDeclaredMethod("planDetail", Map.class);
|
|
|
+ assertEquals(1, m.getParameterCount(),
|
|
|
+ "planDetail 仅接收 body(无 role/userId 归属校验入参)。观察钉住现状:"
|
|
|
+ + "SC-T-13 契约面为 family-list(teacher 按 JWT familyId 过滤),detail 无隔离不在本键断言范围。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 ====================
|
|
|
+
|
|
|
+ private Map<String, Object> bodyWithFamily(Long familyId) {
|
|
|
+ Map<String, Object> body = new HashMap<String, Object>();
|
|
|
+ body.put("familyId", familyId);
|
|
|
+ return body;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<TaskPlanInstance> foreignPlans() {
|
|
|
+ TaskPlanInstance p = new TaskPlanInstance();
|
|
|
+ p.setId(999L);
|
|
|
+ p.setFamilyId(FOREIGN_FAMILY);
|
|
|
+ p.setName("非绑定家庭方案");
|
|
|
+ p.setStatus("generated");
|
|
|
+ p.setCreatedAt(new Date());
|
|
|
+ return Collections.singletonList(p);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String readSchemaSql() {
|
|
|
+ try (java.io.InputStream is =
|
|
|
+ getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
+ if (is == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try (java.util.Scanner scanner =
|
|
|
+ new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|