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