using Admin.NET.Plugin.AiDOP.Const.S8;
using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-UAT-APPROVAL-REPAIR-1:审核链修复。
///
/// 修的是一个完整的静默悬挂(本地取证,非推测):UAT 租户 3 条
/// PENDING_VERIFICATION 异常的 verifier_user_id 都是 UATQualityA,
/// 而对应 EXCEPTION_CLOSURE 实例 Status=Running、tasks=0、
/// 异常上的 active_flow_instance_id 仍是 NULL —— 节点配的是别租户角色 Id,
/// ResolveApprovers 返回 0 人 → ProcessNextNode 抛错 →
/// OnFlowStarted 从未调用 → 实例永久悬挂,而 S8 侧显示一切正常。
///
public class S8ApprovalFlowRepairTests
{
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string CodeOnly(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return string.Join('\n', File.ReadAllLines(full)
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal)
&& !t.StartsWith("//", StringComparison.Ordinal);
}));
}
// ══════════════════════════════════════════════════════════════════════
// 判据:什么算「已经租户安全」
// ══════════════════════════════════════════════════════════════════════
///
/// T5 / T6:判据是「该节点引用的每个对象是否都属于本租户」,
/// 而不是「用了哪种 approverType 写法」。
///
/// 第一版按写法判(只认 RoleCode),把默认租户一条
/// SpecificUser 指向本租户账号的正确配置也改写了 ——
/// 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。
///
[Theory]
// 本租户 RoleCode → 安全
[InlineData("Role", "ROLE_QC", true)]
[InlineData("Role", "ROLE_QC,ROLE_S8_CONFIG_ADMIN", true)]
// 本租户 RoleId(数字)→ 也安全:判据是「引用对象归不归本租户」,不是「用了哪种写法」
[InlineData("Role", "9001", true)]
// 跨租户数字 RoleId(正是坏掉的那个配置)→ 不安全
[InlineData("Role", "1300000000101,1300000000888", false)]
// 混入跨租户对象 → 整体不安全
[InlineData("Role", "ROLE_QC,1300000000101", false)]
[InlineData("Role", "ROLE_NOT_IN_TENANT", false)]
// SpecificUser 指向**本租户**账号 → 安全(第一版把它误判为不安全,改坏了默认租户的正确配置)
[InlineData("SpecificUser", "7001", true)]
// SpecificUser 指向别租户账号 → 不安全(escalation 的现状)
[InlineData("SpecificUser", "789819678195781", false)]
// Department 同理
[InlineData("Department", "8001", true)]
[InlineData("Department", "246", false)]
// Initiator 由运行时按发起人解析,天然不跨租户
[InlineData("Initiator", null, true)]
// 空配置 / 认不出的类型 → 不安全
[InlineData("Role", "", false)]
[InlineData("Role", null, false)]
[InlineData("WhoKnows", "1", false)]
public void TenantSafetyPredicate_JudgesByReferencedObjectOwnership(string type, string? ids, bool expected)
{
var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
RoleCodes: new HashSet(StringComparer.Ordinal) { "ROLE_QC", "ROLE_S8_CONFIG_ADMIN" },
RoleIds: new HashSet { 9001 },
UserIds: new HashSet { 7001 },
OrgIds: new HashSet { 8001 });
Assert.Equal(expected, S8ApprovalFlowRepairService.IsAlreadyTenantSafe(type, ids, scope));
}
///
/// T3:跨租户审批人配置必须被判为需要修复 —— 这正是 escalation 的现状
/// (三个节点全部 SpecificUser=789819678195781,Demo02 属默认租户)。
///
[Fact]
public void T3_CrossTenantApprover_IsRejectedAsUnsafe()
{
var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
RoleCodes: new HashSet(StringComparer.Ordinal) { "ROLE_QC" },
RoleIds: new HashSet(), UserIds: new HashSet(), OrgIds: new HashSet());
Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("SpecificUser", "789819678195781", scope));
Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("Role", "1300000000101,1300000000888", scope));
}
///
/// 对账服务写下的改动必须能按自己的留证撤回。
///
/// 判据本身也会出错:第一版把「SpecificUser 指向本租户账号」误判为不安全,
/// 于是改坏了默认租户一条本来正确的配置。没有回退能力,一次判据失误就永久生效。
///
[Fact]
public void Repair_CanUndoItsOwnWritesWhenPredicateChanges()
{
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("RESTORED", code);
Assert.Contains("s8_flow_repair_restored", code);
Assert.Contains("priorLogs.TryGetValue((flow.Id, nodeName), out var prior)", code);
// 只撤自己写的:当前值必须与留证里的 after 完全一致才回退
Assert.Contains("string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)", code);
// 且回退目标必须按**现在的**判据是安全的
Assert.Contains("IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope)", code);
}
// ══════════════════════════════════════════════════════════════════════
// 修复行为
// ══════════════════════════════════════════════════════════════════════
///
/// 派生依据必须是该租户自己的权限事实,不是硬编码角色名。
/// 升级流程没有专门角色时回落审核角色,且回落必须写进留证 ——
/// 否则将来没人知道升级审批为什么是质检在审。
///
[Fact]
public void Repair_DerivesRolesFromTenantOwnPermissionGrants()
{
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("S8PermissionCatalog.VerificationApprove", code);
Assert.Contains("S8PermissionCatalog.ExceptionUpgrade", code);
Assert.Contains("r.TenantId.Value", code);
Assert.Contains("回落使用审核角色", code);
// 不得出现硬编码的具体角色名
foreach (var hardcoded in new[] { "\"ROLE_QC\"", "\"UAT_FUNCTION_USER\"", "\"ROLE_S8_CONFIG_ADMIN\"" })
Assert.DoesNotContain(hardcoded, code);
}
///
/// T4:推导不出角色时不猜 —— 保持原样并大声报出来。
///
/// 塞一个"看起来能用"的角色进去会让人以为审核链已经通了,
/// 那比留着坏配置更危险。
///
[Fact]
public void T4_NoDerivableRole_BlocksLoudlyInsteadOfGuessing()
{
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("BLOCKED_NO_ROLE", code);
Assert.Contains("s8_flow_repair_blocked", code);
Assert.Contains("保持原样", code);
}
///
/// 只修坏的,不碰好的;判据本身保证幂等(修好后第二次运行命中「已 OK」而跳过)。
///
/// 每次启动无条件回写会覆盖管理员的手工配置 ——
/// 那是比配置错误更难发现的故障。
///
[Fact]
public void Repair_IsIdempotentAndDoesNotOverwriteGoodConfig()
{
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("SKIPPED_ALREADY_OK", code);
Assert.Contains("IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope)", code);
Assert.Contains("result.AlreadyOkCount++", code);
}
///
/// 修复只作用于 S8 自己的两条流程,绝不波及 S1/S5/S6/S7。
///
/// 真库实测 74 个 SpecificUser 节点里有大量跨租户配置
/// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里
/// 都指向租户 797 的账号)。本批不碰它们。
///
[Fact]
public void Repair_ScopeIsLimitedToS8FlowsOnly()
{
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("f.BizType == ClosureBizType || f.BizType == EscalationBizType", code);
Assert.Equal("EXCEPTION_CLOSURE", S8ApprovalFlowRepairService.ClosureBizType);
Assert.Equal("EXCEPTION_ESCALATION", S8ApprovalFlowRepairService.EscalationBizType);
foreach (var other in new[] { "CONTRACT_REVIEW", "ORDER_CHANGE_REVIEW", "MATERIAL_SHORTAGE", "ORDER_REVIEW" })
Assert.DoesNotContain(other, code);
}
///
/// 不改 FlowEngine:给 SpecificUser / Department 补租户谓词会外溢到其它模块,
/// 属另一个批次。本批以「只用 RoleCode 形态」绕开该缺陷。
///
[Fact]
public void FlowEngine_IsNotModifiedByThisBatch()
{
var engine = Path.GetFullPath(Path.Combine(
PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
Assert.True(File.Exists(engine));
var src = File.ReadAllText(engine);
// Role 的 code 分支本来就有租户校验 —— 这正是本批选择 RoleCode 形态的依据
Assert.Contains("r.TenantId == effectiveTenantId", src);
// 本批没有往里加 S8 专属逻辑
Assert.DoesNotContain("S8-UAT-APPROVAL-REPAIR", src);
}
/// 修复必须留证:改前 / 改后 / 原因 / 结论,缺一不可。
[Fact]
public void Repair_LeavesAuditTrail()
{
var t = typeof(AdoS8ApprovalFlowRepairLog);
foreach (var p in new[] { "TenantId", "BizType", "NodeName", "BeforeType", "BeforeIds", "AfterType", "AfterIds", "Reason", "Outcome" })
Assert.NotNull(t.GetProperty(p));
var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
Assert.Contains("REPAIRED", code);
}
// ══════════════════════════════════════════════════════════════════════
// verifier 与审批任务的一致性(§10)
// ══════════════════════════════════════════════════════════════════════
///
/// T7 / T10 / T11:检验人必须具备「审核」动作权限。
///
/// 这条校验保证「UI 能选到的人」⊆「审批任务的收件人」:
/// 复核节点按「被授予 s8:verification:approve 的角色」派发,
/// 而检验人也必须持有同一能力。少了它就会出现错位 ——
/// 页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了
/// verifier_user_id 校验,两边互相卡住且都不报错。
///
[Fact]
public void T7_T10_T11_Verifier_MustHoldVerifyPermission()
{
var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
// S8-RESPONSIBILITY-POOL-1:判据本体迁到 IS8VerifierEligibility(候选列表与提交校验共用同一份),
// 但**保护对象没变**:选出来的人必须真的能点「检验通过 / 检验退回」。
Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", code);
var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
Assert.Contains("S8ExceptionActionCode.Verify", eligibility);
Assert.Contains("没有审核权限,不能作为检验人", eligibility);
// verifier_user_id 仍然只装 SysUser.Id
Assert.Contains("e.VerifierUserId = verifier.UserId;", code);
Assert.DoesNotContain("EmployeeMaster", code.Replace("EmployeeMaster.Department", string.Empty));
}
/// 前端候选集必须与后端校验同源,不能让用户选到一个提交就被拒的账号。
[Fact]
public void VerifierPicker_UsesSameSourceAsBackendCheck()
{
var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
// S8-RESPONSIBILITY-POOL-1:候选来源从「全租户 ∩ 审核权限」收窄为
// 「该规则的复核池 ∩ 审核权限 ∩ 可用账号」,由后端一个端点给出。
// 本守卫保护的一直是「前端能选的 == 后端会放行的」,来源换了但要求没变 ——
// 而且现在更强:候选与提交校验共用同一份 IS8VerifierEligibility 实现。
Assert.Contains("s8ExceptionApi.verifierCandidates(", page);
Assert.DoesNotContain("s8ExceptionApi.users('EXCEPTION_VERIFY')", page);
Assert.Contains("verifierUsers", page);
// 没有可选复核人时必须显式说明原因,而不是给一个空下拉
Assert.Contains("该规则没有可用的复核人员", page);
Assert.Contains("本租户没有任何账号具备「审核」权限", page);
var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
Assert.Contains("ListCandidatesAsync", eligibility);
Assert.Contains("CheckAsync", eligibility);
Assert.Contains("S8ExceptionActionCode.Verify", eligibility);
}
/// T12 / T13:修复链路上没有 Employee、没有 Factory。
[Theory]
[InlineData("Service/S8/S8ApprovalFlowRepairService.cs")]
[InlineData("Service/S8/S8MasterDataAdapter.cs")]
public void T12_T13_NoEmployeeNoFactory(string relativePath)
{
var code = CodeOnly(relativePath);
Assert.DoesNotContain("AdoS0EmployeeMaster", code);
Assert.DoesNotContain("FactoryRefId", code);
}
}