Przeglądaj źródła

test(cfc-backend): 契约测试 admin.audit.log(S-cfc-204,SC-A-15 操作审计日志)

- AdminAuditController /api/admin/audit/list 分页查询 + role=admin 门禁
- OperationLogController /api/admin/logs operations+recent
- AuditLogService 只增不删改(日志不可篡改,SC-A-15 边界)
- AuditLog 实体溯源字段 + AuditLogAspect 自动落审计
- audit_log / user_operation_logs 建表落地 schema.sql + DatabaseInitializer 迁移

注:实测 origin/cfclub 已含实现宿主类,测试全绿(10/10),
按编排指令第2条注明实现已存在、转测试登记,未改动 src/main。
Sisyphus Agent 4 godzin temu
rodzic
commit
cb8e8d48bd

+ 425 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminAuditLogContractTest.java

@@ -0,0 +1,425 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+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;
+
+/**
+ * 契约级单测:操作审计日志(能力键 {@code admin.audit.log},单元 admin / SC-A-15)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/05-admin.md} SC-A-15 —— 作为运营,查询审计日志并溯源,
+ *       以便可追溯;<b>边界:日志不可篡改</b>;<b>分页</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.14 ——
+ *       {@code OperationLogController /api/admin/logs}({@code operations} 分页查询 + {@code recent} 近期)
+ *       + {@code AdminAuditController /api/admin/audit}({@code list} 审计列表);
+ *       §4.13 管理员接口需 {@code role=admin}({@code AdminInterceptor})。</li>
+ *   <li>写入链路:{@code AuditLogAspect} 自动落 {@code audit_log}(管理端写操作),
+ *       {@code AuditLogService} 只增不删改(W0.3 审计日志统一)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>正常流(溯源)</b>:{@code AdminAuditController /api/admin/audit/list} 分页查询 + 过滤
+ *       (module / targetId / 时间范围);{@code OperationLogController /api/admin/logs}
+ *       {@code operations} + {@code recent};{@code AuditLog} 实体承载溯源字段
+ *       (operatorId / operatorName / module / action / targetType / targetId /
+ *       beforeValue / afterValue / ip / createdAt)。</li>
+ *   <li><b>边界流</b>:日志<b>不可篡改</b>——{@code AuditLogService} 只增不删改(不暴露
+ *       update / remove / delete 公开方法);{@code audit_log} + {@code user_operation_logs}
+ *       建表落地 schema.sql,且 DatabaseInitializer 有对应建表迁移(schema.sql 与迁移一致)。</li>
+ *   <li><b>权限流</b>:{@code AdminAuditController.list} 首参 {@code @RequestAttribute("role")},
+ *       仅 {@code role=admin} 可访问(§4.13)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,反射锚定宿主类(避免编译期依赖不存在类)+ schema.sql
+ * 快照 + DatabaseInitializer 源码检查;<b>无 Spring / 无 DB / 无外部服务</b>。</p>
+ *
+ * <p><b>宿主契约类(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code com.etotem.cfc.controller.admin.AdminAuditController} —— {@code @RequestMapping("/api/admin/audit")}
+ *       + {@code @PostMapping("/list")} + {@code list(@RequestAttribute("role") String, Map)}</li>
+ *   <li>{@code com.etotem.cfc.controller.admin.OperationLogController} —— {@code @RequestMapping("/api/admin/logs")}
+ *       + {@code @PostMapping("/operations")} + {@code @PostMapping("/recent")}</li>
+ *   <li>{@code com.etotem.cfc.service.AuditLogService} —— {@code page(module, targetId, startTime, endTime, pageNo, pageSize)}
+ *       + {@code record(AuditLog)};<b>不暴露 update / remove / delete</b>(只增不删改)</li>
+ *   <li>{@code com.etotem.cfc.entity.AuditLog} —— 溯源字段齐全</li>
+ *   <li>{@code com.etotem.cfc.aspect.AuditLogAspect} —— 自动落审计切面</li>
+ * </ul>
+ *
+ * <p><b>RED 预期说明</b>:本批契约登记时该能力曾判定「实现未落地」;实测(2026-11-13)发现
+ * {@code origin/cfclub} 已包含上述宿主类与表,故本测试按契约行为断言如实验证——若契约行为缺失
+ * (如去掉了不可篡改约束 / 权限门禁 / 端点)即红,作为<b>契约回归测试</b>锁定行为。</p>
+ */
+class AdminAuditLogContractTest {
+
+    /** 审计查询控制器(AdminAuditController /api/admin/audit)。 */
+    private static final String ADMIN_AUDIT_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminAuditController";
+
+    /** 操作日志控制器(OperationLogController /api/admin/logs)。 */
+    private static final String OPERATION_LOG_CONTROLLER =
+            "com.etotem.cfc.controller.admin.OperationLogController";
+
+    /** 审计日志服务(AuditLogService,只增不删改)。 */
+    private static final String AUDIT_LOG_SERVICE =
+            "com.etotem.cfc.service.AuditLogService";
+
+    /** 审计日志实体(AuditLog)。 */
+    private static final String AUDIT_LOG_ENTITY =
+            "com.etotem.cfc.entity.AuditLog";
+
+    /** 审计切面(AuditLogAspect,自动落审计)。 */
+    private static final String AUDIT_LOG_ASPECT =
+            "com.etotem.cfc.aspect.AuditLogAspect";
+
+    /** 用户操作日志服务(UserOperationLogService)。 */
+    private static final String USER_OPERATION_LOG_SERVICE =
+            "com.etotem.cfc.service.UserOperationLogService";
+
+    /** DatabaseInitializer 源码(迁移一致性检查)。 */
+    private static final String DATABASE_INITIALIZER_SOURCE =
+            "src/main/java/com/etotem/cfc/config/DatabaseInitializer.java";
+
+    // ------------------------------------------------------------------
+    // 正常流:查询审计日志 → 溯源(SC-A-15 主流程)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AdminAuditController 应挂载 /api/admin/audit 并提供 /list")
+    void adminAuditController_mountsAuditListEndpoint() {
+        Class<?> clazz = loadClass(ADMIN_AUDIT_CONTROLLER);
+        assertNotNull(clazz,
+                "[admin.audit.log] AdminAuditController 类不存在:契约要求 POST /api/admin/audit/list"
+                        + " 审计列表(§4.60.14)。");
+        // 类级 @RequestMapping("/api/admin/audit")
+        assertTrue(hasRequestMapping(clazz, "/api/admin/audit"),
+                "[admin.audit.log] AdminAuditController 应 @RequestMapping(\"/api/admin/audit\")(§4.60.14)。");
+        // 方法级 @PostMapping("/list")
+        Method list = findMethod(clazz, "list");
+        assertNotNull(list,
+                "[admin.audit.log] AdminAuditController 应提供 list(...) 方法作为 /list 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(list, "/list"),
+                "[admin.audit.log] AdminAuditController.list 应 @PostMapping(\"/list\")(统一 POST,§4.13)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:AuditLogService 应提供分页查询 page(module,targetId,start,end,page,size)")
+    void auditLogService_hasPageQuery() {
+        Class<?> clazz = loadClass(AUDIT_LOG_SERVICE);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLogService 类不存在:契约要求分页查询审计日志(SC-A-15 主流程)。");
+        // page(String, String, Date, Date, int, int) —— 6 参分页查询(module/targetId/时间范围/分页)
+        Method page = findMethodByParamCount(clazz, "page", 6);
+        assertNotNull(page,
+                "[admin.audit.log] AuditLogService 应提供 page(module, targetId, startTime, endTime,"
+                        + " pageNo, pageSize) 分页查询(SC-A-15 分页边界)。");
+        // record(AuditLog) 写入入口
+        Method record = findMethodByParamCount(clazz, "record", 1);
+        assertNotNull(record,
+                "[admin.audit.log] AuditLogService 应提供 record(AuditLog) 写入入口(W0.3 审计写入)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:AuditLog 实体应承载溯源字段(操作人/模块/动作/目标/前后值/IP/时间)")
+    void auditLogEntity_carriesTraceFields() {
+        Class<?> clazz = loadClass(AUDIT_LOG_ENTITY);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLog 实体不存在:契约要求 audit_log 表实体承载溯源字段(SC-A-15)。");
+        String[] required = {
+                "operatorId", "operatorName", "module", "action", "targetType",
+                "targetId", "beforeValue", "afterValue", "ip", "createdAt"
+        };
+        List<String> missing = new ArrayList<>();
+        for (String name : required) {
+            if (!hasField(clazz, name)) {
+                missing.add(name);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "[admin.audit.log] AuditLog 实体缺失溯源字段:" + missing
+                        + "(契约要求 operatorId/operatorName/module/action/targetType/"
+                        + "targetId/beforeValue/afterValue/ip/createdAt,SC-A-15 溯源)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:OperationLogController 应挂载 /api/admin/logs 并提供 operations+recent")
+    void operationLogController_mountsOperationsAndRecent() {
+        Class<?> clazz = loadClass(OPERATION_LOG_CONTROLLER);
+        assertNotNull(clazz,
+                "[admin.audit.log] OperationLogController 类不存在:契约要求 POST /api/admin/logs"
+                        + " operations + recent(§4.60.14)。");
+        assertTrue(hasRequestMapping(clazz, "/api/admin/logs"),
+                "[admin.audit.log] OperationLogController 应 @RequestMapping(\"/api/admin/logs\")(§4.60.14)。");
+        Method operations = findMethod(clazz, "queryLogs");
+        assertNotNull(operations,
+                "[admin.audit.log] OperationLogController 应提供 queryLogs(...) 作为 /operations 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(operations, "/operations"),
+                "[admin.audit.log] OperationLogController.queryLogs 应 @PostMapping(\"/operations\")(§4.60.14)。");
+        Method recent = findMethod(clazz, "getRecentLogs");
+        assertNotNull(recent,
+                "[admin.audit.log] OperationLogController 应提供 getRecentLogs(...) 作为 /recent 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(recent, "/recent"),
+                "[admin.audit.log] OperationLogController.getRecentLogs 应 @PostMapping(\"/recent\")(§4.60.14)。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserOperationLogService 应提供 queryLogs + getRecentLogs 服务方法")
+    void userOperationLogService_hasQueryAndRecent() {
+        Class<?> clazz = loadClass(USER_OPERATION_LOG_SERVICE);
+        assertNotNull(clazz,
+                "[admin.audit.log] UserOperationLogService 类不存在:契约要求操作日志分页/近期查询(§4.60.14)。");
+        Method query = findMethod(clazz, "queryLogs");
+        assertNotNull(query,
+                "[admin.audit.log] UserOperationLogService 应提供 queryLogs(...)(/operations 分页查询)。");
+        Method recent = findMethod(clazz, "getRecentLogs");
+        assertNotNull(recent,
+                "[admin.audit.log] UserOperationLogService 应提供 getRecentLogs(...)(/recent 近期查询)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:AuditLogAspect 应存在(管理端写操作自动落审计)")
+    void auditLogAspect_existsForAutoAudit() {
+        Class<?> clazz = loadClass(AUDIT_LOG_ASPECT);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLogAspect 类不存在:契约要求管理端写操作自动落 audit_log(W0.3)。");
+        // 切面应含 @Aspect 注解
+        assertTrue(hasAnnotation(clazz, "org.aspectj.lang.annotation.Aspect"),
+                "[admin.audit.log] AuditLogAspect 应标注 @Aspect(Spring AOP 自动审计切面)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:日志不可篡改(只增不删改) + 建表落地
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:AuditLogService 不得暴露 update/remove/delete(日志不可篡改)")
+    void auditLogService_appendOnly_noUpdateDelete() {
+        Class<?> clazz = loadClass(AUDIT_LOG_SERVICE);
+        if (clazz == null) {
+            fail("[admin.audit.log] AuditLogService 类不存在,无法验证只增不删改(SC-A-15 边界)。");
+        }
+        List<String> exposed = new ArrayList<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            String n = m.getName().toLowerCase();
+            if (n.startsWith("update") || n.startsWith("remove") || n.startsWith("delete")) {
+                exposed.add(m.getName());
+            }
+        }
+        assertTrue(exposed.isEmpty(),
+                "[admin.audit.log] 日志不可篡改(SC-A-15 边界):AuditLogService 不得暴露 "
+                        + "update/remove/delete 方法(只增不删改),发现:" + exposed);
+    }
+
+    @Test
+    @DisplayName("边界流-2:audit_log 与 user_operation_logs 应落地 schema.sql")
+    void schemaSql_containsAuditTables() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("audit_log"),
+                "[admin.audit.log] schema.sql 缺少 audit_log 建表(W0.3 审计日志统一)。");
+        assertTrue(schema.contains("user_operation_logs"),
+                "[admin.audit.log] schema.sql 缺少 user_operation_logs 建表(操作日志)。");
+        // 审计表应为只增不删改形态:含 created_at 时间列
+        assertTrue(schema.contains("created_at"),
+                "[admin.audit.log] audit_log 应含 created_at 时间列(溯源时间)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:DatabaseInitializer 应有 audit_log 建表迁移(schema.sql 与迁移一致)")
+    void databaseInitializer_hasAuditLogMigration() {
+        String source = readDatabaseInitializerSource();
+        assertTrue(source.contains("audit_log"),
+                "[admin.audit.log] DatabaseInitializer 缺少 audit_log 建表迁移(AGENTS 迁移一致原则:"
+                        + "schema.sql 的 DDL 必须在 DatabaseInitializer 有对应迁移)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:仅 admin 角色可访问(§4.13)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:AdminAuditController.list 首参应带 @RequestAttribute(\"role\") 做 admin 门禁")
+    void adminAuditController_list_hasRoleGate() {
+        Class<?> clazz = loadClass(ADMIN_AUDIT_CONTROLLER);
+        if (clazz == null) {
+            fail("[admin.audit.log] AdminAuditController 类不存在,无法验证 role 门禁(§4.13)。");
+        }
+        Method list = findMethod(clazz, "list");
+        if (list == null) {
+            fail("[admin.audit.log] AdminAuditController.list 不存在,无法验证 role 门禁(§4.13)。");
+        }
+        Annotation[][] paramAnns = list.getParameterAnnotations();
+        boolean hasRoleAttr = false;
+        for (Annotation[] anns : paramAnns) {
+            for (Annotation a : anns) {
+                if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.RequestAttribute")) {
+                    hasRoleAttr = true;
+                    break;
+                }
+            }
+            if (hasRoleAttr) {
+                break;
+            }
+        }
+        assertTrue(hasRoleAttr,
+                "[admin.audit.log] AdminAuditController.list 应通过 @RequestAttribute(\"role\") 校验"
+                        + " role=admin(§4.13 管理员接口需 role=admin,AdminInterceptor 口径)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定宿主契约,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Method findMethod(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Method findMethodByParamCount(Class<?> clazz, String name, int paramCount) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == paramCount) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasAnnotation(Class<?> clazz, String annotationName) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Annotation a : clazz.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals(annotationName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 类级 @RequestMapping 是否含给定路径片段。 */
+    private boolean hasRequestMapping(Class<?> clazz, String pathPart) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Annotation a : clazz.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.RequestMapping")) {
+                try {
+                    Method value = a.annotationType().getMethod("value");
+                    String[] paths = (String[]) value.invoke(a);
+                    for (String p : paths) {
+                        if (p.contains(pathPart)) {
+                            return true;
+                        }
+                    }
+                } catch (Exception ignored) {
+                    // 注解反射失败,继续
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 方法级 @PostMapping 是否含给定路径。 */
+    private boolean hasPostMapping(Method method, String path) {
+        if (method == null) {
+            return false;
+        }
+        for (Annotation a : method.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.PostMapping")) {
+                try {
+                    Method value = a.annotationType().getMethod("value");
+                    String[] paths = (String[]) value.invoke(a);
+                    for (String p : paths) {
+                        if (p.equals(path)) {
+                            return true;
+                        }
+                    }
+                } catch (Exception ignored) {
+                    // 注解反射失败,继续
+                }
+            }
+        }
+        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 "";
+        }
+    }
+
+    private String readDatabaseInitializerSource() {
+        // mvn test 工作目录为模块根(cfc-backend),相对路径可直接读取;若失败再回退 classpath。
+        try {
+            byte[] bytes = Files.readAllBytes(Paths.get(DATABASE_INITIALIZER_SOURCE));
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            try (InputStream is = getClass().getClassLoader()
+                    .getResourceAsStream("DatabaseInitializer.java")) {
+                if (is == null) {
+                    return "";
+                }
+                try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                    scanner.useDelimiter("\\A");
+                    return scanner.hasNext() ? scanner.next() : "";
+                }
+            } catch (Exception ignored) {
+                return "";
+            }
+        }
+    }
+}