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