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. S8WatchSchedulerJob 用 IServiceScopeFactory.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(" "));
}
}