Files
flow-center-service/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
T

263 lines
12 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.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)。
*
* <p>钉住的不变量:</p>
* <ul>
* <li><b>新发起必须有生效许可</b>:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;</li>
* <li><b>已受理命令回执可恢复</b>:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类
* 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);</li>
* <li><b>appKey 不是主体</b>:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。</li>
* </ul>
*/
@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<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);
}
private void stubActivation(int appId, boolean activated) {
Map<String, Object> 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;
}
}