| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- 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 ─────────────────────────
- /// <summary>可信作用域解析器必须存在,且只暴露无参解析入口(不接受调用方传入 tenant / factory)。</summary>
- [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);
- }
- /// <summary>
- /// 所有 tenant+factory-owned 配置对象的写入口必须强制要求 <see cref="S8TrustedScope"/>;
- /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
- /// </summary>
- [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)
- {
- // CreateAsync 用「前两个形参匹配」而非精确签名匹配:
- // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3 给 S8WatchRuleService.CreateAsync 追加了
- // 可选形参 origin(string origin = S8RuleCreationOrigin.ExternalApi),
- // 精确 2 参的 GetMethod 会返回 null,导致本用例误报。
- // 真正要保证的契约是「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)]));
- }
- /// <summary>
- /// 写入口是否以 (entity, S8TrustedScope) 开头。允许其后存在形参(如可选的 origin),
- /// 但作用域必须是第 2 个形参且不可省略——调用方无法只传实体就完成写入。
- /// </summary>
- 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;
- });
- /// <summary>监视规则的每个按 Id 的运行态动作都必须带可信作用域。</summary>
- [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)]));
- }
- /// <summary>草稿全部按 Id 的入口都必须带可信作用域。</summary>
- [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)]));
- }
- /// <summary>
- /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列,
- /// 归属必须由父异常派生;因此必须提供作用域校验入口。
- /// </summary>
- [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<bool>), guard!.ReturnType);
- }
- /// <summary>异常流转的补充说明同样按可信作用域绑行,不得只按裸 Id 写他租户时间线。</summary>
- [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)]));
- }
- /// <summary>
- /// 越权 Id 与不存在 Id 必须给出同一响应(404),不泄露「他租户存在该资源」;
- /// 同时继承 S8BizException,保证既有只捕获 S8BizException 的调用方安全降级为 400 而不是 500。
- /// </summary>
- [Fact]
- public void NotFound_IsIndistinguishableAndBackwardCompatible()
- {
- Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8NotFoundException)));
- var ex = new S8NotFoundException();
- Assert.IsAssignableFrom<S8BizException>(ex);
- }
- /// <summary>OrderFlow / ManualReport 两条既有正确范式不得回退为信任客户端作用域。</summary>
- [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));
- }
- /// <summary>
- /// 数据源响应不得出现明文密码:Pwd= / Password= 一律脱敏,其它连接字段保留以便同租户配置管理员识别。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 原用例 DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret 断言
- /// <c>MergeEndpointPreservingSecret</c> 在前端回填 "******" 时保留库中真实密码。
- ///
- /// S8-LEGACY-RUNTIME-RETIREMENT-1 之后该辅助方法连同 <c>UpdateAsync</c> 的实现一并移除:
- /// 数据源写入口全部退役,**不存在任何能改写 endpoint 的路径**。
- /// 原保障("别把占位符当密码写进去")因此被更强的保障取代 ——
- /// 不是"写的时候小心",而是"根本不能写"。
- ///
- /// 用反射断言写方法不可绕过,而不是简单删掉本用例:删掉会让
- /// "凭据不会被覆盖" 这条安全属性失去看守,将来有人恢复写入口时无人报警。
- /// </summary>
- [Fact]
- public void DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired()
- {
- // 写方法必须仍然存在(签名契约),但必须在任何 DB 访问前抛退役异常。
- var svc = (S8DataSourceService)System.Runtime.CompilerServices.RuntimeHelpers
- .GetUninitializedObject(typeof(S8DataSourceService));
- var scope = new S8TrustedScope(838257186181189L, 838257186320453L);
- Assert.Throws<S8WriteRetiredException>(() =>
- {
- _ = svc.UpdateAsync(1L, new AdoS8DataSource
- {
- DataSourceCode = "UT_DS",
- Type = "SQL",
- Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
- }, scope);
- });
- Assert.Throws<S8WriteRetiredException>(() =>
- {
- _ = svc.CreateAsync(new AdoS8DataSource
- {
- DataSourceCode = "UT_DS",
- Type = "SQL",
- Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
- }, scope);
- });
- // 合并辅助方法已随写路径一并移除;若它重新出现,说明写路径可能被恢复,需要复核。
- Assert.Null(typeof(S8DataSourceService).GetMethod(
- "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic));
- }
- /// <summary>
- /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错),
- /// 但它们已不承担安全边界——由 Controller 在调用 Service 前用可信作用域覆盖。
- /// </summary>
- [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 无法覆盖客户端作用域");
- }
- }
- }
|