| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798 |
- 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(" "));
- }
- }
|