263 lines
12 KiB
Java
263 lines
12 KiB
Java
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;
|
||
}
|
||
}
|