Просмотр исходного кода

test(RED): S-cfc-207 admin.user.govern 契约测试 9例7红(/users-search/审计留痕/导出脱敏/注销冷静期/GDPR工单未落地)

Agnes Agent 4 часов назад
Родитель
Сommit
3da6679073

+ 365 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminUserGovernContractTest.java

@@ -0,0 +1,365 @@
+package com.etotem.cfc.unit;
+
+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 org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+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.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端用户/家庭查询治理 + 账号注销/数据导出工单处理(能力键 {@code admin.user.govern})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-11(查用户/家庭 → 代操作(限授权),
+ *       边界:<b>代操作留审计</b>)、SC-A-19(接工单 → 执行归档/导出,边界:<b>审计留痕;冷静期</b>)</li>
+ *   <li>用例:{@code tests/test-cases/admin.md} TC-A-20(注销/导出工单按序处理、越权 403)、
+ *       TC-A-N09(仅工单授权用户可导出;<b>数据脱敏打包</b>;操作留痕审计)</li>
+ *   <li>设计:API_REFERENCE §4.13 {@code /api/admin/*};AdminController(
+ *       {@code users/create/update/reset-password/delete/search/user/search})、
+ *       UserController({@code /api/user/data-export})、AdminFamilyOverviewController(家庭列表治理)</li>
+ * </ul>
+ *
+ * <p><b>实现现状核查(src/main,f941b9212 基线)</b></p>
+ * <ul>
+ *   <li>已落地(本测试相应断言为绿):{@code /users/create}、{@code /users/{id}/update}、
+ *       {@code /users/{id}/reset-password}、{@code /users/{id}/delete}、{@code /user/search}、
+ *       {@code /families}(B-5 行级隔离)、{@code /api/user/data-export},及 role/roles 门禁参数。</li>
+ *   <li>未落地(本测试相应断言为红,即 TDD-RED 契约缺口):
+ *       <ol>
+ *         <li>{@code /users/search} 路由缺失(目前仅 {@code /user/search},契约要求两者并存)</li>
+ *         <li>{@code AuditLogAspect} 仅拦截 {@code AdminCf*} + {@code AdminServiceConfigController},
+ *             未覆盖 {@code AdminController} 用户/家庭治理写操作 → 代操作<b>无审计留痕</b></li>
+ *         <li>{@code AccountCleanupService.exportUserData} 直接回传原始 phone/realName → 导出<b>未脱敏</b></li>
+ *         <li>{@code AccountCleanupService.deactivateAccount} 直接匿名化 → 注销<b>无冷静期</b></li>
+ *         <li>GDPR 工单(entity/表/service)整体未落地 → SC-A-19「接工单 → 执行归档/导出」不可达</li>
+ *       </ol>
+ *   </li>
+ * </ul>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补齐上述 5 项缺口后,本测试自动转绿;届时可将结构探测
+ * 升级为行为级 mock 验证(如 verify auditLogService.persist(...))。</p>
+ *
+ * <p><b>测试基建</b>:纯反射结构契约 + Mockito 行为契约,无 Spring 上下文 / 无 DB / 无外部服务;
+ * 失败断言原文清晰可贴,可编译、可运行、预期红。</p>
+ */
+class AdminUserGovernContractTest {
+
+    /** 设计锚点:管理端用户治理控制器(API_REFERENCE §4.13) */
+    private static final String ADMIN_CONTROLLER = "com.etotem.cfc.controller.admin.AdminController";
+
+    /** 设计锚点:数据导出控制器(/api/user/data-export) */
+    private static final String USER_CONTROLLER = "com.etotem.cfc.controller.UserController";
+
+    /** SC-A-11 边界:代操作审计切面 */
+    private static final String AUDIT_LOG_ASPECT = "com.etotem.cfc.aspect.AuditLogAspect";
+
+    /** SC-A-19 边界:注销冷静期所在服务 */
+    private static final String ACCOUNT_CLEANUP_SERVICE = "com.etotem.cfc.service.AccountCleanupService";
+
+    /** SC-A-19 / TC-A-20:GDPR 工单实体(契约命名,GREEN 阶段创建) */
+    private static final String GDPR_WORK_ORDER_ENTITY = "com.etotem.cfc.entity.GdprWorkOrder";
+
+    /** SC-A-19 / TC-A-20:GDPR 工单处理服务(契约命名,GREEN 阶段创建) */
+    private static final String GDPR_WORK_ORDER_SERVICE = "com.etotem.cfc.service.GdprWorkOrderService";
+
+    /** SC-A-19:工单表名(schema.sql 应含建表) */
+    private static final String WORK_ORDER_TABLE = "work_order";
+
+    /** 已落地路由(设计锚点 user 治理面的 6 条 + 导出 + 家庭列表治理) */
+    private static final String[] EXISTING_GOVERN_ROUTES = {
+            "/users/create",
+            "/users/{id}/update",
+            "/users/{id}/reset-password",
+            "/users/{id}/delete",
+            "/user/search",
+            "/families"
+    };
+
+    // ------------------------------------------------------------------
+    // Mockito 行为契约:AccountCleanupService.exportUserData 脱敏
+    // ------------------------------------------------------------------
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService accountCleanupService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:管理端用户/家庭治理路由契约(API_REFERENCE §4.13 + 设计锚点)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:用户治理既有路由与数据导出路由应存在")
+    void adminUserGovern_existingRoutesPresent() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        Class<?> user = loadClass(USER_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在(API_REFERENCE §4.13)。");
+        assertNotNull(user, "[admin.user.govern] UserController 类不存在(/api/user/data-export)。");
+        for (String route : EXISTING_GOVERN_ROUTES) {
+            assertTrue(hasPostRoute(admin, route),
+                    "[admin.user.govern] AdminController 缺少契约路由 " + route + "(API_REFERENCE §4.13)。");
+        }
+        assertTrue(hasPostRoute(user, "/data-export"),
+                "[admin.user.govern] UserController 缺少 /data-export(数据导出,SC-A-19)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:/api/admin/users/search 路由应存在(设计要求 users/search 与 user/search 并存)")
+    void adminUserGovern_usersSearchRoutePresent() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在(API_REFERENCE §4.13)。");
+        assertTrue(hasPostRoute(admin, "/users/search"),
+                "[admin.user.govern] 契约设计锚点列出 users/create/update/reset-password/delete/"
+                        + "search/user/search 7 条治理路由,当前仅 /user/search 已实现,"
+                        + "/users/search 路由缺失,无法执行「按关键字查用户」治理入口。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:代操作留审计(SC-A-11 边界)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:用户/家庭治理代操作必须落审计留痕(SC-A-11 边界)")
+    void adminUserGovern_writesAudited() {
+        Class<?> aspect = loadClass(AUDIT_LOG_ASPECT);
+        assertNotNull(aspect, "[admin.user.govern] AuditLogAspect 类不存在,审计基建缺失。");
+        String expr = pointcutExpression(aspect, "cfAdminController");
+        assertTrue(expr != null && expr.contains("AdminController"),
+                "[admin.user.govern] 代操作必须留审计(SC-A-11 边界):AuditLogAspect#cfAdminController "
+                        + "pointcut 当前为「" + expr + "」,"
+                        + "未覆盖 AdminController 的 users/create|update|reset-password|delete|families 等"
+                        + "用户/家庭治理写操作 → 后台代操作无审计留痕。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:数据导出脱敏打包(TC-A-N09)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:数据导出必须脱敏打包(TC-A-N09)")
+    void exportUserData_masksSensitiveFields() {
+        User u = new User();
+        u.setId(12345L);
+        u.setNickname("契约测试用户");
+        u.setPhone("13812345678");
+        u.setRealName("张三");
+        u.setGender("male");
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(danAssessmentResultMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> data = accountCleanupService.exportUserData(12345L);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> userInfo = (Map<String, Object>) data.get("userInfo");
+        assertNotNull(userInfo, "[admin.user.govern] 导出应含 userInfo 块(数据导出,SC-A-19)。");
+        String phone = String.valueOf(userInfo.get("phone"));
+        String realName = String.valueOf(userInfo.get("realName"));
+        assertFalse("13812345678".equals(phone) || "张三".equals(realName),
+                "[admin.user.govern] 数据导出必须脱敏打包(TC-A-N09:仅工单授权用户可导出;"
+                        + "数据脱敏打包):当前 exportUserData 直接回传原始 phone/realName,"
+                        + "手机号/真实姓名未经脱敏即落入导出包。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:注销冷静期(SC-A-19 边界)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:账号注销必须含冷静期(SC-A-19 边界)")
+    void accountCancellation_hasCoolingOffPeriod() {
+        Class<?> svc = loadClass(ACCOUNT_CLEANUP_SERVICE);
+        assertNotNull(svc, "[admin.user.govern] AccountCleanupService 类不存在。");
+        boolean hasCooling = false;
+        for (Field f : svc.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("cooling")) {
+                hasCooling = true;
+            }
+        }
+        for (Method m : svc.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains("cooling")) {
+                hasCooling = true;
+            }
+        }
+        assertTrue(hasCooling,
+                "[admin.user.govern] 账号注销必须含冷静期(SC-A-19 边界:审计留痕;冷静期):"
+                        + "当前 deactivateAccount 检查未完成订单后直接匿名化,无冷静期/确认环节,"
+                        + "用户无撤销机会。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:GDPR 工单处理(SC-A-19 / TC-A-20 / TC-A-N09)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-4:注销/导出工单实体应存在(SC-A-19)")
+    void gdprWorkOrder_entityExists() {
+        assertNotNull(loadClass(GDPR_WORK_ORDER_ENTITY),
+                "[admin.user.govern] GDPR 工单实体缺失(SC-A-19:接工单 → 执行归档/导出):"
+                        + "当前无工单承载「注销/导出申请 → 审批/执行」状态机,"
+                        + "TC-A-20 的按序执行/越权处理无从做起。");
+    }
+
+    @Test
+    @DisplayName("边界流-5:工单表应落地 schema.sql(SC-A-19)")
+    void gdprWorkOrder_schemaTableExists() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(WORK_ORDER_TABLE),
+                "[admin.user.govern] schema.sql 缺少 " + WORK_ORDER_TABLE
+                        + " 建表(SC-A-19):注销/导出工单需持久化(含操作者/目标用户/类型/状态/"
+                        + "审计时间戳),当前无对应表。");
+    }
+
+    @Test
+    @DisplayName("边界流-6:工单处理服务应存在(SC-A-19 / TC-A-20)")
+    void gdprWorkOrder_serviceExists() {
+        assertNotNull(loadClass(GDPR_WORK_ORDER_SERVICE),
+                "[admin.user.govern] GDPR 工单处理服务缺失(SC-A-19:执行归档/导出):"
+                        + "当前账号注销/导出由用户自助接口直接执行,无运营端「接单 → 归档/导出」"
+                        + "工单处理链路,TC-A-20(并发按序、越权 403)无对应实现。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:限授权(SC-A-11 流程)——既有路由已具备 role/roles 门禁参数
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:治理写操作应携带 role/roles 门禁参数(SC-A-11 限授权)")
+    void governanceEndpoints_carryRoleGate() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在。");
+        for (String route : EXISTING_GOVERN_ROUTES) {
+            Method m = findPostMethod(admin, route);
+            assertTrue(m != null && hasRoleGateParam(m),
+                    "[admin.user.govern] " + route
+                            + " 应声明 role/roles 的 @RequestAttribute 门禁参数,拦截非后台角色"
+                            + "(SC-A-11 限授权)。");
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 方法级 @PostMapping 是否声明了指定路径 */
+    private boolean hasPostRoute(Class<?> clazz, String path) {
+        return findPostMethod(clazz, path) != null;
+    }
+
+    private Method findPostMethod(Class<?> clazz, String path) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String v : pm.value()) {
+                if (path.equals(v)) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 方法参数是否声明了 role 或 roles 的 @RequestAttribute 门禁 */
+    private boolean hasRoleGateParam(Method m) {
+        for (Parameter p : m.getParameters()) {
+            for (Annotation a : p.getAnnotations()) {
+                if (a instanceof RequestAttribute) {
+                    String name = ((RequestAttribute) a).value();
+                    if ("role".equals(name) || "roles".equals(name)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 反射读取 @Pointcut 注解的 value(不静态依赖 aspectj 类型) */
+    private String pointcutExpression(Class<?> clazz, String methodName) {
+        try {
+            Method m = clazz.getDeclaredMethod(methodName);
+            for (Annotation a : m.getDeclaredAnnotations()) {
+                if ("Pointcut".equals(a.annotationType().getSimpleName())) {
+                    Method value = a.annotationType().getMethod("value");
+                    Object v = value.invoke(a);
+                    return v != null ? String.valueOf(v) : "";
+                }
+            }
+        } catch (Exception e) {
+            return null;
+        }
+        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 "";
+        }
+    }
+}