Преглед изворни кода

test(cfc-backend): S-cfc-243 supplier.data.exportCompliance 数据导出合规 RED 契约测试

能力键 supplier.data.exportCompliance(SC-S-11 数据导出合规/授权使用)契约级单测(TDD-RED 阶段)。

契约锚点:
- 需求:tests/user-scenarios/应用场景总览.md SC-S-11(授权内用数;越授权拒;脱敏)
- 设计:API_REFERENCE §4.62.6 + specs/2026-11-07-service-provider-contract.md §1.6
  (UserController /api/user/data-export + ReportMemberPermissionController 同源授权链)
- 派生用例:tests/test-cases/vendor.md TC-S-11 / TC-S-11-E / TC-S-11-P

断言缺失契约行为(纯 Mockito/反射,无 Spring/DB/外部服务):
- 正常流:exportUserData 敏感字段(phone/realName/idCard)强制脱敏(DataMaskUtil)
- 边界流:导出必须具备授权范围入口(memberId/scope),越授权可拒(TC-S-11)
- 合规流:AccountCleanupService 注入 UserOperationLogService 合规审计(OperationLogController)

RED 预期:6 tests run / 5 failures(脱敏 3 + 授权范围 1 + 审计注入 1),
1 个通过为权限流说明性用例;失败均为功能缺失断言(非编译/拼写错误)。
Sisyphus Agent пре 2 часа
родитељ
комит
f6ea09caa4

+ 210 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierExportComplianceContractTest.java

@@ -0,0 +1,210 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.DataMaskUtil;
+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.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 com.etotem.cfc.service.UserOperationLogService;
+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.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code supplier.data.exportCompliance}(SC-S-11 数据导出合规/授权使用)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-11「数据导出合规(授权使用)」——
+ *       目标:授权内用数。流程:申请授权 → 取数。边界:<b>越授权拒;脱敏</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.62.6 +
+ *       {@code docs/superpowers/specs/2026-11-07-service-provider-contract.md} §1.6 —— 双载体:
+ *       {@code UserController POST /api/user/data-export}(用户侧自助,{@code AccountCleanupService.exportUserData})
+ *       + {@code ReportMemberPermissionController /api/health/report/member-permission}(报告授权可见性治理,
+ *       同源授权链)。</li>
+ *   <li>派生用例:{@code tests/test-cases/vendor.md} TC-S-11(授权内用数;越授权拒;脱敏)、
+ *       TC-S-11-E(越授权 403 拒;<b>敏感字段强制脱敏</b>;并发越权同样 403)、
+ *       TC-S-11-P(非本商户资源隔离,跨商户访问 403)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(当前 src/main 现状核实)</b></p>
+ * <ul>
+ *   <li><b>脱敏(正常流)</b>:{@code AccountCleanupService.exportUserData(Long)} 直接
+ *       {@code userInfo.put("phone", user.getPhone())} / {@code put("realName", ...)},
+ *       未调用 {@link DataMaskUtil#maskPhone} / {@link DataMaskUtil#maskName} / {@link DataMaskUtil#maskIdCard};
+ *       {@code idCard} 完全未导出 → 敏感字段未强制脱敏(TC-S-11-E)。</li>
+ *   <li><b>越授权拒 / 授权内用数(边界流)</b>:{@code exportUserData} 仅单一签名
+ *       {@code (Long userId)},无显式授权范围(memberId/scope)入参 —— 无法承载「家长代导出孩子」,
+ *       越授权拒绝无入口(API_REFERENCE §4.61.5 已记录缺口 G-05「无 memberId 入参 → 无家长代导出孩子」)。</li>
+ *   <li><b>合规审计(合规流)</b>:{@code AccountCleanupService} 未注入 {@link UserOperationLogService}
+ *       (合规审计载体,经 {@code OperationLogController /api/admin/logs} 可查),导出操作无审计事件落库。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:三项契约行为均未落地,本类用例应失败(红);GREEN 阶段实现
+ * (脱敏 + 授权范围入口 + 合规审计)后使绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring 上下文、无 DB、无外部服务;
+ * 仅驱动 {@link AccountCleanupService#exportUserData(Long)} 与结构探测。</p>
+ */
+class SupplierExportComplianceContractTest {
+
+    private static final Long USER_ID = 300L;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private ChatMessageMapper chatMessageMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private AssessmentOrderMapper assessmentOrderMapper;
+
+    @Mock
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 正常流:敏感字段强制脱敏(TC-S-11-E) ====================
+
+    @Test
+    @DisplayName("正常流-1:导出手机号必须脱敏(TC-S-11-E 敏感字段强制脱敏)")
+    void export_phone_forcedMasked() {
+        User u = user(USER_ID);
+        u.setPhone("13812345678");
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+
+        Map<String, Object> data = service.exportUserData(USER_ID);
+        Map<?, ?> userInfo = (Map<?, ?>) data.get("userInfo");
+
+        String expected = DataMaskUtil.maskPhone("13812345678");
+        assertEquals(expected, userInfo.get("phone"),
+                "[supplier.data.exportCompliance] 导出 userInfo.phone 必须经 DataMaskUtil.maskPhone 脱敏"
+                        + "(TC-S-11-E 敏感字段强制脱敏)。当前实现返回原始手机号,未脱敏。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:导出真实姓名必须脱敏(TC-S-11-E 敏感字段强制脱敏)")
+    void export_realName_forcedMasked() {
+        User u = user(USER_ID);
+        u.setRealName("张三");
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+
+        Map<String, Object> data = service.exportUserData(USER_ID);
+        Map<?, ?> userInfo = (Map<?, ?>) data.get("userInfo");
+
+        String expected = DataMaskUtil.maskName("张三");
+        assertEquals(expected, userInfo.get("realName"),
+                "[supplier.data.exportCompliance] 导出 userInfo.realName 必须经 DataMaskUtil.maskName 脱敏"
+                        + "(TC-S-11-E 敏感字段强制脱敏)。当前实现返回原始真实姓名,未脱敏。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:导出身份证号必须脱敏或按最小化原则处理(TC-S-11-E)")
+    void export_idCard_forcedMasked() {
+        User u = user(USER_ID);
+        u.setIdCard("110101199001011234");
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+
+        Map<String, Object> data = service.exportUserData(USER_ID);
+        Map<?, ?> userInfo = (Map<?, ?>) data.get("userInfo");
+
+        String expected = DataMaskUtil.maskIdCard("110101199001011234");
+        assertEquals(expected, userInfo.get("idCard"),
+                "[supplier.data.exportCompliance] 导出 userInfo.idCard 必须经 DataMaskUtil.maskIdCard 脱敏"
+                        + "(TC-S-11-E 敏感字段强制脱敏)。当前实现未导出 idCard(缺失/未脱敏)。");
+    }
+
+    // ==================== 边界流:授权内用数 / 越授权拒(TC-S-11 / TC-S-11-E) ====================
+
+    @Test
+    @DisplayName("边界流-1:导出必须具备授权范围入口(memberId/scope),越授权可拒(TC-S-11)")
+    void export_hasAuthorizationScopeEntry() {
+        boolean scopeAware = false;
+        for (Method m : AccountCleanupService.class.getDeclaredMethods()) {
+            if (m.getName().equals("exportUserData") && m.getParameterCount() >= 2) {
+                scopeAware = true;
+                break;
+            }
+        }
+        assertTrue(scopeAware,
+                "[supplier.data.exportCompliance] exportUserData 应提供含授权范围(memberId/scope)的入口"
+                        + "以承载「申请授权 → 取数」且可拒绝越授权(TC-S-11 越授权拒)。"
+                        + "当前仅存在 exportUserData(Long userId),无授权范围入参(缺口 G-05:无家长代导出孩子)。");
+    }
+
+    // ==================== 合规流:导出操作合规审计(OperationLogController 可查) ====================
+
+    @Test
+    @DisplayName("合规流-1:导出服务应注入合规审计服务并记录导出事件(OperationLogController /api/admin/logs)")
+    void export_injectsComplianceAuditService() {
+        boolean hasAudit = false;
+        for (Field f : AccountCleanupService.class.getDeclaredFields()) {
+            if (UserOperationLogService.class.equals(f.getType())) {
+                hasAudit = true;
+                break;
+            }
+        }
+        assertTrue(hasAudit,
+                "[supplier.data.exportCompliance] AccountCleanupService 应注入 UserOperationLogService"
+                        + "(合规审计载体),导出操作须记录可审计事件(经 OperationLogController /api/admin/logs 可查,"
+                        + "TC-S-11 合规审计)。当前实现无审计服务注入,导出不落审计。");
+    }
+
+    // ==================== 权限流:越授权拒即权限边界(TC-S-11-P 跨资源隔离) ====================
+
+    @Test
+    @DisplayName("权限流:越授权拒即权限边界——导出限定授权范围(TC-S-11-P 跨商户/跨资源 403 隔离)")
+    void permissionFlow_denyBeyondAuthorization() {
+        // 说明性用例:SC-S-11 的权限流与边界流「越授权拒」同源(TC-S-11-E/P:越授权 403 拒、跨资源隔离)。
+        // 授权范围由 scope-aware 导出入口承载(见 边界流-1,当前未落地),本用例不产生额外断言失败,
+        // 仅为三流覆盖标注「权限流 = 边界流」。
+        assertTrue(true,
+                "权限流说明:supplier.data.exportCompliance 的权限流即「越授权拒」(TC-S-11-E/P),"
+                        + "与边界流同源,授权范围入口未落地(见边界流-1)。");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private User user(Long id) {
+        User u = new User();
+        u.setId(id);
+        return u;
+    }
+}