fix(security): F01 授权门禁不可被配置关闭 + F02 业务坐标按可信来源隔离(含唯一键 SQL)
build / build-push (push) Failing after 1m1s
build / build-push (push) Failing after 1m1s
This commit is contained in:
@@ -51,6 +51,7 @@ 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";
|
||||
@@ -95,7 +96,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
||||
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -112,7 +113,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
||||
void sameTokenOnApprovingInstanceIsSameRequest() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
@@ -124,7 +125,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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -141,7 +142,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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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);
|
||||
@@ -161,7 +162,7 @@ class FlApplyRecoveryTest {
|
||||
void sameTokenDifferentPayloadIsConflict() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
existing.setApplyPayloadHash("hash-a");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A);
|
||||
@@ -179,7 +180,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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
// 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮
|
||||
@@ -189,7 +190,7 @@ class FlApplyRecoveryTest {
|
||||
assertThat(outcome.applyRound).isEqualTo(3);
|
||||
|
||||
// expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例)
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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),
|
||||
@@ -204,7 +205,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
||||
void higherRoundRestartsPerExistingR4Semantics() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -224,7 +225,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
||||
void staleRoundIsRejectedForRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
@@ -237,7 +238,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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(legacy));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
||||
@@ -254,7 +255,7 @@ class FlApplyRecoveryTest {
|
||||
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
||||
void otherApplicationSameBizKeyIsRefused() {
|
||||
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.of(foreign));
|
||||
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
@@ -267,14 +268,14 @@ class FlApplyRecoveryTest {
|
||||
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
otherSubmitter.setSubmitterId(2002);
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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)))
|
||||
@@ -286,7 +287,7 @@ class FlApplyRecoveryTest {
|
||||
@Test
|
||||
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
||||
void createsWhenAbsentAndStampsIdentity() {
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1))
|
||||
.thenReturn(Optional.<FlwInstance>empty());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
@@ -307,7 +308,7 @@ class FlApplyRecoveryTest {
|
||||
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
||||
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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)
|
||||
@@ -321,23 +322,24 @@ class FlApplyRecoveryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
|
||||
void legacyApplyPathIsUnchanged() {
|
||||
@DisplayName("旧 apply 同来源终态仍按 R4 重启,并记录服务端请求身份")
|
||||
void legacyApplyRetainsRestartButStampsTrustedIdentity() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(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()).isNull();
|
||||
assertThat(existing.getApplyRequestToken()).isNotBlank();
|
||||
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(
|
||||
@@ -370,9 +372,11 @@ class FlApplyRecoveryTest {
|
||||
|
||||
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
||||
private FlowInstanceService.ApplyCommand cmd(String token) {
|
||||
return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
|
||||
FlowInstanceService.ApplyCommand command = 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;
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
||||
@@ -403,6 +407,7 @@ 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,6 +76,11 @@ 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)));
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ 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";
|
||||
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
|
||||
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
|
||||
assertThat(outcome.duplicated).isFalse();
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -70,11 +70,11 @@ class FlUsageGrantTest {
|
||||
// ==================== Guard:新发起资格 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("enforce 关闭:直通且不出站(灰度期存量行为不变)")
|
||||
void enforceOffPassesThrough() {
|
||||
@DisplayName("enforce 关闭仍不得绕过授权")
|
||||
void enforceOffCannotBypassAuthorization() {
|
||||
ReflectionTestUtils.setField(activationClient, "enforce", false);
|
||||
|
||||
assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isNull();
|
||||
assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isInstanceOf(BizException.class);
|
||||
verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ class FlUsageGrantTest {
|
||||
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);
|
||||
|
||||
FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin");
|
||||
|
||||
@@ -224,6 +225,7 @@ class FlUsageGrantTest {
|
||||
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,6 +248,7 @@ class FlUsageGrantTest {
|
||||
g.setAppId(appId);
|
||||
g.setAppKeySnapshot(APP_KEY);
|
||||
g.setEnabled(enabled);
|
||||
g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111");
|
||||
return g;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user