S0WorkOrdControlTenantTests.cs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.Dto.S0.Manufacturing;
  3. using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
  4. using SqlSugar;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
  7. /// <summary>
  8. /// S0-WORKORD-CONTROL-TENANT-UX-FIX 防回归契约。
  9. ///
  10. /// 守住四条:
  11. /// 1. Upsert 按 Id 定位必须先 tenant scope,禁止裸 GetByIdAsync(P0 越租户更新漏洞);
  12. /// 2. Id 不属于当前租户时必须 NotFound,不得落到 Update 分支;
  13. /// 3. GET / Upsert 的 Domain 均为可选,页面加载不依赖用户手输工厂域;
  14. /// 4. 唯一索引必须以 TenantId 打头,否则 CodeFirst 重建后会退化为跨租户唯一。
  15. ///
  16. /// 与同目录其他契约测试一致:用源码静态扫描,不需要真实 DB / HTTP 上下文。
  17. /// 真实的双租户读写隔离由集成冒烟(两个租户 token 实调)覆盖。
  18. /// </summary>
  19. public class S0WorkOrdControlTenantTests
  20. {
  21. private static string ControllerSource()
  22. {
  23. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  24. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
  25. dir = dir.Parent;
  26. Assert.NotNull(dir);
  27. var path = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0",
  28. "Manufacturing", "AdoS0WorkOrdControlController.cs");
  29. Assert.True(File.Exists(path), $"未找到 {path}");
  30. return File.ReadAllText(path);
  31. }
  32. /// <summary>去掉注释行,避免注释里的说明文字造成误判。</summary>
  33. private static string StripComments(string src)
  34. {
  35. var withoutBlock = Regex.Replace(src, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
  36. var lines = withoutBlock.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
  37. return string.Join('\n', lines);
  38. }
  39. /// <summary>P0:不得使用未加租户条件的按 Id 查询。</summary>
  40. [Fact]
  41. public void Upsert_MustNotUseUnscopedGetById()
  42. {
  43. var src = StripComments(ControllerSource());
  44. Assert.DoesNotContain("GetByIdAsync", src);
  45. Assert.DoesNotContain("GetSingleAsync", src);
  46. // 按 Id 定位必须建立在 ScopedTo(tenantId) 之上。
  47. Assert.Matches(new Regex(@"ScopedTo\(tenantId\)\s*\r?\n?\s*\.Where\(x => x\.Id == dto\.Id"), src);
  48. }
  49. /// <summary>P0:Id 命中失败必须 404,绝不继续走到 Update。</summary>
  50. [Fact]
  51. public void Upsert_MustReturnNotFoundWhenIdBelongsToAnotherTenant()
  52. {
  53. var src = StripComments(ControllerSource());
  54. Assert.Contains("return NotFound();", src);
  55. var scopedIdIdx = src.IndexOf("x.Id == dto.Id", StringComparison.Ordinal);
  56. var notFoundIdx = src.IndexOf("return NotFound();", StringComparison.Ordinal);
  57. Assert.True(scopedIdIdx >= 0 && notFoundIdx > scopedIdIdx,
  58. "按 Id 的租户内查询之后必须紧跟 NotFound 分支。");
  59. var updateIdx = src.IndexOf("AsUpdateable", StringComparison.Ordinal);
  60. Assert.True(updateIdx > notFoundIdx, "NotFound 必须早于任何 Update 调用。");
  61. }
  62. /// <summary>每个 action 仍必须显式解析租户。</summary>
  63. [Fact]
  64. public void AllActions_ResolveTenantFirst()
  65. {
  66. var src = StripComments(ControllerSource());
  67. var actions = Regex.Matches(src, @"public async Task<IActionResult> (\w+)\(").Count;
  68. var resolves = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
  69. Assert.True(actions > 0);
  70. Assert.Equal(actions, resolves);
  71. }
  72. /// <summary>Domain 必须可选:GET 不传也能取到当前租户配置。</summary>
  73. [Fact]
  74. public void DomainMustBeOptional_OnBothDtos()
  75. {
  76. var get = typeof(AdoS0WorkOrdControlGetDto).GetProperty(nameof(AdoS0WorkOrdControlGetDto.Domain))!;
  77. var upsert = typeof(AdoS0WorkOrdControlUpsertDto).GetProperty(nameof(AdoS0WorkOrdControlUpsertDto.Domain))!;
  78. Assert.Equal(typeof(string), get.PropertyType);
  79. Assert.Equal(typeof(string), upsert.PropertyType);
  80. Assert.Empty(get.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
  81. Assert.Empty(upsert.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
  82. // 查询里 Domain 只能作为可选筛选(WhereIF),不得是硬条件。
  83. var src = StripComments(ControllerSource());
  84. Assert.Contains("WhereIF(!string.IsNullOrWhiteSpace(domain)", src);
  85. }
  86. /// <summary>唯一索引必须以 TenantId 打头,否则跨租户抢占同一 Domain。</summary>
  87. [Fact]
  88. public void UniqueIndexMustLeadWithTenantId()
  89. {
  90. var index = typeof(AdoS0WorkOrdControl)
  91. .GetCustomAttributes(typeof(SugarIndexAttribute), inherit: true)
  92. .OfType<SugarIndexAttribute>()
  93. .Single();
  94. Assert.Equal(nameof(AdoS0WorkOrdControl.TenantId), index.IndexFields.Keys.First());
  95. Assert.Contains(nameof(AdoS0WorkOrdControl.Domain), index.IndexFields.Keys);
  96. Assert.True(index.IsUnique);
  97. }
  98. }