package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwUsageGrant; import com.yunhai.flowcenter.enums.FlwEnums; 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 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.HashMap; import java.util.Map; 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.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 应用使用许可闸与恢复感知判定的单测(2026-10-25 T01)。 * *

钉住的不变量:

* */ @DisplayName("应用使用许可闸 FlowUsageGrantGuard + apply 恢复感知判定(T01)") class FlUsageGrantTest { private static final long SCH = 27L; private static final String FLOW = "fg-1"; private static final String APP_KEY = "100018"; private FlwUsageGrantRepository grantRepository; private OpenApiAppClient appClient; private YschOpenCall openCall; private UserBaseActivationClient activationClient; private FlowUsageGrantGuard guard; private final ObjectMapper mapper = new ObjectMapper(); @BeforeEach void setUp() { grantRepository = mock(FlwUsageGrantRepository.class); appClient = mock(OpenApiAppClient.class); 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); assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isInstanceOf(BizException.class); verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt()); } @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)) .isInstanceOf(BizException.class) .hasMessageContaining("使用许可"); } @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))); assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) .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 平台资源同口径):拒绝") 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); assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) .isInstanceOf(BizException.class) .hasMessageContaining("开通"); } @Test @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))); when(openCall.get(anyString(), anyString(), any(), any())) .thenThrow(new RuntimeException("connection refused")); assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) .isInstanceOf(BizException.class) .hasMessageContaining("不可用"); } @Test @DisplayName("appKey 解析不出稳定主体:拒绝(凭据索引不构成主体)") void unresolvablePrincipalRejected() { when(appClient.getAppByKey(APP_KEY)).thenReturn(null); assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) .isInstanceOf(BizException.class) .hasMessageContaining("主体"); } // ==================== apply 恢复感知判定(OapiFlowService 纯函数) ==================== @Test @DisplayName("v1 判定:在途/通过=回执放行;无实例/终态件=需许可") void legacyApplyReceiptDecision() { assertThat(OapiFlowService.legacyApplyIsReceiptOnly(null)).isFalse(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.APPROVING.getCode()))).isTrue(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.COMPLETED.getCode()))).isTrue(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.REJECTED.getCode()))).isFalse(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.WITHDRAWN.getCode()))).isFalse(); } @Test @DisplayName("v2 判定:同 token/retryOnly/在途/陈旧轮次=回执;无实例或更高轮次=新申报") void guardedApplyReceiptDecision() { FlwInstance terminal = instance(FlwEnums.InstanceStatus.REJECTED.getCode()); terminal.setApplyRequestToken("tok-a"); terminal.setApplyRound(2); assertThat(OapiFlowService.guardedApplyIsReceiptOnly(null, "tok-a", 3, false)).isFalse(); assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-a", 9, false)).isTrue(); 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(terminal, "tok-b", 2, false)).isTrue(); assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 3, false)).isFalse(); assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, null, null, false)).isTrue(); } // ==================== 管理面:授予/撤销 ==================== @Test @DisplayName("授予:appKey 当场解析成稳定主体落库;重申复用同一行") void grantResolvesPrincipalAndUpserts() { // 本测主体解析与 upsert,不开资格闸(开通闸已由 Guard 用例钉住) ReflectionTestUtils.setField(activationClient, "enforce", false); 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); FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin"); assertThat(saved.getAppId()).isEqualTo(5001); assertThat(saved.getEnabled()).isEqualTo(1); assertThat(saved.getAppKeySnapshot()).isEqualTo(APP_KEY); } @Test @DisplayName("撤销:enabled=0 行保留(审计与幂等判据不删)") void revokeKeepsRow() { FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient); FlwUsageGrant existing = grant(5001, 1); when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) .thenReturn(Optional.of(existing)); when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0)); FlwUsageGrant saved = admin.revoke(SCH, FLOW, 5001, "违规使用", "admin"); assertThat(saved.getEnabled()).isEqualTo(0); assertThat(saved.getId()).isNotNull(); } // ==================== fixtures ==================== private JsonNode registration(int appId) { Map 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); } private void stubActivation(int appId, boolean activated) { Map vo = new HashMap<>(); vo.put("appId", appId); vo.put("schoolId", SCH); vo.put("activated", activated); Mockito.when(openCall.get(anyString(), anyString(), any(), any())) .thenReturn(mapper.valueToTree(vo)); } private FlwUsageGrant grant(int appId, int enabled) { FlwUsageGrant g = new FlwUsageGrant(); g.setId(71L); g.setSchId(SCH); g.setFlowGuid(FLOW); g.setAppId(appId); g.setAppKeySnapshot(APP_KEY); g.setEnabled(enabled); g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111"); return g; } private FlwInstance instance(int status) { FlwInstance i = new FlwInstance(); i.setId(900L); i.setSchId(SCH); i.setStatus(status); return i; } }