Jelajahi Sumber

test(S-cfc-201): 契约级单测 admin.article.manage 文章发布-审核(TDD-RED,6红18绿)

能力键 admin.article.manage / SC-A-03 文章发布-审核流 / 任务 S-cfc-201。
仅新增 cfc-backend/src/test,不改 src/main。

新增 AdminArticleManageContractTest(24 例):
- 正常流 8(绿锁):toggleStatus 发布、submitForReview、withdraw、reDraft、
  delete、controller list 透传 auditStatus / publish 委托 / toggleFeatured 委托
- 边界流 8(绿锁):缺 id 参数门禁 ×4、非草稿提交审核、非发布中撤回、
  非驳回/撤回转草稿、非法 direction
- 红测 6(预期红,锁契约缺口):
  R1-R3 C 端未审不可见:getPublicList/getFeatured/getDetail 仅过滤 status=published,
    未排除 auditStatus!=approved,status=published+pending/rejected/null 泄漏到
    /api/articles/*(PUBLIC_PATHS 匿名可读)
  R4 直接发布未落审核默认值:toggleStatus(id,'published') 无审核门禁、不置
    auditStatus=approved/auditedAt,绕过 submit-review→audit 审核流直接上架
  R5-R6 权限流 controller 侧未收口:admin role 守卫仅 create/audit/move,
    update/delete/toggleFeatured/submitReview/withdraw/reDraft/publish 无守卫(B-142-1)
- 权限流绿锁 2(B-142-1 拦截器侧已修):LOGIN_REQUIRED_PATHS 已覆盖 /api/admin/、
  匿名 POST /api/admin/articles/publish → 401

RED 验证:mvn -o -Dtest=AdminArticleManageContractTest test
Tests run: 24, Failures: 6, Errors: 0(红 6 = 契约缺口,绿 18 全过)

关联:docs/tdd/orchestration/sc-key-map.md S-cfc-201;B-142-1
E2E Test Bot 55 menit lalu
induk
melakukan
a78add662a

+ 540 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminArticleManageContractTest.java

@@ -0,0 +1,540 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.controller.admin.AdminArticleController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.ArticlePermissionService;
+import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+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.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+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.manage},
+ * SC-A-03 文章发布-审核流,任务登记 {@code S-cfc-201})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/orchestration/sc-key-map.md} L33 —— {@code S-cfc-201} 前瞻契约 RED 登记
+ *       (admin.article.manage);{@code docs/superpowers/api/API_REFERENCE.md} §4.60.4(SC-A-03
+ *       文章发布-审核流,管理端 {@code /api/admin/articles/*});</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-06-08-article-publishing-system-design.md} §4.2
+ *       (/pending、/approve、/reject)+ §6 状态机(draft→pending→approved/rejected,approved 为
+ *       C 端可见终态);{@code 2026-06-09-article-activity-publishing-design.md};</li>
+ *   <li>契约缺口台账:<b>B-142-1</b>(P1 安全:/api/admin/** 整族鉴权门缺失)——拦截器侧已修(见下),
+ *       controller 层角色守卫未收口为本测试红锁之一。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub @ f2f6e9235 实测)</b></p>
+ * <ul>
+ *   <li>状态机已落地(绿锁):submitForReview(draft→pending + auditStatus=pending)、auditArticle
+ *       (pending→approved/published | rejected 见 S-cfc-144 同批)、withdraw(published→withdrawn)、
+ *       reDraft(rejected/withdrawn→draft 清审核痕迹)、toggleStatus(置 publishedAt)、
+ *       toggleFeatured / delete / moveSortOrder 均已实现;</li>
+ *   <li><b>B-142-1 拦截器侧已修</b>:{@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 已含 {@code "/api/admin/"}
+ *       前缀(见 L149),匿名 POST {@code /api/admin/**} → 401 —— 本测试以绿锁固化该防御;</li>
+ *   <li><b>本测试的红(契约缺口,6 例)</b>:
+ *     <ol>
+ *       <li>R1-R3 C 端「未审不可见」缺失:{@code ArticleService.getPublicList}(L120)/ {@code getFeatured}
+ *           (L230)仅过滤 {@code status=published},{@code getDetail}(L437)仅校验 status + isVisible,
+ *           均未排除 {@code auditStatus != "approved"};{@code status=published + auditStatus
+ *           =pending/rejected/null} 的文章会泄漏到 C 端 {@code POST /api/articles/list | /featured | /detail}
+ *           (该三端点在 {@code PUBLIC_PATHS} 白名单、匿名可读),违背设计 §6 状态机 approved 才是
+ *           C 端可见终态;</li>
+ *       <li>R4 直接发布未落审核默认值:{@code get}/${@link AdminArticleController#publish} →
+ *           {@code toggleStatus(id, "published")}(L902)无审核门禁、不置 {@code auditStatus=approved}、
+ *           不落 {@code auditorId/auditedAt},绕过 submit-review→audit 审核流直接上架(前端
+ *           ArticleManage.vue「直接发布」即走此捷径,与设计 §4.2/§6 状态机冲突,B-142-1 关联);</li>
+ *       <li>R5-R6 权限流 controller 层未收口:admin 角色守卫({@code @RequestAttribute("role")} →
+ *           非 admin 拒绝「无权限」)仅 create/audit/move 三处;update/delete/toggleFeatured/
+ *           submitReview/withdraw/reDraft 以及 publish 无守卫,登录 C 端用户(parent/child)可越权
+ *           发布/删除/撤回/置精选(B-142-1 controller 侧遗留)。</li>
+ *     </ol>
+ *   </li>
+ * </ul>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(已实现 → 绿锁定契约):toggleStatus 发布、submitForReview、withdraw、reDraft、
+ *       delete、toggleFeatured、controller list/publish 委托;</li>
+ *   <li>边界流(已实现 → 绿锁定契约):缺 id 参数门禁、错误状态守卫(非草稿提交审核 / 非发布中撤回 /
+ *       非驳回或撤回转草稿 / 非法 direction);</li>
+ *   <li>红测(契约缺口 → 预期红):C 端未审不可见(R1-R3)、直接发布未落审核默认值(R4)、
+ *       管理端点 admin 角色守卫缺失(R5-R6);权限流拦截器侧(G-P1/G-P2)为已修复防护的绿锁。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:6 例失败(红),断言原文含「RED[admin.article.manage]」,失败原因可贴
+ * (未审泄漏 / 直接发布缺审核默认值 / publish 等端点缺 role 守卫)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:
+ * ① C 端可见性:{@code getPublicList} / {@code getFeatured} / {@code getDetail} 查询条件追加
+ * {@code auditStatus="approved"}(或内存过滤),未审(pending/rejected/null)一律不可见;
+ * ② 直接发布审核门禁:{@code toggleStatus} 发布分支置 {@code auditStatus="approved"} 并落
+ * {@code auditorId/auditedAt}(或要求先过审核流),杜绝 published 与 auditStatus 不一致;
+ * ③ 权限流:publish/publishById/update/delete/toggleFeatured/submitReview/withdraw/reDraft 按
+ * create/audit/move 惯例追加 {@code @RequestAttribute("role") String role} 守卫(非 admin →
+ * {@code Result.error("无权限")})。实现后 R1-R6 自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射实例化),无 Spring 上下文 / 无 DB / 无外部服务;
+ * {@code JwtInterceptor} 直接 new(匿名分支不触达其 @Resource 字段)。</p>
+ */
+class AdminArticleManageContractTest {
+
+    private static final String ADMIN_PUBLISH_ENDPOINT = "/api/admin/articles/publish";
+
+    private AdminArticleController controller;
+    private ArticleService articleService;
+
+    private ArticleService service;
+    private ArticleMapper articleMapper;
+    private ArticlePermissionService articlePermissionService;
+    private FiveDimensionScoreService fiveDimensionScoreService;
+    private JdbcTemplate jdbcTemplate;
+    private ThreadPoolTaskExecutor taskExecutor;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ============ Controller 侧:mock 全部 @Resource 字段,回读 articleService ============
+        controller = new AdminArticleController();
+        injectAll(controller);
+        articleService = (ArticleService) readField(controller, "articleService");
+
+        // ============ Service 侧:new 实例 + mock 全部依赖,回读关键依赖 ============
+        service = new ArticleService();
+        injectAll(service);
+        articleMapper = (ArticleMapper) readField(service, "articleMapper");
+        articlePermissionService = (ArticlePermissionService) readField(service, "articlePermissionService");
+        fiveDimensionScoreService = (FiveDimensionScoreService) readField(service, "fiveDimensionScoreService");
+        jdbcTemplate = (JdbcTemplate) readField(service, "jdbcTemplate");
+        taskExecutor = (ThreadPoolTaskExecutor) readField(service, "taskExecutor");
+
+        // 默认桩:可见性原样返回 / fail-open / 空阅读时长,避免正常流与边界流误触 NPE
+        when(articlePermissionService.filterVisible(anyList(), any(), any(), any()))
+                .thenAnswer(inv -> inv.getArgument(0));
+        when(articlePermissionService.isVisible(any(), any(), any(), any())).thenReturn(true);
+        when(fiveDimensionScoreService.getFamilyAverageScores(any())).thenReturn(Collections.emptyMap());
+        when(jdbcTemplate.queryForList(anyString())).thenReturn(Collections.emptyList());
+    }
+
+    // ==================== 正常流(已实现 → 绿锁定契约) ====================
+
+    @Test
+    @DisplayName("正常流-1:toggleStatus 发布 → 置 published/publishedAt 并写库")
+    void green_toggleStatus_发布置publishedAt() {
+        Article a = article(105L, "draft", null);
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        service.toggleStatus(105L, "published");
+
+        assertEquals("published", a.getStatus());
+        assertNotNull(a.getPublishedAt(), "发布应记录发布时间");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("正常流-2:submitForReview 草稿提交审核 → status+auditStatus 均置 pending")
+    void green_submitForReview_草稿提交审核() {
+        Article a = article(105L, "draft", null);
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        service.submitForReview(105L);
+
+        assertEquals("pending", a.getStatus());
+        assertEquals("pending", a.getAuditStatus());
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("正常流-3:withdraw 发布中文章 → status=withdrawn(C 端下架)")
+    void green_withdraw_发布中撤回() {
+        Article a = article(105L, "published", "approved");
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        service.withdraw(105L);
+
+        assertEquals("withdrawn", a.getStatus());
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("正常流-4:reDraft 撤回稿 → 回草稿并清空审核痕迹")
+    void green_reDraft_撤回稿转草稿清审核痕迹() {
+        Article a = article(105L, "withdrawn", "approved");
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        service.reDraft(105L);
+
+        assertEquals("draft", a.getStatus());
+        assertNull(a.getAuditStatus(), "回草稿应清空审核状态");
+        assertNull(a.getAuditorId(), "回草稿应清空审核人");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("正常流-5:delete 委托 mapper.deleteById")
+    void green_delete_委托mapper删除() {
+        service.delete(105L);
+        verify(articleMapper).deleteById(105L);
+    }
+
+    @Test
+    @DisplayName("正常流-6:controller list 透传 auditStatus 并委托 getAdminList")
+    void green_list_透传auditStatus并委托getAdminList() throws Exception {
+        Page<Article> page = new Page<>(1, 20);
+        when(articleService.getAdminList("published", null, null, null, null, null, null, "pending", 1, 20, null))
+                .thenReturn(page);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "published");
+        body.put("auditStatus", "pending");
+        body.put("page", 1);
+        body.put("size", 20);
+
+        Result<Page<Article>> r = controller.list(body);
+
+        assertEquals(200, r.getCode());
+        assertEquals(page, r.getData());
+        verify(articleService).getAdminList(eq("published"), isNull(), isNull(), isNull(), isNull(), isNull(),
+                isNull(), eq("pending"), eq(1), eq(20), isNull());
+    }
+
+    @Test
+    @DisplayName("正常流-7:controller publish 委托 toggleStatus 并返回操作成功")
+    void green_publish_委托toggleStatus() throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 105L);
+        body.put("status", "published");
+
+        Result<String> r = controller.publish(body);
+
+        assertEquals(200, r.getCode());
+        assertEquals("操作成功", r.getData());
+        verify(articleService).toggleStatus(105L, "published");
+    }
+
+    @Test
+    @DisplayName("正常流-8:controller toggleFeatured 委托 toggleFeatured")
+    void green_toggleFeatured_委托toggleFeatured() throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 105L);
+        body.put("isFeatured", 1);
+
+        Result<String> r = controller.toggleFeatured(body);
+
+        assertEquals(200, r.getCode());
+        assertEquals("操作成功", r.getData());
+        verify(articleService).toggleFeatured(105L, 1);
+    }
+
+    // ==================== 边界流(已实现 → 绿锁定契约) ====================
+
+    @Test
+    @DisplayName("边界流-1:publish 缺 id → 缺少文章ID,不触达 service")
+    void green_publish_缺id_拒绝() throws Exception {
+        Result<String> r = controller.publish(new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertEquals("缺少文章ID", r.getMessage());
+        verify(articleService, never()).toggleStatus(anyLong(), any());
+    }
+
+    @Test
+    @DisplayName("边界流-2:submit-review 缺 id → 缺少文章ID")
+    void green_submitReview_缺id_拒绝() throws Exception {
+        Result<String> r = controller.submitReview(new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertEquals("缺少文章ID", r.getMessage());
+    }
+
+    @Test
+    @DisplayName("边界流-3:withdraw 缺 id → 缺少文章ID")
+    void green_withdraw_缺id_拒绝() throws Exception {
+        Result<String> r = controller.withdraw(new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertEquals("缺少文章ID", r.getMessage());
+    }
+
+    @Test
+    @DisplayName("边界流-4:re-draft 缺 id → 缺少文章ID")
+    void green_reDraft_缺id_拒绝() throws Exception {
+        Result<String> r = controller.reDraft(new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertEquals("缺少文章ID", r.getMessage());
+    }
+
+    @Test
+    @DisplayName("边界流-5:submitForReview 非草稿 → 仅草稿状态可提交审核,不写库")
+    void green_submitForReview_非草稿_拒绝() {
+        Article a = article(105L, "published", "approved");
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> service.submitForReview(105L));
+
+        assertEquals("仅草稿状态可提交审核", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("边界流-6:withdraw 非发布中 → 仅发布中的文章可撤回,不写库")
+    void green_withdraw_非发布中_拒绝() {
+        Article a = article(105L, "draft", null);
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> service.withdraw(105L));
+
+        assertEquals("仅发布中的文章可撤回", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("边界流-7:reDraft 非驳回/撤回 → 仅已驳回或已撤回的文章可重新编辑,不写库")
+    void green_reDraft_非驳回或撤回_拒绝() {
+        Article a = article(105L, "published", "approved");
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> service.reDraft(105L));
+
+        assertEquals("仅已驳回或已撤回的文章可重新编辑", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("边界流-8:move 非法 direction → direction 只能是 up 或 down")
+    void green_move_非法direction_拒绝() throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 102L);
+        body.put("direction", "left");
+
+        Result<String> r = (Result<String>) findMethod(AdminArticleController.class, "move", 2)
+                .invoke(controller, body, "admin");
+
+        assertEquals(500, r.getCode());
+        assertEquals("direction 只能是 up 或 down", r.getMessage());
+        verify(articleService, never()).moveSortOrder(anyLong(), any());
+    }
+
+    // ==================== 红测-组1:C 端「未审不可见」(契约缺口,预期红) ====================
+
+    @Test
+    @DisplayName("RED-1:getPublicList 应排除未审核文章(status=published + auditStatus=pending 泄漏到 C 端列表)")
+    void red1_getPublicList_排除未审核文章() {
+        Article approved = article(101L, "published", "approved");
+        Article leaked = article(102L, "published", "pending"); // 未审通过但已置 published → C 端泄漏
+        when(articleMapper.selectList(any())).thenReturn(Arrays.asList(approved, leaked));
+        when(articlePermissionService.filterVisible(anyList(), any(), any(), any()))
+                .thenReturn(Arrays.asList(approved, leaked));
+
+        Page<Article> page = service.getPublicList(null, null, null, null, 1, 20, null, null, null, null);
+
+        List<Article> records = page.getRecords();
+        assertFalse(records.stream().anyMatch(a -> a.getId().equals(leaked.getId())),
+                "RED[admin.article.manage] C 端列表(POST /api/articles/list,PUBLIC_PATHS 匿名可读)应只返回"
+                        + "审核通过(auditStatus=approved)的文章;status=published + auditStatus=pending 的"
+                        + "未审文章不得泄漏。当前 getPublicList 仅过滤 status=published,未排除 "
+                        + "auditStatus!=approved(设计 §6 状态机 draft→pending→approved/rejected,"
+                        + "approved 才是 C 端可见终态)。");
+    }
+
+    @Test
+    @DisplayName("RED-2:getFeatured 应排除未审核文章(直接发布 auditStatus=null 泄漏到 C 端精选)")
+    void red2_getFeatured_排除未审核文章() {
+        Article leaked = article(102L, "published", null); // 直接发布绕过审核:auditStatus 缺失
+        when(articleMapper.selectList(any())).thenReturn(Collections.singletonList(leaked));
+        when(articlePermissionService.filterVisible(anyList(), any(), any(), any()))
+                .thenReturn(Collections.singletonList(leaked));
+
+        List<Article> list = service.getFeatured(10, null, null, null, null);
+
+        assertFalse(list.stream().anyMatch(a -> a.getId().equals(leaked.getId())),
+                "RED[admin.article.manage] C 端精选(POST /api/articles/featured,匿名可读)应只含审核通过"
+                        + "文章;当前 getFeatured 仅过滤 status=published + isFeatured=1,auditStatus=null 的"
+                        + "直接发布文章泄漏(设计 §6 状态机 approved 才是 C 端可见终态)。");
+    }
+
+    @Test
+    @DisplayName("RED-3:getDetail 应拒绝未审核文章(status=published + auditStatus=rejected 详情仍可读)")
+    void red3_getDetail_拒绝未审核文章() {
+        Article leaked = article(102L, "published", "rejected"); // 已驳回却仍 published → 详情泄漏
+        when(articleMapper.selectById(102L)).thenReturn(leaked);
+
+        Article detail = service.getDetail(102L, null, null, null);
+
+        assertNull(detail,
+                "RED[admin.article.manage] C 端详情(POST /api/articles/detail,匿名可读)应拒绝未审核文章"
+                        + "(auditStatus=rejected 但 status=published);当前 getDetail 仅校验 "
+                        + "status=published + isVisible,忽略 auditStatus,已驳回文章仍可被读取。");
+    }
+
+    // ==================== 红测-组2:直接发布未落审核默认值(契约缺口,预期红) ====================
+
+    @Test
+    @DisplayName("RED-4:toggleStatus 直接发布应落审核默认值(auditStatus=approved + auditedAt)")
+    void red4_directPublish_应落审核默认值() {
+        Article a = article(105L, "draft", null);
+        when(articleMapper.selectById(105L)).thenReturn(a);
+
+        service.toggleStatus(105L, "published");
+
+        assertEquals("approved", a.getAuditStatus(),
+                "RED[admin.article.manage] 直接发布 POST " + ADMIN_PUBLISH_ENDPOINT
+                        + "(toggleStatus(id,'published'))应视为完成审核并落 auditStatus=approved"
+                        + "(设计 §6 状态机 approved 才是 C 端可见终态,避免 published 与 auditStatus 不一致"
+                        + "导致 C 端泄漏或不可见);当前 toggleStatus 无审核门禁、auditStatus 保持原值"
+                        + "(null/pending),绕过 submit-review→audit 审核流直接上架(前端「直接发布」即走"
+                        + "此捷径,B-142-1 关联)。");
+        assertNotNull(a.getAuditedAt(),
+                "RED[admin.article.manage] 直接发布应落审核时间 auditedAt(公章性审核痕迹)");
+    }
+
+    // ==================== 红测-组3:权限流 controller 侧 admin 角色守卫(契约缺口,预期红) ====================
+
+    @Test
+    @DisplayName("RED-5:publish 应声明 role 参数并拒绝非 admin(当前无守卫,越权直发)")
+    void red5_publish_非admin应拒绝() throws Exception {
+        Method m = findMethod(AdminArticleController.class, "publish", 2);
+        assertNotNull(m,
+                "RED[admin.article.manage] 契约方法 publish(POST " + ADMIN_PUBLISH_ENDPOINT
+                        + ")应声明 role 参数(@RequestAttribute(\"role\") String role),按项目 admin 守卫"
+                        + "惯例拒绝非 admin(参照 create/audit/move 的「无权限」守卫);当前仅 publish(Map)"
+                        + "单参数、无角色校验,登录 C 端用户(parent/child)即可直接发布/上下架文章"
+                        + "(B-142-1 controller 侧未收口)。");
+        m.setAccessible(true);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 105L);
+        body.put("status", "published");
+        Result<?> r = (Result<?>) m.invoke(controller, body, "parent");
+
+        assertEquals("无权限", r.getMessage());
+        verify(articleService, never()).toggleStatus(anyLong(), any());
+    }
+
+    @Test
+    @DisplayName("RED-6:update/delete/toggleFeatured/submitReview/withdraw/reDraft 应具 admin 角色守卫")
+    void red6_其余管理端点_应具admin角色守卫() {
+        String[] methods = {"update", "delete", "toggleFeatured", "submitReview", "withdraw", "reDraft"};
+        List<String> missing = new ArrayList<>();
+        for (String name : methods) {
+            if (findMethod(AdminArticleController.class, name, 2) == null) {
+                missing.add(name);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[admin.article.manage] 管理端文章端点除 create/audit/move 外应全部具 admin 角色守卫"
+                        + "(@RequestAttribute(\"role\") String role + 非 admin → Result.error(\"无权限\"),"
+                        + "B-142-1 controller 侧收口);当前缺失守卫: " + missing);
+    }
+
+    // ==================== 权限流绿锁(B-142-1 拦截器侧已修,固化防御) ====================
+
+    @Test
+    @DisplayName("权限流-绿锁1:JwtInterceptor.LOGIN_REQUIRED_PATHS 已覆盖 /api/admin/ 前缀")
+    void green_JwtInterceptor_已覆盖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 (ADMIN_PUBLISH_ENDPOINT.startsWith(p)) {
+                covered = true;
+                break;
+            }
+        }
+        assertTrue(covered,
+                "GREEN-lock[admin.article.manage] B-142-1 拦截器侧已修:LOGIN_REQUIRED_PATHS 应含 "
+                        + "/api/admin/ 前缀条目,匿名 POST /api/admin/ → 401");
+    }
+
+    @Test
+    @DisplayName("权限流-绿锁2:匿名 POST /api/admin/articles/publish → JwtInterceptor 401 拒绝")
+    void green_匿名请求_admin端点_401() throws Exception {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn(ADMIN_PUBLISH_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, "GREEN-lock[admin.article.manage] 匿名 POST /api/admin/ 应被 401 拒绝(B-142-1 已修)");
+        verify(response).setStatus(401);
+    }
+
+    // ==================== 辅助 ====================
+
+    private static Article article(Long id, String status, String auditStatus) {
+        Article a = new Article();
+        a.setId(id);
+        a.setStatus(status);
+        a.setAuditStatus(auditStatus);
+        a.setTitle("契约测试文章 " + id);
+        a.setContent("<p>正文</p>");
+        return a;
+    }
+
+    private static Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    /** mock 全部非 static / final 可写字段(对齐 AdminReportParserManageContractTest 注入装置)。 */
+    private static void injectAll(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    private static Method findMethod(Class<?> clazz, String name, int paramCount) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == paramCount) {
+                return m;
+            }
+        }
+        return null;
+    }
+}