|
|
@@ -0,0 +1,171 @@
|
|
|
+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;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1:S8 主数据 / 操作员绑定的跨租户越权守卫。
|
|
|
+///
|
|
|
+/// <para>背景(2026-09-02 以普通租户账号 UATExceptionA 实测,HTTP 200):</para>
|
|
|
+/// <list type="bullet">
|
|
|
+/// <item><c>GET /api/aidop/s8/config/operator-bindings</c>(不带任何参数)→ 500 行,
|
|
|
+/// 按工厂分布 {1000: 498, 1329900200002: 2},**全部属于其它租户,本租户 0 行**;</item>
|
|
|
+/// <item><c>GET /api/aidop/s8/config/operator-bindings?factoryRefId=1000</c> → 500 条外租户员工;</item>
|
|
|
+/// <item><c>GET /api/aidop/s8/master-data/employees?factoryRefId=1000</c> → 500 条外租户员工工号 + 真实姓名。</item>
|
|
|
+/// </list>
|
|
|
+///
|
|
|
+/// <para>根因:两个控制器都未接入 <see cref="S8TrustedScopeResolver"/>(KNOWN-ISSUES I-001 的 6 个之二),
|
|
|
+/// 服务层用 <c>ClearFilter()</c> + **客户端传入的** <c>factoryRefId</c> 作边界,
|
|
|
+/// 且 <c>ListAsync</c> 的该谓词还是 <c>WhereIF(factoryRefId.HasValue, …)</c> —— 可选,不传即无边界。</para>
|
|
|
+///
|
|
|
+/// <para>本测试锁住修复后的口径:作用域只能来自服务端认证身份;客户端 factoryRefId / tenantId
|
|
|
+/// 不得出现在任何 Action 或 Service 签名上,杜绝「以后有人又把它接回去」。</para>
|
|
|
+/// </summary>
|
|
|
+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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
|
|
|
+ private static List<string> 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<MethodInfo> 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));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>客户端不得再通过 query / route 指定作用域:这两个名字必须从 Action 签名彻底消失。</summary>
|
|
|
+ [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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 服务层签名 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>五个入口必须以 S8TrustedScope 作首参,且不得再出现 factoryRefId 形参。</summary>
|
|
|
+ [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));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 服务层查询谓词 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 绑定服务里所有 ClearFilter 都必须收口到唯一的 ScopedEmployees 帮助方法,
|
|
|
+ /// 且该方法带 FactoryRefId == scope.FactoryId 强制边界。
|
|
|
+ /// 出现第二处裸 ClearFilter 即视为回退。
|
|
|
+ /// </summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>SysUser 相关查询必须按可信租户,不得裸主键集合。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>主数据三个入口的工厂边界必须来自 scope,不得来自入参。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 绑定 / 解绑必须先按可信作用域绑行再写。写路径上不得出现「只按主键取员工」的写法
|
|
|
+ /// (旧实现 <c>_empRep.GetFirstAsync(x => x.Id == dto.EmployeeId)</c> 正是如此)。
|
|
|
+ /// </summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+}
|