Browse Source

test(tdd): S-cfc-144 admin.article.audit 契约级单测(TDD-RED:权限流3红B-142-1 + 行为锁定9绿)

Sisyphus Agent 4 days ago
parent
commit
dbb80b12b

+ 376 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminArticleAuditContractTest.java

@@ -0,0 +1,376 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.ArticleService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端文章审核动作(能力键 {@code admin.article.audit},
+ * {@code POST /api/admin/articles/audit})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md}「三、未覆盖控制器」——
+ *       {@code AdminArticleController}(22 方法,0 覆盖),{@code audit} 位于未覆盖方法清单</li>
+ *   <li>设计:{@code AdminArticleController#audit}({@code @PostMapping("/audit")},类级
+ *       {@code /api/admin/articles})+ {@code ArticleService#auditArticle(articleId, auditStatus,
+ *       auditReason, auditorId)} 审核状态机(pending→approved→published / rejected)</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.4(SC-A-03 文章发布-审核流,
+ *       含 {@code /audit})+ §4.60.13(SC-A-13 内容安全审核:
+ *       {@code POST /api/admin/articles/audit} 文章审核动作);权限流按契约核对结论
+ *       (admin-ops-contract §5)TR-13:「补 admin 端点 MockMvc 契约 JUnit +
+ *       <b>非 admin token → 403 权限流</b>」</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub @ 0b7ba4ecb 实测)</b></p>
+ * <ul>
+ *   <li>正常流/边界流已实现:audit 解析 {@code id / auditStatus / auditReason} → 参数门禁
+ *       (auditStatus 仅 {@code approved / rejected},拒审必填原因)→ 委托
+ *       {@code articleService.auditArticle} → {@code Result.success("审核完成")};
+ *       service 层状态机(文章不存在 / 非 pending 拒绝;approved→published、rejected→rejected)
+ *       亦已落地。</li>
+ *   <li><b>权限流未落地(本测试的红)</b>:{@link JwtInterceptor#LOGIN_REQUIRED_PATHS} 未收口
+ *       {@code /api/admin/} 前缀(仅 {@code /api/admin-auth/*} 登录端点走 PUBLIC_PATHS),匿名
+ *       (userId=-1, role=anonymous)被放行至 controller;{@code audit} 亦无
+ *       {@code @RequestAttribute("role")} 角色守卫(参照同控制器 {@code /move} 的「无权限」
+ *       守卫惯例)。对应缺陷台账 <b>B-142-1</b>(P1 安全:{@code /api/admin/**} 整族鉴权门缺失,
+ *       R152~R154 延续未修;S-cfc-143 admin.article.create 同批已锁定该缺口,
+ *       {@code AdminArticleCreateContractTest} 权限流 3 红)。</li>
+ * </ul>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(已实现 → 绿锁定契约):approved/rejected 原样委托 service 并返回「审核完成」;</li>
+ *   <li>边界流(已实现 → 绿锁定契约):非法审核状态 / 驳回缺原因 → 参数门禁错误,不触达 service;
+ *       service RuntimeException 透传 error message;</li>
+ *   <li>权限流(契约缺口 → <b>预期红</b>):匿名请求应 401;{@code LOGIN_REQUIRED_PATHS} 应覆盖
+ *       {@code /api/admin/};非 admin 角色应被拒绝且不执行审核。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:权限流 3 例失败(红),断言原文含「RED[admin.article.audit]」,失败原因
+ * 可贴(匿名放行 / LOGIN_REQUIRED_PATHS 缺 /api/admin/ / audit 无角色守卫)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:按 B-142-1 处置——① {@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 追加
+ * {@code "/api/admin/"}(保留 {@code /api/admin-auth/*} 例外在 PUBLIC_PATHS),匿名 → 401;
+ * ② {@code audit} 增加 {@code @RequestAttribute("role") String role} 参数并在入口拒绝非 admin
+ * ({@code !"admin".equals(role) → Result.error("无权限")},与 {@code /move} 守卫一致)。
+ * 实现后本测试权限流 3 例自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
+ * 无外部服务;{@code JwtInterceptor} 直接 new(匿名分支不触达其 @Resource 字段)。</p>
+ */
+class AdminArticleAuditContractTest {
+
+    /** 契约端点:管理端文章审核 */
+    private static final String ENDPOINT = "/api/admin/articles/audit";
+
+    private static final Long ARTICLE_ID = 100L;
+    private static final Long AUDITOR_ID = 88L;
+
+    private AdminArticleController controller;
+    private ArticleService articleService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new AdminArticleController();
+        articleService = mock(ArticleService.class);
+        setField("articleService", articleService);
+    }
+
+    // ==================== 正常流:参数门禁通过后原样委托 service,返回「审核完成」 ====================
+
+    @Test
+    @DisplayName("正常流-1:审核通过(approved)→ 委托 auditArticle(id, approved, null, adminId),返回审核完成")
+    void audit_审核通过_委托service并返回审核完成() throws Exception {
+        Result<String> r = callAudit(validBody("approved", null), AUDITOR_ID, "admin");
+
+        assertEquals(200, r.getCode());
+        assertEquals("审核完成", r.getData());
+        verify(articleService).auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID);
+    }
+
+    @Test
+    @DisplayName("正常流-2:审核驳回(rejected,含原因)→ 原因原样转发 service,返回审核完成")
+    void audit_审核驳回_原因原样转发service() throws Exception {
+        Result<String> r = callAudit(validBody("rejected", "内容涉嫌夸大"), AUDITOR_ID, "admin");
+
+        assertEquals(200, r.getCode());
+        assertEquals("审核完成", r.getData());
+        verify(articleService).auditArticle(ARTICLE_ID, "rejected", "内容涉嫌夸大", AUDITOR_ID);
+    }
+
+    // ==================== 边界流:参数门禁 / service 异常透传 ====================
+
+    @Test
+    @DisplayName("边界流-1:非法审核状态(非 approved/rejected)→ 审核状态不正确,不触达 service")
+    void audit_非法审核状态_返回审核状态不正确() throws Exception {
+        for (String bad : new String[]{"unknown", "pending", "APPROVED", ""}) {
+            Result<String> r = callAudit(validBody(bad, null), AUDITOR_ID, "admin");
+            assertEquals(500, r.getCode());
+            assertEquals("审核状态不正确", r.getMessage(), "auditStatus=" + bad + " 应被拒");
+        }
+        verify(articleService, never()).auditArticle(any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("边界流-2:驳回但原因缺失/全空白 → 驳回时必须填写原因,不触达 service")
+    void audit_驳回缺原因_返回驳回必须填写原因() throws Exception {
+        // 原因缺失
+        Result<String> noReason = callAudit(validBody("rejected", null), AUDITOR_ID, "admin");
+        assertEquals(500, noReason.getCode());
+        assertEquals("驳回时必须填写原因", noReason.getMessage());
+        // 原因全空白(trim 后为空)
+        Result<String> blankReason = callAudit(validBody("rejected", "   \t  "), AUDITOR_ID, "admin");
+        assertEquals(500, blankReason.getCode());
+        assertEquals("驳回时必须填写原因", blankReason.getMessage());
+        verify(articleService, never()).auditArticle(any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("边界流-3:service 运行时异常 → Result.error 透传 message(如「仅待审核状态可审核」)")
+    void audit_service运行时异常_返回error透传message() throws Exception {
+        doThrow(new RuntimeException("仅待审核状态可审核"))
+                .when(articleService)
+                .auditArticle(eq(ARTICLE_ID), eq("rejected"), eq("原因"), eq(AUDITOR_ID));
+
+        Result<String> r = callAudit(validBody("rejected", "原因"), AUDITOR_ID, "admin");
+
+        assertEquals(500, r.getCode());
+        assertEquals("仅待审核状态可审核", r.getMessage());
+    }
+
+    // ==================== 权限流(契约缺口,预期红,B-142-1 / TR-13) ====================
+
+    @Test
+    @DisplayName("权限流-1:匿名(无 token)POST /api/admin/articles/audit 应被 JwtInterceptor 401 拒绝")
+    void audit_匿名请求_应401拒绝() throws Exception {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn(ENDPOINT);
+        when(request.getHeader("Authorization")).thenReturn(null);
+        when(request.getParameter("token")).thenReturn(null);
+        when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+
+        JwtInterceptor interceptor = new JwtInterceptor(); // @Resource 字段留空:匿名分支不触达 DB
+
+        boolean allowed = interceptor.preHandle(request, response, null);
+
+        assertFalse(allowed,
+                "RED[admin.article.audit] 匿名(无 token)POST " + ENDPOINT
+                        + " 应被 JwtInterceptor 拒绝(401);当前 LOGIN_REQUIRED_PATHS 未收口 /api/admin/,"
+                        + "匿名被放行至 controller,且 audit 无角色守卫,匿名(userId=-1, role=anonymous)"
+                        + "可直接审核文章(B-142-1 P1:/api/admin/** 整族鉴权门缺失,TR-13 权限流要求匿名→401/403)。");
+        verify(response).setStatus(401);
+    }
+
+    @Test
+    @DisplayName("权限流-2:JwtInterceptor.LOGIN_REQUIRED_PATHS 应覆盖 /api/admin/ 前缀")
+    void loginRequiredPaths_应覆盖apiAdmin前缀() throws Exception {
+        Field f = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        f.setAccessible(true);
+        String[] paths = (String[]) f.get(null);
+
+        boolean covered = false;
+        for (String p : paths) {
+            if (ENDPOINT.startsWith(p)) {
+                covered = true;
+                break;
+            }
+        }
+        assertTrue(covered,
+                "RED[admin.article.audit] JwtInterceptor.LOGIN_REQUIRED_PATHS 应含 /api/admin/ 前缀条目,"
+                        + "使匿名 POST " + ENDPOINT + " → 401;当前仅收口 /api/admin-auth/* 登录端点"
+                        + "且它们走 PUBLIC_PATHS 例外,admin 业务族整体缺失(B-142-1-Update,R152~R154 延续未修)。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:audit 应按项目 admin 守卫惯例声明 role 参数并拒绝非 admin(不执行审核)")
+    void audit_非admin角色_应拒绝且不审核() throws Exception {
+        Method m = findAuditWithRole();
+        assertNotNull(m,
+                "RED[admin.article.audit] 契约方法 audit 应声明 role 参数"
+                        + "(@RequestAttribute(\"role\") String role),按项目 admin 守卫惯例拒绝非 admin"
+                        + "(参照 AdminArticleController#move 的「无权限」守卫);"
+                        + "当前仅 audit(Map, Long) 两参数、无角色校验,登录 C 端用户(parent/child)"
+                        + "即可越权审核文章(B-142-1,TR-13 权限流)。");
+        m.setAccessible(true);
+
+        Result<?> r = (Result<?>) m.invoke(controller, validBody("approved", null), AUDITOR_ID, "parent");
+
+        assertEquals("无权限", r.getMessage());
+        verify(articleService, never()).auditArticle(any(), any(), any(), any());
+    }
+
+    // ==================== Service 状态机(auditArticle 契约,纯 Mockito) ====================
+
+    private ArticleService service;
+    private ArticleMapper articleMapper;
+    private ThreadPoolTaskExecutor taskExecutor;
+
+    @BeforeEach
+    void setUpService() throws Exception {
+        service = new ArticleService();
+        articleMapper = mock(ArticleMapper.class);
+        taskExecutor = mock(ThreadPoolTaskExecutor.class);
+        injectIntoService("articleMapper", articleMapper);
+        injectIntoService("taskExecutor", taskExecutor);
+    }
+
+    private void injectIntoService(String field, Object value) throws Exception {
+        Field f = ArticleService.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private Article pendingArticle() {
+        Article a = new Article();
+        a.setId(ARTICLE_ID);
+        a.setStatus("draft");
+        a.setAuditStatus("pending");
+        a.setTitle("契约测试文章");
+        a.setContent("<p>正文</p>");
+        return a;
+    }
+
+    @Test
+    @DisplayName("service-1:审核通过 → status=published、清空驳回原因、记录审核人/时间")
+    void service_审核通过_置published并清空驳回原因() {
+        Article a = pendingArticle();
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+
+        service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID);
+
+        assertEquals("published", a.getStatus(), "approved → status 应置 published");
+        assertEquals("approved", a.getAuditStatus(), "approved → auditStatus 应置 approved");
+        assertNull(a.getAuditReason(), "approved → 驳回原因应清空");
+        assertNotNull(a.getPublishedAt(), "approved → 应记录发布/通过时间");
+        assertNotNull(a.getAuditedAt(), "approved → 应记录审核时间");
+        assertEquals(AUDITOR_ID, a.getAuditorId(), "approved → 应记录审核人 ID");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("service-2:审核驳回 → status=rejected、保留驳回原因、记录审核人/时间")
+    void service_审核拒绝_置rejected并保留驳回原因() {
+        Article a = pendingArticle();
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+
+        service.auditArticle(ARTICLE_ID, "rejected", "内容涉嫌夸大", AUDITOR_ID);
+
+        assertEquals("rejected", a.getStatus(), "rejected → status 应置 rejected");
+        assertEquals("rejected", a.getAuditStatus(), "rejected → auditStatus 应置 rejected");
+        assertEquals("内容涉嫌夸大", a.getAuditReason(), "rejected → 驳回原因应保留");
+        assertNull(a.getPublishedAt(), "rejected → 不应有发布/通过时间");
+        assertNotNull(a.getAuditedAt(), "rejected → 应记录审核时间");
+        assertEquals(AUDITOR_ID, a.getAuditorId(), "rejected → 应记录审核人 ID");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("service-3:文章不存在 → 抛「文章不存在」,不写库")
+    void service_文章不存在_抛文章不存在_不写库() {
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(null);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID));
+
+        assertEquals("文章不存在", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("service-4:非 pending 审核态 → 抛「仅待审核状态可审核」,不写库")
+    void service_非pending审核态_抛仅待审核状态可审核_不写库() {
+        Article a = pendingArticle();
+        a.setAuditStatus("approved"); // 已审核过的文章不允许重复审核
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID));
+
+        assertEquals("仅待审核状态可审核", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    // ==================== 辅助 ====================
+
+    private Map<String, Object> validBody(String auditStatus, String auditReason) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", ARTICLE_ID);
+        body.put("auditStatus", auditStatus);
+        if (auditReason != null) {
+            body.put("auditReason", auditReason);
+        }
+        return body;
+    }
+
+    private void setField(String name, Object value) throws Exception {
+        Field f = AdminArticleController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, value);
+    }
+
+    /**
+     * 反射派发 audit:兼容当前 2 参数签名(RED 现状)与契约 3 参数签名(GREEN 后
+     * audit(Map, Long, String role)),避免 GREEN 阶段改签名导致本测试编译失败。
+     */
+    @SuppressWarnings("unchecked")
+    private Result<String> callAudit(Map<String, Object> body, Long adminId, String role)
+            throws Exception {
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("audit")) {
+                m.setAccessible(true);
+                if (m.getParameterCount() == 3) {
+                    return (Result<String>) m.invoke(controller, body, adminId, role);
+                }
+                if (m.getParameterCount() == 2) {
+                    return (Result<String>) m.invoke(controller, body, adminId);
+                }
+            }
+        }
+        fail("契约方法 audit 不存在");
+        throw new IllegalStateException("unreachable");
+    }
+
+    /** 契约签名:audit(Map body, Long adminId, String role)——role 承载 admin 角色守卫。 */
+    private Method findAuditWithRole() {
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("audit") && m.getParameterCount() == 3) {
+                return m;
+            }
+        }
+        return null;
+    }
+}