|
|
@@ -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 "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|