在 b369527 之上增量移植 FL2.0 后端能力,不改既有线级停发/指派预览/流程图入口:
- 部门目录:FlowDepartmentController(/admin/department/{groups,options,resolve,members})
+ UserBaseDepartmentDirectory(先按会话校域解析引用,再读成员;目录不可用 fail-closed)
- 退回重提 D14:POST /oapi/flow/instance/{instanceId}/resubmit,仅原提交人 CAS 路径放行,
OAPI 侧 submitterId 路径继续 fail-closed
- 授权管理:/api/flow/admin/usage-grant/{schoolList,summary}(校域取管理员会话,无 schId 入参)
- 来源主体:解析器派生 ub-app:{applicationInfo.id},不再读取 UserBase 的 sourcePrincipalId 字段
- 回调来源策略:SELF 身份强校验;NULL 来源不广播;复合幂等键 (event_id,reg_id)
- 测试夹具:许可/主体/开通桩按新契约修正,FlowEngineIT/FormalApplyCommandIT/
CallbackDispatchIT/OapiFlowContractIT 共 117 例在真实 MySQL 8 通过
不含:FlSourceAuthorizationTest(其断言的两个 Repository 派生方法属另一实现线)
This commit is contained in:
@@ -77,6 +77,33 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
/** 计数版 UserMsgClient:只数挂起告警(hang:* msgNo),不真正外发(@Primary 注入 CallbackPushService) */
|
||||
static final CountingUserMsgClient STUB_USER_MSG = new CountingUserMsgClient();
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.repository.FlwUsageGrantRepository grantRepository;
|
||||
|
||||
/**
|
||||
* C03 起新发起还要核"应用已在该校开通"(真实实现出网查 UserBase;本地/测试环境不可达即 fail-closed)。
|
||||
* 本类靶子是回调投递,开通事实由 FormalApplyCommandIT / FlUsageGrantTest 覆盖,这里固定为已开通。
|
||||
*/
|
||||
@org.springframework.boot.test.mock.mockito.MockBean
|
||||
private com.yunhai.flowcenter.service.UserBaseActivationClient activationClient;
|
||||
|
||||
/**
|
||||
* C07:注册 SELF 订阅要求"绑定来源的生效 grant 或已 ACCEPTED 正式命令"。本类只测投递/退避/对账,
|
||||
* 每条新流程线发布后直接补一条绑定 {@code ub-app:18} 的许可(与 StubConfig 的主体桩一致)。
|
||||
*/
|
||||
@Override
|
||||
protected com.yunhai.flowcenter.entity.FlwDef saveAndPublishDef(String code, String name, String contentJson) {
|
||||
com.yunhai.flowcenter.entity.FlwDef def = super.saveAndPublishDef(code, name, contentJson);
|
||||
com.yunhai.flowcenter.entity.FlwUsageGrant grant = new com.yunhai.flowcenter.entity.FlwUsageGrant();
|
||||
grant.setSchId(SCH);
|
||||
grant.setFlowGuid(def.getFlowGuid());
|
||||
grant.setAppId(18);
|
||||
grant.setSourcePrincipalId("ub-app:18");
|
||||
grant.setEnabled(1);
|
||||
grantRepository.saveAndFlush(grant);
|
||||
return def;
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@@ -90,6 +117,23 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
public CountingUserMsgClient countingUserMsgClient() {
|
||||
return STUB_USER_MSG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源主体解析桩(C07/C13):回调注册起必须核 SELF 并写规范来源,真实实现要出网查 UserBase
|
||||
* 应用注册表(appKey→application_info.id)。本类靶子是投递/退避/对账,不是主体解析,
|
||||
* 因此固定 appKey=100018 → ub-app:18;主体不可达 fail-closed 由 FlUsageGrantTest 覆盖。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.platform.openapi.base.OpenApiAppClient stubAppClient(
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper) {
|
||||
com.yunhai.platform.openapi.base.OpenApiAppClient client =
|
||||
org.mockito.Mockito.mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class);
|
||||
org.mockito.Mockito.when(client.getAppByKey(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenAnswer(inv -> mapper.readTree(
|
||||
"{\"id\":18,\"appCode\":\"notice\",\"appName\":\"通知\"}"));
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
static class CountingUserMsgClient extends com.yunhai.flowcenter.notify.UserMsgClient {
|
||||
@@ -126,6 +170,8 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
cleanDb();
|
||||
org.mockito.Mockito.when(activationClient.isAppActivatedStrict(
|
||||
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
|
||||
received.clear();
|
||||
STUB_USER_MSG.hangAlerts.set(0);
|
||||
stubServer = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
|
||||
@@ -10,15 +10,28 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class CallbackSourcePolicyTest {
|
||||
@Test void legacySourceIsRequiredButV1DoesNotNeedFormalRequestIdentity() {
|
||||
FlwInstanceRepository instances=mock(FlwInstanceRepository.class);
|
||||
FlowSourcePrincipalResolver resolver=mock(FlowSourcePrincipalResolver.class);
|
||||
CallbackSourcePolicy policy=new CallbackSourcePolicy(resolver,instances,new ObjectMapper());
|
||||
FlwInstance row=new FlwInstance();row.setId(7L);row.setSchId(1L);row.setBizType("notice");row.setBizId("7");row.setSourcePrincipalId("ub-app:18");
|
||||
FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);reg.setAppKey("key");reg.setSourcePrincipalId("ub-app:18");
|
||||
when(instances.findById(7L)).thenReturn(Optional.of(row));
|
||||
when(resolver.resolve("key")).thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18",18,"key","notice"));
|
||||
String payload="{\"sourcePrincipalId\":\"ub-app:18\",\"instanceId\":7,\"schId\":1,\"bizType\":\"notice\",\"bizId\":\"7\",\"data\":{\"sourcePrincipalId\":\"ub-app:18\"}}";
|
||||
assertTrue(policy.permits(reg,7L,payload));
|
||||
reg.setSchId(2L);assertFalse(policy.permits(reg,7L,payload));reg.setSchId(1L);
|
||||
row.setSourcePrincipalId(null);assertFalse(policy.matches(reg,row));
|
||||
}
|
||||
@Test void selfPolicyRejectsForeignSubscriptionsReusedCredentialsAndCorruptEnvelopes() throws Exception {
|
||||
String source="11111111-1111-4111-8111-111111111111";
|
||||
ObjectMapper mapper=new ObjectMapper();FlwInstanceRepository instances=mock(FlwInstanceRepository.class);
|
||||
FlowSourcePrincipalResolver resolver=mock(FlowSourcePrincipalResolver.class);
|
||||
CallbackSourcePolicy policy=new CallbackSourcePolicy(resolver,instances,mapper);
|
||||
FlwInstance instance=new FlwInstance();instance.setId(7L);instance.setSchId(1L);instance.setBizType("svc-hall");instance.setBizId("7");
|
||||
instance.setDefVersion(1);instance.setSourcePrincipalId(source);instance.setApplyRequestToken("r");instance.setApplyRound(2);instance.setApplyPayloadHash("hash");
|
||||
instance.setLegacySlot(null);instance.setDefVersion(1);instance.setSourcePrincipalId(source);instance.setApplyRequestToken("r");instance.setApplyRound(2);instance.setApplyPayloadHash("hash");
|
||||
when(instances.findById(7L)).thenReturn(Optional.of(instance));
|
||||
FlwCallbackReg reg=new FlwCallbackReg();reg.setAppKey("100018");reg.setSourcePrincipalId(source);
|
||||
FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);reg.setAppKey("100018");reg.setSourcePrincipalId(source);
|
||||
when(resolver.resolve("100018")).thenReturn(new FlowSourcePrincipalResolver.Principal(source,18,"100018","svc-hall"));
|
||||
String json="{\"eventId\":\"e\",\"event\":\"flow.completed\",\"sourcePrincipalId\":\""+source+"\",\"applyRound\":2,\"instanceId\":7,\"schId\":1,\"bizType\":\"svc-hall\",\"bizId\":\"7\",\"data\":{\"defVersion\":1,\"sourcePrincipalId\":\""+source+"\",\"requestId\":\"r\",\"canonicalHash\":\"hash\",\"applyRound\":2}}";
|
||||
assertTrue(policy.permits(reg,7L,json));
|
||||
|
||||
@@ -82,6 +82,17 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 许可闸桩(与 D11AssigneeEligibilityIT 同口径):本类靶子是引擎语义(激活/会签/并行/退回/R4),
|
||||
* 不是授权链;C03 起许可闸对所有新发起无条件生效,真实 grant 供给由 FlUsageGrantTest、
|
||||
* FormalApplyCommandIT 覆盖,这里不重复造授权夹具。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubGrantGuard() {
|
||||
return org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断桩的编程值:outlet=命中出口;failure=非空时抛出(模拟服务不可用) */
|
||||
@@ -124,6 +135,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}",
|
||||
submitterId, submitterName, null, "100001", "notice", assignJson);
|
||||
cmd.sourcePrincipalId = SOURCE;
|
||||
return instanceService.apply(cmd);
|
||||
}
|
||||
|
||||
@@ -335,6 +347,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
|
||||
FlwDef def=saveAndPublishDef("d09round","D09 round",multiOutletDef("d09a","d09b"));
|
||||
FlowInstanceService.ApplyCommand command=new FlowInstanceService.ApplyCommand(SCH,def,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
|
||||
command.sourcePrincipalId=SOURCE;
|
||||
Long id=instanceService.applyGuarded(command,new FlowInstanceService.ApplyRetryPolicy("d09-round-1",1,false)).instanceId;
|
||||
List<FlwTask> old=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过");
|
||||
@@ -343,6 +356,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
FlwDef next=saveAndPublishDef("d09round2","D09 new line",multiOutletDef("d09a","d09b").replace("转乙科","新边").replace("通过","完成"));
|
||||
next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next);
|
||||
FlowInstanceService.ApplyCommand nextCommand=new FlowInstanceService.ApplyCommand(SCH,next,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
|
||||
nextCommand.sourcePrincipalId=SOURCE;
|
||||
FlowInstanceService.GuardedOutcome restarted=instanceService.applyGuarded(nextCommand,new FlowInstanceService.ApplyRetryPolicy("d09-round-2",2,false));
|
||||
assertEquals(id,restarted.instanceId);assertEquals(2,restarted.applyRound);assertEquals("RESTARTED",restarted.branch);
|
||||
for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"新边");
|
||||
|
||||
@@ -53,6 +53,10 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
@Autowired
|
||||
private OapiFlowService flowService;
|
||||
|
||||
/** 回调注册(C07)要求 SELF 订阅权限:要么有绑定来源的生效 grant,要么已有 ACCEPTED 正式命令。 */
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.repository.FlwUsageGrantRepository grants;
|
||||
|
||||
@Autowired
|
||||
private FlowTaskService taskService;
|
||||
|
||||
@@ -75,6 +79,29 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源主体解析桩(F02):真实实现要出站问 UserBase 应用注册表,本 IT 只核 OAPI 契约形状,
|
||||
* 因此把签名 appKey 固定映射成一个稳定来源。跨来源隔离语义由 FlSourceIsolationTest 覆盖。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver stubSourceResolver() {
|
||||
return new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver(null) {
|
||||
@Override
|
||||
public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal resolve(String appKey) {
|
||||
return new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal(
|
||||
"ub-app:100001", 100001, appKey, "flow-test");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 许可闸桩(F01):本 IT 没有 flw_usage_grant 数据面;fail-closed 由 FlUsageGrantTest 覆盖 */
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubGrantGuard() {
|
||||
return org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@@ -445,6 +472,13 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
void approveAndReject_operatorChecked_opinionPropagates() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t22", "办理", linearDef("ybb", "审核"));
|
||||
com.yunhai.flowcenter.entity.FlwUsageGrant selfGrant = new com.yunhai.flowcenter.entity.FlwUsageGrant();
|
||||
selfGrant.setSchId(1L);
|
||||
selfGrant.setFlowGuid(def.getFlowGuid());
|
||||
selfGrant.setAppId(100001);
|
||||
selfGrant.setSourcePrincipalId("ub-app:100001");
|
||||
selfGrant.setEnabled(1);
|
||||
grants.saveAndFlush(selfGrant);
|
||||
// 本地回调接收 sink(探活 POST + 事件推送都回 200),直接捕获 payload 断言
|
||||
List<String> receivedPayloads = java.util.Collections.synchronizedList(new java.util.ArrayList<>());
|
||||
com.sun.net.httpserver.HttpServer sink = com.sun.net.httpserver.HttpServer.create(
|
||||
@@ -724,7 +758,7 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
assertNotEquals(0, resp.getBody().get("code").asInt(), message);
|
||||
assertFalse(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isPresent(),
|
||||
assertTrue(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isEmpty(),
|
||||
message + "(办件未创建)");
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,10 @@ class OapiFlowP35DoneCcContractTest {
|
||||
mockMvc.perform(get("/oapi/flow/tasks/done")
|
||||
.param("schId", "3").param("assigneeId", "10006")
|
||||
.param("page", "2").param("size", "10")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.requestAttr("yh.app.auth", "100008")
|
||||
.requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8"))
|
||||
.andExpect(status().isOk());
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), eq(2), eq(10));
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("ub-app:8"), eq(2), eq(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,10 +74,11 @@ class OapiFlowP35DoneCcContractTest {
|
||||
mockMvc.perform(get("/oapi/flow/tasks/done")
|
||||
.param("schId", "3").param("assigneeId", "10006")
|
||||
.param("bizType", "svc-hall")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.requestAttr("yh.app.auth", "100008")
|
||||
.requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8"))
|
||||
.andExpect(status().isOk());
|
||||
// 第三参(过滤键)必须是签名 appKey,不是 bizType
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), Mockito.isNull(), Mockito.isNull());
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("ub-app:8"), Mockito.isNull(), Mockito.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,9 +95,10 @@ class OapiFlowP35DoneCcContractTest {
|
||||
mockMvc.perform(get("/oapi/flow/cc")
|
||||
.param("schId", "3").param("userId", "10006")
|
||||
.param("page", "1").param("size", "20")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.requestAttr("yh.app.auth", "100008")
|
||||
.requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8"))
|
||||
.andExpect(status().isOk());
|
||||
verify(queryService).pageCc(eq(3L), eq(10006), eq("100008"), eq(1), eq(20));
|
||||
verify(queryService).pageCc(eq(3L), eq(10006), eq("ub-app:8"), eq(1), eq(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -62,33 +62,6 @@ class OapiFlowTasksByBizTest extends FlowTestSupport {
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
|
||||
/** F01:来源主体由验签上下文解析,本契约测试不覆盖该链,固定返回测试主体 */
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver stubSourcePrincipalResolver() {
|
||||
com.yunhai.flowcenter.service.FlowSourcePrincipalResolver resolver =
|
||||
org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.class);
|
||||
org.mockito.Mockito.when(resolver.resolve(org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal(
|
||||
"11111111-2222-3333-4444-555555555555", 100018, APP_KEY, "svc-hall"));
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/** F01:使用许可在专用测试中覆盖,本契约测试放行并以同一主体回填 */
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubUsageGrantGuard() {
|
||||
com.yunhai.flowcenter.service.FlowUsageGrantGuard guard =
|
||||
org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class);
|
||||
org.mockito.Mockito.when(guard.requireNewApplyGrant(
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(
|
||||
com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal.class)))
|
||||
.thenReturn(100018);
|
||||
return guard;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.repository.*;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.*;
|
||||
import java.util.Optional;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** Real boundary methods; no database, HTTP client or Spring database context is started. */
|
||||
class CodexFl2BoundaryTest {
|
||||
@Test void foreignPathCannotBeMaskedByAnOwnedQueryId() {
|
||||
FlowSourcePrincipalResolver resolver = mock(FlowSourcePrincipalResolver.class);
|
||||
when(resolver.resolve("key")).thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18", 18, "key", "app"));
|
||||
FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
|
||||
FlwInstance other = new FlwInstance(); other.setSourcePrincipalId("ub-app:19");
|
||||
when(instances.findById(99L)).thenReturn(Optional.of(other));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oapi/flow/instance/99/withdraw");
|
||||
request.setAttribute("yh.app.auth", "key"); request.setParameter("instanceId", "1");
|
||||
FlowOapiSourceScope scope = new FlowOapiSourceScope(resolver, instances, mock(FlwTaskRepository.class));
|
||||
assertThatThrownBy(() -> scope.preHandle(request, new MockHttpServletResponse(), new Object())).isInstanceOf(BizException.class);
|
||||
verify(instances).findById(99L); verify(instances, never()).save(any());
|
||||
}
|
||||
|
||||
@Test void oapiCannotAuthenticateTheHumanWithQuerySubmitterId() {
|
||||
OapiFlowService service = mock(OapiFlowService.class, CALLS_REAL_METHODS);
|
||||
assertThatThrownBy(() -> service.resubmit(7L, 99L, 18, "{}"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("可信用户");
|
||||
// The real method rejects before touching any injected dependency.
|
||||
}
|
||||
|
||||
@Test void oversizedApplicationIdCannotAliasAnExistingPrincipal() throws Exception {
|
||||
OpenApiAppClient apps = mock(OpenApiAppClient.class);
|
||||
when(apps.getAppByKey("key")).thenReturn(new ObjectMapper().readTree("{\"id\":4294967314}"));
|
||||
assertThatThrownBy(() -> new FlowSourcePrincipalResolver(apps).resolve("key")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void malformedOrForeignActivationDtoFailsClosed() throws Exception {
|
||||
YschOpenCall call = mock(YschOpenCall.class);
|
||||
UserBaseActivationClient client = new UserBaseActivationClient(call);
|
||||
for (String body : new String[]{"{\"appId\":18,\"schoolId\":8,\"activated\":true}",
|
||||
"{\"appId\":18,\"schoolId\":7,\"activated\":\"true\"}"}) {
|
||||
when(call.get(anyString(), anyString(), any(), any())).thenReturn(new ObjectMapper().readTree(body));
|
||||
assertThatThrownBy(() -> client.isAppActivatedStrict(18, 7L)).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,28 @@ class D11AssigneeEligibilityIT extends FlowTestSupport {
|
||||
public MutableStubUserClient stubUserClient() {
|
||||
return new MutableStubUserClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源主体解析桩(F02):真实实现要走 UserBase 应用注册表,本 IT 只测人员资格,
|
||||
* 因此把"验签 appKey → 稳定主体"固定成同一来源;来源分区语义由 FlSourceIsolationTest 覆盖。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public FlowSourcePrincipalResolver stubSourceResolver() {
|
||||
return new FlowSourcePrincipalResolver(null) {
|
||||
@Override
|
||||
public Principal resolve(String authenticatedAppKey) {
|
||||
return new Principal(SOURCE, 100001, authenticatedAppKey, "notice");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 许可闸桩(F01):本 IT 的靶子是人员资格终审,不是授权链;fail-closed 由 FlUsageGrantTest 覆盖 */
|
||||
@Bean
|
||||
@Primary
|
||||
public FlowUsageGrantGuard stubGrantGuard() {
|
||||
return org.mockito.Mockito.mock(FlowUsageGrantGuard.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** 可在用例中动态停用某人的 UserBase 桩(模拟流程启动后人员状态变化)。 */
|
||||
@@ -349,7 +371,7 @@ class D11AssigneeEligibilityIT extends FlowTestSupport {
|
||||
item.setUserIds(Arrays.asList(userIds));
|
||||
body.setAssigns(Collections.singletonList(item));
|
||||
}
|
||||
return oapiFlowService.apply(SCH, null, body);
|
||||
return oapiFlowService.apply(SCH, "100001", body);
|
||||
}
|
||||
|
||||
private String usersDef(Integer... userIds) {
|
||||
|
||||
@@ -172,6 +172,8 @@ class FlApplyAssignAndVariableTest {
|
||||
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwBranchRepository.class), engine,
|
||||
mock(org.springframework.context.ApplicationEventPublisher.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(lifecycle, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(lifecycle, "creationGrants", mock(FlowUsageGrantGuard.class));
|
||||
OapiFlowService service = new OapiFlowService(defs, instances,
|
||||
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwCcRepository.class),
|
||||
@@ -179,6 +181,13 @@ class FlApplyAssignAndVariableTest {
|
||||
mock(FlowTaskService.class), engine, mock(com.yunhai.flowcenter.engine.AssigneeResolver.class), mapper,
|
||||
mock(OpenApiUserClient.class), mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class),
|
||||
mock(DiagramTokenService.class), mock(org.springframework.beans.factory.ObjectProvider.class));
|
||||
// F01/F02:可信来源从验签上下文解析;同 token 的回执分支不得触许可闸(撤权不断已受理回执)
|
||||
FlowSourcePrincipalResolver principals = mock(FlowSourcePrincipalResolver.class);
|
||||
when(principals.resolve("app"))
|
||||
.thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18", 18, "app", null));
|
||||
ReflectionTestUtils.setField(service, "sourcePrincipalResolver", principals);
|
||||
FlowUsageGrantGuard grantGuard = mock(FlowUsageGrantGuard.class);
|
||||
service.setUsageGrantGuard(grantGuard);
|
||||
com.yunhai.flowcenter.entity.FlwDef old = new com.yunhai.flowcenter.entity.FlwDef();
|
||||
old.setId(9L); old.setFlowGuid("g"); old.setVersion(1); old.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[]}");
|
||||
com.yunhai.flowcenter.entity.FlwDef current = new com.yunhai.flowcenter.entity.FlwDef();
|
||||
@@ -192,7 +201,9 @@ class FlApplyAssignAndVariableTest {
|
||||
FlowInstanceService.ApplyCommand original = new FlowInstanceService.ApplyCommand(1L, old, "biz", "7", null, null,
|
||||
null, 201, null, null, "app", null, null);
|
||||
row.setApplyPayloadHash(ReflectionTestUtils.invokeMethod(service, "payloadHash", original));
|
||||
when(instances.findBySchIdAndBizTypeAndBizId(1L,"biz","7")).thenReturn(java.util.Optional.of(row));
|
||||
row.setSourcePrincipalId("ub-app:18");
|
||||
when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
1L, "ub-app:18", "biz", "7", 1)).thenReturn(java.util.Optional.of(row));
|
||||
when(instances.findById(500L)).thenReturn(java.util.Optional.of(row));
|
||||
com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 request = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2();
|
||||
request.setFlowGuid("g"); request.setBizType("biz"); request.setBizId("7"); request.setSubmitterId(201);
|
||||
@@ -213,6 +224,11 @@ class FlApplyAssignAndVariableTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("newRequired");
|
||||
Mockito.verify(instances, Mockito.never()).save(Mockito.any());
|
||||
Mockito.verifyNoInteractions(engine);
|
||||
// 回执恢复(同 token 的 v2 分支)不回读许可表;只有"会建新实例/新轮次"的 v1 路径才过闸一次
|
||||
Mockito.verify(principals, Mockito.atLeastOnce()).resolve("app");
|
||||
Mockito.verify(grantGuard, Mockito.times(1))
|
||||
.requireNewApplyGrant(Mockito.any(), Mockito.any(),
|
||||
Mockito.any(FlowSourcePrincipalResolver.Principal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -51,10 +51,11 @@ import static org.mockito.Mockito.when;
|
||||
class FlApplyRecoveryTest {
|
||||
|
||||
private static final long SCH = 1L;
|
||||
private static final String SOURCE = "11111111-1111-1111-1111-111111111111";
|
||||
private static final String BIZ_TYPE = "svc-hall";
|
||||
private static final String BIZ_ID = "42";
|
||||
private static final String FLOW_GUID = "fg-1";
|
||||
/** 可信来源主体(D11/F02):业务坐标的分区维度,由服务端从验签 appKey 解析 */
|
||||
private static final String SOURCE = "ub-app:18";
|
||||
private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@@ -78,6 +79,8 @@ class FlApplyRecoveryTest {
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
service = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
|
||||
branchRepository, flowEngine, eventPublisher);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", mock(FlowUsageGrantGuard.class));
|
||||
|
||||
FlwDef def = def(1L, FLOW_GUID, 3);
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def));
|
||||
@@ -96,7 +99,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
||||
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -113,7 +116,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
||||
void sameTokenOnApprovingInstanceIsSameRequest() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
@@ -125,7 +128,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
|
||||
void retryOnlyNeverRestartsTerminal() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -142,7 +145,7 @@ class FlApplyRecoveryTest {
|
||||
// T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 →
|
||||
// CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。
|
||||
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(approving));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
|
||||
@@ -162,7 +165,7 @@ class FlApplyRecoveryTest {
|
||||
void sameTokenDifferentPayloadIsConflict() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
existing.setApplyPayloadHash("hash-a");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A);
|
||||
@@ -180,7 +183,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)")
|
||||
void serverAllocatesNextRoundFromExpectedPrev() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
// 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮
|
||||
@@ -190,7 +193,7 @@ class FlApplyRecoveryTest {
|
||||
assertThat(outcome.applyRound).isEqualTo(3);
|
||||
|
||||
// expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例)
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(
|
||||
instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018")));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B),
|
||||
@@ -205,7 +208,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
||||
void higherRoundRestartsPerExistingR4Semantics() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -225,7 +228,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
||||
void staleRoundIsRejectedForRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
@@ -238,7 +241,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
|
||||
void legacyRowWithoutRoundDefaultsToGuarded() {
|
||||
FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(legacy));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
||||
@@ -255,7 +258,8 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
||||
void otherApplicationSameBizKeyIsRefused() {
|
||||
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
foreign.setSourcePrincipalId("ub-app:20");
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(foreign));
|
||||
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
@@ -268,14 +272,14 @@ class FlApplyRecoveryTest {
|
||||
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
otherSubmitter.setSubmitterId(2002);
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(otherSubmitter));
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提交者");
|
||||
|
||||
FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(otherFlow));
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
@@ -287,7 +291,7 @@ class FlApplyRecoveryTest {
|
||||
@Test
|
||||
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
||||
void createsWhenAbsentAndStampsIdentity() {
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.<FlwInstance>empty());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -308,7 +312,7 @@ class FlApplyRecoveryTest {
|
||||
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
||||
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.<FlwInstance>empty())
|
||||
.thenReturn(Optional.of(existing));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
@@ -322,24 +326,23 @@ class FlApplyRecoveryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧 apply 同来源终态仍按 R4 重启,并记录服务端请求身份")
|
||||
void legacyApplyRetainsRestartButStampsTrustedIdentity() {
|
||||
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
|
||||
void legacyApplyPathIsUnchanged() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
|
||||
|
||||
assertThat(outcome.isDuplicated()).isTrue();
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
assertThat(existing.getApplyRequestToken()).isNotBlank();
|
||||
assertThat(existing.getApplyRequestToken()).isNull();
|
||||
assertThat(existing.getApplyRound()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
|
||||
FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018");
|
||||
row.setSourcePrincipalId(null); // This existing test exercises the legacy callback fixture, not source admission.
|
||||
row.setVersion(3);
|
||||
when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
|
||||
com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
|
||||
@@ -372,11 +375,11 @@ class FlApplyRecoveryTest {
|
||||
|
||||
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
||||
private FlowInstanceService.ApplyCommand cmd(String token) {
|
||||
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
|
||||
FlowInstanceService.ApplyCommand c = new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
|
||||
"张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
|
||||
"张三", "{}", "100018", "svc-hall", null);
|
||||
command.sourcePrincipalId = SOURCE;
|
||||
return command;
|
||||
c.sourcePrincipalId = SOURCE;
|
||||
return c;
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
||||
@@ -407,7 +410,6 @@ class FlApplyRecoveryTest {
|
||||
instance.setBizId(BIZ_ID);
|
||||
instance.setStatus(status);
|
||||
instance.setApplyAppKey(appKey);
|
||||
instance.setSourcePrincipalId("100020".equals(appKey) ? "22222222-2222-2222-2222-222222222222" : SOURCE);
|
||||
instance.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
instance.setSubmitterId(1001);
|
||||
|
||||
@@ -76,13 +76,17 @@ class FlDefLineOfflineTest {
|
||||
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
|
||||
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
|
||||
mock(DiagramTokenService.class), mock(ObjectProvider.class));
|
||||
FlowSourcePrincipalResolver resolver = mock(FlowSourcePrincipalResolver.class);
|
||||
when(resolver.resolve(anyString())).thenReturn(new FlowSourcePrincipalResolver.Principal(
|
||||
"11111111-1111-1111-1111-111111111111", 1, "100018", "test"));
|
||||
ReflectionTestUtils.setField(oapiService, "sourcePrincipalResolver", resolver);
|
||||
oapiService.setUsageGrantGuard(mock(FlowUsageGrantGuard.class));
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
|
||||
.thenReturn(Optional.of(def(9, 3, 0)));
|
||||
// F01/F02:来源主体解析与使用许可闸在生产装配里必然存在;单测手工构造服务时必须显式注入
|
||||
// 测试替身,否则 apply 会因"闸门未装配/来源不可解析"被 fail-closed 拒(那是期望行为,不是靶子)。
|
||||
FlowSourcePrincipalResolver stubPrincipals = mock(FlowSourcePrincipalResolver.class);
|
||||
when(stubPrincipals.resolve(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(new FlowSourcePrincipalResolver.Principal(
|
||||
"ub-app:18", 18, "100018", "flow-test"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(oapiService, "sourcePrincipalResolver", stubPrincipals);
|
||||
oapiService.setUsageGrantGuard(mock(FlowUsageGrantGuard.class));
|
||||
|
||||
}
|
||||
|
||||
// ==================== 线级停发/恢复(管理面委托) ====================
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.AssigneeRef;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.DefValidator;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.base.model.DepartmentEntry;
|
||||
import com.yunhai.platform.openapi.base.model.DepartmentRef;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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.when;
|
||||
|
||||
/**
|
||||
* UserBase 部门权威化与审批主体规则(工作包 C,2026-10-29)。
|
||||
*
|
||||
* <p>测试替身边界:{@link OpenApiGroupClient}/{@link OpenApiUserClient} 按 UserBase
|
||||
* {@code /oapi/departments/**} 的真实契约打桩(响应形状=SDK 的 DepartmentEntry/BoaUser),
|
||||
* <b>这不是跨服务联调</b>——真实 UserBase 可达性、SYSTEM/CUSTOM 数据齐备性由后续 WorkBuddy 验证。</p>
|
||||
*
|
||||
* <p>钉住的红线:</p>
|
||||
* <ul>
|
||||
* <li>部门是<b>审批授权主体</b>,不是"前端筛出来的一堆人";解析出的候选仍逐人过 D11 资格终审;</li>
|
||||
* <li>一个用户可属多个部门 → 多部门引用按用户去重,只发一条待办;</li>
|
||||
* <li>目录不可用一律抛错,<b>不静默退回 flw_dept</b>,也不把空候选当成功;</li>
|
||||
* <li>展示口径(显示人员/显示部门)与审批主体分开配置并独立生效。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@DisplayName("UserBase 部门主体解析 + 展示规则(C1/C2/C3)")
|
||||
class FlDepartmentSubjectTest {
|
||||
@Test void foreignSchoolReferenceCannotExposeMembers() {
|
||||
assertThatThrownBy(() -> directory.resolve(SCH,
|
||||
Arrays.asList(new DepartmentRef((int) SCH + 1, SYSTEM, 42))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("学校");
|
||||
DepartmentEntry foreign = entry(SYSTEM, 42, true, false);
|
||||
foreign.setSchoolId((int) SCH + 1); foreign.setName("foreign");
|
||||
when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(foreign));
|
||||
assertThatThrownBy(() -> directory.membersAtSchool(SCH, SYSTEM, 42, "select"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("学校");
|
||||
org.mockito.Mockito.verify(groupClient, org.mockito.Mockito.never()).getDepartmentMembers(any(), any(), any());
|
||||
}
|
||||
|
||||
private static final long SCH = 5L;
|
||||
private static final String SYSTEM = "SYSTEM";
|
||||
private static final String CUSTOM = "CUSTOM";
|
||||
|
||||
private OpenApiGroupClient groupClient;
|
||||
private OpenApiUserClient userClient;
|
||||
private UserBaseDepartmentDirectory directory;
|
||||
private AssigneeResolver resolver;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
groupClient = mock(OpenApiGroupClient.class);
|
||||
userClient = mock(OpenApiUserClient.class);
|
||||
directory = new UserBaseDepartmentDirectory(groupClient);
|
||||
when(groupClient.resolveDepartments(any())).thenAnswer(call -> {
|
||||
java.util.List<DepartmentRef> refs = call.getArgument(0);
|
||||
if (refs == null) return java.util.Collections.emptyList();
|
||||
java.util.List<DepartmentEntry> rows = new java.util.ArrayList<>();
|
||||
for (DepartmentRef ref : refs) {
|
||||
DepartmentEntry e = entry(ref.getDepartmentType(), ref.getDepartmentId(), true, false);
|
||||
e.setSchoolId(ref.getSchoolId()); e.setName("department"); rows.add(e);
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient);
|
||||
resolver.setDepartmentDirectory(directory);
|
||||
givenEligibleUsers(101, 102, 103, 104);
|
||||
}
|
||||
|
||||
// ==================== C1 权威目录接入 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("DEPT 主体从 UserBase 目录解析候选人(scope=select 严档,不读 flw_dept_member)")
|
||||
void departmentSubjectResolvesFromUserBase() {
|
||||
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
|
||||
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
|
||||
|
||||
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)));
|
||||
|
||||
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102);
|
||||
verify(groupClient).getDepartmentMembers(SYSTEM, 42, "select");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一用户归属多个被选部门:只产出一条待办(多组归属不是模型禁忌)")
|
||||
void overlappingMembershipDeduplicatesUser() {
|
||||
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
|
||||
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
|
||||
when(groupClient.getDepartmentMembers(CUSTOM, 7, "select"))
|
||||
.thenReturn(Arrays.asList(user(102, "李老师"), user(103, "王老师")));
|
||||
|
||||
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
|
||||
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
|
||||
assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(SYSTEM, 42), deptRef(CUSTOM, 7)));
|
||||
|
||||
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(assignee));
|
||||
|
||||
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102, 103);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录不可用:拒绝并说明原因,绝不退回 FL 本地科室(双权威 fallback 已禁)")
|
||||
void directoryOutageFailsClosed() {
|
||||
when(groupClient.getDepartmentMembers(anyString(), anyInt(), anyString()))
|
||||
.thenThrow(new RuntimeException("userbase unreachable"));
|
||||
FlowDefModel.Node node = approvalNode(deptAssignee(SYSTEM, 42));
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(SCH, node))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不可用");
|
||||
// 未装配目录通道同样拒绝,而不是回读本地表
|
||||
AssigneeResolver unwired = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient);
|
||||
assertThatThrownBy(() -> unwired.resolve(SCH, node))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("未装配");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("部门解析出 0 人:按「无可用办理人」拒绝,不把空候选当成功")
|
||||
void emptyDepartmentIsRejected() {
|
||||
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")).thenReturn(new ArrayList<>());
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("无可用办理人");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("部门里全是禁用/跨校账号:整体拒绝并点名原因(存在≠有资格,D11)")
|
||||
void ineligibleMembersAreFilteredAndEmptyIsRejected() {
|
||||
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
|
||||
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
|
||||
when(userClient.getUserById("101")).thenReturn(disabled(101));
|
||||
when(userClient.getUserById("102")).thenReturn(otherSchool(102));
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不具备办理资格");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("部分成员不合格:剔除不合格者继续,不因个别人卡死整条流程")
|
||||
void partiallyIneligibleKeepsEligibleOnes() {
|
||||
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
|
||||
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
|
||||
when(userClient.getUserById("102")).thenReturn(disabled(102));
|
||||
|
||||
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)));
|
||||
|
||||
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("部门引用缺类型消歧位:拒绝(同值 id 在 SYSTEM/CUSTOM 不等价)")
|
||||
void untypedReferenceRejected() {
|
||||
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
|
||||
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
|
||||
FlowDefModel.DeptRef bad = new FlowDefModel.DeptRef();
|
||||
bad.departmentId = 42;
|
||||
assignee.deptRefs = new ArrayList<>(Arrays.asList(bad));
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(assignee)))
|
||||
.isInstanceOf(BizException.class);
|
||||
|
||||
assertThatThrownBy(() -> directory.members("OTHER", 42, "select"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("SYSTEM/CUSTOM");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史口径与选择口径分开:resolve-batch 取停用部门名称,select 不返回停用项")
|
||||
void historyAndSelectionScopesAreSeparate() {
|
||||
when(groupClient.getDepartmentMembers(CUSTOM, 9, "history"))
|
||||
.thenReturn(Arrays.asList(user(103, "王老师")));
|
||||
assertThat(directory.members(CUSTOM, 9, "history"))
|
||||
.extracting(BoaUser::getId).containsExactly(103);
|
||||
|
||||
DepartmentEntry deleted = new DepartmentEntry();
|
||||
deleted.setDepartmentType(CUSTOM);
|
||||
deleted.setDepartmentId(9);
|
||||
deleted.setName("已撤销的课题组");
|
||||
deleted.setSelectable(false);
|
||||
deleted.setDeleted(true);
|
||||
deleted.setSchoolId((int) SCH);
|
||||
when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(deleted));
|
||||
|
||||
List<DepartmentEntry> resolved = directory.resolve(SCH,
|
||||
Arrays.asList(new DepartmentRef(null, CUSTOM, 9)));
|
||||
|
||||
assertThat(resolved).hasSize(1);
|
||||
assertThat(resolved.get(0).getName()).isEqualTo("已撤销的课题组");
|
||||
assertThat(resolved.get(0).getSelectable()).isFalse();
|
||||
// 学校范围由服务端会话派生并补进引用,不接受前端传别校 id
|
||||
assertThat(resolved.get(0).getSchoolId()).isEqualTo((int) SCH);
|
||||
ArgumentCaptor<List<DepartmentRef>> sent = ArgumentCaptor.forClass(List.class);
|
||||
verify(groupClient).resolveDepartments(sent.capture());
|
||||
assertThat(sent.getValue().get(0).getSchoolId()).as("校域由服务端会话派生并补进引用").isEqualTo((int) SCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发布校验白名单来自目录 selectable 项,停用部门不可被新选中")
|
||||
void selectableKeysOnlyCatalogSelectable() {
|
||||
DepartmentEntry live = entry(SYSTEM, 42, true, false);
|
||||
DepartmentEntry stopped = entry(SYSTEM, 43, false, false);
|
||||
DepartmentEntry softDeleted = entry(CUSTOM, 9, true, true);
|
||||
when(groupClient.getDepartments(eq((int) SCH), any(), any(), any()))
|
||||
.thenReturn(Arrays.asList(live, stopped, softDeleted));
|
||||
|
||||
assertThat(directory.selectableKeys(SCH)).containsExactly("SYSTEM:42");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空引用批量解析本地短路,不出站也不报错")
|
||||
void emptyResolveBatchShortCircuits() {
|
||||
assertThat(directory.resolve(SCH, new ArrayList<>())).isEmpty();
|
||||
verify(groupClient, never()).resolveDepartments(any());
|
||||
}
|
||||
|
||||
// ==================== C3 展示规则 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("显示部门:任务节点名取独立展示名;未配展示字段=沿用节点名(历史定义零变化)")
|
||||
void displayLabelDrivesTaskNodeName() throws Exception {
|
||||
FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
|
||||
FlowDefModel.Node legacy = approvalNode(deptAssignee(SYSTEM, 42));
|
||||
assertThat(legacy.displayName()).isEqualTo("二级审批");
|
||||
|
||||
legacy.displayMode = FlowDefModel.DISPLAY_MODE_DEPT;
|
||||
legacy.displayLabel = "校长室";
|
||||
assertThat(legacy.displayName()).isEqualTo("校长室");
|
||||
|
||||
// 按人员展示时不受 displayLabel 影响(只展示本节点自己配置/解析到的人)
|
||||
legacy.displayMode = FlowDefModel.DISPLAY_MODE_USER;
|
||||
assertThat(legacy.displayName()).isEqualTo("二级审批");
|
||||
assertThat(model).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发布校验:显示部门必须填展示名;未知 displayMode 取值拒绝")
|
||||
void publishRejectsMissingDisplayLabel() {
|
||||
FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42));
|
||||
model.nodes.get(1).displayMode = FlowDefModel.DISPLAY_MODE_DEPT;
|
||||
|
||||
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
|
||||
new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("展示名称");
|
||||
|
||||
model.nodes.get(1).displayMode = "BOTH";
|
||||
model.nodes.get(1).displayLabel = "校长室";
|
||||
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
|
||||
new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("displayMode");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发布校验:部门不在本校可选目录内(跨校/停用/已删)即拦;引用齐备则放行")
|
||||
void publishChecksDepartmentAgainstDirectoryWhitelist() {
|
||||
FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42));
|
||||
|
||||
DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42")));
|
||||
|
||||
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
|
||||
new java.util.HashSet<>(Arrays.asList("SYSTEM:99"))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不在本校可选部门目录内");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("会签/或签对部门主体与人员主体同样可配(D07/D08 主体定候选,策略定完成条件)")
|
||||
void countersignConfigurableForBothSubjects() {
|
||||
Set<String> whitelist = new java.util.HashSet<>(Arrays.asList("SYSTEM:42"));
|
||||
// 部门主体 + 或签:合法(会签/或签不是只对部门生效)
|
||||
FlowDefModel deptModel = twoLevelModel(deptAssignee(SYSTEM, 42));
|
||||
deptModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY;
|
||||
DefValidator.validate(deptModel, null, null, whitelist);
|
||||
assertThat(deptModel.nodes.get(2).countersign).isEqualTo(FlowDefModel.COUNTERSIGN_ANY);
|
||||
|
||||
// 人员主体 + 或签:同样合法
|
||||
FlowDefModel usersModel = twoLevelModel(usersAssignee(101, 102));
|
||||
usersModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY;
|
||||
DefValidator.validate(usersModel, null, null, whitelist);
|
||||
|
||||
// 非法策略值:两类主体都拦
|
||||
deptModel.nodes.get(2).countersign = "MOST";
|
||||
assertThatThrownBy(() -> DefValidator.validate(deptModel, null, null, whitelist))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("countersign");
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private void givenEligibleUsers(int... ids) {
|
||||
for (int id : ids) {
|
||||
when(userClient.getUserById(String.valueOf(id))).thenReturn(sameSchool(id, "教师" + id));
|
||||
}
|
||||
}
|
||||
|
||||
private FlowDefModel.Node approvalNode(FlowDefModel.Assignee assignee) {
|
||||
FlowDefModel.Node node = new FlowDefModel.Node();
|
||||
node.id = "n2";
|
||||
node.type = FlowDefModel.TYPE_APPROVAL;
|
||||
node.name = "二级审批";
|
||||
node.assignee = assignee;
|
||||
return node;
|
||||
}
|
||||
|
||||
private FlowDefModel twoLevelModel(FlowDefModel.Assignee assignee) {
|
||||
FlowDefModel model = FlowDefModel.parse(mapper, "{\"start\":\"start\",\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}");
|
||||
FlowDefModel.Node deptNode = new FlowDefModel.Node();
|
||||
deptNode.id = "n2";
|
||||
deptNode.type = FlowDefModel.TYPE_APPROVAL;
|
||||
deptNode.name = "二级审批";
|
||||
deptNode.assignee = assignee;
|
||||
model.nodes.add(2, deptNode);
|
||||
model.edges.add(edge("n1", "n2"));
|
||||
model.edges.add(edge("n2", "end"));
|
||||
// 一级→end 的原边由 n1→n2 取代
|
||||
model.edges.removeIf(e -> "n1".equals(e.from) && "end".equals(e.to));
|
||||
return model;
|
||||
}
|
||||
|
||||
private FlowDefModel.Edge edge(String from, String to) {
|
||||
FlowDefModel.Edge e = new FlowDefModel.Edge();
|
||||
e.from = from;
|
||||
e.to = to;
|
||||
return e;
|
||||
}
|
||||
|
||||
private FlowDefModel.Assignee deptAssignee(String type, int id) {
|
||||
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
|
||||
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
|
||||
assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(type, id)));
|
||||
return assignee;
|
||||
}
|
||||
|
||||
private FlowDefModel.Assignee usersAssignee(int... ids) {
|
||||
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
|
||||
assignee.type = FlowDefModel.ASSIGNEE_USERS;
|
||||
List<Integer> list = new ArrayList<>();
|
||||
for (int id : ids) {
|
||||
list.add(id);
|
||||
}
|
||||
assignee.userIds = list;
|
||||
return assignee;
|
||||
}
|
||||
|
||||
private FlowDefModel.DeptRef deptRef(String type, int id) {
|
||||
FlowDefModel.DeptRef ref = new FlowDefModel.DeptRef();
|
||||
ref.departmentType = type;
|
||||
ref.departmentId = id;
|
||||
return ref;
|
||||
}
|
||||
|
||||
private DepartmentEntry entry(String type, int id, boolean enabled, boolean deleted) {
|
||||
DepartmentEntry e = new DepartmentEntry();
|
||||
e.setSchoolId((int) SCH);
|
||||
e.setDepartmentType(type);
|
||||
e.setDepartmentId(id);
|
||||
e.setName(type + "-" + id);
|
||||
e.setEnabled(enabled);
|
||||
e.setDeleted(deleted);
|
||||
e.setSelectable(enabled && !deleted);
|
||||
return e;
|
||||
}
|
||||
|
||||
private BoaUser user(int id, String name) {
|
||||
return sameSchool(id, name);
|
||||
}
|
||||
|
||||
private BoaUser sameSchool(int id, String name) {
|
||||
BoaUser u = new BoaUser();
|
||||
u.setId(id);
|
||||
u.setName(name);
|
||||
u.setStatus(1);
|
||||
com.yunhai.platform.utils.model.BoaGroup school = new com.yunhai.platform.utils.model.BoaGroup();
|
||||
school.setId((int) SCH);
|
||||
u.setSchool(school);
|
||||
return u;
|
||||
}
|
||||
|
||||
private BoaUser disabled(int id) {
|
||||
BoaUser u = sameSchool(id, "停用账号");
|
||||
u.setStatus(0);
|
||||
return u;
|
||||
}
|
||||
|
||||
private BoaUser otherSchool(int id) {
|
||||
BoaUser u = sameSchool(id, "他校账号");
|
||||
u.getSchool().setId((int) SCH + 1);
|
||||
return u;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwAppRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 流程编码自动生成与归属应用可选(A3,2026-10-29)。
|
||||
*
|
||||
* <p>D02:定义归属、使用许可、实例来源是三个概念。此前 {@code save} 硬拦"归属应用必填"
|
||||
* (2026-10-12 拍板)与"code/name 必填",把内部编码变成管理员的手填项。现:</p>
|
||||
* <ul>
|
||||
* <li>新建不传 code → 后端生成稳定唯一码;传合法 code(旧客户端)→ 原样保留,不破坏既有调用方;</li>
|
||||
* <li>更新/升版本不传 code → 继承既有编码,编码不随名称或编辑变化;</li>
|
||||
* <li>appKey 缺省合法;显式传入仍须在本校已开启应用白名单内。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@DisplayName("流程编码后端生成 + 归属应用可选(A3/D02)")
|
||||
class FlFlowCodeTest {
|
||||
|
||||
private static final long SCH = 3L;
|
||||
private static final String EXISTING_CODE = "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwAppRepository appRepository;
|
||||
private FlowDefAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
appRepository = mock(FlwAppRepository.class);
|
||||
service = new FlowDefAdminService(defRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
|
||||
appRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
new ObjectMapper(),
|
||||
mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class));
|
||||
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
|
||||
}
|
||||
|
||||
// ==================== code 自动生成 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("新建不传 code:后端生成 flow- 前缀 + 32 位 hex,且与 flowGuid 是两个概念")
|
||||
void newFlowGetsGeneratedCode() {
|
||||
FlwDef saved = service.save(SCH, null, null, "加班审批", contentJson(), null, null, null);
|
||||
|
||||
assertThat(saved.getCode()).startsWith("flow-");
|
||||
assertThat(saved.getCode()).matches("flow-[0-9a-f]{32}");
|
||||
assertThat(saved.getFlowGuid()).isNotEmpty();
|
||||
assertThat(saved.getCode()).isNotEqualTo(saved.getFlowGuid());
|
||||
assertThat(saved.getVersion()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("批量新建:编码互不相同(唯一性不依赖名称,也不依赖数据库唯一键)")
|
||||
void generatedCodesAreDistinct() {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
FlwDef saved = service.save(SCH, null, "", "同名流程", contentJson(), null, null, null);
|
||||
assertThat(seen.add(saved.getCode())).as("第 " + i + " 次生成撞码:" + saved.getCode()).isTrue();
|
||||
}
|
||||
assertThat(seen).hasSize(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧客户端显式提交合法 code:原样保留(不因新 UI 不展示而破坏既有调用方)")
|
||||
void explicitCodeStillAccepted() {
|
||||
FlwDef saved = service.save(SCH, null, "leave-annual", "年假", contentJson(), null, null, null);
|
||||
|
||||
assertThat(saved.getCode()).isEqualTo("leave-annual");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("改名不重置编码:草稿直改后 code 与 flowGuid 都不变")
|
||||
void renameDoesNotRegenerateCode() {
|
||||
FlwDef existing = draft(80L, "flow-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "旧名称");
|
||||
when(defRepository.findById(80L)).thenReturn(Optional.of(existing));
|
||||
|
||||
FlwDef saved = service.save(SCH, 80L, null, "新名称", contentJson(), null, null, null);
|
||||
|
||||
assertThat(saved.getCode()).isEqualTo("flow-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
|
||||
assertThat(saved.getName()).isEqualTo("新名称");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("升版本继承编码:新版本行与源版本同一 code,flowGuid 不变、版本 +1")
|
||||
void versionBumpInheritsCode() {
|
||||
FlwDef published = published(81L, "flow-ffffffffffffffffffffffffffffffff", "已发布线");
|
||||
when(defRepository.findById(81L)).thenReturn(Optional.of(published));
|
||||
when(defRepository.maxVersionByGuid(SCH, published.getFlowGuid())).thenReturn(4);
|
||||
|
||||
FlwDef saved = service.save(SCH, 81L, null, "已发布线", contentJson(), null, null, null);
|
||||
|
||||
assertThat(saved.getCode()).isEqualTo("flow-ffffffffffffffffffffffffffffffff");
|
||||
assertThat(saved.getFlowGuid()).isEqualTo(published.getFlowGuid());
|
||||
assertThat(saved.getVersion()).isEqualTo(5);
|
||||
assertThat(saved.getStatus()).isEqualTo(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("显式提交不同编码:仍按「流程编码不可修改」拒绝(编码稳定,不随编辑漂移)")
|
||||
void codeChangeOnExistingLineRejected() {
|
||||
FlwDef existing = draft(82L, EXISTING_CODE, "草稿");
|
||||
when(defRepository.findById(82L)).thenReturn(Optional.of(existing));
|
||||
|
||||
assertThatThrownBy(() -> service.save(SCH, 82L, "completely-other-code", "草稿",
|
||||
contentJson(), null, null, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("编码不可修改");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("升版本走 baseGuid:不传 code 时继承源线编码")
|
||||
void baseGuidBranchInheritsCode() {
|
||||
FlwDef base = published(83L, "flow-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "基线");
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, base.getFlowGuid()))
|
||||
.thenReturn(Optional.of(base));
|
||||
when(defRepository.maxVersionByGuid(SCH, base.getFlowGuid())).thenReturn(7);
|
||||
|
||||
FlwDef saved = service.save(SCH, null, null, "基线", contentJson(), base.getFlowGuid(), null, null);
|
||||
|
||||
assertThat(saved.getCode()).isEqualTo("flow-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
assertThat(saved.getVersion()).isEqualTo(8);
|
||||
}
|
||||
|
||||
// ==================== appKey 可选 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("归属应用留空可以保存:定义不绑定唯一消费应用(D02)")
|
||||
void nullAppKeyIsLegal() {
|
||||
FlwDef saved = service.save(SCH, null, null, "无归属流程", contentJson(), null, null, null);
|
||||
|
||||
assertThat(saved.getAppKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空白 appKey 归一为 null,不落「看起来有值实际无效」的字符串")
|
||||
void blankAppKeyNormalizedToNull() {
|
||||
FlwDef saved = service.save(SCH, null, null, "无归属流程", contentJson(), null, " ", null);
|
||||
|
||||
assertThat(saved.getAppKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("显式归属应用仍须在本校已开启白名单内(可选≠任意)")
|
||||
void explicitAppKeyStillWhitelisted() {
|
||||
when(appRepository.isEnabledApp(SCH, "not-enabled")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.save(SCH, null, null, "流程", contentJson(), null, "not-enabled", null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("归属应用未开启或不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("名称仍是必填项:只放开 code/appKey,不放开身份性字段")
|
||||
void nameStillRequired() {
|
||||
assertThatThrownBy(() -> service.save(SCH, null, null, " ", contentJson(), null, null, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("名称");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生成规则:flow- 前缀 + 规范 UUID hex,长度落在 code 列 64 之内")
|
||||
void generatedCodeFitsColumn() {
|
||||
for (int i = 0; i < 50; i++) {
|
||||
String code = FlowDefAdminService.generateFlowCode();
|
||||
assertThat(code).hasSizeLessThanOrEqualTo(64).matches("flow-[0-9a-f]{32}");
|
||||
// 去前缀后必须还能按 UUID 解析:碰撞域就是 UUID 的 122 位随机空间
|
||||
UUID.fromString(code.substring("flow-".length()).replaceFirst(
|
||||
"(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)",
|
||||
"$1-$2-$3-$4-$5"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private String contentJson() {
|
||||
return "{\"nodes\":[{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"END\",\"name\":\"结束\"}],\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}]}";
|
||||
}
|
||||
|
||||
private FlwDef draft(Long id, String code, String name) {
|
||||
return row(id, code, name, FlwEnums.DefStatus.DRAFT.getCode(), 1);
|
||||
}
|
||||
|
||||
private FlwDef published(Long id, String code, String name) {
|
||||
return row(id, code, name, FlwEnums.DefStatus.PUBLISHED.getCode(), 4);
|
||||
}
|
||||
|
||||
private FlwDef row(Long id, String code, String name, int status, int version) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(id);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid("guid-" + id);
|
||||
def.setCode(code);
|
||||
def.setName(name);
|
||||
def.setVersion(version);
|
||||
def.setStatus(status);
|
||||
def.setContentJson(contentJson());
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class FlNewInstancePathTest {
|
||||
FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
|
||||
assignees, mock(ConditionRouteService.class), publisher);
|
||||
FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(apply, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(apply, "creationGrants", mock(FlowUsageGrantGuard.class));
|
||||
FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
|
||||
assignees, engine, publisher);
|
||||
FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
|
||||
@@ -81,7 +83,8 @@ class FlNewInstancePathTest {
|
||||
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
|
||||
1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
|
||||
null, "100001", "notice", null);
|
||||
command.sourcePrincipalId = "11111111-1111-1111-1111-111111111111";
|
||||
// D11/F02:业务坐标必须带可信来源主体(由服务端从验签 appKey 解析,测试里显式给定)
|
||||
command.sourcePrincipalId = "ub-app:100001";
|
||||
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
|
||||
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
|
||||
assertThat(outcome.duplicated).isFalse();
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
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.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* D14「退回发起人修改后重提」(工作包 B,2026-10-29)。
|
||||
*
|
||||
* <p>钉住的四条语义(决策原文:同一实例、同一申报轮次、历史记录保留、从第一级重新审批):</p>
|
||||
* <ol>
|
||||
* <li>退回后办件进入 {@code 50 退回待修改}——非终态,且与"拒绝终止/撤回/终态后重报"互不冒充;</li>
|
||||
* <li>重提沿用同一 {@code instanceId} 与同一 {@code applyRound},不建新实例、不自增轮次;</li>
|
||||
* <li>历史任务/意见/留痕行一律保留,重提只<b>追加</b>新一代任务;</li>
|
||||
* <li>身份与归属由服务端复核:非发起人不能代提,重复点击/网络重试不产生第二代任务。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>mock 边界:仓储、引擎协作、事件发布器。没有真实事务/数据库锁/提交后回调,
|
||||
* 并发只验证到"状态判定 + 引擎调用次数"这一层,真实事务由后续跨服务联调补证。</p>
|
||||
*/
|
||||
@DisplayName("D14 退回发起人修改 + 同实例同轮次重提")
|
||||
class FlReturnToSubmitterTest {
|
||||
|
||||
private static final long SCH = 9L;
|
||||
private static final Long INSTANCE_ID = 700L;
|
||||
private static final Long TASK_ID = 900L;
|
||||
private static final int SUBMITTER = 1001;
|
||||
private static final int APPROVER = 2002;
|
||||
private static final String NODE = "n2";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlwInstanceRepository instanceRepository;
|
||||
private FlwBranchRepository branchRepository;
|
||||
private FlwDefRepository defRepository;
|
||||
private AssigneeResolver assigneeResolver;
|
||||
private FlowEngine flowEngine;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private FlowTaskService taskService;
|
||||
private FlowInstanceService instanceService;
|
||||
|
||||
private FlwInstance instance;
|
||||
private FlwDef def;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
instanceRepository = mock(FlwInstanceRepository.class);
|
||||
branchRepository = mock(FlwBranchRepository.class);
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
assigneeResolver = mock(AssigneeResolver.class);
|
||||
flowEngine = mock(FlowEngine.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
taskService = new FlowTaskService(mapper, taskRepository, instanceRepository, branchRepository,
|
||||
defRepository, assigneeResolver, flowEngine, eventPublisher);
|
||||
instanceService = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
|
||||
branchRepository, flowEngine, eventPublisher);
|
||||
|
||||
instance = approvingInstance();
|
||||
def = levelDef();
|
||||
when(instanceRepository.findById(INSTANCE_ID)).thenReturn(Optional.of(instance));
|
||||
when(instanceRepository.findFormalPreviousForUpdate(INSTANCE_ID)).thenReturn(Optional.of(instance));
|
||||
when(instanceRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(instanceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(pendingTask()));
|
||||
when(taskRepository.claimAndFinish(eq(TASK_ID), any(), any(), anyString(), any())).thenReturn(1);
|
||||
when(defRepository.findById(60L)).thenReturn(Optional.of(def));
|
||||
when(flowEngine.parseModel(any())).thenReturn(model());
|
||||
}
|
||||
|
||||
// ==================== 审批人:退回发起人修改 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("退回发起人:抢占到 RETURNED、整条在途代次失效、办件转 50 且留出原因")
|
||||
void returnToSubmitterParksInstanceAndInvalidatesCohort() {
|
||||
taskService.returnToSubmitter(SCH, TASK_ID, "材料不齐,请补充附件", APPROVER);
|
||||
|
||||
verify(taskRepository).claimAndFinish(eq(TASK_ID), eq(FlwEnums.TaskStatus.RETURNED.getCode()),
|
||||
any(), org.mockito.ArgumentMatchers.contains("退回发起人修改"), any());
|
||||
verify(taskRepository).invalidatePendingByInstance(INSTANCE_ID);
|
||||
verify(branchRepository).invalidateRunningByInstance(INSTANCE_ID);
|
||||
assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
assertThat(instance.getCurrentNodes()).isEmpty();
|
||||
assertThat(instance.getFinishTime()).as("50 不是终态,不得写办结时间").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退回发起人发的是既有 RETURNED 事件,用 returnTo=SUBMITTER 表达去向(不新增事件码)")
|
||||
void returnToSubmitterReusesReturnedEventCode() {
|
||||
taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER);
|
||||
|
||||
ArgumentCaptor<FlowEvent> event = ArgumentCaptor.forClass(FlowEvent.class);
|
||||
verify(eventPublisher).publishEvent(event.capture());
|
||||
FlowEvent published = event.getValue();
|
||||
assertThat(published.getType()).isEqualTo(FlowEvent.Type.RETURNED);
|
||||
assertThat(published.getData().get("returnTo")).isEqualTo("SUBMITTER");
|
||||
assertThat(published.getData().get("targetNodeId"))
|
||||
.isEqualTo(FlwEnums.TaskStatus.NODE_RETURN_SUBMITTER);
|
||||
assertThat(published.getData().get("instanceStatus"))
|
||||
.isEqualTo(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退回发起人必须填原因:缺失即拒,且不动任务也不动办件")
|
||||
void returnToSubmitterRequiresOpinion() {
|
||||
assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, " ", APPROVER))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("退回原因");
|
||||
verify(taskRepository, never()).claimAndFinish(anyLong(), any(), any(), any(), any());
|
||||
assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非本人办理权 / 非待办任务:一律拒绝(沿用既有抢占纪律)")
|
||||
void returnToSubmitterRejectsForeignOrHandledTask() {
|
||||
assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", 9999))
|
||||
.isInstanceOf(BizException.class);
|
||||
|
||||
FlwTask handled = pendingTask();
|
||||
handled.setStatus(FlwEnums.TaskStatus.APPROVED.getCode());
|
||||
when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(handled));
|
||||
assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("办件已终态:不能再退回发起人")
|
||||
void returnToSubmitterRejectsTerminalInstance() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
|
||||
assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审批中");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复点击/并发第二次抢不到办理权:拒绝且不产生第二代失效动作")
|
||||
void doubleReturnIsRejectedByClaim() {
|
||||
when(taskRepository.claimAndFinish(eq(TASK_ID), any(), any(), anyString(), any())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("已被办理");
|
||||
verify(taskRepository, never()).invalidatePendingByInstance(anyLong());
|
||||
}
|
||||
|
||||
// ==================== 发起人:重提 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("重提=同实例同轮次:不新建、不自增 applyRound、从 START 重新推进、历史行保留")
|
||||
void resubmitReusesSameInstanceAndRound() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
instance.setApplyRound(2);
|
||||
instance.setDefId(60L);
|
||||
List<FlwTask> history = historyRows();
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(INSTANCE_ID)).thenReturn(history);
|
||||
|
||||
Long returnedId = instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null);
|
||||
|
||||
assertThat(returnedId).isEqualTo(INSTANCE_ID);
|
||||
assertThat(instance.getId()).isEqualTo(INSTANCE_ID);
|
||||
assertThat(instance.getApplyRound()).as("D14 不增加申报轮次").isEqualTo(2);
|
||||
assertThat(instance.getDefId()).as("在途实例仍固定发起时版本(D04/D05)").isEqualTo(60L);
|
||||
assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
// 重新推进用的是实例钉住的那份定义,从 START 起=第一级审批节点重建一代任务
|
||||
verify(flowEngine, times(1)).startAdvance(instance, def);
|
||||
// 历史任务行没有被删除或被改写
|
||||
assertThat(history).hasSize(3);
|
||||
assertThat(history.get(0).getStatus()).isEqualTo(FlwEnums.TaskStatus.APPROVED.getCode());
|
||||
verify(taskRepository, never()).delete(any());
|
||||
// 追加一条"修改后重新提交"留痕行
|
||||
ArgumentCaptor<FlwTask> trace = ArgumentCaptor.forClass(FlwTask.class);
|
||||
verify(taskRepository, times(1)).save(trace.capture());
|
||||
assertThat(trace.getValue().getNodeId()).isEqualTo(FlwEnums.TaskStatus.NODE_RESUBMIT);
|
||||
assertThat(trace.getValue().getStatus()).isEqualTo(FlwEnums.TaskStatus.SUBMITTED.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非发起人不能代提(应用签名/管理员身份都不算)")
|
||||
void resubmitRejectsNonSubmitter() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
|
||||
assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, 5555, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("发起人本人");
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复提交/网络重试:已是审批中=回原实例幂等收口,不再产生第二代任务")
|
||||
void duplicateResubmitIsIdempotentReceipt() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
FlwTask trace = new FlwTask();
|
||||
trace.setNodeId(FlwEnums.TaskStatus.NODE_RESUBMIT); trace.setNodeName("修改后重新提交");
|
||||
when(taskRepository.findFirstByInstanceIdAndStatusOrderByIdDesc(INSTANCE_ID,
|
||||
FlwEnums.TaskStatus.SUBMITTED.getCode())).thenReturn(Optional.of(trace));
|
||||
|
||||
assertThat(instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null))
|
||||
.isEqualTo(INSTANCE_ID);
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
verify(taskRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test void ordinaryApprovingInstanceIsNotAResubmitReceipt() {
|
||||
instance.setStatus(10);
|
||||
assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("退回待修改");
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
verify(taskRepository, never()).save(any());
|
||||
verify(instanceRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("终态件不能走 D14 重提:拒绝终止/撤回与退回待修改是不同动作")
|
||||
void terminalInstanceCannotResubmit() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
|
||||
assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("退回待修改");
|
||||
|
||||
instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
|
||||
assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回传新的表单快照才覆盖,未传即沿用原快照(FL 不做业务字段 Diff)")
|
||||
void resubmitKeepsFormDataWhenNotSupplied() {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
instance.setFormDataJson("{\"days\":3}");
|
||||
|
||||
instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null);
|
||||
assertThat(instance.getFormDataJson()).isEqualTo("{\"days\":3}");
|
||||
|
||||
instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode());
|
||||
instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, "{\"days\":5}");
|
||||
assertThat(instance.getFormDataJson()).isEqualTo("{\"days\":5}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退回发起人 ≠ 普通退回到审批节点:returnBack 仍只接受审批节点目标")
|
||||
void normalReturnBackStillTargetsApprovalNodes() {
|
||||
FlwDef withNodes = def;
|
||||
when(flowEngine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper,
|
||||
"{\"nodes\":[{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\"},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"二级\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}"));
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(INSTANCE_ID)).thenReturn(historyRows());
|
||||
|
||||
taskService.returnBack(SCH, TASK_ID, "n1", "再看一下", APPROVER);
|
||||
|
||||
assertThat(instance.getStatus()).as("普通退回后办件仍是审批中,不是 50")
|
||||
.isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(flowEngine).returnTo(eq(instance), eq(withNodes), eq(NODE), eq("n1"), any());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private FlwInstance approvingInstance() {
|
||||
FlwInstance row = new FlwInstance();
|
||||
row.setId(INSTANCE_ID);
|
||||
row.setSchId(SCH);
|
||||
row.setDefId(60L);
|
||||
row.setDefCode("flow-d14");
|
||||
row.setDefVersion(1);
|
||||
row.setBizType("d14");
|
||||
row.setBizId("1");
|
||||
row.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
row.setCurrentNodes(NODE);
|
||||
row.setSubmitterId(SUBMITTER);
|
||||
row.setSubmitterName("张三");
|
||||
row.setApplyAppKey("100018");
|
||||
row.setSourcePrincipalId("ub-app:18");
|
||||
row.setLegacySlot(1);
|
||||
row.setVersion(0);
|
||||
return row;
|
||||
}
|
||||
|
||||
private FlwTask pendingTask() {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setId(TASK_ID);
|
||||
task.setInstanceId(INSTANCE_ID);
|
||||
task.setSchId(SCH);
|
||||
task.setNodeId(NODE);
|
||||
task.setNodeName("二级审批");
|
||||
task.setAssigneeId(APPROVER);
|
||||
task.setAssigneeName("李四");
|
||||
task.setStatus(FlwEnums.TaskStatus.PENDING.getCode());
|
||||
return task;
|
||||
}
|
||||
|
||||
/** 退回前已存在的历史行:一级同意、二级退回人本人待办、外加一条更早的通过行 */
|
||||
private List<FlwTask> historyRows() {
|
||||
List<FlwTask> rows = new ArrayList<>();
|
||||
rows.add(historyTask(1L, "n1", FlwEnums.TaskStatus.APPROVED.getCode()));
|
||||
rows.add(historyTask(2L, "n1", FlwEnums.TaskStatus.APPROVED.getCode()));
|
||||
rows.add(historyTask(TASK_ID, NODE, FlwEnums.TaskStatus.RETURNED.getCode()));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private FlwTask historyTask(Long id, String nodeId, int status) {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setId(id);
|
||||
task.setInstanceId(INSTANCE_ID);
|
||||
task.setSchId(SCH);
|
||||
task.setNodeId(nodeId);
|
||||
task.setNodeName("节点" + nodeId);
|
||||
task.setAssigneeId(APPROVER);
|
||||
task.setAssigneeName("李四");
|
||||
task.setStatus(status);
|
||||
return task;
|
||||
}
|
||||
|
||||
private FlwDef levelDef() {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(60L);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid("fg-d14");
|
||||
def.setCode("flow-d14");
|
||||
def.setName("D14 测试线");
|
||||
def.setVersion(1);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson(modelJson());
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlowDefModel model() {
|
||||
return FlowDefModel.parse(mapper, modelJson());
|
||||
}
|
||||
|
||||
private String modelJson() {
|
||||
return "{\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\"},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"二级\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"},"
|
||||
+ "{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.*;
|
||||
import com.yunhai.flowcenter.entity.*;
|
||||
import com.yunhai.flowcenter.model.*;
|
||||
import com.yunhai.flowcenter.repository.*;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.*;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** Real OAPI, source resolver, authorization, activation client and instance service.
|
||||
* Only repositories, outbound UserBase transport, people resolver and engine are mocks.
|
||||
* No real DB/transaction/unique-index claim. */
|
||||
class FlSourceAuthorizationTest {
|
||||
static final String A="11111111-1111-1111-1111-111111111111";
|
||||
static final String B="22222222-2222-2222-2222-222222222222";
|
||||
final ObjectMapper mapper=new ObjectMapper();
|
||||
FlwInstanceRepository instances; FlwDefRepository defs; FlwUsageGrantRepository grants;
|
||||
FlwTaskRepository tasks; FlwBranchRepository branches;
|
||||
OpenApiAppClient apps; YschOpenCall outbound; UserBaseActivationClient activation;
|
||||
ApplicationEventPublisher publisher; FlowEngine engine; FlowUsageGrantGuard guard;
|
||||
FlowInstanceService lifecycle; OapiFlowService api; FlwDef def;
|
||||
Map<String,FlwInstance> rows;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
instances=mock(FlwInstanceRepository.class);defs=mock(FlwDefRepository.class);
|
||||
grants=mock(FlwUsageGrantRepository.class);tasks=mock(FlwTaskRepository.class);branches=mock(FlwBranchRepository.class);
|
||||
apps=mock(OpenApiAppClient.class);outbound=mock(YschOpenCall.class);
|
||||
activation=new UserBaseActivationClient(outbound);
|
||||
// Even an explicitly false legacy value cannot disable either gate.
|
||||
ReflectionTestUtils.setField(activation,"enforce",false);
|
||||
guard=new FlowUsageGrantGuard(grants,activation);ReflectionTestUtils.setField(guard,"appClient",apps);
|
||||
when(apps.getAppByKey(anyString())).thenAnswer(c -> mapper.readTree("{\"id\":"+("app-b".equals(c.getArgument(0))?2:1)+",\"sourcePrincipalId\":\""+("app-b".equals(c.getArgument(0))?B:A)+"\",\"appCode\":\"test\"}"));
|
||||
when(outbound.get(anyString(),anyString(),anyMap(),any())).thenAnswer(c -> {
|
||||
Map<String,String> q=c.getArgument(2);
|
||||
return mapper.readTree("{\"appId\":"+q.get("appId")+",\"schoolId\":"+q.get("schoolId")+",\"activated\":true}");
|
||||
});
|
||||
when(grants.findBySchIdAndFlowGuidAndAppId(eq(27L),eq("flow"),anyInt())).thenAnswer(c -> {
|
||||
int id=c.getArgument(2);FlwUsageGrant g=new FlwUsageGrant();g.setAppId(id);g.setSchId(27L);g.setFlowGuid("flow");g.setEnabled(1);g.setSourcePrincipalId(id==1?A:B);return Optional.of(g);
|
||||
});
|
||||
def=new FlwDef();def.setId(7L);def.setSchId(27L);def.setFlowGuid("flow");def.setCode("test");def.setVersion(1);def.setStatus(1);def.setContentJson("{\"nodes\":[],\"edges\":[]}");
|
||||
when(defs.findById(7L)).thenReturn(Optional.of(def));
|
||||
when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(27L,"flow",1)).thenReturn(Optional.of(def));
|
||||
engine=mock(FlowEngine.class);publisher=mock(ApplicationEventPublisher.class);
|
||||
when(engine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper,def.getContentJson()));
|
||||
lifecycle=new FlowInstanceService(instances,defs,tasks,branches,engine,publisher);
|
||||
api=new OapiFlowService(defs,instances,tasks,mock(FlwCcRepository.class),mock(FlwCallbackRegRepository.class),lifecycle,mock(FlowTaskService.class),engine,mock(AssigneeResolver.class),mapper,mock(OpenApiUserClient.class),apps,mock(DiagramTokenService.class),mock(ObjectProvider.class));
|
||||
api.setUsageGrantGuard(guard);
|
||||
ReflectionTestUtils.setField(api,"sourcePrincipalResolver",new FlowSourcePrincipalResolver(apps));
|
||||
rows=new HashMap<>();AtomicLong seq=new AtomicLong(100);
|
||||
when(instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(anyLong(),anyString(),anyString(),anyString(),eq(1)))
|
||||
.thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1))));
|
||||
when(instances.saveAndFlush(any())).thenAnswer(c -> {FlwInstance i=c.getArgument(0);if(i.getId()==null)i.setId(seq.incrementAndGet());rows.put(i.getSourcePrincipalId(),i);return i;});
|
||||
when(instances.findById(anyLong())).thenAnswer(c -> rows.values().stream().filter(i -> i.getId().equals(c.getArgument(0))).findFirst());
|
||||
when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(anyLong(),anyString(),anyString(),anyString()))
|
||||
.thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1))));
|
||||
}
|
||||
OapiModels.ApplyBody body() {
|
||||
OapiModels.ApplyBody r=new OapiModels.ApplyBody();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);return r;
|
||||
}
|
||||
FlowApplyRetryModels.ApplyRequestV2 v2(String token) {
|
||||
FlowApplyRetryModels.ApplyRequestV2 r=new FlowApplyRetryModels.ApplyRequestV2();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);r.setRequestToken(token);r.setAttempt(1);return r;
|
||||
}
|
||||
void noWrites() {verify(instances,never()).saveAndFlush(any());verifyNoInteractions(tasks,branches,engine,publisher);}
|
||||
@Test void bothGatesRequiredAndSameSourceIsIdempotent() {
|
||||
long a=api.apply(27L,"app-a",body()).getInstanceId();
|
||||
assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a);
|
||||
assertThat(rows).hasSize(1);verify(engine,times(1)).startAdvance(any(),any());
|
||||
assertThat(rows.get(A).getSourcePrincipalId()).isEqualTo(A);
|
||||
}
|
||||
@Test void distinctSourcesCreateIndependentInstancesAndNeverRestartOthers() {
|
||||
long a=api.apply(27L,"app-a",body()).getInstanceId();rows.get(A).setStatus(30);
|
||||
long b=api.apply(27L,"app-b",body()).getInstanceId();
|
||||
assertThat(b).isNotEqualTo(a);assertThat(rows.get(A).getStatus()).isEqualTo(30);
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a);
|
||||
assertThat(rows.get(A).getStatus()).isEqualTo(10);
|
||||
assertThat(rows.get(B).getId()).isEqualTo(b);
|
||||
verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString());
|
||||
}
|
||||
@Test void missingGrantRejectsWithoutAnySideEffects() {
|
||||
when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void inactiveOrRevokedSchoolRejectsEvenWithGrant() throws Exception {
|
||||
when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":1,\"schoolId\":27,\"activated\":false}"));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void unavailableAuthorityRejects() {
|
||||
when(outbound.get(anyString(),anyString(),anyMap(),any())).thenThrow(new RuntimeException("offline"));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void malformedAuthorityResponseRejects() throws Exception {
|
||||
when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":2,\"schoolId\":27,\"activated\":true}"));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void missingSchoolSourceOrGuardRejects() {
|
||||
assertThatThrownBy(() -> api.apply(null,"app-a",body())).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> api.apply(0L,"app-a",body())).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> api.apply(27L,null,body())).isInstanceOf(BizException.class);
|
||||
api.setUsageGrantGuard(null);
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void sourceLookupUnavailableOrUnknownRejects() throws Exception {
|
||||
when(apps.getAppByKey("app-a")).thenReturn(mapper.readTree("{\"id\":1}"));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);
|
||||
when(apps.getAppByKey("app-a")).thenThrow(new RuntimeException("offline"));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void mismatchedGrantSourceRejects() {
|
||||
FlwUsageGrant g=new FlwUsageGrant();g.setEnabled(1);g.setSourcePrincipalId(B);
|
||||
when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.of(g));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void v2ExactReceiptSurvivesRevocationButNewCommandDoesNot() {
|
||||
long id=api.applyV2(27L,"app-a",v2("token-1")).getInstanceId();
|
||||
when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty());
|
||||
assertThat(api.applyV2(27L,"app-a",v2("token-1")).getInstanceId()).isEqualTo(id);
|
||||
assertThatThrownBy(() -> api.applyV2(27L,"app-a",v2("new-token"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> api.applyV2(27L,"app-b",v2("token-1"))).isInstanceOf(BizException.class);
|
||||
}
|
||||
@Test void v2IndependentSourcesAndConflictRetryUseScopedLookup() {
|
||||
long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId();
|
||||
long b=api.applyV2(27L,"app-b",v2("b-token")).getInstanceId();assertThat(a).isNotEqualTo(b);
|
||||
verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString());
|
||||
}
|
||||
@Test void v1ConflictRecoveryCannotReturnOtherSourcesRow() {
|
||||
long a=api.apply(27L,"app-a",body()).getInstanceId();
|
||||
doAnswer(new org.mockito.stubbing.Answer<FlwInstance>() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) {
|
||||
if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i;
|
||||
}}).when(instances).saveAndFlush(any());
|
||||
assertThat(api.apply(27L,"app-b",body()).getInstanceId()).isNotEqualTo(a);
|
||||
}
|
||||
@Test void otherSchoolOrFlowCannotUseGrant() {
|
||||
assertThatThrownBy(() -> api.apply(28L,"app-a",body())).isInstanceOf(BizException.class);
|
||||
OapiModels.ApplyBody r=body();r.setFlowGuid("not-granted");
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",r)).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
@Test void v2ConflictRetryNeverRecoversAnotherSource() {
|
||||
long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId();
|
||||
doAnswer(new org.mockito.stubbing.Answer<FlwInstance>() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) {
|
||||
if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i;
|
||||
}}).when(instances).saveAndFlush(any());
|
||||
FlowApplyRetryModels.ApplyResultV2 result=api.applyV2(27L,"app-b",v2("b-token"));
|
||||
assertThat(result.getInstanceId()).isNotEqualTo(a);
|
||||
assertThat(result.isDuplicated()).isFalse();
|
||||
}
|
||||
@Test void businessReadsAndExplicitInstanceAccessAreSourceScopedEvenWithFormalModeOff() throws Exception {
|
||||
long a=api.apply(27L,"app-a",body()).getInstanceId();
|
||||
long b=api.apply(27L,"app-b",body()).getInstanceId();
|
||||
FlowOapiSourceScope scope=new FlowOapiSourceScope(new FlowSourcePrincipalResolver(apps),instances,tasks);
|
||||
org.springframework.mock.web.MockHttpServletRequest req=new org.springframework.mock.web.MockHttpServletRequest("GET","/oapi/flow/instance");
|
||||
req.setAttribute("yh.app.auth","app-b");
|
||||
scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object());
|
||||
org.springframework.web.context.request.RequestContextHolder.setRequestAttributes(new org.springframework.web.context.request.ServletRequestAttributes(req));
|
||||
try {assertThat(api.instanceByBiz(27L,"type","same-id").getInstanceId()).isEqualTo(b);}
|
||||
finally {org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes();}
|
||||
req.setParameter("instanceId",String.valueOf(a));
|
||||
assertThatThrownBy(() -> scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object())).isInstanceOf(BizException.class);
|
||||
}
|
||||
@Test void v1UsesExistingCallbackIdentityFieldsWithoutChangingEnvelopeContract() {
|
||||
api.apply(27L,"app-a",body());
|
||||
assertThat(rows.get(A).getApplyRequestToken()).isNotBlank();
|
||||
assertThat(rows.get(A).getApplyPayloadHash()).hasSize(64);
|
||||
assertThat(rows.get(A).getApplyRound()).isEqualTo(1);
|
||||
}
|
||||
@Test void legacyWriterCannotDuplicateAnExistingFormalCoordinate() {
|
||||
FlwInstance formal=new FlwInstance();formal.setId(99L);formal.setSourcePrincipalId(A);formal.setLegacySlot(null);
|
||||
when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(27L,A,"type","same-id"))
|
||||
.thenReturn(Optional.of(formal));
|
||||
assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
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.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 跨应用业务键隔离(A2/F02,2026-10-29)。
|
||||
*
|
||||
* <p>D11 的业务对象身份=<b>可信来源应用</b>+学校+bizType+bizId。旧实现按裸坐标
|
||||
* {@code findBySchIdAndBizTypeAndBizId(schId,bizType,bizId)} 复用实例,于是应用 B 用与
|
||||
* 应用 A 相同的业务键会拿到 A 的 instanceId 并以 {@code duplicated=true} 当成功返回。</p>
|
||||
*
|
||||
* <p>本类用真实 {@code FlowInstanceService}(mock 仅仓储/引擎/发布器)钉住三条事实:</p>
|
||||
* <ol>
|
||||
* <li>同来源同业务键 → 同一个实例(合法幂等重投不被打断);</li>
|
||||
* <li>异来源同业务键 → 各自建独立实例,<b>绝不回读对方 id</b>;</li>
|
||||
* <li>缺可信来源 → 直接拒,不"猜归属"也不建归属不明的行。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@DisplayName("业务坐标来源分区:同来源幂等、跨来源隔离(F02/D11)")
|
||||
class FlSourceIsolationTest {
|
||||
@Test void credentialRotationDoesNotChangeTheSourceIdentity() {
|
||||
FlwInstance existing = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
existing.setApplyAppKey("retired-key"); given(SOURCE_A, existing);
|
||||
assertThat(service.apply(command(SOURCE_A)).getInstanceId()).isEqualTo(500L);
|
||||
verify(instanceRepository, never()).saveAndFlush(any());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
@Test void missingCreationGuardCannotCreateOrRestart() {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", null);
|
||||
assertThatThrownBy(() -> service.apply(command(SOURCE_A))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("门禁");
|
||||
verify(instanceRepository, never()).saveAndFlush(any());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
@Test void formalHistoryCannotBeBypassedThroughLegacySlot() {
|
||||
when(instanceRepository.formalIdsForUpdate(SCH, SOURCE_A, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(java.util.Collections.singletonList(700L));
|
||||
assertThatThrownBy(() -> service.apply(command(SOURCE_A))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("正式命令");
|
||||
assertThatThrownBy(() -> service.applyGuarded(command(SOURCE_A),
|
||||
new FlowInstanceService.ApplyRetryPolicy("new", 1, false))).isInstanceOf(BizException.class);
|
||||
verify(instanceRepository, never()).saveAndFlush(any());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
private static final long SCH = 7L;
|
||||
private static final String BIZ_TYPE = "notice";
|
||||
private static final String BIZ_ID = "3301";
|
||||
private static final String FLOW_GUID = "fg-source";
|
||||
private static final String SOURCE_A = "ub-app:11";
|
||||
private static final String SOURCE_B = "ub-app:22";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private FlwInstanceRepository instanceRepository;
|
||||
private FlwDefRepository defRepository;
|
||||
private FlowEngine flowEngine;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private FlowInstanceService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
instanceRepository = mock(FlwInstanceRepository.class);
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
flowEngine = mock(FlowEngine.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
service = new FlowInstanceService(instanceRepository, defRepository,
|
||||
mock(FlwTaskRepository.class), mock(FlwBranchRepository.class), flowEngine, eventPublisher);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", mock(FlowUsageGrantGuard.class));
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(publishedDef()));
|
||||
when(flowEngine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}"));
|
||||
when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> {
|
||||
FlwInstance row = call.getArgument(0);
|
||||
row.setId(row.getId() == null ? 1000L + System.nanoTime() % 1000 : row.getId());
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同来源、同业务键、在途:回读原实例(合法幂等重投仍生效)")
|
||||
void sameSourceReturnsOriginalInstance() {
|
||||
FlwInstance existing = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
given(SOURCE_A, existing);
|
||||
|
||||
FlowInstanceService.ApplyOutcome outcome = service.apply(command(SOURCE_A));
|
||||
|
||||
assertThat(outcome.getInstanceId()).isEqualTo(500L);
|
||||
assertThat(outcome.isDuplicated()).isTrue();
|
||||
verify(instanceRepository, never()).saveAndFlush(any(FlwInstance.class));
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("异来源、同学校同业务键:本来源无行→建独立实例,不泄漏 A 的 instanceId")
|
||||
void otherSourceBuildsItsOwnInstance() {
|
||||
FlwInstance instanceOfA = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
SCH, SOURCE_B, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.empty());
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
SCH, SOURCE_A, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.of(instanceOfA));
|
||||
|
||||
FlowInstanceService.ApplyOutcome outcomeOfB = service.apply(command(SOURCE_B));
|
||||
|
||||
assertThat(outcomeOfB.getInstanceId()).isNotEqualTo(500L);
|
||||
assertThat(outcomeOfB.isDuplicated()).isFalse();
|
||||
ArgumentCaptor<FlwInstance> created = ArgumentCaptor.forClass(FlwInstance.class);
|
||||
verify(instanceRepository).saveAndFlush(created.capture());
|
||||
assertThat(created.getValue().getSourcePrincipalId()).isEqualTo(SOURCE_B);
|
||||
assertThat(created.getValue().getLegacySlot()).isEqualTo(1);
|
||||
// A 的实例一个字节都没被动过
|
||||
assertThat(instanceOfA.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(instanceRepository, never()).findById(500L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺可信来源:拒绝,既不建件也不按裸坐标回读他人实例")
|
||||
void missingSourceFailsClosed() {
|
||||
assertThatThrownBy(() -> service.apply(command(null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("来源");
|
||||
assertThatThrownBy(() -> service.apply(command(" ")))
|
||||
.isInstanceOf(BizException.class);
|
||||
verify(instanceRepository, never()).saveAndFlush(any(FlwInstance.class));
|
||||
verify(instanceRepository, never())
|
||||
.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2 守卫路径同样按来源分区:缺来源即拒,异来源建独立实例")
|
||||
void guardedApplyIsAlsoSourceScoped() {
|
||||
assertThatThrownBy(() -> service.applyGuarded(command(null),
|
||||
new FlowInstanceService.ApplyRetryPolicy("tok-1", 1, false)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("来源");
|
||||
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
eq(SCH), eq(SOURCE_B), eq(BIZ_TYPE), eq(BIZ_ID), eq(1))).thenReturn(Optional.empty());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome = service.applyGuarded(command(SOURCE_B),
|
||||
new FlowInstanceService.ApplyRetryPolicy("tok-1", 1, false));
|
||||
|
||||
assertThat(outcome.duplicated).isFalse();
|
||||
assertThat(outcome.branch).isEqualTo("CREATED");
|
||||
ArgumentCaptor<FlwInstance> created = ArgumentCaptor.forClass(FlwInstance.class);
|
||||
verify(instanceRepository).saveAndFlush(created.capture());
|
||||
assertThat(created.getValue().getSourcePrincipalId()).isEqualTo(SOURCE_B);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同来源终态件重报:走既有 R4 重启推进(复用同一行,不是新实例)")
|
||||
void sameSourceTerminalRestartKeepsSameRow() {
|
||||
FlwInstance terminal = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
given(SOURCE_A, terminal);
|
||||
|
||||
FlowInstanceService.ApplyOutcome outcome = service.apply(command(SOURCE_A));
|
||||
|
||||
assertThat(outcome.getInstanceId()).isEqualTo(500L);
|
||||
assertThat(outcome.isDuplicated()).isTrue();
|
||||
assertThat(terminal.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
ArgumentCaptor<FlwInstance> flushed = ArgumentCaptor.forClass(FlwInstance.class);
|
||||
verify(instanceRepository).saveAndFlush(flushed.capture());
|
||||
assertThat(flushed.getValue()).as("复用同一行,不新建实例").isSameAs(terminal);
|
||||
verify(flowEngine, times(1)).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private void given(String source, FlwInstance existing) {
|
||||
when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
SCH, source, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.of(existing));
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyCommand command(String source) {
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
SCH, publishedDef(), BIZ_TYPE, BIZ_ID, "测试事项", "/notice/detail?id=3301", "{}",
|
||||
1001, "张三", "{}", "100022", "notice", null);
|
||||
cmd.sourcePrincipalId = source;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private FlwDef publishedDef() {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(60L);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(FLOW_GUID);
|
||||
def.setCode("flow-abc");
|
||||
def.setName("隔离测试线");
|
||||
def.setVersion(2);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson("{\"nodes\":[],\"edges\":[]}");
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwInstance ownedInstance(Long id, String source, int status) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(id);
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(60L);
|
||||
instance.setDefCode("flow-abc");
|
||||
instance.setDefVersion(2);
|
||||
instance.setBizType(BIZ_TYPE);
|
||||
instance.setBizId(BIZ_ID);
|
||||
instance.setStatus(status);
|
||||
instance.setSourcePrincipalId(source);
|
||||
instance.setLegacySlot(1);
|
||||
instance.setSubmitterId(1001);
|
||||
instance.setSubmitterName("张三");
|
||||
instance.setVersion(0);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,24 +30,27 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.assertj.core.api.ThrowableAssert.catchThrowableOfType;
|
||||
|
||||
/**
|
||||
* 应用使用许可闸与恢复感知判定的单测(2026-10-25 T01)。
|
||||
* 应用使用许可闸(T01 + F01 整改 2026-10-29)。
|
||||
*
|
||||
* <p>钉住的不变量:</p>
|
||||
* <ul>
|
||||
* <li><b>新发起必须有生效许可</b>:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;</li>
|
||||
* <li><b>已受理命令回执可恢复</b>:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类
|
||||
* 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);</li>
|
||||
* <li><b>appKey 不是主体</b>:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。</li>
|
||||
* </ul>
|
||||
* <p>本轮钉住的核心变化:<b>闸门不再有任何直通分支</b>。原
|
||||
* {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"灰度开关关闭 /
|
||||
* 闸门未装配 / 缺验签主体"三种情况静默放行,正是 Codex 终审 F01 指出的 P1。
|
||||
* 现在 {@code yh.flow.school-activation-enforce} 这个开关已从代码里删除,四类缺证一律拒绝。</p>
|
||||
*
|
||||
* <p>同时保留 P0-3 的收口顺序:<b>已受理命令的回执恢复不回读本表</b>——撤权不能没收已经受理的
|
||||
* 申报回执(判定逻辑见 {@code OapiFlowService#legacyApplyIsReceiptOnly / guardedApplyIsReceiptOnly},
|
||||
* 由 {@code FlApplyAssignAndVariableTest} 与本类下方用例分别钉住)。</p>
|
||||
*/
|
||||
@DisplayName("应用使用许可闸 FlowUsageGrantGuard + apply 恢复感知判定(T01)")
|
||||
@DisplayName("应用使用许可闸:fail-closed + 恢复感知(F01)")
|
||||
class FlUsageGrantTest {
|
||||
|
||||
private static final long SCH = 27L;
|
||||
private static final String FLOW = "fg-1";
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final int APP_ID = 5001;
|
||||
|
||||
private FlwUsageGrantRepository grantRepository;
|
||||
private OpenApiAppClient appClient;
|
||||
@@ -63,28 +67,69 @@ class FlUsageGrantTest {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
activationClient = new UserBaseActivationClient(openCall);
|
||||
guard = new FlowUsageGrantGuard(grantRepository, activationClient);
|
||||
ReflectionTestUtils.setField(guard, "appClient", appClient);
|
||||
ReflectionTestUtils.setField(activationClient, "enforce", true);
|
||||
}
|
||||
|
||||
// ==================== Guard:新发起资格 ====================
|
||||
// ==================== 主体解析 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("enforce 关闭仍不得绕过授权")
|
||||
void enforceOffCannotBypassAuthorization() {
|
||||
ReflectionTestUtils.setField(activationClient, "enforce", false);
|
||||
@DisplayName("appKey → UserBase 稳定主体:来源串挂在该主体上,不直接使用 appKey")
|
||||
void resolvesStablePrincipal() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isInstanceOf(BizException.class);
|
||||
verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt());
|
||||
FlowSourcePrincipalResolver.Principal source =
|
||||
new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY);
|
||||
|
||||
assertThat(source.appId).isEqualTo(APP_ID);
|
||||
assertThat(source.id).isEqualTo(FlowSourcePrincipalResolver.SOURCE_PREFIX + APP_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺验签 appKey:拒绝(不降级为「无来源」放行)")
|
||||
void blankAppKeyRejected() {
|
||||
FlowSourcePrincipalResolver resolver = new FlowSourcePrincipalResolver(appClient);
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(null)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> resolver.resolve(" ")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("UserBase 查无该 appKey:拒绝(凭据索引不构成主体)")
|
||||
void unresolvablePrincipalRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("主体");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("UserBase 出站异常:拒绝而不是放行")
|
||||
void principalLookupFailureRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenThrow(new RuntimeException("connection refused"));
|
||||
|
||||
assertThatThrownBy(() -> new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不可用");
|
||||
}
|
||||
|
||||
// ==================== 许可闸 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("有生效许可 + 本校已开通:放行,不再出站重复解析主体")
|
||||
void grantedAndActivatedPasses() {
|
||||
givenGrant(grant(APP_ID, 1));
|
||||
stubActivation(APP_ID, true);
|
||||
|
||||
guard.requireNewApplyGrant(SCH, FLOW, principal());
|
||||
|
||||
verify(grantRepository).findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无许可行:拒绝新发起")
|
||||
void noGrantRowRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY))
|
||||
givenGrant(null);
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("使用许可");
|
||||
}
|
||||
@@ -92,71 +137,95 @@ class FlUsageGrantTest {
|
||||
@Test
|
||||
@DisplayName("许可已撤销(enabled=0):拒绝新发起")
|
||||
void revokedGrantRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001))
|
||||
.thenReturn(Optional.of(grant(5001, 0)));
|
||||
givenGrant(grant(APP_ID, 0));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY))
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("使用许可");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有生效许可且该校开通:放行并返回稳定主体 appId")
|
||||
void grantedAndActivatedPasses() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001))
|
||||
.thenReturn(Optional.of(grant(5001, 1)));
|
||||
stubActivation(5001, true);
|
||||
|
||||
assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isEqualTo(5001);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("持本地许可但未在学校开通(平台 schId=0 平台资源同口径):拒绝")
|
||||
@DisplayName("持本地许可但未在该校开通:拒绝(grant 与学校开通是两重的独立事实)")
|
||||
void grantedButNotActivatedRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001))
|
||||
.thenReturn(Optional.of(grant(5001, 1)));
|
||||
stubActivation(5001, false);
|
||||
givenGrant(grant(APP_ID, 1));
|
||||
stubActivation(APP_ID, false);
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY))
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("开通");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("UserBase 资格核验不可达:fail-closed 拒绝(不降级为仅本地 grant 即放行)")
|
||||
@DisplayName("UserBase 开通核验不可达:fail-closed(不降级为「仅本地 grant 即放行」)")
|
||||
void activationUnreachableFailClosed() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001))
|
||||
.thenReturn(Optional.of(grant(5001, 1)));
|
||||
givenGrant(grant(APP_ID, 1));
|
||||
when(openCall.get(anyString(), anyString(), any(), any()))
|
||||
.thenThrow(new RuntimeException("connection refused"));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY))
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appKey 解析不出稳定主体:拒绝(凭据索引不构成主体)")
|
||||
void unresolvablePrincipalRejected() {
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(null);
|
||||
@DisplayName("开通核验返回异常形状:同样拒绝,不当成未开通也不当成已开通")
|
||||
void activationMalformedResponseFailClosed() {
|
||||
givenGrant(grant(APP_ID, 1));
|
||||
when(openCall.get(anyString(), anyString(), any(), any()))
|
||||
.thenReturn(mapper.valueToTree(new HashMap<String, Object>()));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("主体");
|
||||
BizException failure = catchThrowableOfType(
|
||||
() -> guard.requireNewApplyGrant(SCH, FLOW, principal()), BizException.class);
|
||||
|
||||
assertThat(failure).isNotNull();
|
||||
assertThat(failure.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ==================== apply 恢复感知判定(OapiFlowService 纯函数) ====================
|
||||
@Test
|
||||
@DisplayName("缺学校/流程线/主体任一:拒绝(判定条件不齐不等于放行)")
|
||||
void incompleteCoordinatesRejected() {
|
||||
givenGrant(grant(APP_ID, 1));
|
||||
FlowSourcePrincipalResolver.Principal source = principal();
|
||||
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(null, FLOW, source))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, " ", source))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, null))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v1 判定:在途/通过=回执放行;无实例/终态件=需许可")
|
||||
@DisplayName("旧灰度开关已从代码中删除:没有任何方法能关掉开通核验")
|
||||
void bypassSwitchIsGone() {
|
||||
boolean switchStillExists = false;
|
||||
for (java.lang.reflect.Method method : UserBaseActivationClient.class.getMethods()) {
|
||||
if ("isEnforce".equals(method.getName()) || "isAppActivatedAtSchool".equals(method.getName())) {
|
||||
switchStillExists = true;
|
||||
}
|
||||
}
|
||||
assertThat(switchStillExists)
|
||||
.as("enforce 开关方法必须已删除,否则默认关闭即可整体绕过授权(F01)")
|
||||
.isFalse();
|
||||
boolean switchFieldStillDeclared = false;
|
||||
for (java.lang.reflect.Field field : UserBaseActivationClient.class.getDeclaredFields()) {
|
||||
if ("enforce".equals(field.getName())) {
|
||||
switchFieldStillDeclared = true;
|
||||
}
|
||||
}
|
||||
assertThat(switchFieldStillDeclared).as("enforce 字段本身必须已删除").isFalse();
|
||||
}
|
||||
|
||||
// ==================== 恢复感知判定(OapiFlowService 纯函数) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("v1 判定:在途(10)/退回待修改(50)/通过(20)=回执放行;无实例/终件=需许可")
|
||||
void legacyApplyReceiptDecision() {
|
||||
assertThat(OapiFlowService.legacyApplyIsReceiptOnly(null)).isFalse();
|
||||
assertThat(OapiFlowService.legacyApplyIsReceiptOnly(
|
||||
instance(FlwEnums.InstanceStatus.APPROVING.getCode()))).isTrue();
|
||||
assertThat(OapiFlowService.legacyApplyIsReceiptOnly(
|
||||
instance(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()))).isTrue();
|
||||
assertThat(OapiFlowService.legacyApplyIsReceiptOnly(
|
||||
instance(FlwEnums.InstanceStatus.COMPLETED.getCode()))).isTrue();
|
||||
assertThat(OapiFlowService.legacyApplyIsReceiptOnly(
|
||||
@@ -166,7 +235,7 @@ class FlUsageGrantTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2 判定:同 token/retryOnly/在途/陈旧轮次=回执;无实例或更高轮次=新申报")
|
||||
@DisplayName("v2 判定:同 token/retryOnly/在途/退回待修改/陈旧轮次=回执;无实例或更高轮次=新申报")
|
||||
void guardedApplyReceiptDecision() {
|
||||
FlwInstance terminal = instance(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
terminal.setApplyRequestToken("tok-a");
|
||||
@@ -177,6 +246,8 @@ class FlUsageGrantTest {
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 9, true)).isTrue();
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(
|
||||
instance(FlwEnums.InstanceStatus.APPROVING.getCode()), "tok-b", 9, false)).isTrue();
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(
|
||||
instance(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()), "tok-b", 9, false)).isTrue();
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 2, false)).isTrue();
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 3, false)).isFalse();
|
||||
assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, null, null, false)).isTrue();
|
||||
@@ -185,36 +256,48 @@ class FlUsageGrantTest {
|
||||
// ==================== 管理面:授予/撤销 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("授予:appKey 当场解析成稳定主体落库;重申复用同一行")
|
||||
@DisplayName("授予:appKey 当场解析成稳定主体、绑定来源串并落库;重申复用同一行")
|
||||
void grantResolvesPrincipalAndUpserts() {
|
||||
// 本测主体解析与 upsert,不开资格闸(开通闸已由 Guard 用例钉住)
|
||||
ReflectionTestUtils.setField(activationClient, "enforce", false);
|
||||
FlowUsageGrantAdminService admin =
|
||||
new FlowUsageGrantAdminService(grantRepository, activationClient);
|
||||
FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient);
|
||||
ReflectionTestUtils.setField(admin, "appClient", appClient);
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty());
|
||||
when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
||||
stubActivation(5001, true);
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID));
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID)).thenReturn(Optional.empty());
|
||||
when(grantRepository.save(any())).thenAnswer(call -> call.getArgument(0));
|
||||
stubActivation(APP_ID, true);
|
||||
|
||||
FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin");
|
||||
|
||||
assertThat(saved.getAppId()).isEqualTo(5001);
|
||||
assertThat(saved.getAppId()).isEqualTo(APP_ID);
|
||||
assertThat(saved.getEnabled()).isEqualTo(1);
|
||||
assertThat(saved.getAppKeySnapshot()).isEqualTo(APP_KEY);
|
||||
assertThat(saved.getSourcePrincipalId())
|
||||
.isEqualTo(FlowSourcePrincipalResolver.sourcePrincipalIdOf(APP_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("授予:应用未在该校开通即拒(许可不发到未开通的应用上)")
|
||||
void grantRequiresSchoolActivation() {
|
||||
FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient);
|
||||
ReflectionTestUtils.setField(admin, "appClient", appClient);
|
||||
when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID));
|
||||
stubActivation(APP_ID, false);
|
||||
|
||||
assertThatThrownBy(() -> admin.grant(SCH, FLOW, APP_KEY, null, "admin"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("开通");
|
||||
verify(grantRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤销:enabled=0 行保留(审计与幂等判据不删)")
|
||||
void revokeKeepsRow() {
|
||||
FlowUsageGrantAdminService admin =
|
||||
new FlowUsageGrantAdminService(grantRepository, activationClient);
|
||||
FlwUsageGrant existing = grant(5001, 1);
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001))
|
||||
FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient);
|
||||
FlwUsageGrant existing = grant(APP_ID, 1);
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
||||
when(grantRepository.save(any())).thenAnswer(call -> call.getArgument(0));
|
||||
|
||||
FlwUsageGrant saved = admin.revoke(SCH, FLOW, 5001, "违规使用", "admin");
|
||||
FlwUsageGrant saved = admin.revoke(SCH, FLOW, APP_ID, "违规使用", "admin");
|
||||
|
||||
assertThat(saved.getEnabled()).isEqualTo(0);
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
@@ -222,10 +305,19 @@ class FlUsageGrantTest {
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private FlowSourcePrincipalResolver.Principal principal() {
|
||||
return new FlowSourcePrincipalResolver.Principal(
|
||||
FlowSourcePrincipalResolver.sourcePrincipalIdOf(APP_ID), APP_ID, APP_KEY, "flow-test");
|
||||
}
|
||||
|
||||
private void givenGrant(FlwUsageGrant grant) {
|
||||
when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID))
|
||||
.thenReturn(Optional.ofNullable(grant));
|
||||
}
|
||||
|
||||
private JsonNode registration(int appId) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", appId);
|
||||
row.put("sourcePrincipalId", "11111111-1111-1111-1111-111111111111");
|
||||
row.put("appKey", APP_KEY);
|
||||
row.put("appName", "测试应用");
|
||||
return mapper.valueToTree(row);
|
||||
@@ -246,9 +338,9 @@ class FlUsageGrantTest {
|
||||
g.setSchId(SCH);
|
||||
g.setFlowGuid(FLOW);
|
||||
g.setAppId(appId);
|
||||
g.setSourcePrincipalId(FlowSourcePrincipalResolver.sourcePrincipalIdOf(appId));
|
||||
g.setAppKeySnapshot(APP_KEY);
|
||||
g.setEnabled(enabled);
|
||||
g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111");
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -259,4 +351,5 @@ class FlUsageGrantTest {
|
||||
i.setStatus(status);
|
||||
return i;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -149,10 +149,12 @@ class FlowDefAdminServiceSaveTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建_appKey不传_必填被拦() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, null, "work-leave", "请假", JSON, null, " ", null));
|
||||
assertTrue(e.getMessage().contains("归属应用必填"));
|
||||
void 新建_appKey可选_不伪造归属() {
|
||||
FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, " ", null);
|
||||
assertNull(saved.getAppKey());
|
||||
assertEquals(SCH, saved.getSchId());
|
||||
assertEquals(1, saved.getVersion());
|
||||
verify(defRepository).save(saved);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -167,15 +169,15 @@ class FlowDefAdminServiceSaveTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 升版本_源线appKey为空且不传_必填被拦() {
|
||||
void 升版本_源线appKey为空且不传_保留独立流程线() {
|
||||
// 存量 NULL 线(D2 不兜底的 19 条):第一次升版本被强制补填——随编辑自然收敛
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G"))
|
||||
.thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode())));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4);
|
||||
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null));
|
||||
assertTrue(e.getMessage().contains("归属应用必填"));
|
||||
FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null);
|
||||
assertNull(saved.getAppKey()); assertEquals("G", saved.getFlowGuid());
|
||||
assertEquals(5, saved.getVersion()); verify(defRepository).save(saved);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,13 +191,13 @@ class FlowDefAdminServiceSaveTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 草稿直改_源行appKey为空且不传_必填被拦() {
|
||||
void 草稿直改_源行appKey为空且不传_不制造归属() {
|
||||
FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
|
||||
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null));
|
||||
assertTrue(e.getMessage().contains("归属应用必填"));
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null);
|
||||
assertSame(draft, saved); assertNull(saved.getAppKey());
|
||||
assertEquals(5, saved.getVersion()); verify(defRepository).save(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,7 +32,7 @@ import static org.mockito.Mockito.*;
|
||||
@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT,properties="flow-center.formal-command.enabled=true")
|
||||
@ActiveProfiles("test")
|
||||
class FormalApplyCommandIT extends FlowTestSupport {
|
||||
static final String A="11111111-1111-4111-8111-111111111111", B="22222222-2222-4222-8222-222222222222";
|
||||
static final String A="ub-app:18", B="ub-app:19";
|
||||
@Autowired FormalApplyCommandService service;
|
||||
@Autowired FormalPayloadCanonicalizer hashes;
|
||||
@Autowired FlwApplyCommandRepository commands;
|
||||
@@ -261,9 +261,9 @@ class FormalApplyCommandIT extends FlowTestSupport {
|
||||
}
|
||||
@Test void missingOrRecreatedSourceNeverInheritsHistoricalCommandsOrGrants() throws Exception {
|
||||
service.submit(1L,"100018",request("r"));
|
||||
doReturn(mapper.readTree("{\"id\":18}")).when(apps).getAppByKey("100018");
|
||||
doReturn(mapper.readTree("{}")).when(apps).getAppByKey("100018");
|
||||
assertThrows(RuntimeException.class,()->service.query(1L,"100018","r",null));
|
||||
doReturn(mapper.readTree("{\"id\":18,\"sourcePrincipalId\":\""+B+"\"}")).when(apps).getAppByKey("100018");
|
||||
doReturn(mapper.readTree("{\"id\":20}")).when(apps).getAppByKey("100018");
|
||||
assertEquals("NOT_FOUND",service.query(1L,"100018","r",null).getState());
|
||||
assertEquals("USAGE_DENIED",service.submit(1L,"100018",request("new")).getRejectCode());
|
||||
}
|
||||
|
||||
@@ -42,14 +42,12 @@ class OapiFlowApplyDefVersionTest {
|
||||
|
||||
private static final Long SCH_ID = 1L;
|
||||
private static final String FLOW_GUID = "g-assign";
|
||||
private static final String SOURCE_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlowInstanceService instanceService;
|
||||
private AssigneeResolver assigneeResolver;
|
||||
private OapiFlowService service;
|
||||
private FlwInstanceRepository instances;
|
||||
private OpenApiAppClient appClient;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -58,29 +56,24 @@ class OapiFlowApplyDefVersionTest {
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
assigneeResolver = mock(AssigneeResolver.class);
|
||||
instances = mock(FlwInstanceRepository.class);
|
||||
appClient = mock(OpenApiAppClient.class);
|
||||
com.fasterxml.jackson.databind.node.ObjectNode registered =
|
||||
new ObjectMapper().createObjectNode()
|
||||
.put("id", 100009)
|
||||
.put("appCode", "flow-center")
|
||||
.put("sourcePrincipalId", SOURCE_ID);
|
||||
when(appClient.getAppByKey(any())).thenReturn(registered);
|
||||
service = new OapiFlowService(defRepository, instances,
|
||||
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
|
||||
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
|
||||
mock(FlowEngine.class), assigneeResolver, new ObjectMapper(),
|
||||
mock(OpenApiUserClient.class), appClient,
|
||||
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
|
||||
mock(DiagramTokenService.class), mock(ObjectProvider.class));
|
||||
// F01:来源解析与使用许可在真实装配中由 Spring 注入,单测按同一契约显式装配
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(
|
||||
service, "sourcePrincipalResolver", new FlowSourcePrincipalResolver(appClient));
|
||||
FlowUsageGrantGuard guard = mock(FlowUsageGrantGuard.class);
|
||||
when(guard.requireNewApplyGrant(anyLong(), any(),
|
||||
any(FlowSourcePrincipalResolver.Principal.class))).thenReturn(100009);
|
||||
service.setUsageGrantGuard(guard);
|
||||
// 本校校验在单测里关闭(@Value 字段未注入=默认 false),引擎出参给一个既有办件
|
||||
when(instanceService.apply(any(FlowInstanceService.ApplyCommand.class)))
|
||||
.thenReturn(new FlowInstanceService.ApplyOutcome(500L, false));
|
||||
// F01/F02:来源主体解析与使用许可闸在生产装配里必然存在;单测手工构造服务时必须显式注入
|
||||
// 测试替身,否则 apply 会因"闸门未装配/来源不可解析"被 fail-closed 拒(那是期望行为,不是靶子)。
|
||||
FlowSourcePrincipalResolver stubPrincipals = mock(FlowSourcePrincipalResolver.class);
|
||||
when(stubPrincipals.resolve(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(new FlowSourcePrincipalResolver.Principal(
|
||||
"ub-app:18", 18, "100018", "flow-test"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "sourcePrincipalResolver", stubPrincipals);
|
||||
service.setUsageGrantGuard(mock(FlowUsageGrantGuard.class));
|
||||
|
||||
}
|
||||
|
||||
private static FlwDef publishedDef(int version) {
|
||||
|
||||
@@ -33,6 +33,11 @@ import java.util.UUID;
|
||||
public abstract class FlowTestSupport {
|
||||
|
||||
public static final long SCH = 1L;
|
||||
/**
|
||||
* 可信来源主体(D11/F02):业务坐标的分区维度。真实链路由服务端从验签 appKey 经
|
||||
* UserBase 应用注册表解析,测试里显式给定同一个值。
|
||||
*/
|
||||
public static final String SOURCE = "ub-app:100001";
|
||||
|
||||
@Autowired
|
||||
protected FlwDefRepository defRepository;
|
||||
|
||||
Reference in New Issue
Block a user