feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
build / build-push (push) Failing after 55s

在 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:
chenxing
2026-10-30 10:15:12 +08:00
parent b369527fe1
commit d72128672f
52 changed files with 3208 additions and 859 deletions
@@ -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;