Files
svc-hall-service/src/test/java/com/yunhai/svchall/controller/api/SvcApprovalControllerTest.java
T
chenxing 6086c4374c
build / build-push (push) Failing after 10s
feat(p3.5): 审批代理增 Done/Cc 两端点(中心 done/cc 中转)
- SvcApprovalClient.doneTasks/ccMine:/oapi/flow/tasks/done、/oapi/flow/cc(中心按
  签名方应用过滤,出站不传 bizType——P2-02 接缝,双值双查不适用)
- SvcApprovalController GET Done/Cc:requireOperator+身份字段入参拒绝,信封透传
- 测试 +3(client 拼装 2+controller 透传与拒绝 1),全量 166/166 绿(Stage S5/P3.5)
2026-10-20 00:16:20 +08:00

450 lines
21 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.svchall.controller.api;
import com.yunhai.platform.apistd.spring.GlobalExceptionHandler;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.auth.spring.BoaUserArgumentResolver;
import com.yunhai.platform.auth.spring.CasSsoApiRedirectFilter;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.service.ApplyViewGuard;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.impl.ServiceApplyService;
import com.yunhai.svchall.service.impl.SvcApprovalClient;
import org.junit.jupiter.api.AfterEach;
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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* P3.1 审批代理三层测试之 MVC 契约层:MockMvc standalone <b>显式装配</b>
* {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与
* {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测,
* 证明不了解析器/advice 装配)。
* <p>
* 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN);
* 无学校=业务码 2(PARAM);<b>拒绝路径审批出站必须为 0</b>(verifyNoInteractions)。
* 另有一组挂真实 {@link CasSsoApiRedirectFilter} 的过滤链用例:API 头未登录=HTTP200+code302
* (平台 CAS 口径,非 HTTP 403——R3.0 勘误)。
*/
@DisplayName("SvcApprovalController - MVC 契约(双闸/身份注入/信封映射)")
class SvcApprovalControllerTest {
private SvcApprovalClient approvalClient;
private ServiceApplyRepository applyRepository;
private ApplyViewGuard applyViewGuard;
private ServiceApplyService applyService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
approvalClient = mock(SvcApprovalClient.class);
applyRepository = mock(ServiceApplyRepository.class);
FlowClient flowClient = mock(FlowClient.class);
applyViewGuard = new ApplyViewGuard(flowClient);
applyService = mock(ServiceApplyService.class);
SvcApprovalController controller = new SvcApprovalController(
approvalClient, applyRepository, applyViewGuard, applyService);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
.setControllerAdvice(new GlobalExceptionHandler(null),
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
.build();
}
@AfterEach
void tearDown() {
BoaUserContext.clear();
}
/** 深桩用户(沿 ServiceApplyFlowSubmitTest 形态):roleIds 可指定以驱动 Guard 管理员分支 */
private BoaUser user(int id, int schId, Integer... roleIds) {
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(user.getId()).thenReturn(id);
when(user.getName()).thenReturn("张三");
when(user.getAccount()).thenReturn("zhangsan");
when(user.getCurrentRole().getSchId()).thenReturn(schId);
when(user.getRoleIds()).thenReturn(roleIds == null || roleIds.length == 0 ? java.util.Collections.<Integer>emptyList() : java.util.Arrays.asList(roleIds));
when(user.getType()).thenReturn(null);
return user;
}
private ServiceApply apply(int id, int schId, Integer submitterId, Long instanceId) {
ServiceApply apply = new ServiceApply();
org.springframework.test.util.ReflectionTestUtils.setField(apply, "id", id);
apply.setSchId((long) schId);
apply.setSubmitterId(submitterId);
apply.setFlowInstanceId(instanceId);
return apply;
}
private static Map<String, Object> byBizTasks(Long instanceId, Map<String, Object>... tasks) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("instanceId", instanceId);
m.put("tasks", java.util.Arrays.asList(tasks));
return m;
}
private static Map<String, Object> task(int taskId) {
Map<String, Object> t = new LinkedHashMap<>();
t.put("taskId", taskId);
t.put("nodeName", "部门负责人");
return t;
}
private static Map<String, Object> row(int id) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", id);
return m;
}
private static Map<String, Object> pair(Object value) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("total", 0L);
m.put("list", value == null ? new java.util.ArrayList<>() : Collections.singletonList(value));
return m;
}
/** RecordUrl 换址应答 {pageUrl} */
private static Map<String, Object> pageUrl(String url) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("pageUrl", url);
return m;
}
// ===== 身份层 =====
@Test
@DisplayName("未登录(无 BoaUser):HTTP200 + 业务码 3,零出站")
void todoWithoutLogin() throws Exception {
mockMvc.perform(get("/api/apply/approval/Todo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(3));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("会话无学校归属:HTTP200 + 业务码 2,零出站")
void noSchoolIdentity() throws Exception {
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(user.getId()).thenReturn(10006);
when(user.getCurrentRole()).thenReturn(null);
BoaUserContext.set(user);
mockMvc.perform(get("/api/apply/approval/Todo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("请求参数携带身份字段(operatorId/schId/…):一律拒绝,零出站")
void identityParamsRejected() throws Exception {
BoaUserContext.set(user(10006, 3));
mockMvc.perform(get("/api/apply/approval/Todo").param("operatorId", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42").param("schId", "3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
mockMvc.perform(post("/api/apply/approval/Approve")
.param("applyId", "42").param("taskId", "442").param("operatorName", "李四")
.contentType("application/json").content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient, applyRepository);
}
// ===== Todo / My =====
@Test
@DisplayName("Todo:会话身份出站(双值双查在 client),信封透传")
void todoHappyPath() throws Exception {
BoaUserContext.set(user(10006, 3));
Map<String, Object> data = new LinkedHashMap<>();
data.put("total", 0);
data.put("content", new java.util.ArrayList<Object>());
when(approvalClient.todoTasks(3, 10006, 2, 10)).thenReturn(data);
mockMvc.perform(get("/api/apply/approval/Todo").param("page", "2").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(0));
}
@Test
@DisplayName("Done/Cc(P3.5):会话身份出站,信封透传,身份字段入参拒绝")
void doneAndCcEndpoints() throws Exception {
BoaUserContext.set(user(10006, 3));
Map<String, Object> data = new LinkedHashMap<>();
data.put("total", 0);
data.put("content", new java.util.ArrayList<>());
when(approvalClient.doneTasks(3, 10006, 1, 10)).thenReturn(data);
when(approvalClient.ccMine(3, 10006, 1, 10)).thenReturn(data);
mockMvc.perform(get("/api/apply/approval/Done"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/apply/approval/Cc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/apply/approval/Done").param("assigneeId", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
@Test
@DisplayName("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射")
void myEnvelopeMapping() throws Exception {
BoaUserContext.set(user(10006, 3));
Map<String, Object> local = new LinkedHashMap<>();
local.put("total", 21L);
local.put("list", java.util.Arrays.asList(row(42), row(43)));
when(applyService.myApplies(any(), any(), eq(3), eq(10))).thenReturn(local);
mockMvc.perform(get("/api/apply/approval/My").param("page", "3").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(21))
.andExpect(jsonPath("$.data.content.length()").value(2))
.andExpect(jsonPath("$.data.pageIndex").value(3))
.andExpect(jsonPath("$.data.pageSize").value(10))
.andExpect(jsonPath("$.data.totalPages").value(3))
.andExpect(jsonPath("$.data.hasNext").value(false));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("My 非法页码/页大小归一(page<1→1,size<1→10,size>100→100)")
void myNormalizesPaging() throws Exception {
BoaUserContext.set(user(10006, 3));
when(applyService.myApplies(any(), any(), anyInt(), anyInt())).thenReturn(pair(0L));
mockMvc.perform(get("/api/apply/approval/My").param("page", "0").param("size", "500"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.pageIndex").value(1))
.andExpect(jsonPath("$.data.pageSize").value(100));
}
// ===== ByBiz 双闸 =====
@Test
@DisplayName("ByBiz 跨校/不存在(findByIdAndSchId 空):业务码 2,零审批出站")
void byBizCrossSchool() throws Exception {
BoaUserContext.set(user(10006, 3));
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("ByBiz 同校陌生人(Guard 拒):业务码 3,零审批出站")
void byBizStrangerRejected() throws Exception {
BoaUserContext.set(user(10006, 3));
ServiceApply row = apply(42, 3, 20001, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
// flowClient.enabled()=false → Guard 办理人分支 fail-closed;非提交人/非管理员 → 拒
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(3));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("ByBiz 管理员(Guard 放行=只读可见):放行查询,出站=byBiz")
void byBizAdminReadonly() throws Exception {
BoaUserContext.set(user(10006, 3, 7));
ServiceApply row = apply(42, 3, 20001, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L));
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.instanceId").value(139));
}
// ===== 写面双闸(Approve/Reject/TaskPageUrl) =====
@Test
@DisplayName("Approve:归属预检不过(管理员可见但非办理人)→ 业务码 3,approve 零出站")
void approveGuardFailsForNonAssigneeAdmin() throws Exception {
BoaUserContext.set(user(10006, 3, 7));
ServiceApply row = apply(42, 3, 20001, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); // tasks 空=非本人待办
mockMvc.perform(post("/api/apply/approval/Approve")
.param("applyId", "42").param("taskId", "442")
.contentType("application/json").content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(3));
verify(approvalClient, never()).approve(any(), any(), any(), any(), any(), any());
}
@Test
@DisplayName("Approve:预检命中本人待办 → approve 一次(opinion/edgeName 从 body)")
void approveHappyPath() throws Exception {
BoaUserContext.set(user(10006, 3, 7));
ServiceApply row = apply(42, 3, 20001, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
mockMvc.perform(post("/api/apply/approval/Approve")
.param("applyId", "42").param("taskId", "442")
.contentType("application/json")
.content("{\"opinion\":\"同意\",\"edgeName\":\"通过\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(approvalClient).approve(442L, 3, 10006, "张三", "同意", "通过");
}
@Test
@DisplayName("Reject:空白 opinion 前置拒(业务码 2),零出站")
void rejectBlankOpinion() throws Exception {
BoaUserContext.set(user(10006, 3));
mockMvc.perform(post("/api/apply/approval/Reject")
.param("applyId", "42").param("taskId", "442")
.contentType("application/json").content("{\"opinion\":\" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("Reject:空 body/缺 opinion → 业务码 2")
void rejectMissingBody() throws Exception {
BoaUserContext.set(user(10006, 3));
mockMvc.perform(post("/api/apply/approval/Reject")
.param("applyId", "42").param("taskId", "442")
.contentType("application/json").content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("Reject:预检+opinion 齐 → reject 一次")
void rejectHappyPath() throws Exception {
BoaUserContext.set(user(10006, 3));
ServiceApply row = apply(42, 3, 10006, 139L); // 提交人=本人(Guard owner 短路)
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
mockMvc.perform(post("/api/apply/approval/Reject")
.param("applyId", "42").param("taskId", "442")
.contentType("application/json").content("{\"opinion\":\"材料不全\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(approvalClient).reject(442L, 3, 10006, "张三", "材料不全");
}
@Test
@DisplayName("TaskPageUrl:非本人待办 → 业务码 3,taskPageUrl 零出站")
void taskPageUrlNotMyTask() throws Exception {
BoaUserContext.set(user(10006, 3));
ServiceApply row = apply(42, 3, 10006, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(999)));
mockMvc.perform(get("/api/apply/approval/TaskPageUrl")
.param("applyId", "42").param("taskId", "442"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(3));
verify(approvalClient, never()).taskPageUrl(any(), any());
}
// ===== RecordUrl =====
@Test
@DisplayName("RecordUrl:未进流程(instanceId 空)→ 业务码 2,零出站")
void recordUrlNoInstance() throws Exception {
BoaUserContext.set(user(10006, 3));
ServiceApply row = apply(42, 3, 10006, null);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("RecordUrl:instanceId 形态换址(pageUrl 透传)")
void recordUrlHappyPath() throws Exception {
BoaUserContext.set(user(10006, 3));
ServiceApply row = apply(42, 3, 10006, 139L);
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
when(approvalClient.embedRecordUrl(3, 139L)).thenReturn(pageUrl("/embed/instance?token=x"));
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.pageUrl").value("/embed/instance?token=x"));
verify(approvalClient).embedRecordUrl(3, 139L);
}
// ===== 参数形态(与中心端点同构) =====
@Test
@DisplayName("缺参 applyId:非 0 业务码(GlobalExceptionHandler 兜底,code=6),零出站")
void missingParam() throws Exception {
BoaUserContext.set(user(10006, 3));
mockMvc.perform(get("/api/apply/approval/ByBiz"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(6));
verifyNoInteractions(approvalClient);
}
@Test
@DisplayName("非法数字 applyId=abc:非 0 业务码(code=6),零出站")
void illegalNumber() throws Exception {
BoaUserContext.set(user(10006, 3));
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "abc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(6));
verifyNoInteractions(approvalClient);
}
// ===== 真实 Servlet 过滤链(平台 CAS 口径) =====
/**
* 挂真实 {@link CasSsoApiRedirectFilter}(yh-auth starter 同一实现类):API 头(XHR)未登录
* → HTTP 200 + code 302 + urlToRedirectTo(平台口径,<b>不是</b> HTTP 403——R3.0 P1-04 勘误)。
* 无 API 头的请求过滤器直通(浏览器导航场景走 CAS 重定向链,另由整合测试覆盖)。
*/
@Test
void realFilterChainUnauthenticatedApiGetsCode302() throws Exception {
CasSsoApiRedirectFilter filter = new CasSsoApiRedirectFilter(
"X-Requested-With", "XMLHttpRequest", "http://sso.yunhaixingtong.com/cas", "http://localhost");
MockMvc withFilter = MockMvcBuilders.standaloneSetup(
new SvcApprovalController(approvalClient, applyRepository, applyViewGuard, applyService))
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
.setControllerAdvice(new GlobalExceptionHandler(null),
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
.addFilter(filter, "/api/*")
.build();
withFilter.perform(get("/api/apply/approval/Todo").header("X-Requested-With", "XMLHttpRequest"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(302))
.andExpect(jsonPath("$.urlToRedirectTo").exists());
verifyNoInteractions(approvalClient);
}
}