release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)
按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。
- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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 enforceOffPassesThrough() {
|
||||
ReflectionTestUtils.setField(activationClient, "enforce", false);
|
||||
|
||||
assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isNull();
|
||||
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));
|
||||
|
||||
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("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);
|
||||
return g;
|
||||
}
|
||||
|
||||
private FlwInstance instance(int status) {
|
||||
FlwInstance i = new FlwInstance();
|
||||
i.setId(900L);
|
||||
i.setSchId(SCH);
|
||||
i.setStatus(status);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user