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; [Fact] public void AutoExceptionCreation_RequiresExplicitTenantAndFactory() { Assert.NotNull(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromWatchAsync), [typeof(long), typeof(long), typeof(S8WatchHitResult)])); Assert.NotNull(typeof(S8ManualReportService).GetMethod( nameof(S8ManualReportService.CreateFromHitAsync), [typeof(long), typeof(long), typeof(S8RuleHit)])); } [Fact] public void SchedulerServices_DiscoverValidTenantScopes() { Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod( nameof(S8WatchSchedulerService.ListEnabledScopesAsync))); Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod( nameof(S8ActiveFlowWatchService.ListActiveScopesAsync))); } [Fact] public void ManualReport_ResolvesTenantAndFactoryFromServerContext() { Assert.NotNull(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()); // 工厂口径锁定为「租户内工厂类型组织」,不是 SysTenant.OrgId。 Assert.Equal("501", S8TrustedScopeResolver.FactoryOrgType); var scope = new S8TrustedScope(11L, 22L); Assert.Equal(11L, scope.TenantId); Assert.Equal(22L, scope.FactoryId); } /// /// 所有 tenant+factory-owned 配置对象的写入口必须强制要求 ; /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。 /// [Theory] [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))] [InlineData(typeof(S8DataSourceService), typeof(AdoS8DataSource))] [InlineData(typeof(S8AlertRuleService), typeof(AdoS8AlertRule))] [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))] [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))] [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))] [InlineData(typeof(S8ExceptionTypeService), typeof(AdoS8ExceptionType))] [InlineData(typeof(S8WatchRuleService), typeof(AdoS8WatchRule))] public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType) { Assert.NotNull(serviceType.GetMethod("CreateAsync", [entityType, typeof(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)])); } /// 监视规则的每个按 Id 的运行态动作都必须带可信作用域。 [Theory] [InlineData("RunNowAsync")] [InlineData("PauseAsync")] [InlineData("ResumeAsync")] [InlineData("TestAsync")] public void WatchRuleRuntimeActions_RequireTrustedScope(string method) { Assert.NotNull(typeof(S8WatchRuleService).GetMethod(method, [typeof(long), typeof(S8TrustedScope)])); Assert.Null(typeof(S8WatchRuleService).GetMethod(method, [typeof(long)])); } /// 草稿全部按 Id 的入口都必须带可信作用域。 [Fact] public void ConfigDraft_ByIdEntryPoints_RequireTrustedScope() { var t = typeof(S8ConfigDraftService); Assert.NotNull(t.GetMethod("GetAsync", [typeof(long), typeof(S8TrustedScope)])); Assert.NotNull(t.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)])); Assert.NotNull(t.GetMethod("CreateAsync", [typeof(AdoS8ConfigDraftCreateDto), typeof(S8TrustedScope)])); Assert.NotNull(t.GetMethod("UpdateAsync", [typeof(long), typeof(AdoS8ConfigDraftUpdateDto), typeof(S8TrustedScope)])); Assert.NotNull(t.GetMethod("GenerateRuleAsync", [typeof(long), typeof(AdoS8ConfigDraftGenerateRuleDto), typeof(S8TrustedScope)])); Assert.Null(t.GetMethod("GetAsync", [typeof(long)])); Assert.Null(t.GetMethod("DeleteAsync", [typeof(long)])); } /// /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列, /// 归属必须由父异常派生;因此必须提供作用域校验入口。 /// [Fact] public void ExceptionSubResources_ExposeParentScopeGuard() { var guard = typeof(S8DecisionService).GetMethod( nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long), typeof(long)]); Assert.NotNull(guard); Assert.Equal(typeof(Task), guard!.ReturnType); } /// 异常流转的补充说明同样按可信作用域绑行,不得只按裸 Id 写他租户时间线。 [Fact] public void ExceptionComment_RequiresTenantAndFactory() { Assert.NotNull(typeof(S8TaskFlowService).GetMethod( nameof(S8TaskFlowService.CommentAsync), [typeof(long), 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 的公共查询入口。 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( "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic)); } /// /// 数据源响应不得出现明文密码:Pwd= / Password= 一律脱敏,其它连接字段保留以便同租户配置管理员识别。 /// [Theory] [InlineData("Server=h;Database=d;Uid=u;Pwd=S3cr3tValue;SslMode=None;")] [InlineData("Server=h;Database=d;User Id=u;Password=S3cr3tValue;Encrypt=false;")] [InlineData("server=h;pwd=S3cr3tValue")] public void DataSourceEndpoint_NeverExposesPlaintextSecret(string endpoint) { var mask = typeof(S8DataSourceService).GetMethod( "MaskSecret", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(mask); var masked = (string)mask!.Invoke(null, [endpoint]); Assert.DoesNotContain("S3cr3tValue", masked); Assert.Contains("******", masked); // 非敏感字段保留(供同租户管理员识别是哪一条连接)。 Assert.Contains("Server=h", masked, StringComparison.OrdinalIgnoreCase); } /// /// 前端回填脱敏占位符时不得把 "******" 当作真实密码写库;未提交新密码则保留原密码。 /// [Fact] public void DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret() { var merge = typeof(S8DataSourceService).GetMethod( "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(merge); const string stored = "Server=h;Uid=u;Pwd=RealSecret;"; // 1) 回填脱敏值 → 保留原始密码 var merged = (string)merge!.Invoke(null, ["Server=h;Uid=u;Pwd=******;", stored]); Assert.Contains("Pwd=RealSecret", merged); // 2) endpoint 为空 → 完全保留原值(不清空密码) var kept = (string)merge.Invoke(null, [null, stored]); Assert.Equal(stored, kept); // 3) 明确提交新密码 → 才真正替换 var replaced = (string)merge.Invoke(null, ["Server=h;Uid=u;Pwd=BrandNew;", stored]); Assert.Contains("Pwd=BrandNew", replaced); Assert.DoesNotContain("RealSecret", replaced); } /// /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错), /// 但它们已不承担安全边界——由 Controller 在调用 Service 前用可信作用域覆盖。 /// [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 无法覆盖客户端作用域"); } } }