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]
// 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))]
[InlineData(typeof(S8WatchRuleService), typeof(AdoS8WatchRule))]
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")]
[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));
}
// S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有两条数据源用例
// · DataSourceEndpoint_NeverExposesPlaintextSecret(连接串脱敏)
// · DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired(写入口已退役)
// 二者守的都是「S8 存了含凭据的连接串,别泄露、别被覆盖」这一风险。
// 该风险随 ado_s8_data_source 与 S8DataSourceService 一并物理消失 ——
// S8 已不再持有任何连接串,因此没有可脱敏、也没有可覆盖的东西。
// 「数据源功能不存在」由 S8LegacySymbolsRemovedTests 断言。
///
/// 兼容性:查询 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 无法覆盖客户端作用域");
}
}
}