using System.Reflection; using Admin.NET.Plugin.AiDOP.Service.S8; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):角色 → 用户解析必须显式携带租户边界。 /// /// 修复前的缺陷链: /// 1. SysRole.Code 跨租户重复(每个业务角色每租户一份,实测 ROLE_PURCHASER 共 9 份); /// 2. 平台租户过滤器只在有登录上下文时挂载 —— SqlSugarSetup 中 /// App.User 为空则不挂、超管直接 return; /// 3. S8WatchSchedulerJobIServiceScopeFactory.CreateScope(),**没有 App.User**; /// ⇒ 后台派发分层通知时 "ROLE_PRODUCTION_PLANNER" 会解析到全部租户的同名角色, /// 把 A 租户的异常通知投递给 B/C/D 租户的用户。 /// /// 本文件锁定的契约(实例方法依赖 SqlSugarRepository + SysUserRoleService, /// 需 DB 才能端到端验证,因此这里锁定「签名」与「前置守卫」两层, /// 沿用仓内既有范式:S8TenantIsolationContractTests 亦用反射断言写入口必须带作用域): /// A. 方法签名必须以 tenantId 开头 → 防止有人改回无作用域版本; /// B. 不存在无 tenantId 的旧重载 → 防止新增调用方走回老路; /// C. tenantId <= 0 时直接返回空 → 作用域缺失宁可不通知,绝不放行全量; /// D. 守卫必须在任何仓储访问之前 → 用 null 依赖构造,若先碰 DB 会 NullReference。 /// /// 不接 DB、不接 DI。 /// 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), ps[1].ParameterType); } [Fact] public void ResolveUserIds_HasNoTenantlessOverload() { // 若将来有人加回 ResolveUserIdsAsync(IEnumerable),跨租户缺陷会以「新调用方忘记传租户」的形式复发。 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.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.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(" ")); } }