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