using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// S6-TENANT-AUTHORITY-1 源码契约测试(Phase E · Resolver 守卫)。 /// /// 与 分文件:迁移(角色 / 成员 / 定义) /// 与守卫是**两个可独立发布的阶段** —— 守卫必须在迁移完成并通过 Runtime 验证之后才允许启用, /// 否则会把仍靠 legacy 跨租户 RoleId 工作的租户当场打成 0 审批人。拆开后两个提交各自自洽。 /// /// 守卫的范围铁律:只覆盖 IPQC_INSPECTION / S6_PROCESS_INSPECTION。 /// S5_IQC_INSPBILL / S7_FQC_INSPBILL 等同样写死了默认租户物理 RoleId,但它们当前正靠跨租户 /// SysUserRole 绑定工作(实测 28 个节点组合),本批不在迁移范围,无条件启用会直接打断它们。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowEngineService 依赖 /// SqlSugarRepository<T>,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。 /// public class S6TenantAuthorityGuardContractTests { private static string Engine() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs")); // ───────────── Resolver 守卫:S6 专属 fail-closed ───────────── /// 守卫必须只覆盖两条 S6 链。 [Fact] public void Guard_ScopedToTheTwoS6BizTypes() { var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};"); Assert.Contains("\"IPQC_INSPECTION\"", set); Assert.Contains("\"S6_PROCESS_INSPECTION\"", set); } /// S5 / S7 不得被本批守卫波及——它们仍持默认租户物理 RoleId 且当前正靠它工作。 [Theory] [InlineData("S5_IQC_INSPBILL")] [InlineData("S7_FQC_INSPBILL")] [InlineData("SMART_OPS_IMPROVEMENT")] [InlineData("EXCEPTION_REPORT")] public void Guard_DoesNotAffectOtherBizTypes(string bizType) { var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};"); Assert.DoesNotContain(bizType, set); } /// 守卫必须是精确白名单查表,不得退化成前缀/模糊匹配把无关业务一起挡掉。 [Fact] public void Guard_UsesExactAllowList_NotFuzzyMatch() { var s = Engine(); Assert.Contains("TenantStrictRoleBizTypes.Contains(bizType)", s); Assert.DoesNotContain("BizType.StartsWith(\"S6", s); Assert.DoesNotContain("BizType.Contains(\"IPQC", s); } /// 守卫必须在 Code 解析之前跑,针对的是 FlowJson 里写死的数字 RoleId。 [Fact] public void Guard_RunsBeforeRoleCodeResolution() { var s = Engine(); var guardAt = s.IndexOf("await EnsureRoleAuthorityTenantScopedAsync(ids, effectiveTenantId, authorityContext);", StringComparison.Ordinal); var codeAt = s.IndexOf("codeRoleIds", StringComparison.Ordinal); Assert.True(guardAt > 0, "未找到守卫调用点"); Assert.True(codeAt > guardAt, "守卫必须先于 RoleCode 解析执行"); } /// 跨租户 RoleId 必须 fail-closed;且刻意不做 auto-heal(不按 Code 隐式替换)。 [Fact] public void Guard_FailsClosedOnCrossTenantRole_AndDoesNotAutoHeal() { var body = Slice(Engine(), "private async Task EnsureRoleAuthorityTenantScopedAsync", "/// \n /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1"); Assert.Contains("r.TenantId != effectiveTenantId", body); Assert.Contains("throw Oops.Oh(", body); // auto-heal 会让坏定义继续成功运行、永远没人去修 Assert.DoesNotContain("codes.Contains(r.Code)", body); Assert.DoesNotContain("roleIds.Add(", body); } /// 三类失败必须可区分,且日志带全部定位字段。 [Theory] [InlineData("CROSS_TENANT_ROLE_REFERENCE")] [InlineData("INVALID_ROLE")] [InlineData("LOCAL_ROLE_HAS_NO_MEMBER")] public void Guard_ClassifiesFailures(string classification) { Assert.Contains(classification, Engine()); } /// 日志必须能定位到具体定义与节点。 [Fact] public void Guard_LogsDiagnosticFields() { var s = Engine(); foreach (var field in new[] { "BizType={BizType}", "FlowId={FlowId}", "NodeId={NodeId}", "RoleRef={RoleRef}", "EffectiveTenantId={TenantId}" }) Assert.Contains(field, s); } /// Branch B 必须保留——历史定义仍可能存 RoleId,本批不是删除 RoleId 支持。 [Fact] public void Guard_KeepsRoleIdBranch() { var s = Engine(); Assert.Contains("var userIds = await _userRoleRep.AsQueryable()", s); Assert.Contains("ids.Contains(ur.RoleId)", s); } /// RoleCode 分支(目标路径)必须始终按当前租户 + 启用状态解析,本批不得改动它。 [Fact] public void RoleCodeBranch_StaysTenantScoped() { var s = Engine(); Assert.Contains("r.TenantId == effectiveTenantId", s); Assert.Contains("r.Status == StatusEnum.Enable", s); Assert.Contains("codes.Contains(r.Code)", s); } /// 成员过滤这道租户收口不得被移除——它是跨租户泄漏的最后一道闸。 [Fact] public void MemberResolution_KeepsTenantScope() { Assert.Contains("u.TenantId == effectiveTenantId", Engine()); } 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)); } }