Преглед на файлове

Merge branch 'tmp/merge-e09-authz': E09 安全漏洞修复——健康报告删除/编辑/下载接口补归属校验

Agnes Agent преди 1 ден
родител
ревизия
28da03bc33

+ 52 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -741,12 +741,34 @@ public class HealthReportController {
     @PostMapping("/report/edit")
     public Result<String> editReport(
             @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
 
         Long reportId = ParamUtils.getLong(params.get("reportId"));
         if (reportId == null) {
             return Result.error("reportId不能为空");
         }
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        // 归属校验(与 getReportPayload 同范式:能回填即可保存)
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权编辑该报告");
+        }
+        Long reportSubjectId = report.getSubjectId();
+        if (reportSubjectId == null) {
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权编辑该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, reportSubjectId, reportFamilyId)) {
+            return Result.error("无权编辑该报告");
+        }
         Long subjectId = ParamUtils.getLong(params.get("subjectId"));
         // subjectId 可为 null:用户取消绑定成员时传 null,表示解绑(不纳入家庭维度计算)
         // subjectId 与旧值不同时:重算旧成员 + 新成员维度分
@@ -767,6 +789,9 @@ public class HealthReportController {
      * <p>删除守卫(SC-P-24 / TC-P-24-E):报告已关联成长方案时拒绝删除并提示
      * 「请先解除关联方案」;归属校验(SC-P-24-P / TC-P-24-P):报告须归属当前用户家庭,
      * 横向越权删除他人报告统一被拒。</p>
+     *
+     * <p>E09 家庭内成员级校验:跨家庭拒绝后,家庭内亦仅上传者本人或家庭管理员可删除,
+     * 防止同家庭成员越权删除他人报告。</p>
      */
     @Operation(summary = "删除健康报告")
     @PostMapping("/report/delete")
@@ -778,14 +803,24 @@ public class HealthReportController {
         if (reportId == null) {
             return Result.error("reportId不能为空");
         }
-        // 归属校验(TC-P-24-P):报告须归属当前用户家庭,横向越权删除被拒
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
         HealthReport report = healthReportService.getReportById(reportId);
         if (report == null) {
             return Result.error("报告不存在");
         }
+        // 归属校验(TC-P-24-P):报告须归属当前用户家庭,横向越权删除被拒
         if (familyId != null && report.getFamilyId() != null && !familyId.equals(report.getFamilyId())) {
             return Result.error(403, "无权删除该报告");
         }
+        // E09 越权修复:家庭内亦仅上传者本人或家庭管理员可删除
+        boolean isOwner = report.getUserId() != null && report.getUserId().equals(userId);
+        boolean isAdmin = report.getFamilyId() != null
+                && reportMemberPermissionService.isFamilyAdmin(userId, report.getFamilyId());
+        if (!isOwner && !isAdmin) {
+            return Result.error("无权删除该报告");
+        }
         try {
             healthReportService.deleteReport(reportId);
         } catch (IllegalStateException e) {
@@ -824,6 +859,7 @@ public class HealthReportController {
     public Result<Map<String, Object>> downloadReport(
             @RequestBody Map<String, Object> params,
             @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             HttpServletRequest request) {
         if (userId == null) {
             return Result.error("用户未登录");
@@ -836,6 +872,20 @@ public class HealthReportController {
         if (report == null) {
             return Result.error("报告不存在");
         }
+        // E09-b 越权下载修复:与 detail/payload 同范式(能查看即可下载)
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权下载该报告");
+        }
+        Long reportSubjectId = report.getSubjectId();
+        if (reportSubjectId == null) {
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权下载该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, reportSubjectId, reportFamilyId)) {
+            return Result.error("无权下载该报告");
+        }
         String fileUrl = report.getFileUrl();
         if (fileUrl == null || fileUrl.trim().isEmpty()) {
             return Result.error("该报告无文件可下载");

+ 313 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerDeleteAuthzTest.java

@@ -0,0 +1,313 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import java.lang.reflect.Field;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * E09 越权修复契约单测:健康报告 删除/编辑/下载 三个端点的归属/权限校验。
+ *
+ * 契约(对应 HealthReportController 的 deleteReport / editReport / downloadReport):
+ * - userId 未注入 → Result.error("用户未登录")
+ * - reportId 不存在 → Result.error("报告不存在"),不触发底层写/删操作
+ * - 上传者本人(report.userId == userId 且 subjectId 为空)→ 允许
+ * - 家庭管理员(report.familyId 匹配且 isFamilyAdmin=true 且 subjectId 为空)→ 允许
+ * - 被授权成员(subjectId 非空且 canViewMemberReports=true)→ 允许
+ * - 其他登录用户(非本人、非管理员、未被授权)→ Result.error("无权..."),不触发底层操作
+ * - familyId 与 report.familyId 均为空 → 拒绝(edit/download;delete 由成员级判定兜底仅限本人)
+ * - deleteReport:请求方家庭与报告家庭不一致 → 403(SC-P-24-P 跨家庭横向越权)
+ * - deleteReport:报告已关联成长方案(service 抛 IllegalStateException)→ 409 透传,不吞异常
+ *
+ * 测试不依赖数据库/外部服务:healthReportService / reportMemberPermissionService 全 mock;
+ * 不启 Spring 上下文,沿用 HealthPlanDeleteControllerTest 的直接调用模式。
+ */
+class HealthReportControllerDeleteAuthzTest {
+
+    private HealthReportController controller;
+    private HealthReportService healthReportService;
+    private ReportMemberPermissionService reportMemberPermissionService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new HealthReportController();
+        healthReportService = mock(HealthReportService.class);
+        reportMemberPermissionService = mock(ReportMemberPermissionService.class);
+        inject("healthReportService", healthReportService);
+        inject("reportMemberPermissionService", reportMemberPermissionService);
+    }
+
+    private void inject(String name, Object value) throws Exception {
+        Field f = controller.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, value);
+    }
+
+    private Map<String, Object> params(Object reportId) {
+        Map<String, Object> p = new HashMap<>();
+        p.put("reportId", reportId);
+        return p;
+    }
+
+    private HealthReport report(Long reportId, Long ownerUserId, Long familyId) {
+        HealthReport r = new HealthReport();
+        r.setId(reportId);
+        r.setUserId(ownerUserId);
+        r.setFamilyId(familyId);
+        return r;
+    }
+
+    @Test
+    void 未登录_返回用户未登录不触发删除() {
+        Result<?> r = controller.deleteReport(params(1L), null, null);
+        assertEquals("用户未登录", r.getMessage());
+        verify(healthReportService, never()).deleteReport(anyLong());
+    }
+
+    @Test
+    void 报告不存在_返回报告不存在不触发删除() {
+        when(healthReportService.getReportById(99L)).thenReturn(null);
+        Result<?> r = controller.deleteReport(params(99L), 1L, null);
+        assertEquals("报告不存在", r.getMessage());
+        verify(healthReportService, never()).deleteReport(anyLong());
+    }
+
+    @Test
+    void 上传者本人_可删除自己报告() {
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        Result<?> r = controller.deleteReport(params(100L), 10L, null);
+        assertEquals(200, r.getCode());
+        assertEquals("已删除", r.getData());
+        verify(healthReportService).deleteReport(100L);
+    }
+
+    @Test
+    void 家庭管理员_可删除家庭内报告() {
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        when(reportMemberPermissionService.isFamilyAdmin(20L, 7L)).thenReturn(true);
+        Result<?> r = controller.deleteReport(params(100L), 20L, null);
+        assertEquals(200, r.getCode());
+        assertEquals("已删除", r.getData());
+        verify(healthReportService).deleteReport(100L);
+        verify(reportMemberPermissionService).isFamilyAdmin(20L, 7L);
+    }
+
+    @Test
+    void 非本人非管理员_拒绝删除() {
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        when(reportMemberPermissionService.isFamilyAdmin(30L, 7L)).thenReturn(false);
+        Result<?> r = controller.deleteReport(params(100L), 30L, null);
+        assertEquals("无权删除该报告", r.getMessage());
+        assertNotEquals(200, r.getCode());
+        verify(healthReportService, never()).deleteReport(anyLong());
+    }
+
+    @Test
+    void 跨家庭_拒绝删除_403() {
+        // 本人 userId=10 也无权删他家庭(7)的报告:请求方家庭 8 ≠ 报告家庭 7,先于成员级判定拦截
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        Result<?> r = controller.deleteReport(params(100L), 10L, 8L);
+        assertEquals("无权删除该报告", r.getMessage());
+        assertEquals(403, r.getCode());
+        verify(healthReportService, never()).deleteReport(anyLong());
+    }
+
+    @Test
+    void 报告已关联成长方案_拒绝删除_409不吞异常() {
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        doThrow(new IllegalStateException("报告已关联成长方案,请先解除关联")).when(healthReportService).deleteReport(100L);
+        Result<?> r = controller.deleteReport(params(100L), 10L, 7L);
+        assertEquals("报告已关联成长方案,请先解除关联", r.getMessage());
+        assertEquals(409, r.getCode());
+        // 守卫异常被捕获转 409,但 deleteReport 确已调用(不是被静默吞掉)
+        verify(healthReportService).deleteReport(100L);
+    }
+
+    /* ============ editReport:越权编辑 ============ */
+
+    private HealthReport reportWithSubject(Long reportId, Long ownerUserId, Long familyId, Long subjectId) {
+        HealthReport r = report(reportId, ownerUserId, familyId);
+        r.setSubjectId(subjectId);
+        return r;
+    }
+
+    private Map<String, Object> editParams(Object reportId, Object subjectId) {
+        Map<String, Object> p = params(reportId);
+        p.put("subjectId", subjectId);
+        p.put("payload", new HashMap<String, Object>());
+        return p;
+    }
+
+    @Test
+    void 编辑_未登录_返回用户未登录() {
+        Result<?> r = controller.editReport(editParams(100L, null), null, null);
+        assertEquals("用户未登录", r.getMessage());
+        verify(healthReportService, never()).updateReportFromPayload(any(), any(), any(), any());
+    }
+
+    @Test
+    void 编辑_报告不存在_返回报告不存在() {
+        when(healthReportService.getReportById(99L)).thenReturn(null);
+        Result<?> r = controller.editReport(editParams(99L, null), 1L, null);
+        assertEquals("报告不存在", r.getMessage());
+        verify(healthReportService, never()).updateReportFromPayload(any(), any(), any(), any());
+    }
+
+    @Test
+    void 编辑_本人可编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, 7L, null));
+        Result<?> r = controller.editReport(editParams(100L, null), 10L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals("已保存", r.getData());
+        verify(healthReportService).updateReportFromPayload(eq(100L), any(), any(), eq(10L));
+    }
+
+    @Test
+    void 编辑_管理员可编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, 7L, null));
+        when(reportMemberPermissionService.isFamilyAdmin(20L, 7L)).thenReturn(true);
+        Result<?> r = controller.editReport(editParams(100L, null), 20L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals("已保存", r.getData());
+        verify(healthReportService).updateReportFromPayload(eq(100L), any(), any(), eq(20L));
+    }
+
+    @Test
+    void 编辑_被授权成员可编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, 7L, 50L));
+        when(reportMemberPermissionService.canViewMemberReports(30L, 50L, 7L)).thenReturn(true);
+        Result<?> r = controller.editReport(editParams(100L, null), 30L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals("已保存", r.getData());
+        verify(healthReportService).updateReportFromPayload(eq(100L), any(), any(), eq(30L));
+    }
+
+    @Test
+    void 编辑_非本人非管理员_拒绝编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, 7L, null));
+        when(reportMemberPermissionService.isFamilyAdmin(30L, 7L)).thenReturn(false);
+        Result<?> r = controller.editReport(editParams(100L, null), 30L, 7L);
+        assertEquals("无权编辑该报告", r.getMessage());
+        verify(healthReportService, never()).updateReportFromPayload(any(), any(), any(), any());
+    }
+
+    @Test
+    void 编辑_授权成员但不可见_拒绝编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, 7L, 50L));
+        when(reportMemberPermissionService.canViewMemberReports(30L, 50L, 7L)).thenReturn(false);
+        Result<?> r = controller.editReport(editParams(100L, null), 30L, 7L);
+        assertEquals("无权编辑该报告", r.getMessage());
+        verify(healthReportService, never()).updateReportFromPayload(any(), any(), any(), any());
+    }
+
+    @Test
+    void 编辑_家庭缺失_拒绝编辑() {
+        when(healthReportService.getReportById(100L)).thenReturn(reportWithSubject(100L, 10L, null, null));
+        Result<?> r = controller.editReport(editParams(100L, null), 10L, null);
+        assertEquals("无权编辑该报告", r.getMessage());
+        verify(healthReportService, never()).updateReportFromPayload(any(), any(), any(), any());
+    }
+
+    /* ============ downloadReport:越权下载 ============ */
+
+    private static final String FILE_URL = "https://cdn.example.com/report.pdf";
+
+    private HealthReport downloadReportData(Long reportId, Long ownerUserId, Long familyId, Long subjectId) {
+        HealthReport r = report(reportId, ownerUserId, familyId);
+        r.setSubjectId(subjectId);
+        r.setFileUrl(FILE_URL);
+        return r;
+    }
+
+    private HttpServletRequest mockRequest() {
+        return mock(HttpServletRequest.class);
+    }
+
+    @Test
+    void 下载_未登录_返回用户未登录() {
+        Result<?> r = controller.downloadReport(params(100L), null, null, mockRequest());
+        assertEquals("用户未登录", r.getMessage());
+    }
+
+    @Test
+    void 下载_报告不存在_返回报告不存在() {
+        when(healthReportService.getReportById(99L)).thenReturn(null);
+        Result<?> r = controller.downloadReport(params(99L), 1L, null, mockRequest());
+        assertEquals("报告不存在", r.getMessage());
+    }
+
+    @Test
+    void 下载_本人可下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, 7L, null));
+        Result<Map<String, Object>> r = controller.downloadReport(params(100L), 10L, 7L, mockRequest());
+        assertEquals(200, r.getCode());
+        assertEquals(FILE_URL, r.getData().get("fileUrl"));
+        assertEquals("report.pdf", r.getData().get("fileName"));
+    }
+
+    @Test
+    void 下载_管理员可下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, 7L, null));
+        when(reportMemberPermissionService.isFamilyAdmin(20L, 7L)).thenReturn(true);
+        Result<Map<String, Object>> r = controller.downloadReport(params(100L), 20L, 7L, mockRequest());
+        assertEquals(200, r.getCode());
+        assertEquals(FILE_URL, r.getData().get("fileUrl"));
+    }
+
+    @Test
+    void 下载_被授权成员可下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, 7L, 50L));
+        when(reportMemberPermissionService.canViewMemberReports(30L, 50L, 7L)).thenReturn(true);
+        Result<Map<String, Object>> r = controller.downloadReport(params(100L), 30L, 7L, mockRequest());
+        assertEquals(200, r.getCode());
+        assertEquals(FILE_URL, r.getData().get("fileUrl"));
+    }
+
+    @Test
+    void 下载_非授权_拒绝下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, 7L, null));
+        when(reportMemberPermissionService.isFamilyAdmin(30L, 7L)).thenReturn(false);
+        Result<?> r = controller.downloadReport(params(100L), 30L, 7L, mockRequest());
+        assertEquals("无权下载该报告", r.getMessage());
+    }
+
+    @Test
+    void 下载_授权成员但不可见_拒绝下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, 7L, 50L));
+        when(reportMemberPermissionService.canViewMemberReports(30L, 50L, 7L)).thenReturn(false);
+        Result<?> r = controller.downloadReport(params(100L), 30L, 7L, mockRequest());
+        assertEquals("无权下载该报告", r.getMessage());
+    }
+
+    @Test
+    void 下载_家庭缺失_拒绝下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(downloadReportData(100L, 10L, null, null));
+        Result<?> r = controller.downloadReport(params(100L), 10L, null, mockRequest());
+        assertEquals("无权下载该报告", r.getMessage());
+    }
+
+    @Test
+    void 下载_无文件_返回无文件可下载() {
+        when(healthReportService.getReportById(100L)).thenReturn(report(100L, 10L, 7L));
+        Result<?> r = controller.downloadReport(params(100L), 10L, 7L, mockRequest());
+        assertEquals("该报告无文件可下载", r.getMessage());
+    }
+}