按《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 项哈希/版本核对)。
320 lines
18 KiB
Java
320 lines
18 KiB
Java
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);
|
||
}
|
||
}
|