fix(security): F01 授权门禁不可被配置关闭 + F02 业务坐标按可信来源隔离(含唯一键 SQL)
build / build-push (push) Failing after 1m1s

This commit is contained in:
chenxing
2026-10-28 11:46:20 +08:00
parent 302216a07c
commit 8a1294aef2
17 changed files with 366 additions and 211 deletions
@@ -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;
}