|
|
@@ -0,0 +1,337 @@
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S7-FQC-TENANT-AUTHORITY-1 源码契约测试:S7 成品检验审批权威**租户本地化**(仅未来新建)。
|
|
|
+///
|
|
|
+/// 修复的根因(2026-09-14 审计 EVIDENCE COMPLETE):
|
|
|
+/// - <c>ROLE_S7_FQC_*</c> 三个 Code 全库各只有一行、固定挂默认租户(对照 ROLE_S6_IPQC_* 已各 10 份);
|
|
|
+/// - 所有 S7 定义(57 条,V1–V3 全版本)的 N2/N3 都写死默认租户物理 RoleId
|
|
|
+/// 1329915020002 / 1329915020003;
|
|
|
+/// - 各租户靠 9 条跨租户 SysUserRole 绑定借用,其中 4 条被 Running 实例实际依赖。
|
|
|
+///
|
|
|
+/// 本批只关未来新建这条口子:租户本地角色 + 定义按租户播种 + N2/N3 改 RoleCode。
|
|
|
+/// **不迁** 7 条 Running 的 FlowJsonSnapshot、**不删**跨租户绑定、**不开** TenantStrictRoleBizTypes
|
|
|
+/// (顺序必须是 local roles → future definition → active migration → strict guard)。
|
|
|
+///
|
|
|
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:seed 依赖 ISqlSugarClient,
|
|
|
+/// 裸 xUnit 进程无法实例化。真实效果由本批 Runtime R1–R10 覆盖。
|
|
|
+/// </summary>
|
|
|
+public class S7TenantAuthorityContractTests
|
|
|
+{
|
|
|
+ private const string SupervisorCode = "ROLE_S7_FQC_SUPERVISOR";
|
|
|
+ private const string QeCode = "ROLE_S7_FQC_QE";
|
|
|
+ private const string InspectorCode = "ROLE_S7_FQC_INSPECTOR";
|
|
|
+
|
|
|
+ private static string AuthoritySeed() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S7TenantAuthoritySeed.cs"));
|
|
|
+
|
|
|
+ private static string FlowSeed() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "FqcInspBillFlowSeed.cs"));
|
|
|
+
|
|
|
+ private static string FlowConst() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
|
|
|
+
|
|
|
+ private static string Engine() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
|
|
|
+
|
|
|
+ private static string FlowTaskService() => File.ReadAllText(FindFile(
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// **去注释**后的 C# 源码视图。这些文件的注释里会成段引用被淘汰的写法
|
|
|
+ /// (如「原写 RoleSupervisorId=1329915020002」「历史实例各用自己的 FlowJsonSnapshot」)来解释
|
|
|
+ /// 为什么不再那么做;裸文本 DoesNotContain 会把说明文字当成违规实现命中。
|
|
|
+ /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
|
|
|
+ /// </summary>
|
|
|
+ private static string CsCode(string src)
|
|
|
+ {
|
|
|
+ var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
|
|
+ var kept = s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
|
|
|
+ return string.Join("\n", kept);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string FlowJsonBody() => Slice(CsCode(FlowSeed()), "private static string BuildFlowJson()", "private static ApprovalFlowEdgeItem BuildEdge");
|
|
|
+
|
|
|
+ // ── §27-1 / §27-2:N2 / N3 权威必须是 RoleCode ──
|
|
|
+
|
|
|
+ /// <summary>N2 检验主管节点必须用 RoleCode 常量,而不是任何数字 Id。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void DefinitionN2_UsesSupervisorRoleCode()
|
|
|
+ {
|
|
|
+ var n2 = Slice(FlowJsonBody(), "NodeName = \"检验主管审核\"", "Text = new FlowTextItem");
|
|
|
+ Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleSupervisorCode", n2);
|
|
|
+ Assert.DoesNotContain("RoleSupervisorId", n2);
|
|
|
+ Assert.DoesNotContain(".ToString()", n2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>N3 QE 处置节点同理。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void DefinitionN3_UsesQeRoleCode()
|
|
|
+ {
|
|
|
+ var n3 = Slice(FlowJsonBody(), "NodeName = \"QE处置\"", "Text = new FlowTextItem");
|
|
|
+ Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleQeCode", n3);
|
|
|
+ Assert.DoesNotContain("RoleQeId", n3);
|
|
|
+ Assert.DoesNotContain(".ToString()", n3);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>RoleCode 常量的字面值必须与 DB 中既有 Code 完全一致(改字面量会让解析静默落空)。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("RoleInspectorCode", InspectorCode)]
|
|
|
+ [InlineData("RoleSupervisorCode", SupervisorCode)]
|
|
|
+ [InlineData("RoleQeCode", QeCode)]
|
|
|
+ public void RoleCodeConstants_MatchExistingDbCodes(string constName, string expected)
|
|
|
+ {
|
|
|
+ Assert.Contains($"public const string {constName} = \"{expected}\"", FlowConst());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §27-3:定义里禁止出现默认租户物理 RoleId ──
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 三个 legacy 物理 RoleId 只能作为「默认租户既有固定 Id」出现在租户本地化种子里,
|
|
|
+ /// **不得**出现在流程定义 JSON 的构造中。
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("1329915020001")]
|
|
|
+ [InlineData("1329915020002")]
|
|
|
+ [InlineData("1329915020003")]
|
|
|
+ public void DefinitionJson_ContainsNoLegacyPhysicalRoleId(string legacyId)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(legacyId, FlowJsonBody()); // FlowJsonBody 已是去注释视图
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>定义构造中不得引用任何 RoleId 常量(即使是本租户的,也只能用 Code)。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("RoleInspectorId")]
|
|
|
+ [InlineData("RoleSupervisorId")]
|
|
|
+ [InlineData("RoleQeId")]
|
|
|
+ public void DefinitionJson_ReferencesNoRoleIdConstant(string idConst)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(idConst, FlowJsonBody());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>legacy 固定 Id 仍必须保留在常量与种子里——默认租户的历史 FlowJson 与本地角色靠它对齐。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void LegacyRoleIds_StillRetainedForDefaultTenantIdentity()
|
|
|
+ {
|
|
|
+ Assert.Contains("RoleSupervisorId = 1329915020002", FlowConst());
|
|
|
+ Assert.Contains("LegacyDefaultTenantRoleId", AuthoritySeed());
|
|
|
+ Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", AuthoritySeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §27-4 / §27-5:角色供给是租户本地的,同 Code 可跨租户共存 ──
|
|
|
+
|
|
|
+ /// <summary>查重键必须是 (TenantId, Code);用全局 Any(Code) 就是「全库单例」的成因。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void RoleProvisioning_DedupesByTenantAndCode()
|
|
|
+ {
|
|
|
+ var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
|
|
|
+ Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", body);
|
|
|
+ Assert.Contains("TenantId = tenantId,", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 同一个 RoleCode 在不同租户必须是不同 RoleId:默认租户沿用固定 Id,其余走雪花号。
|
|
|
+ /// 若两处都用固定 Id 就会主键冲突/退化回单例。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void SameRoleCode_GetsDistinctRoleIdPerTenant()
|
|
|
+ {
|
|
|
+ var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
|
|
|
+ Assert.Contains("? spec.LegacyDefaultTenantRoleId", body);
|
|
|
+ Assert.Contains(": YitIdHelper.NextId()", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>旧的全局 EnsureRole(单例成因)必须已从 S7 种子中移除。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void LegacyGlobalEnsureRole_IsRemovedFromFlowSeed()
|
|
|
+ {
|
|
|
+ var s = CsCode(FlowSeed());
|
|
|
+ Assert.Contains("S7TenantAuthoritySeed.EnsureTenantAuthority(db)", s);
|
|
|
+ Assert.DoesNotContain("ClearFilter().Any(x => x.Code == code)", s);
|
|
|
+ Assert.DoesNotContain("private static void EnsureRoles(", s);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>租户清单取自 SysTenant 注册表,禁止硬编码具体租户 Id / UAT 账号。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("797403760988229")]
|
|
|
+ [InlineData("838257186181189")]
|
|
|
+ [InlineData("838257212780613")]
|
|
|
+ [InlineData("AIDOPDemo")]
|
|
|
+ [InlineData("UATQualityA")]
|
|
|
+ public void TenantAuthoritySeed_HardcodesNoTenantOrAccount(string forbidden)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(forbidden, AuthoritySeed());
|
|
|
+ Assert.DoesNotContain(forbidden, FlowSeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>租户来源必须是 SysTenant,而不是 SysOrg(按 Org 播种正是 57 条定义与租户错配的成因)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void TenantSet_ComesFromSysTenantNotSysOrg()
|
|
|
+ {
|
|
|
+ Assert.Contains("db.Queryable<SysTenant>()", AuthoritySeed());
|
|
|
+ var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
|
|
|
+ Assert.Contains("S7TenantAuthoritySeed.GetActivatableTenantIds(db)", publish);
|
|
|
+ Assert.DoesNotContain("db.Queryable<SysOrg>()", publish);
|
|
|
+ Assert.DoesNotContain("foreach (var orgId in orgIds)", publish);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §十一:定义租户必须等于目标租户 ──
|
|
|
+
|
|
|
+ /// <summary>新定义的 TenantId 必须就是目标租户,不得再经 Org→Tenant 间接解析。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void NewDefinition_TenantIdEqualsTargetTenant()
|
|
|
+ {
|
|
|
+ var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
|
|
|
+ Assert.Contains("TenantId = tenantId,", publish);
|
|
|
+ Assert.DoesNotContain("ApprovalFlowTenantSeed.ResolveTenantId", publish);
|
|
|
+ Assert.Contains("x.TenantId == tenantId", publish);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §十二:历史定义不得被改动 ──
|
|
|
+
|
|
|
+ /// <summary>不得下架 / 改写 / 删除任何历史定义(原 v2→v3 升级会 SetColumns(IsPublished=false))。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void HistoricalDefinitions_AreNeitherUnpublishedNorRewritten()
|
|
|
+ {
|
|
|
+ var s = CsCode(FlowSeed());
|
|
|
+ Assert.DoesNotContain("x.IsPublished == false", s);
|
|
|
+ Assert.DoesNotContain("Updateable<ApprovalFlowEntity>", s);
|
|
|
+ Assert.DoesNotContain("Deleteable", s);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>补发靠新版本号(既有最大 + 1),不原地改已被实例引用的定义。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void NewDefinition_UsesIncrementedVersion()
|
|
|
+ {
|
|
|
+ var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
|
|
|
+ Assert.Contains("Version = (existing?.Version ?? 0) + 1", publish);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>幂等判据是「已含 RoleCode」——重复启动不会反复补发。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void Seeding_IsIdempotentOnRoleCodePresence()
|
|
|
+ {
|
|
|
+ var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
|
|
|
+ Assert.Contains("Contains(FqcInspBillFlowConst.RoleSupervisorCode)", publish);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §27-6:N1 仍是 Initiator ──
|
|
|
+
|
|
|
+ /// <summary>本批不得把 N1 改回 Inspector Role —— 业务设计是「认领人(发起人)」。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void DefinitionN1_RemainsInitiator()
|
|
|
+ {
|
|
|
+ var n1 = Slice(FlowJsonBody(), "NodeName = \"检验员检验\"", "Text = new FlowTextItem");
|
|
|
+ Assert.Contains("ApproverType = nameof(ApproverTypeEnum.Initiator)", n1);
|
|
|
+ Assert.Contains("ApproverIds = \"\"", n1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §十四 / §十五:成员语义只复制既有事实,legacy 绑定保留 ──
|
|
|
+
|
|
|
+ /// <summary>成员复制必须是 COPY 而非 MOVE:不得删除任何既有绑定。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void MembershipCopy_NeverDeletesLegacyBinding()
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain("Deleteable", CsCode(AuthoritySeed()));
|
|
|
+ Assert.Contains("COPY 不是 MOVE", AuthoritySeed()); // 该约定必须写在注释里
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>只复制已存在的授权事实,不凭猜测新增业务成员。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void MembershipCopy_DerivesOnlyFromExistingBindings()
|
|
|
+ {
|
|
|
+ var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
|
|
|
+ Assert.Contains("ur.RoleId == spec.LegacyDefaultTenantRoleId", body);
|
|
|
+ Assert.Contains("u.TenantId != SqlSugarConst.DefaultTenantId", body);
|
|
|
+ // SysUserRole 无唯一键,必须自己防重
|
|
|
+ Assert.Contains("x.UserId == user.Id && x.RoleId == localRoleId.Value", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>Activation Gate:三角色都有本地角色且各有同租户成员,才为该租户发布 RoleCode 定义。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void ActivationGate_RequiresLocalRoleWithSameTenantMember()
|
|
|
+ {
|
|
|
+ var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
|
|
|
+ Assert.Contains("ResolveLocalRoleId(db, tenantId, spec.Code)", body);
|
|
|
+ Assert.Contains("u.TenantId == tenantId", body);
|
|
|
+ Assert.Contains("if (sameTenantMembers == 0) { allReady = false; break; }", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §27-7 / §二十五:本批不得触碰历史实例 ──
|
|
|
+
|
|
|
+ /// <summary>种子与租户权威基线都不得读写 ApprovalFlowInstance / FlowJsonSnapshot。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("ApprovalFlowInstance")]
|
|
|
+ [InlineData("FlowJsonSnapshot")]
|
|
|
+ [InlineData("InitiatorId")]
|
|
|
+ public void Seeding_NeverTouchesExistingInstances(string forbidden)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(forbidden, CsCode(AuthoritySeed()));
|
|
|
+ Assert.DoesNotContain(forbidden, CsCode(FlowSeed()));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── §二十八 / §二十九:回归与顺序 ──
|
|
|
+
|
|
|
+ /// <summary>S7 通用审批旁路守卫必须仍然生效(上一批 CLOSED,不得被本批影响)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void GenericApprovalGuard_StillCoversS7()
|
|
|
+ {
|
|
|
+ var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
|
|
|
+ Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// §29:本批**不得**把 S7 加入 TenantStrictRoleBizTypes ——
|
|
|
+ /// 7 条 Running 仍是 legacy 物理 RoleId 快照,先开守卫会当场把它们全部拦死。
|
|
|
+ /// 正确顺序:local roles → future definition RoleCode → active migration → strict guard。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void StrictRoleGuard_NotYetOpenedForS7()
|
|
|
+ {
|
|
|
+ var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
|
|
|
+ Assert.DoesNotContain("S7_FQC_INSPBILL", set);
|
|
|
+ Assert.Contains("\"IPQC_INSPECTION\"", set);
|
|
|
+ Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>RoleCode 解析必须是租户内解析(这是 RoleCode 方案成立的前提)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void RoleCodeResolver_IsTenantScoped()
|
|
|
+ {
|
|
|
+ var body = Slice(Engine(), "if (codes.Count > 0)", "if (ids.Count == 0)");
|
|
|
+ Assert.Contains("r.TenantId == effectiveTenantId", body);
|
|
|
+ Assert.Contains("r.Status == StatusEnum.Enable", body);
|
|
|
+ Assert.Contains("codes.Contains(r.Code)", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>设计代次常量已升到 v4,且明确它不等于 ApprovalFlow.Version。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void FlowDefinitionVersion_BumpedAndDocumented()
|
|
|
+ {
|
|
|
+ var s = FlowConst();
|
|
|
+ Assert.Contains("public const int FlowDefinitionVersion = 4;", s);
|
|
|
+ Assert.Contains("不等于", s);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string Slice(string src, string from, string to)
|
|
|
+ {
|
|
|
+ var a = src.IndexOf(from, StringComparison.Ordinal);
|
|
|
+ Assert.True(a >= 0, $"未找到起点:{from}");
|
|
|
+ var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
|
|
|
+ return b > a ? src[a..b] : src[a..];
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string FindFile(params string[] parts)
|
|
|
+ {
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
+ while (dir != null)
|
|
|
+ {
|
|
|
+ var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
|
|
|
+ if (File.Exists(candidate)) return candidate;
|
|
|
+ dir = dir.Parent;
|
|
|
+ }
|
|
|
+ throw new FileNotFoundException(string.Join("/", parts));
|
|
|
+ }
|
|
|
+}
|