Explorar o código

test(S-cfc-291): RED 契约单测 data.export.gdpr(SC-P-72 个人/家庭数据导出 GDPR,申请->加密包->下载/脱敏/频次/越权)

Sisyphus Agent hai 18 horas
pai
achega
45dd982a4b

+ 400 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DataExportGdprContractTest.java

@@ -0,0 +1,400 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+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 org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+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.List;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.when;
+
+/**
+ * 契约级单测:个人/家庭数据导出(GDPR)(能力键 {@code data.export.gdpr},SC-P-72,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-72「个人/家庭数据导出(GDPR)」(业务域 J:账号与数据主权)——
+ *       <b>流程:申请导出 → 生成加密包 → 下载</b>;<b>边界:敏感字段脱敏;频次限制;越权导出被拒</b>;
+ *       模块:数据导出(GDPR)。用例台账 GAP-028(TC-P-72 正常流 / TC-P-72-E 异常流 / TC-P-72-P 权限流,
+ *       见 {@code docs/tdd/test/scenario-gap-backlog.csv});sc-key-map 台账登记 SC-P-72 → {@code data.export.gdpr}(RED/未跑通)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.5——数据主权(GDPR)能力<b>无独立端点</b>,
+ *       需复用 {@code /api/user}、{@code /api/family/member/list} 汇集导出;
+ *       「仅导出登录用户本人;无 memberId 入参 → 无『家长代导出孩子』(G-05)」;
+ *       {@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §5 G-05:
+ *       {@code exportUserData(userId)} 不接受 memberId,无家长代导出指定成员。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(本 RED 测试定稿,GREEN 实现方按此落地)</b>:{@code com.etotem.cfc.service.DataExportService}
+ * ——「数据导出(GDPR)」模块服务(申请→加密包→下载 流程宿主)。origin/cfclub @ d6dac931a 实测 <b>不存在</b>
+ * (实现未落地),测试以反射结构契约探测缺失,失败断言原文红色清晰可贴。</p>
+ *
+ * <p><b>建议契约方法签名(实现方可按仓库惯例微调命名)</b></p>
+ * <ul>
+ *   <li>{@code applyExport(Long userId, Long scopeMemberId)}——申请导出入口:频次窗口校验(如 24h)+ 归属校验
+ *       (越权拒)后创建导出工单(pending);</li>
+ *   <li>{@code buildExportPackage(...)}(或 generatePackage)——汇集数据(复用 user / family-member 数据)+
+ *       敏感字段脱敏(复用 {@code DataMaskUtil})+ 加密 → 生成加密包;</li>
+ *   <li>{@code downloadPackage(...)}——下载入口:本人/家庭归属校验 → 返回加密包(含密钥交接);</li>
+ *   <li>配套实体/表:{@code DataExportTask}({@code data_export_task})承载 status / packagePath / packageKey /
+ *       expiresAt 等工单状态字段。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 JUnit + Mockito + 反射(无 Spring / 无 DB / 无外部服务,同批
+ * {@code TeamProfitHookContractTest} / {@code TeacherExportContractTest} 风格);
+ * 行为断言锚定现有数据导出载体 {@code AccountCleanupService.exportUserData}(设计指定复用的 /api/user 载体)。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code DataExportService} 未实现(反射结构用例全红);且现有载体
+ * {@code AccountCleanupService.exportUserData} 明文导出家庭成员 phone、无 memberId 范围入参(行为/签名用例红),
+ * GREEN 阶段按上述契约落地后转绿。</p>
+ */
+class DataExportGdprContractTest {
+
+    /** 宿主服务类(SC-P-72 数据导出(GDPR) 模块,设计锚点;当前实现未落地)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.DataExportService";
+
+    /** 配套导出工单实体(申请→加密包→下载 状态载体)。 */
+    private static final String TASK_ENTITY = "com.etotem.cfc.entity.DataExportTask";
+
+    /** 配套导出工单表(schema.sql 建表快照)。 */
+    private static final String TASK_TABLE = "data_export_task";
+
+    /** 现有数据导出载体(设计指定复用 /api/user,UserController POST /api/user/data-export → exportUserData)。 */
+    private static final String EXPORT_CARRIER = "com.etotem.cfc.service.AccountCleanupService";
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long FAMILY_ID = 5001L;
+    private static final Long CHILD_MEMBER_ID = 2001L;
+    private static final String RAW_PHONE = "13800138000";
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 正常流(TC-P-72):申请导出 → 加密包 → 下载 ====================
+
+    @Test
+    @DisplayName("正常流-1:DataExportService 数据导出(GDPR) 服务类应存在(宿主锚点)")
+    void dataExportService_classExists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[data.export.gdpr] DataExportService 类不存在:SC-P-72 个人/家庭数据导出(GDPR)"
+                        + "要求「申请导出 → 生成加密包 → 下载」流程宿主(模块:数据导出(GDPR),复用 /api/user、"
+                        + "/api/family/member/list 汇集导出)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:DataExportService 应提供 applyExport 申请导出入口")
+    void dataExportService_hasApplyExportEntry() {
+        Method apply = findMethodContains(hostClass(), "apply");
+        assertNotNull(apply,
+                "[data.export.gdpr] DataExportService 应提供 applyExport(...) 申请导出入口"
+                        + "(申请 → 生成加密包 → 下载 三阶段之首,SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:DataExportService 应提供加密包生成入口(package/generate/encrypt)")
+    void dataExportService_hasPackageGenerateEntry() {
+        Class<?> clazz = hostClass();
+        boolean hasPackageBuilder = methodNameContains(clazz, "package") && (
+                methodNameContains(clazz, "generate")
+                        || methodNameContains(clazz, "build")
+                        || methodNameContains(clazz, "create")
+                        || methodNameContains(clazz, "encrypt"));
+        assertTrue(hasPackageBuilder,
+                "[data.export.gdpr] DataExportService 应提供加密包生成入口(方法名含 package 且 "
+                        + "generate/build/create/encrypt,如 buildExportPackage):契约要求「生成加密包」"
+                        + "而非同步裸返回明文 Map(SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:DataExportService 应提供 downloadPackage 下载入口")
+    void dataExportService_hasDownloadEntry() {
+        Method download = findMethodContains(hostClass(), "download");
+        assertNotNull(download,
+                "[data.export.gdpr] DataExportService 应提供 downloadPackage(...) 下载入口"
+                        + "(申请导出 → 生成加密包 → 下载 三阶段之末,SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    // ==================== 边界/异常流(TC-P-72-E):频次限制 + 脱敏 + 加密包字段 ====================
+
+    @Test
+    @DisplayName("边界流-1:DataExportService 应有频次限制守卫(RATE_LIMIT 常量或 rateLimit/checkFrequency 方法)")
+    void applyExport_hasRateLimitGuard() {
+        Class<?> clazz = hostClass();
+        boolean hasRateLimit = fieldNameContains(clazz, "rate_limit")
+                || fieldNameContains(clazz, "frequency")
+                || methodNameContains(clazz, "ratelimit")
+                || methodNameContains(clazz, "checkfrequency")
+                || methodNameContains(clazz, "assertfrequency");
+        assertTrue(hasRateLimit,
+                "[data.export.gdpr] applyExport 应含频次限制守卫:SC-P-72 边界「频次限制」"
+                        + "(建议 24h 窗口,RATE_LIMIT_WINDOW_HOURS 常量或 checkRateLimit 方法),"
+                        + "超限申请应被拒。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:DataExportTask 实体与 data_export_task 表应存在(申请→加密包→下载 工单载体)")
+    void dataExportTask_entityAndTableExist() {
+        String schema = readSchemaSql();
+        assertNotNull(loadClass(TASK_ENTITY),
+                "[data.export.gdpr] DataExportTask 实体不存在:SC-P-72 要求导出为「申请 → 生成加密包 → 下载」"
+                        + "有状态流程,需 data_export_task 工单承载。实现未落地。");
+        assertTrue(schema.contains(TASK_TABLE),
+                "[data.export.gdpr] schema.sql 缺少 " + TASK_TABLE + " 建表(导出工单,SC-P-72 流程载体)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:DataExportTask 应含工单状态与加密包字段(status/packagePath/packageKey/expiresAt)")
+    void dataExportTask_hasPackageStateFields() {
+        Class<?> entity = loadClass(TASK_ENTITY);
+        if (entity == null) {
+            fail("[data.export.gdpr] DataExportTask 实体不存在,无法校验工单状态字段(SC-P-72 加密包流程)。");
+        }
+        for (String required : new String[]{"status", "packagePath", "packageKey", "expiresAt"}) {
+            boolean has = fieldNameEquals(entity, required);
+            assertTrue(has,
+                    "[data.export.gdpr] DataExportTask 应含 " + required + " 字段:加密包流程工单"
+                            + "(status=导出状态、packagePath=加密包路径、packageKey=加密密钥、expiresAt=有效/频次窗口)。"
+                            + "当前实体缺失。");
+        }
+    }
+
+    @Test
+    @DisplayName("边界流-4:DataExportService 应暴露敏感字段脱敏方法(mask/desensitize,复用 DataMaskUtil)")
+    void dataExportService_masksSensitiveFields() {
+        Method mask = findMethodContains(hostClass(), "mask");
+        assertNotNull(mask,
+                "[data.export.gdpr] DataExportService 应提供敏感字段脱敏方法(如 maskSensitiveFields,"
+                        + "复用 DataMaskUtil.maskPhone/maskName):SC-P-72 边界「敏感字段脱敏」(对齐 SC-T-29/"
+                        + "SC-S-11 合规口径,phone/realName/idCard 强制脱敏)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("边界流-5:现有导出载体 exportUserData 的家庭成员块 phone 应脱敏(当前明文,红)")
+    void exportUserData_familyMemberPhone_shouldBeMasked() {
+        // Given:家庭内有一名成员(phone 明文),其余导出区块无数据
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(CHILD_MEMBER_ID);
+        member.setFamilyId(FAMILY_ID);
+        member.setPhone(RAW_PHONE);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(member));
+        when(danAssessmentResultMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        // When:导出个人数据包(复用 /api/user 载体,设计指定汇集导出)
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:家庭成员(他人数据)的敏感字段 phone 必须脱敏,不得明文
+        Object children = data.get("children");
+        assertNotNull(children,
+                "[data.export.gdpr] 导出包应包含家庭成员 children 区块(SC-P-72 个人/家庭数据导出)。");
+        @SuppressWarnings("unchecked")
+        List<FamilyMember> members = (List<FamilyMember>) children;
+        assertFalse(members.isEmpty(),
+                "[data.export.gdpr] 家庭成员区块不应为空(SC-P-72 家庭数据导出汇集 /api/family/member/list)。");
+        for (FamilyMember fm : members) {
+            String phone = fm.getPhone();
+            assertFalse(phone != null && phone.contains(RAW_PHONE),
+                    "[data.export.gdpr] 导出包家庭成员 phone 应脱敏(如 138****8000,复用 DataMaskUtil):"
+                            + "SC-P-72 边界「敏感字段脱敏」。当前实现将 family_members 全量实体裸导出"
+                            + "(含 phone 明文),脱敏契约未落地。");
+        }
+    }
+
+    // ==================== 权限流(TC-P-72-P):越权导出被拒 ====================
+
+    @Test
+    @DisplayName("权限流-1:applyExport/downloadPackage 应携带显式 memberId 范围入参(G-05 家长代导出指定成员)")
+    void applyExport_carriesScopeMemberId() {
+        Class<?> clazz = hostClass();
+        Method apply = findMethodContains(clazz, "apply");
+        assertNotNull(apply,
+                "[data.export.gdpr] 申请导出入口缺失(宿主未实现),无法校验范围入参契约。");
+        boolean scopeAware = hasNumberOrMapParam(apply);
+        assertTrue(scopeAware,
+                "[data.export.gdpr] applyExport 应携带显式导出范围(scopeMemberId/memberId 数值入参或 Map 载体),"
+                        + "支撑「家长代导出指定成员」(G-05,API_REFERENCE §4.61.5:无 memberId 入参 → 无代导出)"
+                        + "与「越权导出被拒」(TC-P-72-P 权限流)。");
+    }
+
+    @Test
+    @DisplayName("权限流-2:DataExportService 应有归属校验守卫(assertCanExport/checkExportPermission,越权拒)")
+    void dataExportService_hasOwnershipGuard() {
+        Class<?> clazz = hostClass();
+        boolean guarded = methodNameContains(clazz, "assert")        // assertCanExport
+                || methodNameContains(clazz, "check")               // checkExportPermission
+                || methodNameContains(clazz, "permission")
+                || methodNameContains(clazz, "ownership")
+                || methodNameContains(clazz, "authorize");
+        assertTrue(guarded,
+                "[data.export.gdpr] applyExport/downloadPackage 须有归属校验守卫(如 assertCanExport/"
+                        + "checkExportPermission):非本家庭成员/非绑定家庭不得导出他人数据(SC-P-72 边界"
+                        + "「越权导出被拒」,TC-P-72-P 权限流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:现有导出载体 exportUserData 应支持 memberId 范围入参(G-05;当前仅 1 参,红)")
+    void exportUserData_shouldCarryScopeMemberId() {
+        Class<?> carrier = loadClass(EXPORT_CARRIER);
+        if (carrier == null) {
+            fail("[data.export.gdpr] AccountCleanupService 载体类不存在(异常状态)。");
+        }
+        boolean hasScopeParam = false;
+        for (Method m : carrier.getDeclaredMethods()) {
+            if (m.getName().equals("exportUserData") && m.getParameterCount() >= 2) {
+                hasScopeParam = true;
+                break;
+            }
+        }
+        assertTrue(hasScopeParam,
+                "[data.export.gdpr] " + EXPORT_CARRIER + ".exportUserData 应支持 memberId 范围入参"
+                        + "(如 exportUserData(Long userId, Long scopeMemberId)):G-05 缺口——无 memberId 入参则"
+                        + "无「家长代导出指定成员」、也无越权归属校验载体(TC-P-72-P 权限流)。"
+                        + "当前仅 exportUserData(Long) 1 参,实现未落地。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    /** 解析宿主类;不存在即断言失败(RED 信号:实现未落地),并清晰贴出缺口。 */
+    private Class<?> hostClass() {
+        try {
+            return Class.forName(HOST_CLASS);
+        } catch (ClassNotFoundException e) {
+            fail("RED[data.export.gdpr] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):"
+                    + "SC-P-72 个人/家庭数据导出(GDPR)「申请导出 → 生成加密包 → 下载」流程宿主缺失"
+                    + "(模块:数据导出(GDPR);复用 /api/user、/api/family/member/list 汇集导出;"
+                    + "现有 UserController /api/user/data-export 仅同步返回明文 Map,无加密包/频次/越权校验)。"
+                    + "缺失详情:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 方法名包含关键字的首个匹配;无则 null。 */
+    private Method findMethodContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains(keywordLower)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private boolean methodNameContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains(keywordLower)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean fieldNameContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains(keywordLower)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean fieldNameEquals(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 方法参数含数值 ID(Long/memberId 载体)或 Map(scopeMemberId 键)→ 支持显式导出范围。 */
+    private boolean hasNumberOrMapParam(Method m) {
+        for (Class<?> p : m.getParameterTypes()) {
+            if (Number.class.isAssignableFrom(p) || p.isPrimitive()
+                    || Map.class.isAssignableFrom(p)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    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 "";
+        }
+    }
+}