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);
}
}