using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Job; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using System.Reflection; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; public class S8TenantIsolationContractTests { private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; /// /// S8-TENANT-ONLY-BATCH6:自动建单要求显式租户(不再要求工厂)。 /// 反向断言不可省:旧的三参重载一旦被加回来,调用方会悄悄退回按工厂建单。 /// [Fact] public void AutoExceptionCreation_RequiresExplicitTenant_NotFactory() { Assert.NotNull(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromWatchAsync), [typeof(long), typeof(S8WatchHitResult)])); Assert.NotNull(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromHitAsync), [typeof(long), typeof(S8RuleHit)])); Assert.Null(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromWatchAsync), [typeof(long), typeof(long), typeof(S8WatchHitResult)])); Assert.Null(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromHitAsync), [typeof(long), typeof(long), typeof(S8RuleHit)])); } /// /// S8-TENANT-ONLY-BATCH5:规则调度的遍历维度已从 (租户, 工厂) 收敛为租户。 /// 断言的是「新形态存在 + 旧形态不存在」——只断言新方法在,挡不住有人把旧方法加回来。 /// /// S8-TENANT-ONLY-BATCH6:S8ActiveFlowWatchService 的扫描维度同步收敛。 /// 它原来的 SQL 带 AND e.factory_id > 0,而本批之后新建异常的 factory_id 恒为 0—— /// 不改就等于卡死扫描静默漏掉本批之后的全部异常。 /// [Fact] public void SchedulerDiscoversTenants_NotTenantFactoryPairs() { Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod( nameof(S8WatchSchedulerService.ListEnabledTenantsAsync))); Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync")); Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod( nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync))); Assert.Null(typeof(S8ActiveFlowWatchService).GetMethod("ListActiveScopesAsync")); } [Fact] public void ManualReport_ResolvesTenantFromServerContext() { Assert.NotNull(typeof(S8ManualReportService).GetMethod( "ResolveTrustedTenantId", BindingFlags.Instance | BindingFlags.NonPublic)); Assert.Null(typeof(S8ManualReportService).GetMethod( "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic)); } [Fact] public void BackgroundJobs_DoNotKeepLegacyDefaultTenantConstants() { const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public; Assert.Null(typeof(S8WatchSchedulerJob).GetField("DefaultTenantId", flags)); Assert.Null(typeof(S8ActiveFlowStuckScanJob).GetField("DefaultTenantId", flags)); } // ───────────────────────── S8-TENANT-FACTORY-P0-CLOSURE-1 ───────────────────────── /// 可信作用域解析器必须存在,且只暴露无参解析入口(不接受调用方传入 tenant / factory)。 [Fact] public void TrustedScopeResolver_ExposesParameterlessServerSideResolve() { var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync)); Assert.NotNull(resolve); Assert.Empty(resolve!.GetParameters()); // S8-TENANT-ONLY-BATCH6:可信作用域只剩租户。 // FactoryOrgType("501",租户内工厂类型组织)与整条工厂解析链一并删除—— // 它解析出来的值曾被当作数据边界用,而工厂在真库里横跨租户,根本不构成边界。 Assert.Null(typeof(S8TrustedScopeResolver).GetField( "FactoryOrgType", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)); Assert.Null(typeof(S8TrustedScopeResolver).GetMethod( "ResolveFactoryIdAsync", Instance)); var scope = new S8TrustedScope(11L); Assert.Equal(11L, scope.TenantId); // LegacyFactoryId 只服务「还要往 factory_id 列写值」的旧代码,恒为平台默认哨兵 0, // 刻意不叫 FactoryId —— 那个名字会让人以为它还是个作用域。 Assert.Equal(S8ConfigScope.GlobalFactoryId, scope.LegacyFactoryId); Assert.Null(typeof(S8TrustedScope).GetProperty("FactoryId")); } /// /// 所有 tenant+factory-owned 配置对象的写入口必须强制要求 ; /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。 /// [Theory] // S8-STANDARD-DATASET-HARD-CUTOVER-1:AlertRule 与 DataSource 两组服务/实体已物理删除, // 其条目随之移除——类型都不存在了,「写入口必须带作用域」自然无从违反。 // 这些功能的消失本身由 S8LegacySymbolsRemovedTests 断言。 [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))] [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))] [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))] [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))] [InlineData(typeof(S8ExceptionTypeService), typeof(AdoS8ExceptionType))] // S8-RULE-GOVERNANCE-BATCH3:S8WatchRuleService 已移出本用例 —— // 它的 Create / Update / Delete 三个业务写入口全部退役,不再存在"带作用域的写入口"这回事。 // 取而代之的断言见 S8RuleCreationRetiredTests:业务根本不能创建 / 删除规则。 public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType) { // CreateAsync 用「前两个形参匹配」而非精确签名匹配。 // 触发原因(origin 可选形参)已随 S8-STANDARD-DATASET-HARD-CUTOVER-1 消失, // 但前缀匹配予以保留:真正要保证的契约是「entity + S8TrustedScope 必须显式出现在最前」, // 后置可选形参不破坏该契约。改回精确匹配只会让下一次同类扩展再次误报。 Assert.True( HasScopedWriteEntry(serviceType, "CreateAsync", entityType), $"{serviceType.Name}.CreateAsync 必须以 ({entityType.Name}, S8TrustedScope) 开头"); Assert.NotNull(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType, typeof(S8TrustedScope)])); Assert.NotNull(serviceType.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)])); // 旧的无作用域重载必须彻底消失,否则调用方可能悄悄退回不安全路径。 Assert.Null(serviceType.GetMethod("CreateAsync", [entityType])); Assert.Null(serviceType.GetMethod("UpdateAsync", [typeof(long), entityType])); Assert.Null(serviceType.GetMethod("DeleteAsync", [typeof(long)])); } /// /// 写入口是否以 (entity, S8TrustedScope) 开头。允许其后存在形参, /// 但作用域必须是第 2 个形参且不可省略——调用方无法只传实体就完成写入。 /// private static bool HasScopedWriteEntry(Type serviceType, string methodName, Type entityType) => serviceType.GetMethods(BindingFlags.Instance | BindingFlags.Public) .Where(m => m.Name == methodName) .Any(m => { var p = m.GetParameters(); return p.Length >= 2 && p[0].ParameterType == entityType && p[1].ParameterType == typeof(S8TrustedScope) && !p[1].IsOptional; }); /// 监视规则的每个按 Id 的运行态动作都必须带可信作用域。 [Theory] [InlineData("RunNowAsync")] [InlineData("PauseAsync")] [InlineData("ResumeAsync")] // S8-RULE-GOVERNANCE-BATCH3:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。 [InlineData("EnableAsync")] [InlineData("DisableAsync")] public void WatchRuleRuntimeActions_RequireTrustedScope(string method) { Assert.NotNull(typeof(S8WatchRuleService).GetMethod(method, [typeof(long), typeof(S8TrustedScope)])); Assert.Null(typeof(S8WatchRuleService).GetMethod(method, [typeof(long)])); } // S8-RULE-GOVERNANCE-BATCH3:原 ConfigDraft_ByIdEntryPoints_RequireTrustedScope 已删除 —— // S8ConfigDraftService / AdoS8ConfigDraftsController / 草稿 DTO 已整体退役。 // 「草稿的入口要不要带作用域」这个问题随入口一起消失了; // 「草稿链路不存在」由 S8RuleCreationRetiredTests 断言。 /// /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列, /// 归属必须由父异常派生;因此必须提供作用域校验入口。 /// [Fact] public void ExceptionSubResources_ExposeParentScopeGuard() { var guard = typeof(S8DecisionService).GetMethod( nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long)]); Assert.NotNull(guard); Assert.Equal(typeof(Task), guard!.ReturnType); Assert.Null(typeof(S8DecisionService).GetMethod( nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long), typeof(long)])); } /// 异常流转的补充说明同样按可信租户绑行,不得只按裸 Id 写他租户时间线。 [Fact] public void ExceptionComment_RequiresTenant() { Assert.NotNull(typeof(S8TaskFlowService).GetMethod( nameof(S8TaskFlowService.CommentAsync), [typeof(long), typeof(long), typeof(string)])); Assert.Null(typeof(S8TaskFlowService).GetMethod( nameof(S8TaskFlowService.CommentAsync), [typeof(long), typeof(string)])); } /// /// 越权 Id 与不存在 Id 必须给出同一响应(404),不泄露「他租户存在该资源」; /// 同时继承 S8BizException,保证既有只捕获 S8BizException 的调用方安全降级为 400 而不是 500。 /// [Fact] public void NotFound_IsIndistinguishableAndBackwardCompatible() { Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8NotFoundException))); var ex = new S8NotFoundException(); Assert.IsAssignableFrom(ex); } /// OrderFlow / ManualReport 两条既有正确范式不得回退为信任客户端作用域。 [Fact] public void ReferenceTrustedPaths_DoNotRegress() { // OrderFlow:服务端自解析作用域,且不暴露任何接受 tenant/factory 的公共查询入口。 // OrderFlow 明确不在 Batch 6 范围内(§三),其 ResolveFactoryIdAsync 保持原状,此处照旧断言。 var orderFlow = typeof(Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow.S8OrderFlowService); Assert.NotNull(orderFlow.GetMethod("ResolveTenantId", Instance)); Assert.NotNull(orderFlow.GetMethod("ResolveFactoryIdAsync", Instance)); Assert.NotNull(orderFlow.GetMethod("ResolveScopeAsync", Instance)); // ManualReport:建单与表单选项都必须经服务端可信租户。 Assert.NotNull(typeof(S8ManualReportService).GetMethod( "ResolveTrustedTenantId", BindingFlags.Instance | BindingFlags.NonPublic)); } // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有两条数据源用例 // · DataSourceEndpoint_NeverExposesPlaintextSecret(连接串脱敏) // · DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired(写入口已退役) // 二者守的都是「S8 存了含凭据的连接串,别泄露、别被覆盖」这一风险。 // 该风险随 ado_s8_data_source 与 S8DataSourceService 一并物理消失 —— // S8 已不再持有任何连接串,因此没有可脱敏、也没有可覆盖的东西。 // 「数据源功能不存在」由 S8LegacySymbolsRemovedTests 断言。 /// /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错)。 /// /// S8-TENANT-ONLY-BATCH6 之后两者语义已分家:TenantId 仍由 Controller 用可信作用域覆盖; /// FactoryId 则**彻底失效**——Controller 不再赋值,Service 也不再读, /// 保留它纯粹是为了老前端继续发送该字段时不报 400。 /// 「Service 不得再读 q.FactoryId」由 S8TenantOnlyExceptionScopeTests 的源码扫描守。 /// [Fact] public void QueryDtos_KeepScopeFieldsForCompatibilityOnly() { foreach (var t in new[] { typeof(AdoS8ExceptionQueryDto), typeof(AdoS8MonitoringSummaryQueryDto), typeof(AdoS8DetectionLogQueryDto), typeof(AdoS8NotificationLogQueryDto), typeof(AdoS8IssueLedgerQueryDto), typeof(AdoS8CellDataQueryDto), }) { var tenant = t.GetProperty("TenantId"); var factory = t.GetProperty("FactoryId"); Assert.NotNull(tenant); Assert.NotNull(factory); // 必须可写,Controller 才能用可信作用域覆盖客户端传入值。 Assert.True(tenant!.CanWrite, $"{t.Name}.TenantId 必须可写,否则 Controller 无法覆盖客户端作用域"); Assert.True(factory!.CanWrite, $"{t.Name}.FactoryId 必须可写,否则 Controller 无法覆盖客户端作用域"); } } }