using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Service.S8; using System.Reflection; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1:S8 主数据 / 操作员绑定的跨租户越权守卫。 /// /// 背景(2026-09-02 以普通租户账号 UATExceptionA 实测,HTTP 200): /// /// GET /api/aidop/s8/config/operator-bindings(不带任何参数)→ 500 行, /// 按工厂分布 {1000: 498, 1329900200002: 2},**全部属于其它租户,本租户 0 行**; /// GET /api/aidop/s8/config/operator-bindings?factoryRefId=1000 → 500 条外租户员工; /// GET /api/aidop/s8/master-data/employees?factoryRefId=1000 → 500 条外租户员工工号 + 真实姓名。 /// /// /// 根因:两个控制器都未接入 (KNOWN-ISSUES I-001 的 6 个之二), /// 服务层用 ClearFilter() + **客户端传入的** factoryRefId 作边界, /// 且 ListAsync 的该谓词还是 WhereIF(factoryRefId.HasValue, …) —— 可选,不传即无边界。 /// /// 本测试锁住修复后的口径:作用域只能来自服务端认证身份;客户端 factoryRefId / tenantId /// 不得出现在任何 Action 或 Service 签名上,杜绝「以后有人又把它接回去」。 /// public class S8MasterDataScopeGuardTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private const string BindingSrc = "Service/S8/S8OperatorBindingService.cs"; private const string MasterDataSrc = "Service/S8/S8MasterDataAdapter.cs"; private static string ReadSource(string relativePath) { var full = Path.Combine(PluginRoot, relativePath); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static List CodeLines(string relativePath) => ReadSource(relativePath) .Split('\n') .Select(l => l.Trim()) .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal)) .ToList(); private static IEnumerable ActionsOf(Type controller) => controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly); // ───────────────────────── 控制器层 ───────────────────────── [Theory] [InlineData(typeof(AdoS8ConfigBindingsController))] [InlineData(typeof(AdoS8MasterDataController))] public void Controller_InjectsTrustedScopeResolver(Type controller) { var ctor = controller.GetConstructors().Single(); Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver)); } /// 客户端不得再通过 query / route 指定作用域:这两个名字必须从 Action 签名彻底消失。 [Theory] [InlineData(typeof(AdoS8ConfigBindingsController))] [InlineData(typeof(AdoS8MasterDataController))] public void ControllerActions_DoNotAcceptClientSuppliedScope(Type controller) { foreach (var action in ActionsOf(controller)) { foreach (var p in action.GetParameters()) { Assert.False( string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase), $"{controller.Name}.{action.Name} 仍接受客户端作用域参数 {p.Name}"); } } } // ───────────────────────── 服务层签名 ───────────────────────── /// 五个入口必须以 S8TrustedScope 作首参,且不得再出现 factoryRefId 形参。 [Theory] [InlineData(typeof(S8OperatorBindingService), "ListAsync")] [InlineData(typeof(S8OperatorBindingService), "ListSysUsersAsync")] [InlineData(typeof(S8OperatorBindingService), "BindAsync")] [InlineData(typeof(S8OperatorBindingService), "UnbindAsync")] [InlineData(typeof(S8MasterDataAdapter), "GetDepartmentsAsync")] [InlineData(typeof(S8MasterDataAdapter), "GetEmployeesAsync")] [InlineData(typeof(S8MasterDataAdapter), "GetLinesAsync")] public void ServiceEntryPoints_TakeTrustedScopeAndRejectClientFactory(Type service, string method) { var m = service.GetMethod(method, BindingFlags.Instance | BindingFlags.Public); Assert.NotNull(m); var ps = m!.GetParameters(); Assert.NotEmpty(ps); Assert.Equal(typeof(S8TrustedScope), ps[0].ParameterType); Assert.DoesNotContain(ps, p => string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase)); } // ───────────────────────── 服务层查询谓词 ───────────────────────── /// /// 绑定服务里所有 ClearFilter 都必须收口到唯一的 ScopedEmployees 帮助方法, /// 且该方法带 FactoryRefId == scope.FactoryId 强制边界。 /// 出现第二处裸 ClearFilter 即视为回退。 /// [Fact] public void OperatorBinding_ClearFilterIsFunneledThroughScopedHelper() { var lines = CodeLines(BindingSrc); var clearFilterLines = lines.Where(l => l.Contains("ClearFilter()", StringComparison.Ordinal)).ToList(); Assert.Single(clearFilterLines); var src = string.Join('\n', lines); Assert.Contains("_empRep.AsQueryable().ClearFilter()", src); Assert.Contains(".Where(x => x.FactoryRefId == scope.FactoryId)", src); // 旧的「可选工厂谓词」写法必须消失。 Assert.DoesNotContain("WhereIF(factoryRefId.HasValue", src); Assert.DoesNotContain("factoryRefId!.Value", src); } /// SysUser 相关查询必须按可信租户,不得裸主键集合。 [Fact] public void OperatorBinding_SysUserQueriesAreTenantBound() { var src = string.Join('\n', CodeLines(BindingSrc)); Assert.Contains("u.TenantId == scope.TenantId", src); Assert.Contains("x.TenantId == scope.TenantId", src); } /// 主数据三个入口的工厂边界必须来自 scope,不得来自入参。 [Fact] public void MasterData_ScopeComesFromTrustedScopeOnly() { var src = string.Join('\n', CodeLines(MasterDataSrc)); Assert.Contains("x.FactoryRefId == scope.FactoryId && x.IsActive", src); Assert.Contains("u.TenantId == scope.TenantId", src); // 客户端参数写法必须消失。 Assert.DoesNotContain("factoryRefId!.Value", src); Assert.DoesNotContain("factoryRefId.HasValue", src); } /// /// 绑定 / 解绑必须先按可信作用域绑行再写。写路径上不得出现「只按主键取员工」的写法 /// (旧实现 _empRep.GetFirstAsync(x => x.Id == dto.EmployeeId) 正是如此)。 /// [Fact] public void BindAndUnbind_ResolveEmployeeThroughScopedLookup() { var src = string.Join('\n', CodeLines(BindingSrc)); Assert.Contains("ScopedEmployee(scope, dto.EmployeeId)", src); Assert.Contains("ScopedEmployee(scope, employeeId)", src); Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == dto.EmployeeId)", src); Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == employeeId)", src); } }