S8TenantIsolationContractTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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. [Fact]
  14. public void AutoExceptionCreation_RequiresExplicitTenantAndFactory()
  15. {
  16. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  17. nameof(S8ManualReportService.CreateFromWatchAsync),
  18. [typeof(long), typeof(long), typeof(S8WatchHitResult)]));
  19. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  20. nameof(S8ManualReportService.CreateFromHitAsync),
  21. [typeof(long), typeof(long), typeof(S8RuleHit)]));
  22. }
  23. [Fact]
  24. public void SchedulerServices_DiscoverValidTenantScopes()
  25. {
  26. Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod(
  27. nameof(S8WatchSchedulerService.ListEnabledScopesAsync)));
  28. Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod(
  29. nameof(S8ActiveFlowWatchService.ListActiveScopesAsync)));
  30. }
  31. [Fact]
  32. public void ManualReport_ResolvesTenantAndFactoryFromServerContext()
  33. {
  34. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  35. "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic));
  36. }
  37. [Fact]
  38. public void BackgroundJobs_DoNotKeepLegacyDefaultTenantConstants()
  39. {
  40. const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
  41. Assert.Null(typeof(S8WatchSchedulerJob).GetField("DefaultTenantId", flags));
  42. Assert.Null(typeof(S8ActiveFlowStuckScanJob).GetField("DefaultTenantId", flags));
  43. }
  44. // ───────────────────────── S8-TENANT-FACTORY-P0-CLOSURE-1 ─────────────────────────
  45. /// <summary>可信作用域解析器必须存在,且只暴露无参解析入口(不接受调用方传入 tenant / factory)。</summary>
  46. [Fact]
  47. public void TrustedScopeResolver_ExposesParameterlessServerSideResolve()
  48. {
  49. var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync));
  50. Assert.NotNull(resolve);
  51. Assert.Empty(resolve!.GetParameters());
  52. // 工厂口径锁定为「租户内工厂类型组织」,不是 SysTenant.OrgId。
  53. Assert.Equal("501", S8TrustedScopeResolver.FactoryOrgType);
  54. var scope = new S8TrustedScope(11L, 22L);
  55. Assert.Equal(11L, scope.TenantId);
  56. Assert.Equal(22L, scope.FactoryId);
  57. }
  58. /// <summary>
  59. /// 所有 tenant+factory-owned 配置对象的写入口必须强制要求 <see cref="S8TrustedScope"/>;
  60. /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
  61. /// </summary>
  62. [Theory]
  63. [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))]
  64. [InlineData(typeof(S8DataSourceService), typeof(AdoS8DataSource))]
  65. [InlineData(typeof(S8AlertRuleService), typeof(AdoS8AlertRule))]
  66. [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))]
  67. [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
  68. [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]
  69. [InlineData(typeof(S8ExceptionTypeService), typeof(AdoS8ExceptionType))]
  70. [InlineData(typeof(S8WatchRuleService), typeof(AdoS8WatchRule))]
  71. public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType)
  72. {
  73. Assert.NotNull(serviceType.GetMethod("CreateAsync", [entityType, typeof(S8TrustedScope)]));
  74. Assert.NotNull(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType, typeof(S8TrustedScope)]));
  75. Assert.NotNull(serviceType.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)]));
  76. // 旧的无作用域重载必须彻底消失,否则调用方可能悄悄退回不安全路径。
  77. Assert.Null(serviceType.GetMethod("CreateAsync", [entityType]));
  78. Assert.Null(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType]));
  79. Assert.Null(serviceType.GetMethod("DeleteAsync", [typeof(long)]));
  80. }
  81. /// <summary>监视规则的每个按 Id 的运行态动作都必须带可信作用域。</summary>
  82. [Theory]
  83. [InlineData("RunNowAsync")]
  84. [InlineData("PauseAsync")]
  85. [InlineData("ResumeAsync")]
  86. [InlineData("TestAsync")]
  87. public void WatchRuleRuntimeActions_RequireTrustedScope(string method)
  88. {
  89. Assert.NotNull(typeof(S8WatchRuleService).GetMethod(method, [typeof(long), typeof(S8TrustedScope)]));
  90. Assert.Null(typeof(S8WatchRuleService).GetMethod(method, [typeof(long)]));
  91. }
  92. /// <summary>草稿全部按 Id 的入口都必须带可信作用域。</summary>
  93. [Fact]
  94. public void ConfigDraft_ByIdEntryPoints_RequireTrustedScope()
  95. {
  96. var t = typeof(S8ConfigDraftService);
  97. Assert.NotNull(t.GetMethod("GetAsync", [typeof(long), typeof(S8TrustedScope)]));
  98. Assert.NotNull(t.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)]));
  99. Assert.NotNull(t.GetMethod("CreateAsync", [typeof(AdoS8ConfigDraftCreateDto), typeof(S8TrustedScope)]));
  100. Assert.NotNull(t.GetMethod("UpdateAsync", [typeof(long), typeof(AdoS8ConfigDraftUpdateDto), typeof(S8TrustedScope)]));
  101. Assert.NotNull(t.GetMethod("GenerateRuleAsync", [typeof(long), typeof(AdoS8ConfigDraftGenerateRuleDto), typeof(S8TrustedScope)]));
  102. Assert.Null(t.GetMethod("GetAsync", [typeof(long)]));
  103. Assert.Null(t.GetMethod("DeleteAsync", [typeof(long)]));
  104. }
  105. /// <summary>
  106. /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列,
  107. /// 归属必须由父异常派生;因此必须提供作用域校验入口。
  108. /// </summary>
  109. [Fact]
  110. public void ExceptionSubResources_ExposeParentScopeGuard()
  111. {
  112. var guard = typeof(S8DecisionService).GetMethod(
  113. nameof(S8DecisionService.IsExceptionInScopeAsync),
  114. [typeof(long), typeof(long), typeof(long)]);
  115. Assert.NotNull(guard);
  116. Assert.Equal(typeof(Task<bool>), guard!.ReturnType);
  117. }
  118. /// <summary>异常流转的补充说明同样按可信作用域绑行,不得只按裸 Id 写他租户时间线。</summary>
  119. [Fact]
  120. public void ExceptionComment_RequiresTenantAndFactory()
  121. {
  122. Assert.NotNull(typeof(S8TaskFlowService).GetMethod(
  123. nameof(S8TaskFlowService.CommentAsync),
  124. [typeof(long), typeof(long), typeof(long), typeof(string)]));
  125. Assert.Null(typeof(S8TaskFlowService).GetMethod(
  126. nameof(S8TaskFlowService.CommentAsync), [typeof(long), typeof(string)]));
  127. }
  128. /// <summary>
  129. /// 越权 Id 与不存在 Id 必须给出同一响应(404),不泄露「他租户存在该资源」;
  130. /// 同时继承 S8BizException,保证既有只捕获 S8BizException 的调用方安全降级为 400 而不是 500。
  131. /// </summary>
  132. [Fact]
  133. public void NotFound_IsIndistinguishableAndBackwardCompatible()
  134. {
  135. Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8NotFoundException)));
  136. var ex = new S8NotFoundException();
  137. Assert.IsAssignableFrom<S8BizException>(ex);
  138. }
  139. /// <summary>OrderFlow / ManualReport 两条既有正确范式不得回退为信任客户端作用域。</summary>
  140. [Fact]
  141. public void ReferenceTrustedPaths_DoNotRegress()
  142. {
  143. // OrderFlow:服务端自解析租户与工厂,且不暴露任何接受 tenant/factory 的公共查询入口。
  144. var orderFlow = typeof(Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow.S8OrderFlowService);
  145. Assert.NotNull(orderFlow.GetMethod("ResolveTenantId", Instance));
  146. Assert.NotNull(orderFlow.GetMethod("ResolveFactoryIdAsync", Instance));
  147. Assert.NotNull(orderFlow.GetMethod("ResolveScopeAsync", Instance));
  148. // ManualReport:建单与表单选项都必须经服务端可信作用域。
  149. Assert.NotNull(typeof(S8ManualReportService).GetMethod(
  150. "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic));
  151. }
  152. /// <summary>
  153. /// 数据源响应不得出现明文密码:Pwd= / Password= 一律脱敏,其它连接字段保留以便同租户配置管理员识别。
  154. /// </summary>
  155. [Theory]
  156. [InlineData("Server=h;Database=d;Uid=u;Pwd=S3cr3tValue;SslMode=None;")]
  157. [InlineData("Server=h;Database=d;User Id=u;Password=S3cr3tValue;Encrypt=false;")]
  158. [InlineData("server=h;pwd=S3cr3tValue")]
  159. public void DataSourceEndpoint_NeverExposesPlaintextSecret(string endpoint)
  160. {
  161. var mask = typeof(S8DataSourceService).GetMethod(
  162. "MaskSecret", BindingFlags.Static | BindingFlags.NonPublic);
  163. Assert.NotNull(mask);
  164. var masked = (string)mask!.Invoke(null, [endpoint]);
  165. Assert.DoesNotContain("S3cr3tValue", masked);
  166. Assert.Contains("******", masked);
  167. // 非敏感字段保留(供同租户管理员识别是哪一条连接)。
  168. Assert.Contains("Server=h", masked, StringComparison.OrdinalIgnoreCase);
  169. }
  170. /// <summary>
  171. /// 前端回填脱敏占位符时不得把 "******" 当作真实密码写库;未提交新密码则保留原密码。
  172. /// </summary>
  173. [Fact]
  174. public void DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret()
  175. {
  176. var merge = typeof(S8DataSourceService).GetMethod(
  177. "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic);
  178. Assert.NotNull(merge);
  179. const string stored = "Server=h;Uid=u;Pwd=RealSecret;";
  180. // 1) 回填脱敏值 → 保留原始密码
  181. var merged = (string)merge!.Invoke(null, ["Server=h;Uid=u;Pwd=******;", stored]);
  182. Assert.Contains("Pwd=RealSecret", merged);
  183. // 2) endpoint 为空 → 完全保留原值(不清空密码)
  184. var kept = (string)merge.Invoke(null, [null, stored]);
  185. Assert.Equal(stored, kept);
  186. // 3) 明确提交新密码 → 才真正替换
  187. var replaced = (string)merge.Invoke(null, ["Server=h;Uid=u;Pwd=BrandNew;", stored]);
  188. Assert.Contains("Pwd=BrandNew", replaced);
  189. Assert.DoesNotContain("RealSecret", replaced);
  190. }
  191. /// <summary>
  192. /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错),
  193. /// 但它们已不承担安全边界——由 Controller 在调用 Service 前用可信作用域覆盖。
  194. /// </summary>
  195. [Fact]
  196. public void QueryDtos_KeepScopeFieldsForCompatibilityOnly()
  197. {
  198. foreach (var t in new[]
  199. {
  200. typeof(AdoS8ExceptionQueryDto),
  201. typeof(AdoS8MonitoringSummaryQueryDto),
  202. typeof(AdoS8DetectionLogQueryDto),
  203. typeof(AdoS8NotificationLogQueryDto),
  204. typeof(AdoS8IssueLedgerQueryDto),
  205. typeof(AdoS8CellDataQueryDto),
  206. })
  207. {
  208. var tenant = t.GetProperty("TenantId");
  209. var factory = t.GetProperty("FactoryId");
  210. Assert.NotNull(tenant);
  211. Assert.NotNull(factory);
  212. // 必须可写,Controller 才能用可信作用域覆盖客户端传入值。
  213. Assert.True(tenant!.CanWrite, $"{t.Name}.TenantId 必须可写,否则 Controller 无法覆盖客户端作用域");
  214. Assert.True(factory!.CanWrite, $"{t.Name}.FactoryId 必须可写,否则 Controller 无法覆盖客户端作用域");
  215. }
  216. }
  217. }