| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- 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));
- }
- }
|