Explorar o código

test(S-cfc-291): data.export.gdpr RED contract test, GDPR export workflow unimplemented (SC-P-72)

E2E Test Bot hai 22 horas
pai
achega
c84567a7ce

+ 308 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GdprDataExportContractTest.java

@@ -0,0 +1,308 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.ChatMessageMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GrowthRecordMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:个人/家庭数据导出(GDPR,能力键 {@code data.export.gdpr},业务域 J)。
+ *
+ * <p><b>需求溯源</b>:{@code tests/user-scenarios/应用场景总览.md} SC-P-72
+ * (个人/家庭数据导出(GDPR)——目标:下载本人数据;流程:<b>申请导出 → 生成加密包 → 下载</b>;
+ * 边界:<b>敏感字段脱敏</b>;<b>频次限制</b>;<b>越权导出被拒</b>;模块:数据导出(GDPR))。</p>
+ *
+ * <p><b>设计溯源</b>:{@code docs/superpowers/api/API_REFERENCE.md} —— 数据主权(GDPR)能力
+ * <b>无独立端点</b>,需<b>复用</b> {@code /api/user}({@code POST /api/user/data-export},
+ * {@code AccountCleanupService.exportUserData})与 {@code /api/family/member/list} 汇集导出;
+ * 个保法可携带权导出(SC-T-29 / SC-A-19 同源口径)。</p>
+ *
+ * <p><b>实现现状(2026-11-15 核实)</b>:{@code src/main} 中:
+ * <ul>
+ *   <li>{@code AccountCleanupService.exportUserData} 仅返回<b>裸数据 Map</b>(userInfo 直出
+ *       {@code phone}/{@code realName} 明文,无脱敏),无申请/加密包/下载三阶段载体,
+ *       无频次限制、无越权校验;</li>
+ *   <li>全库无 {@code GdprDataExportService} 宿主类、无 {@code data_export_request} 表
+ *       (schema.sql 0 命中)。</li>
+ * </ul>
+ * 因此本测试以「契约行为缺失」为断言核心:能编译通过、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者落地 GDPR 宿主 {@code GdprDataExportService}
+ * (含 {@code requestExport}/{@code generatePackage}/{@code downloadPackage}/
+ * {@code assertWithinFrequencyLimit}/{@code assertCanExport})、在 {@code schema.sql} 增加
+ * {@code data_export_request} 表、并让 {@code AccountCleanupService.exportUserData} 对敏感字段
+ * (phone/realName)脱敏、返回导出包载体({@code downloadToken}/{@code packageStatus})后,
+ * 本测试自动转绿。</p>
+ *
+ * <p><b>权限流</b>:本能力权限流<b>适用</b>——SC-P-72 边界「越权导出被拒」即权限维度
+ * (仅本人/本家庭可导出,跨家庭/跨成员导出被拒),以 {@link #unauthorizedExport_hasGuard()}
+ * 用例覆盖。</p>
+ */
+class GdprDataExportContractTest {
+
+    /** 契约宿主:数据导出(GDPR) 模块服务类(设计复用 /api/user + /api/family/member/list 汇集导出)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.GdprDataExportService";
+
+    /** SC-P-72 边界:导出频次限制需持久化载体(申请记录/频次窗口/有效期/下载令牌)。 */
+    private static final String EXPORT_REQUEST_TABLE = "data_export_request";
+
+    // ==================== Mockito 行为探测载体(现有 /api/user/data-export 实现) ====================
+
+    private AccountCleanupService cleanupService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        cleanupService = new AccountCleanupService();
+        inject(cleanupService, "userMapper", mock(UserMapper.class));
+        inject(cleanupService, "danAssessmentResultMapper", mock(DanAssessmentResultMapper.class));
+        inject(cleanupService, "healthReportMapper", mock(HealthReportMapper.class));
+        inject(cleanupService, "chatMessageMapper", mock(ChatMessageMapper.class));
+        inject(cleanupService, "familyMemberMapper", mock(FamilyMemberMapper.class));
+        inject(cleanupService, "assessmentOrderMapper", mock(AssessmentOrderMapper.class));
+        inject(cleanupService, "paymentOrderMapper", mock(PaymentOrderMapper.class));
+        inject(cleanupService, "taskMapper", mock(TaskMapper.class));
+        inject(cleanupService, "growthRecordMapper", mock(GrowthRecordMapper.class));
+    }
+
+    // ==================== 正常流:申请导出 → 生成加密包 → 下载(宿主契约,反射) ====================
+
+    @Test
+    @DisplayName("正常流-1:GdprDataExportService 宿主类应存在(数据导出(GDPR) 模块)")
+    void hostClass_exists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[data.export.gdpr] GdprDataExportService 类不存在:SC-P-72 要求" +
+                        "「申请导出 → 生成加密包 → 下载」三阶段 GDPR 导出能力。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:宿主应提供 requestExport(userId) 申请导出入口")
+    void host_requestExportEntry() {
+        Object host = contractHost();
+        boolean hasRequest = false;
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals("requestExport")) {
+                hasRequest = true;
+                break;
+            }
+        }
+        assertTrue(hasRequest,
+                "[data.export.gdpr] GdprDataExportService 应提供 requestExport(Long userId)" +
+                        "申请导出入口(SC-P-72 流程第一步)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:宿主应提供 generatePackage(requestId) 生成加密包")
+    void host_generatePackage() {
+        Object host = contractHost();
+        assertMethod(host, "generatePackage",
+                "[data.export.gdpr] GdprDataExportService 应提供 generatePackage(Long requestId)" +
+                        "生成加密包(SC-P-72 流程第二步)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:宿主应提供 downloadPackage(requestId, userId) 下载(含归属校验)")
+    void host_downloadPackage() {
+        Object host = contractHost();
+        assertMethod(host, "downloadPackage",
+                "[data.export.gdpr] GdprDataExportService 应提供 downloadPackage(Long requestId, Long userId)" +
+                        "下载加密包并校验归属(SC-P-72 流程第三步 + 越权导出被拒)。");
+    }
+
+    // ==================== 正常流:现有 /api/user/data-export 应返回 GDPR 导出包载体 ====================
+
+    @Test
+    @DisplayName("正常流-5:/api/user/data-export 应返回导出包载体(downloadToken/packageStatus),而非裸数据")
+    void existingExport_returnsPackageCarrier() throws Exception {
+        Map<String, Object> data = exportOf(1L);
+        // SC-P-72 流程:申请后生成加密包 → 下载。导出结果必须携带包下载载体(下载令牌 + 包状态),
+        // 而不是当前裸数据 Map(无 downloadToken/packageStatus)。
+        assertTrue(data.containsKey("downloadToken"),
+                "[data.export.gdpr] exportUserData 应返回 downloadToken(加密包下载令牌)——" +
+                        "当前仅返回裸数据 Map,缺「申请→加密包→下载」载体(SC-P-72 流程)。");
+        assertTrue(data.containsKey("packageStatus"),
+                "[data.export.gdpr] exportUserData 应返回 packageStatus(加密包状态 pending/ready)——" +
+                        "当前裸数据无包状态(SC-P-72 流程)。");
+    }
+
+    // ==================== 边界流:敏感字段脱敏 ====================
+
+    @Test
+    @DisplayName("边界流-1:导出的手机号必须脱敏(含 *),禁止明文直出")
+    void sensitivePhone_masked() throws Exception {
+        Map<String, Object> userInfo = userInfoOf(exportOf(1L));
+        Object phone = userInfo.get("phone");
+        assertNotNull(phone, "[data.export.gdpr] GDPR 导出包应含本人手机号字段(脱敏后)");
+        assertTrue(String.valueOf(phone).contains("*"),
+                "[data.export.gdpr] 敏感字段 phone 必须脱敏(如 138****8000),当前明文直出:"
+                        + phone + "(SC-P-72 边界「敏感字段脱敏」)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:导出的真实姓名必须脱敏(姓氏+某),禁止明文直出")
+    void sensitiveRealName_masked() throws Exception {
+        Map<String, Object> userInfo = userInfoOf(exportOf(1L));
+        Object realName = userInfo.get("realName");
+        assertNotNull(realName, "[data.export.gdpr] GDPR 导出包应含本人真实姓名字段(脱敏后)");
+        assertTrue(String.valueOf(realName).contains("某") || String.valueOf(realName).contains("*"),
+                "[data.export.gdpr] 敏感字段 realName 必须脱敏(如 张×),当前明文直出:"
+                        + realName + "(SC-P-72 边界「敏感字段脱敏」)。");
+    }
+
+    // ==================== 边界流:频次限制 ====================
+
+    @Test
+    @DisplayName("边界流-3:宿主应提供 assertWithinFrequencyLimit(userId) 频次守卫")
+    void frequencyLimit_guardExists() {
+        Object host = contractHost();
+        assertMethod(host, "assertWithinFrequencyLimit",
+                "[data.export.gdpr] GdprDataExportService 应提供 assertWithinFrequencyLimit(Long userId)" +
+                        "频次限制守卫(SC-P-72 边界「频次限制」:窗口内多次申请导出被拒)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:schema.sql 应含 data_export_request 表(申请/频次窗口/有效期/下载令牌)")
+    void frequencyLimit_persisted() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(EXPORT_REQUEST_TABLE),
+                "[data.export.gdpr] schema.sql 缺少 " + EXPORT_REQUEST_TABLE
+                        + " 建表:GDPR 申请导出需持久化申请记录/频次窗口/包有效期/下载令牌" +
+                        "(SC-P-72 流程「申请导出→加密包→下载」+ 边界「频次限制」)。");
+    }
+
+    // ==================== 边界流:越权导出被拒(权限流) ====================
+
+    @Test
+    @DisplayName("边界流-5:宿主应提供 assertCanExport(requesterUserId, targetFamilyId) 越权守卫")
+    void unauthorizedExport_hasGuard() {
+        Object host = contractHost();
+        assertMethod(host, "assertCanExport",
+                "[data.export.gdpr] GdprDataExportService 应提供 assertCanExport(Long requesterUserId," +
+                        " Long targetFamilyId) 越权守卫:仅本人/本家庭可导出," +
+                        "跨家庭/跨成员导出必须被拒(SC-P-72 边界「越权导出被拒」= 权限流)。");
+    }
+
+    // ==================== 反射/Mockito 辅助 ====================
+
+    private Object contractHost() {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("[data.export.gdpr] GdprDataExportService 类不存在,无法验证 GDPR 契约(SC-P-72)。");
+        }
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            fail("[data.export.gdpr] GdprDataExportService 实例化失败(契约宿主须有无参构造): " + e);
+            return null;
+        }
+    }
+
+    private void assertMethod(Object host, String name, String msg) {
+        boolean found = false;
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                found = true;
+                break;
+            }
+        }
+        assertTrue(found, msg);
+    }
+
+    /** 现有 /api/user/data-export 载体:构造 userMapper mock,调用 exportUserData。 */
+    private Map<String, Object> exportOf(long userId) throws Exception {
+        UserMapper userMapper = (UserMapper) field(cleanupService, "userMapper");
+        when(userMapper.selectById(userId)).thenReturn(testUser());
+        when(((com.etotem.cfc.mapper.FamilyMemberMapper) field(cleanupService, "familyMemberMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.DanAssessmentResultMapper) field(cleanupService, "danAssessmentResultMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.HealthReportMapper) field(cleanupService, "healthReportMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.TaskMapper) field(cleanupService, "taskMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.GrowthRecordMapper) field(cleanupService, "growthRecordMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        return cleanupService.exportUserData(userId);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> userInfoOf(Map<String, Object> data) {
+        Object info = data.get("userInfo");
+        assertNotNull(info, "[data.export.gdpr] exportUserData 应返回 userInfo 块");
+        return (Map<String, Object>) info;
+    }
+
+    private User testUser() {
+        User u = new User();
+        u.setId(1L);
+        u.setFamilyId(100L);
+        u.setNickname("测试用户");
+        u.setPhone("13800138000");
+        u.setRealName("张小明");
+        u.setGender("male");
+        u.setBirthday(new Date());
+        u.setFamilyRole("parent");
+        u.setMemberLevel("standard");
+        return u;
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Object field(Object target, String field) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}