using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// 租户库位安全边界纯逻辑测试(脱库)。 /// 覆盖:白名单归一化、用户筛选只能收窄、越界筛选 fail closed、空边界不得生成 IN、参数化与参数上限。 /// public class TenantLocationScopeTests { private static TenantLocationScope Uat() => TenantLocationScope.FromWhitelist(new[] { "1000", "1001", "1002", "5007", "8001" }); // —— 归一化 —— [Fact] public void FromWhitelist_TrimsAndDedupesAndDropsBlanks() { var scope = TenantLocationScope.FromWhitelist(new[] { " 1001 ", "1001", "", " ", null, "1002" }); Assert.Equal(new[] { "1001", "1002" }, scope.Locations); } [Fact] public void FromWhitelist_NullOrAllBlank_IsEmpty() { Assert.True(TenantLocationScope.FromWhitelist(null).IsEmpty); Assert.True(TenantLocationScope.FromWhitelist(new string[] { null, "", " " }).IsEmpty); } // —— Case 12 / 第八节:用户 Location 筛选不得绕过租户边界 —— [Fact] public void Intersect_NoRequestedLocation_KeepsFullTenantScope() { var scope = Uat(); Assert.Equal(scope.Locations, scope.Intersect(null).Locations); Assert.Equal(scope.Locations, scope.Intersect(" ").Locations); } [Fact] public void Intersect_LegalLocation_NarrowsToThatLocation() { var narrowed = Uat().Intersect(" 1001 "); Assert.Single(narrowed.Locations); Assert.Equal("1001", narrowed.Locations[0]); } [Theory] [InlineData("10000047")] [InlineData("10000054")] [InlineData("10000092")] public void Intersect_ForeignTenantLocation_FailsClosed(string foreign) { // Case 5 / Case 12:显式指定他租户库位必须归零,绝不放行 Assert.True(Uat().Intersect(foreign).IsEmpty); } [Fact] public void Intersect_NeverWidensScope() { var scope = Uat(); foreach (var candidate in new[] { "1001", "10000047", null, "", "9999" }) { var result = scope.Intersect(candidate); Assert.True(result.Count <= scope.Count); Assert.All(result.Locations, x => Assert.Contains(x, scope.Locations)); } } [Fact] public void Contains_IsCaseInsensitiveAndTrimmed() { var scope = TenantLocationScope.FromWhitelist(new[] { "VMI01" }); Assert.True(scope.Contains(" vmi01 ")); Assert.False(scope.Contains("VMI02")); Assert.False(scope.Contains(null)); } // —— Case 9 / 第十节:EMPTY SCOPE != FULL DOMAIN —— [Fact] public void BuildInClause_EmptyScope_Throws_NeverDegradesToFullDomain() { var ex = Assert.Throws( () => TenantLocationScope.Empty.BuildInClause("d.Location", "loc")); Assert.Contains("fail closed", ex.Message); } // —— 第十七节:参数化 —— [Fact] public void BuildInClause_ParameterizesEveryLocation() { var (clause, pars) = Uat().BuildInClause("d.Location", "loc"); Assert.Equal("d.Location IN (@loc0,@loc1,@loc2,@loc3,@loc4)", clause); Assert.Equal(5, pars.Count); Assert.Equal(new[] { "1000", "1001", "1002", "5007", "8001" }, pars.Select(p => (string)p.Value)); } [Fact] public void BuildInClause_NeverInlinesLocationValueIntoSql() { // 库位值一律走参数,SQL 文本里不得出现任何库位字面量(防注入 / 防拼接) var (clause, _) = TenantLocationScope .FromWhitelist(new[] { "1001", "O'BRIEN", "x') OR 1=1 --" }) .BuildInClause("d.Location", "loc"); Assert.DoesNotContain("1001", clause); Assert.DoesNotContain("OR 1=1", clause); Assert.DoesNotContain("'", clause); } [Fact] public void BuildInClause_RejectsOversizedWhitelist_ToStayUnderDbParameterLimit() { var oversized = TenantLocationScope.FromWhitelist( Enumerable.Range(0, TenantLocationScope.MaxParameters + 1).Select(i => $"L{i}")); var ex = Assert.Throws(() => oversized.BuildInClause("d.Location", "loc")); Assert.Contains("参数上限", ex.Message); } [Fact] public void BuildInClause_AtParameterLimit_IsAccepted() { var atLimit = TenantLocationScope.FromWhitelist( Enumerable.Range(0, TenantLocationScope.MaxParameters).Select(i => $"L{i}")); var (_, pars) = atLimit.BuildInClause("d.Location", "loc"); Assert.Equal(TenantLocationScope.MaxParameters, pars.Count); } }