using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S7; /// /// S7-FQC-INITIATOR-TENANT-GUARD-1 契约测试:阻止 S7 FQC 再产生跨租户 jyfzr / Initiator。 /// /// 要守住的不变量: /// BusinessTenant == jyfzr 用户.TenantId == Instance.Initiator 用户.TenantId。 /// S7 契约是三者恒等(SubmitResult 的负责人门 + GenerateInspBill 的认领人门保证 /// jyfzr == 当前用户 == N1 Assignee),故只要 jyfzr 恒同租户,Initiator 就恒同租户。 /// /// 三道防线:Assign → Claim → 起流边界(GenerateInspBill / SubmitResult)。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:这些服务依赖 /// ISqlSugarClient / UserManager / FlowEngineService,裸 xUnit 进程无法实例化。 /// 真实 ALLOW/DENY 由本批 Runtime R1–R7 覆盖。 /// public class S7FqcInitiatorTenantGuardTests { private static string Guard() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "S7FqcInitiatorTenantGuard.cs")); private static string TaskEntry() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcTaskEntryService.cs")); private static string FlowService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.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")); // ───────────── 守卫本体 ───────────── /// §十七:不得只判「用户存在」,必须显式比较 SysUser.TenantId 与单据租户。 [Fact] public void Guard_ComparesUserTenantAgainstBusinessTenant() { var g = Guard(); Assert.Contains("if (user.TenantId != businessTenantId)", g); Assert.Contains("不属于当前单据租户", g); } /// 存在性与启用状态也要判 —— 三者缺一都不能当负责人。 [Theory] [InlineData("if (user == null)")] [InlineData("if (user.Status != StatusEnum.Enable)")] [InlineData("if (userId <= 0)")] public void Guard_ChecksExistenceAndEnabled(string expected) { Assert.Contains(expected, Guard()); } /// /// 必须跨租户查用户(ClearFilter<ITenantIdFilter>)—— 否则跨租户用户表现为「查不到」, /// 无法把「不存在」与「存在但不属于本租户」区分开,只能给出误导性文案。 /// [Fact] public void Guard_LooksUpUserAcrossTenantsOnPurpose() { var g = Guard(); Assert.Contains("ClearFilter()", g); Assert.Contains("db.Queryable()", g); } /// §二十七:文案不得泄漏 TenantId / RoleId / 内部过滤器。 [Theory] [InlineData("TenantId=")] [InlineData("RoleId")] [InlineData("ITenantIdFilter}")] public void Guard_MessageDoesNotLeakInternals(string leak) { var messages = string.Join("\n", Guard().Split('\n').Where(l => l.Contains("Oops.Oh(", StringComparison.Ordinal))); Assert.DoesNotContain(leak, messages); } /// 存量脏数据:jyfzr 可能根本不是用户 Id,必须显式拒绝而不是当 0 处理。 [Fact] public void Guard_RejectsNonNumericLegacyOwnerValue() { var body = Slice(Guard(), "public static async Task EnsureBillOwnerTenantSafeAsync", "private sealed class GuardUser"); Assert.Contains("!long.TryParse(raw, out var ownerId) || ownerId <= 0", body); Assert.Contains("检验负责人记录无效", body); Assert.Contains("尚未指定检验负责人", body); } /// 守卫只读不写 —— 任何 blocked 路径都不能产生副作用。 [Theory] [InlineData("AsInsertable")] [InlineData("AsUpdateable")] [InlineData("ExecuteCommandAsync")] [InlineData("Deleteable")] public void Guard_PerformsNoMutation(string write) { Assert.DoesNotContain(write, CsCode(Guard())); } // ───────────── §二十九 Assign ───────────── /// Assign 必须在写 jyfzr **之前**校验目标用户。 [Fact] public void Assign_GuardsBeforeWritingOwner() { var body = Slice(TaskEntry(), "public async Task Assign(", "/// 优先级调整"); var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal); var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal); Assert.True(guardAt > 0, "Assign 缺少租户守卫"); Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入"); Assert.Contains("EnsureCanOwnFqcAsync(_db, input.InspectorUserId, tenantId, \"指定检验负责人\")", body); } // ───────────── §三十 Claim ───────────── /// Claim 必须校验**当前用户实体**的租户,而不是只看上下文租户等于单据租户。 [Fact] public void Claim_GuardsCurrentUserEntityTenant() { var body = Slice(TaskEntry(), "public async Task Claim(", "/// 检验员调配"); var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal); var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal); Assert.True(guardAt > 0, "Claim 缺少租户守卫"); Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入"); Assert.Contains("EnsureCanOwnFqcAsync(_db, _userManager.UserId, tenantId, \"当前用户\")", body); } /// /// §十九/§二十:超管切租户后 token 的 TenantId claim 是目标租户、SysUser.TenantId 仍是默认租户。 /// 守卫比对的必须是 SysUser.TenantId(跨租户查出来的实体),不是 _userManager.TenantId。 /// [Fact] public void Claim_DoesNotTrustTokenTenantAsUserTenant() { var g = Guard(); // 守卫**实现**内不得出现「用 _userManager.TenantId 当作用户归属」的写法 Assert.DoesNotContain("_userManager", CsCode(g)); Assert.Contains("user.TenantId != businessTenantId", g); } // ───────────── §三十一 起流边界 ───────────── /// GenerateInspBill 在 EnsureFlowAsync(起流)之前校验业务行上的 jyfzr。 [Fact] public void GenerateInspBill_GuardsBeforeStartFlow() { var body = Slice(TaskEntry(), "public async Task GenerateInspBill(", "private async Task EnsureFlowAsync"); var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal); var startAt = body.IndexOf("EnsureFlowAsync(bill.Id", StringComparison.Ordinal); Assert.True(guardAt > 0, "GenerateInspBill 缺少起流边界守卫"); Assert.True(startAt > guardAt, "守卫必须早于起流"); } /// SubmitResult 在事务(含 StartFlow)之前校验 jyfzr。 [Fact] public void SubmitResult_GuardsBeforeTransactionAndStartFlow() { var body = Slice(FlowService(), "public async Task SubmitResult(", "/// 主管审核通过"); var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal); var tranAt = body.IndexOf("UseTranAsync", StringComparison.Ordinal); var startAt = body.IndexOf("_flowEngine.StartFlow", StringComparison.Ordinal); Assert.True(guardAt > 0, "SubmitResult 缺少起流边界守卫"); Assert.True(tranAt > guardAt, "守卫必须早于事务开启"); Assert.True(startAt > guardAt, "守卫必须早于 StartFlow"); } /// 业务租户必须来自业务上下文/业务行,不是 token 直接透传(§十)。 [Fact] public void StartFlowBoundary_UsesBusinessTenant() { Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, apply.OwnerId, tenantId)", TaskEntry()); Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, jyfzrOwner, gateTenantId)", FlowService()); } /// 三道防线都在,形成 defense-in-depth(§十五)。 [Fact] public void ThreeLayersOfDefenseArePresent() { var t = TaskEntry(); Assert.Equal(2, CountOf(t, "EnsureCanOwnFqcAsync")); // Assign + Claim Assert.Equal(1, CountOf(t, "EnsureBillOwnerTenantSafeAsync")); // GenerateInspBill Assert.Equal(1, CountOf(FlowService(), "EnsureBillOwnerTenantSafeAsync")); // SubmitResult } // ───────────── §二十四/§五十三 范围守卫 ───────────── /// 不得为 S7 改 FlowEngine:引擎照实记录操作者是正确行为,改它会扩散到全部 BizType。 [Theory] [InlineData("S7_FQC_INSPBILL")] [InlineData("FqcInspBillFlowConst")] public void FlowEngine_HasNoS7SpecialCase(string forbidden) { Assert.DoesNotContain(forbidden, CsCode(Engine())); } /// 引擎仍按「当前用户」捕获 Initiator —— 本批没有改这条语义。 [Fact] public void FlowEngine_StillCapturesCurrentUserAsInitiator() { Assert.Contains("InitiatorId = _userManager.UserId,", Engine()); } /// 守卫不得改写业务语义:jyfzr 仍是 SysUser.Id,Initiator 仍 == jyfzr == 当前用户。 [Fact] public void Contract_InitiatorEqualsOwnerEqualsCurrentUser_Preserved() { var f = FlowService(); Assert.Contains("当前用户不是该检验单负责人,无权提交检验结果", f); Assert.Contains("jyfzr==当前用户==N1 Assignee 三者一致", f); Assert.Contains("尚未认领或非认领人操作!", TaskEntry()); } // ───────────── §四十四/§四十五 回归 ───────────── /// S7 通用审批旁路守卫必须仍然生效。 [Fact] public void GenericApprovalGuard_StillCoversS7() { var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};"); Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict); } /// 本批不得把 S7 加入 TenantStrictRoleBizTypes(属另一治理边界)。 [Fact] public void StrictRoleGuard_StillNotOpenedForS7() { var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};"); Assert.DoesNotContain("S7_FQC_INSPBILL", set); } /// §四十七/§五十五:本批是 prevention,不得出现任何数据迁移/历史回写。 [Theory] [InlineData("ApprovalFlowInstance")] [InlineData("ApprovalFlowTask")] [InlineData("ApprovalFlowLog")] [InlineData("840240525086789")] public void Guard_NeverTouchesFlowOrHistoricalData(string forbidden) { Assert.DoesNotContain(forbidden, CsCode(Guard())); } /// /// **去注释**后的 C# 源码视图。这些文件的注释会成段引用被淘汰/被解释的写法 /// (如 FlowEngineService 注释里提到 S7_FQC_INSPBILL、守卫文档里提到 _userManager 与 /// ApprovalFlowInstance)来说明「为何如此设计」;裸文本 DoesNotContain 会把说明当成违规实现。 /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。 /// 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 => { var t = l.TrimStart(); return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("///", StringComparison.Ordinal); }); return string.Join("\n", kept); } private static int CountOf(string src, string needle) { int n = 0, i = 0; while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; } return n; } 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)); } }