MdpConfigApiTenantIsolationTests.cs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  3. /// <summary>
  4. /// 对接配置接口的租户收口契约。匿名写入与跨租户改登记必须在源码层被拿掉,
  5. /// 不靠运行时铺两套租户数据。
  6. /// </summary>
  7. public class MdpConfigApiTenantIsolationTests
  8. {
  9. [Fact]
  10. public void ConfigWriteServices_AreNotAnonymous()
  11. {
  12. foreach (var file in new[]
  13. {
  14. "MdpSourceConfigService.cs",
  15. "MdpTenantStdSourceService.cs",
  16. "MdpSourceMappingService.cs",
  17. "MdpSyncEntityConfigService.cs",
  18. "MdpSyncTaskConfigService.cs"
  19. })
  20. {
  21. var source = ReadPluginFile("DataPlatform", file);
  22. Assert.DoesNotContain("[AllowAnonymous]", source, StringComparison.Ordinal);
  23. }
  24. }
  25. [Fact]
  26. public void TenantStdSource_RejectsCrossTenantWrite()
  27. {
  28. var source = ReadPluginFile("DataPlatform", "MdpTenantStdSourceService.cs");
  29. Assert.Contains("不能修改其他租户的来源登记", source, StringComparison.Ordinal);
  30. Assert.Contains("不能查看其他租户的来源登记", source, StringComparison.Ordinal);
  31. }
  32. [Fact]
  33. public void SourceList_IsTenantScoped_AndDoesNotReturnPassword()
  34. {
  35. var source = ReadPluginFile("DataPlatform", "MdpSourceConfigService.cs");
  36. Assert.Contains("x.TenantId == _user.TenantId", source, StringComparison.Ordinal);
  37. Assert.DoesNotContain("dbPassword", source, StringComparison.Ordinal);
  38. Assert.DoesNotContain("DbPasswordEnc =", source.Split("public async Task<object> GetList")[1].Split("public async Task<object> Save")[0], StringComparison.Ordinal);
  39. }
  40. [Fact]
  41. public void FuncAndMenu_RegisterMappingPagesWithoutFuncInTitle()
  42. {
  43. var func = ReadRepoFile("Web", "src", "constants", "aidopFuncCodes.ts");
  44. Assert.Contains("FUNC-S9-019", func, StringComparison.Ordinal);
  45. Assert.Contains("/aidop/data-platform/tenant-std-sources", func, StringComparison.Ordinal);
  46. Assert.Contains("FUNC-S9-020", func, StringComparison.Ordinal);
  47. Assert.Contains("/aidop/data-platform/source-mappings", func, StringComparison.Ordinal);
  48. var menu = ReadPluginFile("SeedData", "SysMenuSeedData.cs");
  49. Assert.Contains("业务对象来源", menu, StringComparison.Ordinal);
  50. Assert.Contains("来源映射维护", menu, StringComparison.Ordinal);
  51. Assert.DoesNotContain("FUNC-S9-019", menu, StringComparison.Ordinal);
  52. Assert.DoesNotContain("FUNC-S9-020", menu, StringComparison.Ordinal);
  53. }
  54. /// <summary>
  55. /// 手动拉取端点是配置层自定义实体唯一的运行时入口,也是唯一能把 entityCode
  56. /// 递到 dispatcher 的口子,因此它的收口条件必须在源码层被钉住。
  57. /// </summary>
  58. [Fact]
  59. public void ManualEntityPull_IsTaskScoped_AndTenantGuarded()
  60. {
  61. var source = ReadPluginFile("DataPlatform", "MdpSyncEntityConfigService.cs");
  62. Assert.Contains("sync-tasks/{taskCode}/entities/{entityCode}/pull", source, StringComparison.Ordinal);
  63. var method = source
  64. .Split("[DisplayName(\"按实体立即拉取一次\")]")[1]
  65. .Split("[DisplayName(\"更新同步实体\")]")[0];
  66. // 无租户上下文必须硬失败:否则贴源写入的租户解析回落到 null,整行被静默跳过,
  67. // 对外表现为「拉取成功但 0 行」。
  68. Assert.Contains("tenantId <= 0", method, StringComparison.Ordinal);
  69. Assert.Contains("无法解析当前租户", method, StringComparison.Ordinal);
  70. // entityCode 不得直接透传:必须先按「归属本任务 + 当前租户可见 + 已启用」收口
  71. Assert.Contains("u.JobId == jobCode", method, StringComparison.Ordinal);
  72. Assert.Contains("(u.TenantId == tenantId || u.TenantId == 0)", method, StringComparison.Ordinal);
  73. Assert.Contains("u.Status == 1", method, StringComparison.Ordinal);
  74. }
  75. private static string ReadPluginFile(string folder, string file) =>
  76. ReadRepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", folder, file);
  77. private static string ReadRepoFile(params string[] parts)
  78. {
  79. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  80. while (dir != null)
  81. {
  82. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  83. if (File.Exists(candidate)) return File.ReadAllText(candidate);
  84. dir = dir.Parent;
  85. }
  86. throw new FileNotFoundException(parts[^1]);
  87. }
  88. }