S7FqcInitiatorTenantGuardTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S7;
  3. /// <summary>
  4. /// S7-FQC-INITIATOR-TENANT-GUARD-1 契约测试:阻止 S7 FQC 再产生跨租户 jyfzr / Initiator。
  5. ///
  6. /// 要守住的不变量:
  7. /// <c>BusinessTenant == jyfzr 用户.TenantId == Instance.Initiator 用户.TenantId</c>。
  8. /// S7 契约是三者恒等(SubmitResult 的负责人门 + GenerateInspBill 的认领人门保证
  9. /// jyfzr == 当前用户 == N1 Assignee),故只要 jyfzr 恒同租户,Initiator 就恒同租户。
  10. ///
  11. /// 三道防线:Assign → Claim → 起流边界(GenerateInspBill / SubmitResult)。
  12. ///
  13. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:这些服务依赖
  14. /// ISqlSugarClient / UserManager / FlowEngineService,裸 xUnit 进程无法实例化。
  15. /// 真实 ALLOW/DENY 由本批 Runtime R1–R7 覆盖。
  16. /// </summary>
  17. public class S7FqcInitiatorTenantGuardTests
  18. {
  19. private static string Guard() => File.ReadAllText(FindFile(
  20. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "S7FqcInitiatorTenantGuard.cs"));
  21. private static string TaskEntry() => File.ReadAllText(FindFile(
  22. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcTaskEntryService.cs"));
  23. private static string FlowService() => File.ReadAllText(FindFile(
  24. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
  25. private static string Engine() => File.ReadAllText(FindFile(
  26. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  27. private static string FlowTaskService() => File.ReadAllText(FindFile(
  28. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  29. // ───────────── 守卫本体 ─────────────
  30. /// <summary>§十七:不得只判「用户存在」,必须显式比较 SysUser.TenantId 与单据租户。</summary>
  31. [Fact]
  32. public void Guard_ComparesUserTenantAgainstBusinessTenant()
  33. {
  34. var g = Guard();
  35. Assert.Contains("if (user.TenantId != businessTenantId)", g);
  36. Assert.Contains("不属于当前单据租户", g);
  37. }
  38. /// <summary>存在性与启用状态也要判 —— 三者缺一都不能当负责人。</summary>
  39. [Theory]
  40. [InlineData("if (user == null)")]
  41. [InlineData("if (user.Status != StatusEnum.Enable)")]
  42. [InlineData("if (userId <= 0)")]
  43. public void Guard_ChecksExistenceAndEnabled(string expected)
  44. {
  45. Assert.Contains(expected, Guard());
  46. }
  47. /// <summary>
  48. /// 必须跨租户查用户(ClearFilter&lt;ITenantIdFilter&gt;)—— 否则跨租户用户表现为「查不到」,
  49. /// 无法把「不存在」与「存在但不属于本租户」区分开,只能给出误导性文案。
  50. /// </summary>
  51. [Fact]
  52. public void Guard_LooksUpUserAcrossTenantsOnPurpose()
  53. {
  54. var g = Guard();
  55. Assert.Contains("ClearFilter<ITenantIdFilter>()", g);
  56. Assert.Contains("db.Queryable<SysUser>()", g);
  57. }
  58. /// <summary>§二十七:文案不得泄漏 TenantId / RoleId / 内部过滤器。</summary>
  59. [Theory]
  60. [InlineData("TenantId=")]
  61. [InlineData("RoleId")]
  62. [InlineData("ITenantIdFilter}")]
  63. public void Guard_MessageDoesNotLeakInternals(string leak)
  64. {
  65. var messages = string.Join("\n", Guard().Split('\n').Where(l => l.Contains("Oops.Oh(", StringComparison.Ordinal)));
  66. Assert.DoesNotContain(leak, messages);
  67. }
  68. /// <summary>存量脏数据:jyfzr 可能根本不是用户 Id,必须显式拒绝而不是当 0 处理。</summary>
  69. [Fact]
  70. public void Guard_RejectsNonNumericLegacyOwnerValue()
  71. {
  72. var body = Slice(Guard(), "public static async Task EnsureBillOwnerTenantSafeAsync", "private sealed class GuardUser");
  73. Assert.Contains("!long.TryParse(raw, out var ownerId) || ownerId <= 0", body);
  74. Assert.Contains("检验负责人记录无效", body);
  75. Assert.Contains("尚未指定检验负责人", body);
  76. }
  77. /// <summary>守卫只读不写 —— 任何 blocked 路径都不能产生副作用。</summary>
  78. [Theory]
  79. [InlineData("AsInsertable")]
  80. [InlineData("AsUpdateable")]
  81. [InlineData("ExecuteCommandAsync")]
  82. [InlineData("Deleteable")]
  83. public void Guard_PerformsNoMutation(string write)
  84. {
  85. Assert.DoesNotContain(write, CsCode(Guard()));
  86. }
  87. // ───────────── §二十九 Assign ─────────────
  88. /// <summary>Assign 必须在写 jyfzr **之前**校验目标用户。</summary>
  89. [Fact]
  90. public void Assign_GuardsBeforeWritingOwner()
  91. {
  92. var body = Slice(TaskEntry(), "public async Task<object> Assign(", "/// <summary>优先级调整");
  93. var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
  94. var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
  95. Assert.True(guardAt > 0, "Assign 缺少租户守卫");
  96. Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
  97. Assert.Contains("EnsureCanOwnFqcAsync(_db, input.InspectorUserId, tenantId, \"指定检验负责人\")", body);
  98. }
  99. // ───────────── §三十 Claim ─────────────
  100. /// <summary>Claim 必须校验**当前用户实体**的租户,而不是只看上下文租户等于单据租户。</summary>
  101. [Fact]
  102. public void Claim_GuardsCurrentUserEntityTenant()
  103. {
  104. var body = Slice(TaskEntry(), "public async Task<object> Claim(", "/// <summary>检验员调配");
  105. var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
  106. var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
  107. Assert.True(guardAt > 0, "Claim 缺少租户守卫");
  108. Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
  109. Assert.Contains("EnsureCanOwnFqcAsync(_db, _userManager.UserId, tenantId, \"当前用户\")", body);
  110. }
  111. /// <summary>
  112. /// §十九/§二十:超管切租户后 token 的 TenantId claim 是目标租户、SysUser.TenantId 仍是默认租户。
  113. /// 守卫比对的必须是 SysUser.TenantId(跨租户查出来的实体),不是 _userManager.TenantId。
  114. /// </summary>
  115. [Fact]
  116. public void Claim_DoesNotTrustTokenTenantAsUserTenant()
  117. {
  118. var g = Guard();
  119. // 守卫**实现**内不得出现「用 _userManager.TenantId 当作用户归属」的写法
  120. Assert.DoesNotContain("_userManager", CsCode(g));
  121. Assert.Contains("user.TenantId != businessTenantId", g);
  122. }
  123. // ───────────── §三十一 起流边界 ─────────────
  124. /// <summary>GenerateInspBill 在 EnsureFlowAsync(起流)之前校验业务行上的 jyfzr。</summary>
  125. [Fact]
  126. public void GenerateInspBill_GuardsBeforeStartFlow()
  127. {
  128. var body = Slice(TaskEntry(), "public async Task<FqcTaskGenerateOutput> GenerateInspBill(", "private async Task EnsureFlowAsync");
  129. var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
  130. var startAt = body.IndexOf("EnsureFlowAsync(bill.Id", StringComparison.Ordinal);
  131. Assert.True(guardAt > 0, "GenerateInspBill 缺少起流边界守卫");
  132. Assert.True(startAt > guardAt, "守卫必须早于起流");
  133. }
  134. /// <summary>SubmitResult 在事务(含 StartFlow)之前校验 jyfzr。</summary>
  135. [Fact]
  136. public void SubmitResult_GuardsBeforeTransactionAndStartFlow()
  137. {
  138. var body = Slice(FlowService(), "public async Task<FqcFlowStateOutput> SubmitResult(", "/// <summary>主管审核通过");
  139. var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
  140. var tranAt = body.IndexOf("UseTranAsync", StringComparison.Ordinal);
  141. var startAt = body.IndexOf("_flowEngine.StartFlow", StringComparison.Ordinal);
  142. Assert.True(guardAt > 0, "SubmitResult 缺少起流边界守卫");
  143. Assert.True(tranAt > guardAt, "守卫必须早于事务开启");
  144. Assert.True(startAt > guardAt, "守卫必须早于 StartFlow");
  145. }
  146. /// <summary>业务租户必须来自业务上下文/业务行,不是 token 直接透传(§十)。</summary>
  147. [Fact]
  148. public void StartFlowBoundary_UsesBusinessTenant()
  149. {
  150. Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, apply.OwnerId, tenantId)", TaskEntry());
  151. Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, jyfzrOwner, gateTenantId)", FlowService());
  152. }
  153. /// <summary>三道防线都在,形成 defense-in-depth(§十五)。</summary>
  154. [Fact]
  155. public void ThreeLayersOfDefenseArePresent()
  156. {
  157. var t = TaskEntry();
  158. Assert.Equal(2, CountOf(t, "EnsureCanOwnFqcAsync")); // Assign + Claim
  159. Assert.Equal(1, CountOf(t, "EnsureBillOwnerTenantSafeAsync")); // GenerateInspBill
  160. Assert.Equal(1, CountOf(FlowService(), "EnsureBillOwnerTenantSafeAsync")); // SubmitResult
  161. }
  162. // ───────────── §二十四/§五十三 范围守卫 ─────────────
  163. /// <summary>不得为 S7 改 FlowEngine:引擎照实记录操作者是正确行为,改它会扩散到全部 BizType。</summary>
  164. [Theory]
  165. [InlineData("S7_FQC_INSPBILL")]
  166. [InlineData("FqcInspBillFlowConst")]
  167. public void FlowEngine_HasNoS7SpecialCase(string forbidden)
  168. {
  169. Assert.DoesNotContain(forbidden, CsCode(Engine()));
  170. }
  171. /// <summary>引擎仍按「当前用户」捕获 Initiator —— 本批没有改这条语义。</summary>
  172. [Fact]
  173. public void FlowEngine_StillCapturesCurrentUserAsInitiator()
  174. {
  175. Assert.Contains("InitiatorId = _userManager.UserId,", Engine());
  176. }
  177. /// <summary>守卫不得改写业务语义:jyfzr 仍是 SysUser.Id,Initiator 仍 == jyfzr == 当前用户。</summary>
  178. [Fact]
  179. public void Contract_InitiatorEqualsOwnerEqualsCurrentUser_Preserved()
  180. {
  181. var f = FlowService();
  182. Assert.Contains("当前用户不是该检验单负责人,无权提交检验结果", f);
  183. Assert.Contains("jyfzr==当前用户==N1 Assignee 三者一致", f);
  184. Assert.Contains("尚未认领或非认领人操作!", TaskEntry());
  185. }
  186. // ───────────── §四十四/§四十五 回归 ─────────────
  187. /// <summary>S7 通用审批旁路守卫必须仍然生效。</summary>
  188. [Fact]
  189. public void GenericApprovalGuard_StillCoversS7()
  190. {
  191. var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
  192. Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
  193. }
  194. /// <summary>本批不得把 S7 加入 TenantStrictRoleBizTypes(属另一治理边界)。</summary>
  195. [Fact]
  196. public void StrictRoleGuard_StillNotOpenedForS7()
  197. {
  198. var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
  199. Assert.DoesNotContain("S7_FQC_INSPBILL", set);
  200. }
  201. /// <summary>§四十七/§五十五:本批是 prevention,不得出现任何数据迁移/历史回写。</summary>
  202. [Theory]
  203. [InlineData("ApprovalFlowInstance")]
  204. [InlineData("ApprovalFlowTask")]
  205. [InlineData("ApprovalFlowLog")]
  206. [InlineData("840240525086789")]
  207. public void Guard_NeverTouchesFlowOrHistoricalData(string forbidden)
  208. {
  209. Assert.DoesNotContain(forbidden, CsCode(Guard()));
  210. }
  211. /// <summary>
  212. /// **去注释**后的 C# 源码视图。这些文件的注释会成段引用被淘汰/被解释的写法
  213. /// (如 FlowEngineService 注释里提到 S7_FQC_INSPBILL、守卫文档里提到 _userManager 与
  214. /// ApprovalFlowInstance)来说明「为何如此设计」;裸文本 DoesNotContain 会把说明当成违规实现。
  215. /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
  216. /// </summary>
  217. private static string CsCode(string src)
  218. {
  219. var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
  220. var kept = s.Split('\n').Where(l =>
  221. {
  222. var t = l.TrimStart();
  223. return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("///", StringComparison.Ordinal);
  224. });
  225. return string.Join("\n", kept);
  226. }
  227. private static int CountOf(string src, string needle)
  228. {
  229. int n = 0, i = 0;
  230. while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
  231. return n;
  232. }
  233. private static string Slice(string src, string from, string to)
  234. {
  235. var a = src.IndexOf(from, StringComparison.Ordinal);
  236. Assert.True(a >= 0, $"未找到起点:{from}");
  237. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  238. return b > a ? src[a..b] : src[a..];
  239. }
  240. private static string FindFile(params string[] parts)
  241. {
  242. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  243. while (dir != null)
  244. {
  245. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  246. if (File.Exists(candidate)) return candidate;
  247. dir = dir.Parent;
  248. }
  249. throw new FileNotFoundException(string.Join("/", parts));
  250. }
  251. }