Prechádzať zdrojové kódy

fix(health-plan): rejectPlan 增加状态守卫,新增 getPlanForFamily 防止 IDOR 越权

iwt 2 dní pred
rodič
commit
36da5d06d0

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -23,6 +23,12 @@ public interface HealthPlanService {
     HealthPlan approveAndPublish(Long planId, Long reviewedBy, String comment);
     /** 规划师驳回方案 */
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
+    /**
+     * IDOR 防护:校验方案确实属于指定家庭。
+     *
+     * @return 匹配返回方案;familyId 不匹配返回 null;方案不存在抛异常
+     */
+    HealthPlan getPlanForFamily(Long planId, Long familyId);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
 

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -602,6 +602,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     public HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
         if (plan == null) throw new RuntimeException("方案不存在");
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
         plan.setStatus("rejected");
         plan.setReviewedBy(reviewedBy);
         plan.setReviewedAt(new Date());
@@ -611,6 +614,16 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return plan;
     }
 
+    @Override
+    public HealthPlan getPlanForFamily(Long planId, Long familyId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (plan.getFamilyId() == null || !plan.getFamilyId().equals(familyId)) {
+            return null;
+        }
+        return plan;
+    }
+
     // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
     private static class TaskDraft {
         final String title;

+ 110 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java

@@ -0,0 +1,110 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HealthPlanReviewGuardTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        mapper = mock(HealthPlanMapper.class);
+        Field f = HealthPlanServiceImpl.class.getDeclaredField("healthPlanMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    private HealthPlan planWithStatus(String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(5L);
+        p.setFamilyId(10L);
+        p.setStatus(status);
+        return p;
+    }
+
+    // ---- rejectPlan 状态守卫 ----
+
+    @Test
+    void 已发布方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("published"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void 已驳回方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("rejected"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void pending_review方案可驳回() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        HealthPlan r = service.rejectPlan(5L, 1L, "内容需调整");
+
+        assertEquals("rejected", r.getStatus());
+        assertEquals(Long.valueOf(1L), r.getReviewedBy());
+        assertEquals("内容需调整", r.getReviewComment());
+        verify(mapper).updateById(p);
+    }
+
+    @Test
+    void draft方案可驳回() {
+        HealthPlan p = planWithStatus("draft");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        assertEquals("rejected", service.rejectPlan(5L, 1L, "c").getStatus());
+    }
+
+    @Test
+    void 方案不存在抛方案不存在() {
+        when(mapper.selectById(5L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案不存在", ex.getMessage());
+    }
+
+    // ---- getPlanForFamily IDOR 反查 ----
+
+    @Test
+    void getPlanForFamily_匹配返回方案() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        HealthPlan p = service.getPlanForFamily(99L, 10L);
+        assertNotNull(p);
+        assertEquals(Long.valueOf(99L), p.getId());
+    }
+
+    @Test
+    void getPlanForFamily_不属于该家庭返回null() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        assertNull(service.getPlanForFamily(99L, 20L));
+    }
+
+    @Test
+    void getPlanForFamily_方案不存在抛异常() {
+        when(mapper.selectById(99L)).thenReturn(null);
+        assertEquals("方案不存在",
+                assertThrows(RuntimeException.class, () -> service.getPlanForFamily(99L, 10L)).getMessage());
+    }
+}