Files
flow-center-service/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
T
chenxing 500a9a2fcb release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)
按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。

- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
  CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
  FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
  main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
  6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
2026-10-27 12:05:08 +08:00

320 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
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.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* T06 发起终审规则单测:required 指派全覆盖(空 assigns 不绕过)、禁用账号拒绝、
* EXPRESSION 变量缺失/显式 null 零实例。
*/
@DisplayName("发起终审:required 指派覆盖 + 禁用人员 + 变量缺失(T06)")
class FlApplyAssignAndVariableTest {
private final ObjectMapper mapper = new ObjectMapper();
// ==================== required 覆盖 ====================
@Test
@DisplayName("空 assigns + 存在必选指派节点:全部缺失(空 assigns 不绕过 required)")
void emptyAssignsWithRequiredNodesIsMissing() throws Exception {
FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, false));
List<String> missing = OapiFlowService.missingRequiredAssignNodes(model, null);
assertThat(missing).containsExactly("n-assign-1");
assertThat(OapiFlowService.missingRequiredAssignNodes(model, Collections.emptyList()))
.containsExactly("n-assign-1");
}
@Test
@DisplayName("提交项只覆盖部分必选节点:缺失的逐一点名(不只遍历请求里出现的项)")
void partialCoverageIsMissing() throws Exception {
FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, true));
OapiModels.AssignItem onlyFirst = new OapiModels.AssignItem();
onlyFirst.setNodeId("n-assign-1");
onlyFirst.setUserIds(java.util.Collections.singletonList(101));
List<String> missing = OapiFlowService.missingRequiredAssignNodes(model, java.util.Collections.singletonList(onlyFirst));
assertThat(missing).containsExactly("n-assign-2");
}
@Test
@DisplayName("无必选节点 + 空 assigns:放行(既有宽松回退 D3 不变)")
void noRequiredNodesEmptyAssignsPasses() throws Exception {
FlowDefModel model = FlowDefModel.parse(mapper, defJson(false, false));
assertThat(OapiFlowService.missingRequiredAssignNodes(model, null)).isEmpty();
}
// ==================== 变量终审 ====================
@Test
@DisplayName("EXPRESSION 引用的业务变量缺失:拒绝(零实例,不等运行期 nil)")
void missingExpressionVariableRejected() throws Exception {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel model = modelWithCondition("flowAmount > 100");
assertThat(validator.referencedBusinessKeys(model)).containsExactly("flowAmount");
assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"other\":1}"))
.isInstanceOf(BizException.class)
.hasMessageContaining("flowAmount");
}
@Test
@DisplayName("变量为显式 null:拒绝(缺失键与 null 是同一种'没有值')")
void nullVariableRejected() throws Exception {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel model = modelWithCondition("flowAmount > 100");
assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"flowAmount\":null}"))
.isInstanceOf(BizException.class)
.hasMessageContaining("flowAmount");
}
@Test
@DisplayName("变量齐备:放行;flow.* 内置键不参与本查")
void presentVariablesPassAndBuiltinsIgnored() throws Exception {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel model = modelWithCondition("flowAmount > 100 && flow.submitterId != null");
validator.requireVariablesPresent(model, "{\"flowAmount\":200}");
validator.requireVariablesPresent(model, null);
}
// ==================== 禁用人员终审 ====================
private FlowDefModel contract(String declarations) {
return FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[],\"variables\":" + declarations + "}");
}
@Test
void requiredUnknownAndOptionalNullRules() {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel m = contract("[{\"key\":\"reason\",\"type\":\"string\",\"required\":true},"
+ "{\"key\":\"optional\",\"type\":\"number\"}]");
validator.requireVariablesPresent(m, "{\"reason\":\"ok\",\"optional\":null}");
for (String bad : java.util.Arrays.asList("{}", "{\"reason\":null}", "{\"reason\":\"\"}",
"{\"reason\":\" \\t\"}", "{\"reason\":\"ok\",\"extra\":1}",
"{\"reason\":\"ok\",\"optional\":\"\"}", "[]", "null", "invalid")) {
assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class);
}
validator.requireVariablesPresent(contract("[]"), null);
assertThatThrownBy(() -> validator.requireVariablesPresent(contract("[]"), "{\"x\":1}"))
.isInstanceOf(BizException.class);
validator.requireVariablesPresent(contract("[{\"key\":\"text\",\"type\":\"string\"}]"), "{\"text\":\"\"}");
validator.requireVariablesPresent(contract("[{\"key\":\"date\",\"type\":\"date\"},"
+ "{\"key\":\"grade\",\"type\":\"string\",\"enum\":[\"g1\"]}]"), "{\"date\":\"\",\"grade\":\"\"}");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource(delimiter = '|', value = {
"number|12.5|\"12.5\"", "money|0.01|\"0.01\"", "boolean|true|\"true\"",
"date|\"2024-02-29\"|\"2025-02-29\"",
"datetime|\"2026-10-26T12:30:00\"|\"2026-10-26 12:30:00\"",
"datetime|\"2026-10-26T12:30:00+08:00\"|\"2026-10-26T25:30:00Z\"",
"string|\"ok\"|123", "stringArray|[\"x\"]|[1]",
"fileArray|[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}]|[{\"id\":1}]",
"userArray|[{\"id\":101,\"name\":\"A\"}]|[101]"
})
void realJsonShapesAreCheckedWithoutCoercion(String type, String good, String bad) {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel model = contract("[{\"key\":\"value\",\"type\":\"" + type + "\",\"required\":true}]");
validator.requireVariablesPresent(model, "{\"value\":" + good + "}");
for (String invalid : java.util.Arrays.asList(bad, "\"\"", "null", "[]")) {
assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"value\":" + invalid + "}"))
.isInstanceOf(BizException.class);
}
}
@Test
void optionalReferencedVariableStillNeedsValueAndLegacyKeepsOldBehavior() throws Exception {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
FlowDefModel m = modelWithCondition("flowAmount > 100");
validator.requireVariablesPresent(m, "{\"flowAmount\":\"\",\"extra\":1}"); // 历史行为
m.variables = contract("[{\"key\":\"flowAmount\",\"type\":\"number\",\"required\":false}]").variables;
for (String bad : java.util.Arrays.asList("{}", "{\"flowAmount\":null}", "{\"flowAmount\":\"\"}")) {
assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class);
}
validator.requireVariablesPresent(m, "{\"flowAmount\":200}");
}
@Test
void scalarAndArrayEnumUseFrozenValues() {
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
for (String type : java.util.Arrays.asList("string", "stringArray")) {
FlowDefModel model = contract("[{\"key\":\"grade\",\"type\":\"" + type + "\",\"enum\":[\"g1\",\"g2\"]}]");
validator.requireVariablesPresent(model, "{\"grade\":" + (type.equals("string") ? "\"g1\"" : "[\"g1\",\"g2\"]") + "}");
assertThatThrownBy(() -> validator.requireVariablesPresent(model,
"{\"grade\":" + (type.equals("string") ? "\"g9\"" : "[\"g1\",\"g9\"]") + "}"))
.isInstanceOf(BizException.class);
}
}
@Test
void v2SameTokenUsesAcceptedDefinitionBeforeCurrentContractAndVersionChecks() {
com.yunhai.flowcenter.repository.FlwDefRepository defs = mock(com.yunhai.flowcenter.repository.FlwDefRepository.class);
com.yunhai.flowcenter.repository.FlwInstanceRepository instances = mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class);
com.yunhai.flowcenter.engine.FlowEngine engine = mock(com.yunhai.flowcenter.engine.FlowEngine.class);
FlowInstanceService lifecycle = new FlowInstanceService(instances, defs,
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
mock(com.yunhai.flowcenter.repository.FlwBranchRepository.class), engine,
mock(org.springframework.context.ApplicationEventPublisher.class));
OapiFlowService service = new OapiFlowService(defs, instances,
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCcRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), lifecycle,
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));
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();
current.setId(10L); current.setFlowGuid("g"); current.setVersion(2);
current.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[{\"key\":\"newRequired\",\"type\":\"string\",\"required\":true}]}");
when(defs.findById(9L)).thenReturn(java.util.Optional.of(old));
when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(1L,"g",1)).thenReturn(java.util.Optional.of(current));
com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance();
row.setId(500L); row.setDefId(9L); row.setSchId(1L); row.setStatus(30);
row.setSubmitterId(201); row.setApplyAppKey("app"); row.setApplyRequestToken("token"); row.setApplyRound(1);
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));
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);
request.setRequestToken("token"); request.setExpectedDefVersion(1);
assertThat(service.applyV2(1L,"app",request).getBranch()).isEqualTo("SAME_REQUEST");
request.setBizTitle("different payload");
assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class);
request.setBizTitle(null); request.setFlowGuid("other");
assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class);
Mockito.verify(instances, Mockito.never()).save(Mockito.any());
Mockito.verifyNoInteractions(engine);
Mockito.verify(defs, Mockito.never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(Mockito.any(),Mockito.any(),Mockito.any());
// 新请求仍过当前契约;v1/v2 均在任何实例/任务写入之前拒绝,手工构造也不能跳过校验。
request.setFlowGuid("g"); request.setRequestToken("fresh"); request.setExpectedDefVersion(null);
assertThatThrownBy(() -> service.applyV2(1L,"app",request))
.isInstanceOf(BizException.class).hasMessageContaining("newRequired");
assertThatThrownBy(() -> service.apply(1L,"app",request))
.isInstanceOf(BizException.class).hasMessageContaining("newRequired");
Mockito.verify(instances, Mockito.never()).save(Mockito.any());
Mockito.verifyNoInteractions(engine);
}
@Test
@DisplayName("禁用账号(status=0):拒绝(存在≠可办理)")
void disabledUserRejected() {
com.yunhai.flowcenter.engine.AssigneeResolver resolver =
new com.yunhai.flowcenter.engine.AssigneeResolver(
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
userClient(0));
assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101))
.isInstanceOf(BizException.class)
.hasMessageContaining("禁用");
}
@Test
@DisplayName("status=null(存量下发未知态):fail-closed 拒绝")
void unknownStatusUserRejected() {
com.yunhai.flowcenter.engine.AssigneeResolver resolver =
new com.yunhai.flowcenter.engine.AssigneeResolver(
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
userClient(null));
assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101))
.isInstanceOf(BizException.class)
.hasMessageContaining("禁用");
}
@Test
@DisplayName("正常账号(status=1)且属于本校:放行")
void activeUserPasses() {
com.yunhai.flowcenter.engine.AssigneeResolver resolver =
new com.yunhai.flowcenter.engine.AssigneeResolver(
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
userClient(1));
assertThat(resolver.requireEligibleAssignee(1L, 101).getName()).isEqualTo("甲");
}
@Test
@DisplayName("正常账号但属于外校:拒绝(校域终审)")
void crossSchoolUserRejected() {
OpenApiUserClient client = mock(OpenApiUserClient.class);
BoaUser user = new BoaUser();
user.setId(101);
user.setName("甲");
user.setStatus(1);
user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(2, "外校"));
when(client.getUserById(Mockito.anyString())).thenReturn(user);
com.yunhai.flowcenter.engine.AssigneeResolver resolver =
new com.yunhai.flowcenter.engine.AssigneeResolver(
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), client);
assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于该学校");
}
// ==================== fixtures ====================
private OpenApiUserClient userClient(Integer status) {
OpenApiUserClient client = mock(OpenApiUserClient.class);
BoaUser user = new BoaUser();
user.setId(101);
user.setName("甲");
user.setStatus(status);
user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(1, "测试学校"));
when(client.getUserById(Mockito.anyString())).thenReturn(user);
return client;
}
/** 一个 START + required 指派节点(one/two 个)+ END 的最小定义 */
private String defJson(boolean oneRequired, boolean twoRequired) {
StringBuilder sb = new StringBuilder();
sb.append("{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":[");
sb.append("{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"},");
sb.append("{\"id\":\"n-assign-1\",\"type\":\"APPROVAL\",\"name\":\"审批一\",")
.append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]},")
.append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":").append(oneRequired).append("}},");
if (twoRequired) {
sb.append("{\"id\":\"n-assign-2\",\"type\":\"APPROVAL\",\"name\":\"审批二\",")
.append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[102]},")
.append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":true}},");
}
sb.append("{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}],");
sb.append("\"edges\":[{\"from\":\"n-start\",\"to\":\"n-assign-1\",\"name\":\"提交\"},")
.append("{\"from\":\"n-assign-1\",\"to\":\"")
.append(twoRequired ? "n-assign-2" : "n-end").append("\",\"name\":\"通过\"}");
if (twoRequired) {
sb.append(",{\"from\":\"n-assign-2\",\"to\":\"n-end\",\"name\":\"通过\"}");
}
sb.append("]}");
return sb.toString();
}
/** START → CONDITION(EXPRESSION) → END,条件含给定表达式 */
private FlowDefModel modelWithCondition(String condition) throws Exception {
String json = "{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":["
+ "{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n-cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
+ "{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}],"
+ "\"edges\":["
+ "{\"from\":\"n-start\",\"to\":\"n-cond\",\"name\":\"提交\"},"
+ "{\"from\":\"n-cond\",\"to\":\"n-end\",\"name\":\"高额\",\"condition\":\"" + condition + "\"}"
+ "]}";
return FlowDefModel.parse(mapper, json);
}
}