|
|
@@ -0,0 +1,98 @@
|
|
|
+using System.Reflection;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
+using Microsoft.Extensions.Logging.Abstractions;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Test.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):角色 → 用户解析必须显式携带租户边界。
|
|
|
+///
|
|
|
+/// <b>修复前的缺陷链</b>:
|
|
|
+/// 1. <c>SysRole.Code</c> 跨租户重复(每个业务角色每租户一份,实测 ROLE_PURCHASER 共 9 份);
|
|
|
+/// 2. 平台租户过滤器只在有登录上下文时挂载 —— <c>SqlSugarSetup</c> 中
|
|
|
+/// <c>App.User</c> 为空则不挂、超管直接 return;
|
|
|
+/// 3. <c>S8WatchSchedulerJob</c> 用 <c>IServiceScopeFactory.CreateScope()</c>,**没有 App.User**;
|
|
|
+/// ⇒ 后台派发分层通知时 "ROLE_PRODUCTION_PLANNER" 会解析到全部租户的同名角色,
|
|
|
+/// 把 A 租户的异常通知投递给 B/C/D 租户的用户。
|
|
|
+///
|
|
|
+/// <b>本文件锁定的契约</b>(实例方法依赖 SqlSugarRepository + SysUserRoleService,
|
|
|
+/// 需 DB 才能端到端验证,因此这里锁定「签名」与「前置守卫」两层,
|
|
|
+/// 沿用仓内既有范式:S8TenantIsolationContractTests 亦用反射断言写入口必须带作用域):
|
|
|
+/// A. 方法签名必须以 tenantId 开头 → 防止有人改回无作用域版本;
|
|
|
+/// B. 不存在无 tenantId 的旧重载 → 防止新增调用方走回老路;
|
|
|
+/// C. tenantId <= 0 时直接返回空 → 作用域缺失宁可不通知,绝不放行全量;
|
|
|
+/// D. 守卫必须在任何仓储访问之前 → 用 null 依赖构造,若先碰 DB 会 NullReference。
|
|
|
+///
|
|
|
+/// 不接 DB、不接 DI。
|
|
|
+/// </summary>
|
|
|
+public class S8RoleResolutionTenantScopeTests
|
|
|
+{
|
|
|
+ private static MethodInfo[] ResolveOverloads() =>
|
|
|
+ typeof(S8RoleResolver)
|
|
|
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
|
|
+ .Where(m => m.Name == nameof(S8RoleResolver.ResolveUserIdsAsync))
|
|
|
+ .ToArray();
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // A/B. 签名契约 —— 防回退
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void ResolveUserIds_MustTakeTenantIdAsFirstParameter()
|
|
|
+ {
|
|
|
+ var overloads = ResolveOverloads();
|
|
|
+ var m = Assert.Single(overloads);
|
|
|
+
|
|
|
+ var ps = m.GetParameters();
|
|
|
+ Assert.Equal(2, ps.Length);
|
|
|
+ Assert.Equal(typeof(long), ps[0].ParameterType);
|
|
|
+ Assert.Equal("tenantId", ps[0].Name);
|
|
|
+ Assert.Equal(typeof(IEnumerable<string>), ps[1].ParameterType);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void ResolveUserIds_HasNoTenantlessOverload()
|
|
|
+ {
|
|
|
+ // 若将来有人加回 ResolveUserIdsAsync(IEnumerable<string>),跨租户缺陷会以「新调用方忘记传租户」的形式复发。
|
|
|
+ Assert.DoesNotContain(
|
|
|
+ ResolveOverloads(),
|
|
|
+ m => m.GetParameters().Length == 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // C/D. 作用域缺失守卫 —— 且必须早于任何 DB 访问
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task InvalidTenantScope_ReturnsEmpty_WithoutTouchingRepository()
|
|
|
+ {
|
|
|
+ // 故意注入 null 仓储与 null 服务:守卫若不是第一步,这里会 NullReferenceException 而非返回空集。
|
|
|
+ var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
|
|
|
+
|
|
|
+ Assert.Empty(await resolver.ResolveUserIdsAsync(0, new[] { "ROLE_PURCHASER" }));
|
|
|
+ Assert.Empty(await resolver.ResolveUserIdsAsync(-1, new[] { "ROLE_PURCHASER", "12345" }));
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task NullTokens_ReturnsEmpty_RegardlessOfTenant()
|
|
|
+ {
|
|
|
+ var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
|
|
|
+
|
|
|
+ Assert.Empty(await resolver.ResolveUserIdsAsync(838257186181189L, null));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // Token 切分(保持既有软失败语义,不因本次收口而改变)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SplitTokens_StillPartitionsCodesAndIds()
|
|
|
+ {
|
|
|
+ // 数字 token → RoleId,非数字 token → RoleCode。两条路径在实现里都要过租户校验。
|
|
|
+ Assert.Equal(new[] { "ROLE_A", "123", "ROLE_B" },
|
|
|
+ S8RoleResolver.SplitTokens(" ROLE_A , 123, , ROLE_B "));
|
|
|
+ Assert.Empty(S8RoleResolver.SplitTokens(null));
|
|
|
+ Assert.Empty(S8RoleResolver.SplitTokens(" "));
|
|
|
+ }
|
|
|
+}
|