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:
chenxing
2026-10-27 12:05:08 +08:00
parent 11e077867b
commit 500a9a2fcb
99 changed files with 7973 additions and 633 deletions
@@ -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;
}
}