S8RoleResolutionTenantScopeTests.cs 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. using Microsoft.Extensions.Logging.Abstractions;
  4. using Xunit;
  5. namespace Admin.NET.Test.S8;
  6. /// <summary>
  7. /// S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):角色 → 用户解析必须显式携带租户边界。
  8. ///
  9. /// <b>修复前的缺陷链</b>:
  10. /// 1. <c>SysRole.Code</c> 跨租户重复(每个业务角色每租户一份,实测 ROLE_PURCHASER 共 9 份);
  11. /// 2. 平台租户过滤器只在有登录上下文时挂载 —— <c>SqlSugarSetup</c> 中
  12. /// <c>App.User</c> 为空则不挂、超管直接 return;
  13. /// 3. <c>S8WatchSchedulerJob</c> 用 <c>IServiceScopeFactory.CreateScope()</c>,**没有 App.User**;
  14. /// ⇒ 后台派发分层通知时 "ROLE_PRODUCTION_PLANNER" 会解析到全部租户的同名角色,
  15. /// 把 A 租户的异常通知投递给 B/C/D 租户的用户。
  16. ///
  17. /// <b>本文件锁定的契约</b>(实例方法依赖 SqlSugarRepository + SysUserRoleService,
  18. /// 需 DB 才能端到端验证,因此这里锁定「签名」与「前置守卫」两层,
  19. /// 沿用仓内既有范式:S8TenantIsolationContractTests 亦用反射断言写入口必须带作用域):
  20. /// A. 方法签名必须以 tenantId 开头 → 防止有人改回无作用域版本;
  21. /// B. 不存在无 tenantId 的旧重载 → 防止新增调用方走回老路;
  22. /// C. tenantId &lt;= 0 时直接返回空 → 作用域缺失宁可不通知,绝不放行全量;
  23. /// D. 守卫必须在任何仓储访问之前 → 用 null 依赖构造,若先碰 DB 会 NullReference。
  24. ///
  25. /// 不接 DB、不接 DI。
  26. /// </summary>
  27. public class S8RoleResolutionTenantScopeTests
  28. {
  29. private static MethodInfo[] ResolveOverloads() =>
  30. typeof(S8RoleResolver)
  31. .GetMethods(BindingFlags.Public | BindingFlags.Instance)
  32. .Where(m => m.Name == nameof(S8RoleResolver.ResolveUserIdsAsync))
  33. .ToArray();
  34. // ============================================================
  35. // A/B. 签名契约 —— 防回退
  36. // ============================================================
  37. [Fact]
  38. public void ResolveUserIds_MustTakeTenantIdAsFirstParameter()
  39. {
  40. var overloads = ResolveOverloads();
  41. var m = Assert.Single(overloads);
  42. var ps = m.GetParameters();
  43. Assert.Equal(2, ps.Length);
  44. Assert.Equal(typeof(long), ps[0].ParameterType);
  45. Assert.Equal("tenantId", ps[0].Name);
  46. Assert.Equal(typeof(IEnumerable<string>), ps[1].ParameterType);
  47. }
  48. [Fact]
  49. public void ResolveUserIds_HasNoTenantlessOverload()
  50. {
  51. // 若将来有人加回 ResolveUserIdsAsync(IEnumerable<string>),跨租户缺陷会以「新调用方忘记传租户」的形式复发。
  52. Assert.DoesNotContain(
  53. ResolveOverloads(),
  54. m => m.GetParameters().Length == 1);
  55. }
  56. // ============================================================
  57. // C/D. 作用域缺失守卫 —— 且必须早于任何 DB 访问
  58. // ============================================================
  59. [Fact]
  60. public async Task InvalidTenantScope_ReturnsEmpty_WithoutTouchingRepository()
  61. {
  62. // 故意注入 null 仓储与 null 服务:守卫若不是第一步,这里会 NullReferenceException 而非返回空集。
  63. var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
  64. Assert.Empty(await resolver.ResolveUserIdsAsync(0, new[] { "ROLE_PURCHASER" }));
  65. Assert.Empty(await resolver.ResolveUserIdsAsync(-1, new[] { "ROLE_PURCHASER", "12345" }));
  66. }
  67. [Fact]
  68. public async Task NullTokens_ReturnsEmpty_RegardlessOfTenant()
  69. {
  70. var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
  71. Assert.Empty(await resolver.ResolveUserIdsAsync(838257186181189L, null));
  72. }
  73. // ============================================================
  74. // Token 切分(保持既有软失败语义,不因本次收口而改变)
  75. // ============================================================
  76. [Fact]
  77. public void SplitTokens_StillPartitionsCodesAndIds()
  78. {
  79. // 数字 token → RoleId,非数字 token → RoleCode。两条路径在实现里都要过租户校验。
  80. Assert.Equal(new[] { "ROLE_A", "123", "ROLE_B" },
  81. S8RoleResolver.SplitTokens(" ROLE_A , 123, , ROLE_B "));
  82. Assert.Empty(S8RoleResolver.SplitTokens(null));
  83. Assert.Empty(S8RoleResolver.SplitTokens(" "));
  84. }
  85. }