S8TenantIsolationContractTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.Job;
  5. using Admin.NET.Plugin.AiDOP.Service.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  7. using System.Reflection;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  10. public class S8TenantIsolationContractTests
  11. {
  12. private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
  13. /// <summary>
  14. /// S8-TENANT-ONLY-BATCH6:自动建单要求显式租户(不再要求工厂)。
  15. /// 反向断言不可省:旧的三参重载一旦被加回来,调用方会悄悄退回按工厂建单。
  16. /// </summary>
  17. [Fact]
  18. public void AutoExceptionCreation_RequiresExplicitTenant_NotFactory()
  19. {
  20. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  21. nameof(S8ManualReportService.CreateFromWatchAsync),
  22. [typeof(long), typeof(S8WatchHitResult)]));
  23. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  24. nameof(S8ManualReportService.CreateFromHitAsync),
  25. [typeof(long), typeof(S8RuleHit)]));
  26. Assert.Null(typeof(S8ManualReportService).GetMethod(
  27. nameof(S8ManualReportService.CreateFromWatchAsync),
  28. [typeof(long), typeof(long), typeof(S8WatchHitResult)]));
  29. Assert.Null(typeof(S8ManualReportService).GetMethod(
  30. nameof(S8ManualReportService.CreateFromHitAsync),
  31. [typeof(long), typeof(long), typeof(S8RuleHit)]));
  32. }
  33. /// <summary>
  34. /// S8-TENANT-ONLY-BATCH5:规则调度的遍历维度已从 (租户, 工厂) 收敛为租户。
  35. /// 断言的是「新形态存在 + 旧形态不存在」——只断言新方法在,挡不住有人把旧方法加回来。
  36. ///
  37. /// <para>S8-TENANT-ONLY-BATCH6:<c>S8ActiveFlowWatchService</c> 的扫描维度同步收敛。
  38. /// 它原来的 SQL 带 <c>AND e.factory_id &gt; 0</c>,而本批之后新建异常的 factory_id 恒为 0——
  39. /// 不改就等于卡死扫描静默漏掉本批之后的全部异常。</para>
  40. /// </summary>
  41. [Fact]
  42. public void SchedulerDiscoversTenants_NotTenantFactoryPairs()
  43. {
  44. Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod(
  45. nameof(S8WatchSchedulerService.ListEnabledTenantsAsync)));
  46. Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
  47. Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod(
  48. nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync)));
  49. Assert.Null(typeof(S8ActiveFlowWatchService).GetMethod("ListActiveScopesAsync"));
  50. }
  51. [Fact]
  52. public void ManualReport_ResolvesTenantFromServerContext()
  53. {
  54. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  55. "ResolveTrustedTenantId", BindingFlags.Instance | BindingFlags.NonPublic));
  56. Assert.Null(typeof(S8ManualReportService).GetMethod(
  57. "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic));
  58. }
  59. [Fact]
  60. public void BackgroundJobs_DoNotKeepLegacyDefaultTenantConstants()
  61. {
  62. const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
  63. Assert.Null(typeof(S8WatchSchedulerJob).GetField("DefaultTenantId", flags));
  64. Assert.Null(typeof(S8ActiveFlowStuckScanJob).GetField("DefaultTenantId", flags));
  65. }
  66. // ───────────────────────── S8-TENANT-FACTORY-P0-CLOSURE-1 ─────────────────────────
  67. /// <summary>可信作用域解析器必须存在,且只暴露无参解析入口(不接受调用方传入 tenant / factory)。</summary>
  68. [Fact]
  69. public void TrustedScopeResolver_ExposesParameterlessServerSideResolve()
  70. {
  71. var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync));
  72. Assert.NotNull(resolve);
  73. Assert.Empty(resolve!.GetParameters());
  74. // S8-TENANT-ONLY-BATCH6:可信作用域只剩租户。
  75. // FactoryOrgType("501",租户内工厂类型组织)与整条工厂解析链一并删除——
  76. // 它解析出来的值曾被当作数据边界用,而工厂在真库里横跨租户,根本不构成边界。
  77. Assert.Null(typeof(S8TrustedScopeResolver).GetField(
  78. "FactoryOrgType", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
  79. Assert.Null(typeof(S8TrustedScopeResolver).GetMethod(
  80. "ResolveFactoryIdAsync", Instance));
  81. var scope = new S8TrustedScope(11L);
  82. Assert.Equal(11L, scope.TenantId);
  83. // LegacyFactoryId 只服务「还要往 factory_id 列写值」的旧代码,恒为平台默认哨兵 0,
  84. // 刻意不叫 FactoryId —— 那个名字会让人以为它还是个作用域。
  85. Assert.Equal(S8ConfigScope.GlobalFactoryId, scope.LegacyFactoryId);
  86. Assert.Null(typeof(S8TrustedScope).GetProperty("FactoryId"));
  87. }
  88. /// <summary>
  89. /// 所有 tenant+factory-owned 配置对象的写入口必须强制要求 <see cref="S8TrustedScope"/>;
  90. /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
  91. /// </summary>
  92. [Theory]
  93. // S8-STANDARD-DATASET-HARD-CUTOVER-1:AlertRule 与 DataSource 两组服务/实体已物理删除,
  94. // 其条目随之移除——类型都不存在了,「写入口必须带作用域」自然无从违反。
  95. // 这些功能的消失本身由 S8LegacySymbolsRemovedTests 断言。
  96. [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))]
  97. [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))]
  98. [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
  99. [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]
  100. [InlineData(typeof(S8ExceptionTypeService), typeof(AdoS8ExceptionType))]
  101. // S8-RULE-GOVERNANCE-BATCH3:S8WatchRuleService 已移出本用例 ——
  102. // 它的 Create / Update / Delete 三个业务写入口全部退役,不再存在"带作用域的写入口"这回事。
  103. // 取而代之的断言见 S8RuleCreationRetiredTests:业务根本不能创建 / 删除规则。
  104. public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType)
  105. {
  106. // CreateAsync 用「前两个形参匹配」而非精确签名匹配。
  107. // 触发原因(origin 可选形参)已随 S8-STANDARD-DATASET-HARD-CUTOVER-1 消失,
  108. // 但前缀匹配予以保留:真正要保证的契约是「entity + S8TrustedScope 必须显式出现在最前」,
  109. // 后置可选形参不破坏该契约。改回精确匹配只会让下一次同类扩展再次误报。
  110. Assert.True(
  111. HasScopedWriteEntry(serviceType, "CreateAsync", entityType),
  112. $"{serviceType.Name}.CreateAsync 必须以 ({entityType.Name}, S8TrustedScope) 开头");
  113. Assert.NotNull(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType, typeof(S8TrustedScope)]));
  114. Assert.NotNull(serviceType.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)]));
  115. // 旧的无作用域重载必须彻底消失,否则调用方可能悄悄退回不安全路径。
  116. Assert.Null(serviceType.GetMethod("CreateAsync", [entityType]));
  117. Assert.Null(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType]));
  118. Assert.Null(serviceType.GetMethod("DeleteAsync", [typeof(long)]));
  119. }
  120. /// <summary>
  121. /// 写入口是否以 (entity, S8TrustedScope) 开头。允许其后存在形参,
  122. /// 但作用域必须是第 2 个形参且不可省略——调用方无法只传实体就完成写入。
  123. /// </summary>
  124. private static bool HasScopedWriteEntry(Type serviceType, string methodName, Type entityType) =>
  125. serviceType.GetMethods(BindingFlags.Instance | BindingFlags.Public)
  126. .Where(m => m.Name == methodName)
  127. .Any(m =>
  128. {
  129. var p = m.GetParameters();
  130. return p.Length >= 2
  131. && p[0].ParameterType == entityType
  132. && p[1].ParameterType == typeof(S8TrustedScope)
  133. && !p[1].IsOptional;
  134. });
  135. /// <summary>监视规则的每个按 Id 的运行态动作都必须带可信作用域。</summary>
  136. [Theory]
  137. [InlineData("RunNowAsync")]
  138. [InlineData("PauseAsync")]
  139. [InlineData("ResumeAsync")]
  140. // S8-RULE-GOVERNANCE-BATCH3:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。
  141. [InlineData("EnableAsync")]
  142. [InlineData("DisableAsync")]
  143. public void WatchRuleRuntimeActions_RequireTrustedScope(string method)
  144. {
  145. Assert.NotNull(typeof(S8WatchRuleService).GetMethod(method, [typeof(long), typeof(S8TrustedScope)]));
  146. Assert.Null(typeof(S8WatchRuleService).GetMethod(method, [typeof(long)]));
  147. }
  148. // S8-RULE-GOVERNANCE-BATCH3:原 ConfigDraft_ByIdEntryPoints_RequireTrustedScope 已删除 ——
  149. // S8ConfigDraftService / AdoS8ConfigDraftsController / 草稿 DTO 已整体退役。
  150. // 「草稿的入口要不要带作用域」这个问题随入口一起消失了;
  151. // 「草稿链路不存在」由 S8RuleCreationRetiredTests 断言。
  152. /// <summary>
  153. /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列,
  154. /// 归属必须由父异常派生;因此必须提供作用域校验入口。
  155. /// </summary>
  156. [Fact]
  157. public void ExceptionSubResources_ExposeParentScopeGuard()
  158. {
  159. var guard = typeof(S8DecisionService).GetMethod(
  160. nameof(S8DecisionService.IsExceptionInScopeAsync),
  161. [typeof(long), typeof(long)]);
  162. Assert.NotNull(guard);
  163. Assert.Equal(typeof(Task<bool>), guard!.ReturnType);
  164. Assert.Null(typeof(S8DecisionService).GetMethod(
  165. nameof(S8DecisionService.IsExceptionInScopeAsync),
  166. [typeof(long), typeof(long), typeof(long)]));
  167. }
  168. /// <summary>异常流转的补充说明同样按可信租户绑行,不得只按裸 Id 写他租户时间线。</summary>
  169. [Fact]
  170. public void ExceptionComment_RequiresTenant()
  171. {
  172. Assert.NotNull(typeof(S8TaskFlowService).GetMethod(
  173. nameof(S8TaskFlowService.CommentAsync),
  174. [typeof(long), typeof(long), typeof(string)]));
  175. Assert.Null(typeof(S8TaskFlowService).GetMethod(
  176. nameof(S8TaskFlowService.CommentAsync), [typeof(long), typeof(string)]));
  177. }
  178. /// <summary>
  179. /// 越权 Id 与不存在 Id 必须给出同一响应(404),不泄露「他租户存在该资源」;
  180. /// 同时继承 S8BizException,保证既有只捕获 S8BizException 的调用方安全降级为 400 而不是 500。
  181. /// </summary>
  182. [Fact]
  183. public void NotFound_IsIndistinguishableAndBackwardCompatible()
  184. {
  185. Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8NotFoundException)));
  186. var ex = new S8NotFoundException();
  187. Assert.IsAssignableFrom<S8BizException>(ex);
  188. }
  189. /// <summary>OrderFlow / ManualReport 两条既有正确范式不得回退为信任客户端作用域。</summary>
  190. [Fact]
  191. public void ReferenceTrustedPaths_DoNotRegress()
  192. {
  193. // OrderFlow:服务端自解析作用域,且不暴露任何接受 tenant/factory 的公共查询入口。
  194. // OrderFlow 明确不在 Batch 6 范围内(§三),其 ResolveFactoryIdAsync 保持原状,此处照旧断言。
  195. var orderFlow = typeof(Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow.S8OrderFlowService);
  196. Assert.NotNull(orderFlow.GetMethod("ResolveTenantId", Instance));
  197. Assert.NotNull(orderFlow.GetMethod("ResolveFactoryIdAsync", Instance));
  198. Assert.NotNull(orderFlow.GetMethod("ResolveScopeAsync", Instance));
  199. // ManualReport:建单与表单选项都必须经服务端可信租户。
  200. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  201. "ResolveTrustedTenantId", BindingFlags.Instance | BindingFlags.NonPublic));
  202. }
  203. // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有两条数据源用例
  204. // · DataSourceEndpoint_NeverExposesPlaintextSecret(连接串脱敏)
  205. // · DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired(写入口已退役)
  206. // 二者守的都是「S8 存了含凭据的连接串,别泄露、别被覆盖」这一风险。
  207. // 该风险随 ado_s8_data_source 与 S8DataSourceService 一并物理消失 ——
  208. // S8 已不再持有任何连接串,因此没有可脱敏、也没有可覆盖的东西。
  209. // 「数据源功能不存在」由 S8LegacySymbolsRemovedTests 断言。
  210. /// <summary>
  211. /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错)。
  212. ///
  213. /// <para>S8-TENANT-ONLY-BATCH6 之后两者语义已分家:<c>TenantId</c> 仍由 Controller 用可信作用域覆盖;
  214. /// <c>FactoryId</c> 则**彻底失效**——Controller 不再赋值,Service 也不再读,
  215. /// 保留它纯粹是为了老前端继续发送该字段时不报 400。
  216. /// 「Service 不得再读 q.FactoryId」由 <c>S8TenantOnlyExceptionScopeTests</c> 的源码扫描守。</para>
  217. /// </summary>
  218. [Fact]
  219. public void QueryDtos_KeepScopeFieldsForCompatibilityOnly()
  220. {
  221. foreach (var t in new[]
  222. {
  223. typeof(AdoS8ExceptionQueryDto),
  224. typeof(AdoS8MonitoringSummaryQueryDto),
  225. typeof(AdoS8DetectionLogQueryDto),
  226. typeof(AdoS8NotificationLogQueryDto),
  227. typeof(AdoS8IssueLedgerQueryDto),
  228. typeof(AdoS8CellDataQueryDto),
  229. })
  230. {
  231. var tenant = t.GetProperty("TenantId");
  232. var factory = t.GetProperty("FactoryId");
  233. Assert.NotNull(tenant);
  234. Assert.NotNull(factory);
  235. // 必须可写,Controller 才能用可信作用域覆盖客户端传入值。
  236. Assert.True(tenant!.CanWrite, $"{t.Name}.TenantId 必须可写,否则 Controller 无法覆盖客户端作用域");
  237. Assert.True(factory!.CanWrite, $"{t.Name}.FactoryId 必须可写,否则 Controller 无法覆盖客户端作用域");
  238. }
  239. }
  240. }