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