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

test(S-cfc-143): RED contract test admin.article.create - permission flow gap (B-142-1) + create behavior lock (fail 3/6)

E2E Test Bot 1 день назад
Родитель
Сommit
8e49e7e611

+ 285 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminArticleCreateContractTest.java

@@ -0,0 +1,285 @@
+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.service.ArticleService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+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 java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+
+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.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.doAnswer;
+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.create},{@code POST /api/admin/articles/create})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md}「三、未覆盖控制器」——
+ *       {@code AdminArticleController}(22 方法,0 覆盖)</li>
+ *   <li>设计:{@code AdminArticleController#create}({@code @PostMapping("/create")},类级
+ *       {@code /api/admin/articles})+ {@code ArticleService#create(Article, Long)}</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.4(SC-A-03 文章 CRUD);
+ *       权限流按契约核对结论(admin-ops-contract §5)TR-13:「补 admin 端点 MockMvc 契约
+ *       JUnit + <b>非 admin token → 403 权限流</b>」</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub @ 0a95662a8 实测)</b></p>
+ * <ul>
+ *   <li>正常流/边界流已实现:create 解析请求体 → 文本安全检测(fail-open)→
+ *       {@code articleService.create} → 返回 {@code {id}};重复标题异常透传 error。</li>
+ *   <li><b>权限流未落地(本测试的红)</b>:{@link JwtInterceptor#LOGIN_REQUIRED_PATHS} 未收口
+ *       {@code /api/admin/} 前缀(仅 {@code /api/admin-auth/*} 登录端点走 PUBLIC_PATHS),匿名
+ *       (userId=-1, role=anonymous)被放行至 controller;{@code create} 亦无
+ *       {@code @RequestAttribute("role")} 角色守卫(参照同控制器 {@code /move} 与
+ *       {@code AdminReferralController#summary} 的「无权限」守卫惯例)。对应缺陷台账
+ *       <b>B-142-1</b>(P1 安全:{@code /api/admin/**} 整族鉴权门缺失,R152~R154 延续未修)。</li>
+ * </ul>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(已实现 → 绿锁定契约):创建委托 service、回源新 id、字段落位;
+ *       内容安全检测不通过 → 拒绝且不落库。</li>
+ *   <li>边界流(已实现 → 绿锁定契约):重复标题异常透传 error。</li>
+ *   <li>权限流(契约缺口 → <b>预期红</b>):匿名请求应 401;{@code LOGIN_REQUIRED_PATHS} 应覆盖
+ *       {@code /api/admin/};非 admin 角色应被拒绝且不落库。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:权限流 3 例失败(红),断言原文含「RED[admin.article.create]」,失败原因
+ * 可贴(匿名放行 / LOGIN_REQUIRED_PATHS 缺 /api/admin/ / create 无角色守卫)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:按 B-142-1 处置——① {@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 追加
+ * {@code "/api/admin/"}(保留 {@code /api/admin-auth/*} 例外在 PUBLIC_PATHS),匿名 → 401;
+ * ② {@code create} 增加 {@code @RequestAttribute("role") String role} 参数并在入口拒绝非 admin
+ * ({@code !"admin".equals(role) → Result.error("无权限")})。实现后本测试自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
+ * 无外部服务;{@code JwtInterceptor} 直接 new(匿名分支不触达其 @Resource 字段)。</p>
+ */
+class AdminArticleCreateContractTest {
+
+    /** 契约端点:管理端文章创建 */
+    private static final String ENDPOINT = "/api/admin/articles/create";
+
+    private AdminArticleController controller;
+    private ArticleService articleService;
+    private ThreadPoolTaskExecutor taskExecutor;
+    private WechatServiceInterface wechatService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new AdminArticleController();
+        articleService = mock(ArticleService.class);
+        taskExecutor = mock(ThreadPoolTaskExecutor.class);
+        wechatService = mock(WechatServiceInterface.class);
+        setField("articleService", articleService);
+        setField("taskExecutor", taskExecutor);
+        setField("wechatService", wechatService);
+    }
+
+    // ==================== 正常流:创建委托 service、回源新 id、字段落位 ====================
+
+    @Test
+    @DisplayName("正常流-1:create 解析请求体字段并委托 articleService.create(article, adminId),返回新 id")
+    void create_委托service并返回新id() throws Exception {
+        stubTextSafety(true);
+        doAnswer(inv -> {
+            ((Article) inv.getArgument(0)).setId(999L);
+            return null;
+        }).when(articleService).create(any(Article.class), anyLong());
+
+        Result<Map<String, Object>> r = callCreate(validBody(), 1L, "admin");
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData(), "成功响应应携带 {id}");
+        assertEquals(999L, r.getData().get("id"), "应返回新文章 id");
+
+        ArgumentCaptor<Article> cap = ArgumentCaptor.forClass(Article.class);
+        verify(articleService).create(cap.capture(), eq(1L));
+        Article saved = cap.getValue();
+        assertEquals("契约测试文章", saved.getTitle());
+        assertEquals("<p>契约正文</p>", saved.getContent());
+        assertEquals("契约摘要", saved.getSummary());
+        assertEquals(5L, saved.getCategoryId().longValue());
+        assertEquals("运营管理员", saved.getAuthor());
+        assertEquals(Integer.valueOf(3), saved.getReadTime());
+        assertEquals("article", saved.getContentType());
+        assertEquals("published", saved.getStatus());
+    }
+
+    @Test
+    @DisplayName("正常流-2:文本内容安全检测不通过 → 拒绝且不落库")
+    void create_内容安全不通过_拒绝且不落库() throws Exception {
+        stubTextSafety(false);
+        Map<String, Object> body = validBody();
+        body.put("title", "标题含敏感词");
+
+        Result<Map<String, Object>> r = callCreate(body, 1L, "admin");
+
+        assertEquals("文章标题包含敏感信息,发布失败", r.getMessage());
+        verify(articleService, never()).create(any(Article.class), anyLong());
+    }
+
+    // ==================== 边界流:重复标题 / service 异常透传 ====================
+
+    @Test
+    @DisplayName("边界流-1:重复标题 → service 异常透传 error,不吞错")
+    void create_重复标题_异常透传error() throws Exception {
+        stubTextSafety(true);
+        doThrow(new RuntimeException("标题「契约测试文章」已存在,请勿重复创建"))
+                .when(articleService).create(any(Article.class), anyLong());
+
+        Result<Map<String, Object>> r = callCreate(validBody(), 1L, "admin");
+
+        assertEquals("标题「契约测试文章」已存在,请勿重复创建", r.getMessage());
+    }
+
+    // ==================== 权限流(契约缺口,预期红,B-142-1 / TR-13) ====================
+
+    @Test
+    @DisplayName("权限流-1:匿名(无 token)POST /api/admin/articles/create 应被 JwtInterceptor 401 拒绝")
+    void create_匿名请求_应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.create] 匿名(无 token)POST " + ENDPOINT
+                        + " 应被 JwtInterceptor 拒绝(401);当前 LOGIN_REQUIRED_PATHS 未收口 /api/admin/,"
+                        + "匿名被放行至 controller,且 create 无角色守卫,匿名(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.create] JwtInterceptor.LOGIN_REQUIRED_PATHS 应含 /api/admin/ 前缀条目,"
+                        + "使匿名 POST " + ENDPOINT + " → 401;当前仅收口 /api/admin-auth/* 登录端点"
+                        + "且它们走 PUBLIC_PATHS 例外,admin 业务族整体缺失(B-142-1-Update,R152~R154 延续未修)。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:create 应按项目 admin 守卫惯例声明 role 参数并拒绝非 admin(不落库)")
+    void create_非admin角色_应拒绝且不落库() throws Exception {
+        Method m = findCreateWithRole();
+        assertNotNull(m,
+                "RED[admin.article.create] 契约方法 create 应声明 role 参数"
+                        + "(@RequestAttribute(\"role\") String role),按项目 admin 守卫惯例拒绝非 admin"
+                        + "(参照 AdminArticleController#move 与 AdminReferralController#summary 的「无权限」守卫);"
+                        + "当前仅 create(Map, Long) 两参数、无角色校验,登录 C 端用户(parent/child)"
+                        + "即可越权建文章(B-142-1,TR-13 权限流)。");
+        m.setAccessible(true);
+
+        Result<?> r = (Result<?>) m.invoke(controller, validBody(), 1L, "parent");
+
+        assertEquals("无权限", r.getMessage());
+        verify(articleService, never()).create(any(Article.class), anyLong());
+    }
+
+    // ==================== 辅助 ====================
+
+    private Map<String, Object> validBody() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("title", "契约测试文章");
+        body.put("content", "<p>契约正文</p>");
+        body.put("summary", "契约摘要");
+        body.put("categoryId", 5L);
+        body.put("author", "运营管理员");
+        body.put("readTime", 3);
+        body.put("contentType", "article");
+        body.put("status", "published");
+        return body;
+    }
+
+    /** 文本安全检测:stub taskExecutor.submit 返回指定结果(true=fail-open 放行,false=检测不通过)。 */
+    private void stubTextSafety(boolean safe) {
+        when(taskExecutor.submit(any(Callable.class)))
+                .thenReturn(CompletableFuture.completedFuture(safe));
+    }
+
+    private void setField(String name, Object value) throws Exception {
+        Field f = AdminArticleController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, value);
+    }
+
+    /**
+     * 反射派发 create:兼容当前 2 参数签名(RED 现状)与契约 3 参数签名(GREEN 后
+     * create(Map, Long, String role)),避免 GREEN 阶段改签名导致本测试编译失败。
+     */
+    @SuppressWarnings("unchecked")
+    private Result<Map<String, Object>> callCreate(Map<String, Object> body, Long adminId, String role)
+            throws Exception {
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("create")) {
+                m.setAccessible(true);
+                if (m.getParameterCount() == 3) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, adminId, role);
+                }
+                if (m.getParameterCount() == 2) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, adminId);
+                }
+            }
+        }
+        fail("契约方法 create 不存在");
+        throw new IllegalStateException("unreachable");
+    }
+
+    /** 契约签名:create(Map body, Long adminId, String role)——role 承载 admin 角色守卫。 */
+    private Method findCreateWithRole() {
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("create") && m.getParameterCount() == 3) {
+                return m;
+            }
+        }
+        return null;
+    }
+}