diff --git a/DynamicWhere.Tests/CloneTests.cs b/DynamicWhere.Tests/CloneTests.cs new file mode 100644 index 0000000..b369035 --- /dev/null +++ b/DynamicWhere.Tests/CloneTests.cs @@ -0,0 +1,182 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; + +namespace DynamicWhere.Tests +{ + /// + /// Clone on the three request types a caller builds. + /// + /// + /// Public since 3.3.0. A caller reading the same request again with one part changed — the next + /// page, another order — used to rebuild the request around the caller's own clauses, which + /// leaves both requests holding one condition tree. The bug that follows is the one the library + /// already avoids internally by cloning before it rewrites anything. + /// + public class CloneTests + { + private static Filter Filled() => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + }, + SubConditionGroups = new List + { + new() + { + Conditions = + { + new Condition { Field = "Age", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } + } + } + } + }, + Selects = new List { "Id", "Name" }, + Orders = new List { new() { Field = "Name", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + [Fact] + public void A_filter_clone_shares_nothing_with_the_original() + { + Filter original = Filled(); + Filter copy = original.Clone(); + + Assert.NotSame(original, copy); + Assert.NotSame(original.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(original.ConditionGroup!.Conditions[0], copy.ConditionGroup!.Conditions[0]); + Assert.NotSame(original.ConditionGroup.SubConditionGroups![0], copy.ConditionGroup.SubConditionGroups![0]); + Assert.NotSame(original.Selects, copy.Selects); + Assert.NotSame(original.Orders, copy.Orders); + Assert.NotSame(original.Orders![0], copy.Orders![0]); + Assert.NotSame(original.Page, copy.Page); + } + + [Fact] + public void Changing_the_copy_leaves_the_caller_s_request_alone() + { + Filter original = Filled(); + Filter copy = original.Clone(); + + copy.Page!.PageNumber = 2; + copy.Orders![0].Direction = Direction.Descending; + copy.Selects!.Add("Age"); + copy.ConditionGroup!.Conditions[0].Values[0] = "b"; + + Assert.Equal(1, original.Page!.PageNumber); + Assert.Equal(Direction.Ascending, original.Orders![0].Direction); + Assert.Equal(2, original.Selects!.Count); + Assert.Equal("a", original.ConditionGroup!.Conditions[0].Values[0]); + } + + [Fact] + public void The_copy_carries_every_value() + { + Filter copy = Filled().Clone(); + + Assert.Equal("Name", copy.ConditionGroup!.Conditions[0].Field); + Assert.Equal("Age", copy.ConditionGroup.SubConditionGroups![0].Conditions[0].Field); + Assert.Equal(new[] { "Id", "Name" }, copy.Selects!); + Assert.Equal("Name", copy.Orders![0].Field); + Assert.Equal(10, copy.Page!.PageSize); + } + + [Fact] + public void A_branch_the_caller_left_null_stays_null() + { + Filter copy = new Filter().Clone(); + + Assert.Null(copy.ConditionGroup); + Assert.Null(copy.Selects); + Assert.Null(copy.Orders); + Assert.Null(copy.Page); + } + + [Fact] + public void A_segment_clone_copies_every_set() + { + Segment original = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + } + } + } + }, + Orders = new List { new() { Field = "Name", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Segment copy = original.Clone(); + + copy.ConditionSets[0].ConditionGroup!.Conditions[0].Values[0] = "b"; + copy.Page!.PageSize = 50; + + Assert.NotSame(original.ConditionSets[0], copy.ConditionSets[0]); + Assert.Equal("a", original.ConditionSets[0].ConditionGroup!.Conditions[0].Values[0]); + Assert.Equal(5, original.Page!.PageSize); + } + + [Fact] + public void A_summary_clone_copies_the_having_clause_as_well() + { + Summary original = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + } + }, + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Total", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } + } + }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Summary copy = original.Clone(); + + copy.Having!.Conditions[0].Values[0] = "9"; + copy.GroupBy!.Fields[0] = "Age"; + + Assert.NotSame(original.Having, copy.Having); + Assert.NotSame(original.GroupBy, copy.GroupBy); + Assert.Equal("1", original.Having!.Conditions[0].Values[0]); + Assert.Equal("Name", original.GroupBy!.Fields[0]); + } + + [Fact] + public void The_next_page_is_what_this_exists_for() + { + Filter caller = Filled(); + + Filter page2 = caller.Clone(); + page2.Page!.PageNumber = 2; + + Assert.Equal(1, caller.Page!.PageNumber); + Assert.Equal(2, page2.Page.PageNumber); + Assert.Equal(caller.Page.PageSize, page2.Page.PageSize); + } + } +} diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 48e78ca..d221b97 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -30,6 +30,10 @@ + + @@ -37,6 +41,18 @@ + + + + + - - + + @@ -73,6 +89,10 @@ where the expression-tree differences between EF Core 6 and 8 would surface. The sales fixture is excluded because it maps DateOnly/TimeOnly, which EF Core 6 cannot. --> + + + + + + + diff --git a/DynamicWhere.Tests/NumberValueTests.cs b/DynamicWhere.Tests/NumberValueTests.cs new file mode 100644 index 0000000..5f160a5 --- /dev/null +++ b/DynamicWhere.Tests/NumberValueTests.cs @@ -0,0 +1,377 @@ +using System.Globalization; +using System.Linq.Dynamic.Core; +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests; + +/// +/// A number value is read the way the predicate builder writes it: as a literal of the expression +/// parser, in the invariant culture, compared with the member the condition names. +/// +/// +/// Validation used to ask the host's culture through TryParse, so a value could pass it and +/// then fail in the parser with the parser's own exception, which a host maps to a server error. +/// Every test here runs the whole pipeline, so it holds the validator and the builder to one answer. +/// +public class NumberValueTests +{ + /// A shade, for the members a number compares with through its underlying type. + public enum Shade + { + /// One. + Dark = 1, + + /// Two. + Light = 2 + } + + /// One member of every type a number condition can name, and of several it cannot. + public class Row + { + public byte B { get; set; } + public sbyte Sb { get; set; } + public short S { get; set; } + public ushort Us { get; set; } + public int I { get; set; } + public uint Ui { get; set; } + public long L { get; set; } + public ulong Ul { get; set; } + public float F { get; set; } + public double D { get; set; } + public decimal M { get; set; } + public byte? Bn { get; set; } + public sbyte? Sbn { get; set; } + public short? Sn { get; set; } + public ushort? Usn { get; set; } + public int? In { get; set; } + public uint? Uin { get; set; } + public long? Ln { get; set; } + public ulong? Uln { get; set; } + public float? Fn { get; set; } + public double? Dn { get; set; } + public decimal? Mn { get; set; } + public Shade E { get; set; } + public Shade? En { get; set; } + public string Text { get; set; } = "x"; + public bool Flag { get; set; } + public Guid G { get; set; } + public DateTime When { get; set; } + public char C { get; set; } = 'c'; + public List Scores { get; set; } = new() { 1 }; + public List Kids { get; set; } = new() { new Child() }; + } + + /// An element, so a path through a collection is covered. + public class Child + { + public int Qty { get; set; } + public decimal? Cost { get; set; } + } + + /// A row with members named as the two values a number parser also reads as names. + public class Named + { + public int Id { get; set; } + public double Ratio { get; set; } + public double Infinity { get; set; } + public double NaN { get; set; } + } + + private static readonly Operator[] Comparisons = + { + Operator.Equal, Operator.NotEqual, Operator.GreaterThan, Operator.GreaterThanOrEqual, Operator.LessThan, + Operator.LessThanOrEqual, Operator.In, Operator.NotIn, Operator.Between, Operator.NotBetween + }; + + private static Filter Where(string field, Operator op, params object[] values) + { + Condition condition = new() { Sort = 1, Field = field, DataType = DataType.Number, Operator = op }; + + condition.Values.AddRange(values); + + if (op is Operator.Between or Operator.NotBetween && values.Length == 1) + { + condition.Values.Add(values[0]); + } + + return new Filter { ConditionGroup = new ConditionGroup { Connector = Connector.And, Conditions = { condition } } }; + } + + private static void UnderCulture(string name, Action act) + { + CultureInfo saved = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo(name); + act(); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + /// The values the host's TryParse accepted and the parser then refused. + public static TheoryData Unreadable() + { + TheoryData data = new() + { + "+5", "5-", "5+", "1,000", "1,5", ".5", "5.", "-.5", "1.e5", "1e", "e5", "1.5e", "NaN", "nan", "Infinity", + "-Infinity", "infinity", "99999999999999999999", "18446744073709551616", "-9223372036854775809", + "79228162514264337593543950336", "(5)", "1_000", "1 000", "1'000", "--5", "1.5.5", "", " ", "abc" + }; + + // Built from code points, so no such character stands in this file: a no-break space as a + // thousands separator, the minus sign several cultures write, an infinity sign, and a digit + // that is not an ASCII one. + data.Add("1" + (char)0x00A0 + "000"); + data.Add((char)0x2212 + "5"); + data.Add(((char)0x221E).ToString()); + data.Add(((char)0x0665).ToString()); + + return data; + } + + [Theory] + [MemberData(nameof(Unreadable))] + public void A_value_the_parser_cannot_read_is_a_format_error(string value) + { + foreach (string field in new[] { "I", "D", "M", "Ln" }) + { + LogicException refusal = Assert.Throws( + () => new List { new() }.AsQueryable().ToList(Where(field, Operator.Equal, value))); + + Assert.Equal(ErrorCode.InvalidFormat, refusal.Message); + } + } + + [Theory] + [InlineData("5")] + [InlineData("-5")] + [InlineData(" 5 ")] + [InlineData("\t5")] + [InlineData("5\n")] + [InlineData("00005")] + [InlineData("-0")] + [InlineData("1.5")] + [InlineData("1e5")] + [InlineData("1E+20")] + [InlineData("1e400")] + [InlineData("18446744073709551615")] + [InlineData("-9223372036854775808")] + public void A_value_the_parser_reads_runs(string value) => + Assert.NotNull(new List { new() }.AsQueryable().ToList(Where("D", Operator.Equal, value)).Data); + + /// What TryParse never accepted stays refused, though the parser has a reading for it. + [Theory] + [InlineData("5L")] + [InlineData("5m")] + [InlineData("5.0m")] + [InlineData("5f")] + [InlineData("5d")] + [InlineData("0x1F")] + [InlineData("- 5")] + public void Nothing_is_accepted_that_was_not(string value) + { + LogicException refusal = Assert.Throws( + () => new List { new() }.AsQueryable().ToList(Where("D", Operator.Equal, value))); + + Assert.Equal(ErrorCode.InvalidFormat, refusal.Message); + } + + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + [InlineData("sv-SE")] + [InlineData("ar-SA")] + [InlineData("fa-IR")] + [InlineData("en-US")] + public void The_culture_of_the_host_decides_nothing(string culture) => UnderCulture(culture, () => + { + List rows = new() { new Row { D = 1.5, M = 1.5m }, new Row { D = 15, M = 15m } }; + + // A point is the decimal separator on every host, and the row that holds 1.5 is the one found. + Assert.Equal(1.5, Assert.Single(rows.AsQueryable().ToList(Where("D", Operator.Equal, "1.5")).Data!).D); + Assert.Equal(1.5m, Assert.Single(rows.AsQueryable().ToList(Where("M", Operator.Equal, "1.5")).Data!).M); + + // A comma is not one on any host. + Assert.Equal( + ErrorCode.InvalidFormat, + Assert.Throws(() => rows.AsQueryable().ToList(Where("D", Operator.Equal, "1,5"))).Message); + + // A number placed in Values from code is written in the invariant culture. + Assert.Equal(1.5, Assert.Single(rows.AsQueryable().ToList(Where("D", Operator.Equal, 1.5)).Data!).D); + Assert.Equal(1.5m, Assert.Single(rows.AsQueryable().ToList(Where("M", Operator.Equal, 1.5m)).Data!).M); + }); + + /// + /// A value is never written into the expression as a name. Infinity and NaN passed + /// the old check as numbers, and on a type with a member of that name the condition compared two + /// columns. + /// + [Theory] + [InlineData("Infinity")] + [InlineData("NaN")] + [InlineData("infinity")] + [InlineData("nan")] + public void A_value_is_never_read_as_a_member(string value) + { + List rows = new() { new Named { Id = 1, Ratio = 7, Infinity = 7, NaN = 7 } }; + + LogicException refusal = Assert.Throws( + () => rows.AsQueryable().ToList(Where("Ratio", Operator.Equal, value))); + + Assert.Equal(ErrorCode.InvalidFormat, refusal.Message); + } + + [Fact] + public void One_unreadable_value_among_several_refuses_the_condition() + { + List rows = new() { new Row() }; + + Assert.Throws(() => rows.AsQueryable().ToList(Where("I", Operator.In, 1, "2,0", 3))); + Assert.Throws(() => rows.AsQueryable().ToList(Where("I", Operator.Between, 1, "NaN"))); + Assert.NotNull(rows.AsQueryable().ToList(Where("I", Operator.In, 1, "2", 3.0)).Data); + } + + /// + /// Over every kind of member, literal and comparison: validation accepts exactly what the parser + /// compares, and what it refuses it refuses as a format error, never with the parser's exception. + /// + /// + /// The expectation is worked out here, by asking the parser about a parameter of the member's + /// type, found by reflection. It shares nothing with the library's reader but the parser itself, + /// so the shortcuts that reader takes, the type it is handed for a path through a collection and + /// the exceptions it maps are all held to the parser's answer. + /// + [Fact] + public void Validation_accepts_exactly_what_the_parser_compares() + { + string[] literals = + { + "5", "-5", "300", "-300", "70000", "999999999", "1000000000", "3000000000", "5000000000", "-5000000000", + "9223372036854775808", "18446744073709551615", "1.5", "-1.5", "1e5", "1E-5", "1.5e3", "0.1", "00005", " 5 ", + "0", "-0", "1E+20", "1e400", "0.0000000000000000000000000001", "79228162514264337593543950335.5", + "1234567890123456789012345678", "12345678901234567890123456789", "1.0", "5.0" + }; + + string[] fields = typeof(Row).GetProperties().Select(p => p.Name).Where(n => n != "Kids") + .Concat(new[] { "Kids.Qty", "Kids.Cost" }).ToArray(); + + List rows = new() { new Row() }; + List wrong = new(); + + foreach (string field in fields) + { + Type memberType = field.StartsWith("Kids.", StringComparison.Ordinal) + ? typeof(Child).GetProperty(field.Substring(5))!.PropertyType + : typeof(Row).GetProperty(field)!.PropertyType; + + foreach (string literal in literals) + { + foreach (Operator op in Comparisons) + { + bool expected = ParserCompares(memberType, op, literal); + string actual; + + try + { + _ = rows.AsQueryable().ToList(Where(field, op, literal)).Data!.Count; + actual = "ran"; + } + catch (LogicException refusal) when (refusal.Message == ErrorCode.InvalidFormat) + { + actual = "refused"; + } + catch (Exception other) + { + actual = other.GetType().Name; + } + + if (actual != (expected ? "ran" : "refused")) + { + wrong.Add($"{field} {op} [{literal}]: expected {(expected ? "ran" : "refused")}, got {actual}"); + } + } + } + } + + Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong.Take(40))); + } + + private static bool ParserCompares(Type memberType, Operator op, string literal) + { + string symbol = op switch + { + Operator.Equal or Operator.In => "==", + Operator.NotEqual or Operator.NotIn => "!=", + Operator.GreaterThan => ">", + Operator.GreaterThanOrEqual or Operator.Between => ">=", + Operator.LessThan or Operator.NotBetween => "<", + _ => "<=" + }; + + try + { + ParameterExpression x = Expression.Parameter(memberType, "x"); + + DynamicExpressionParser.ParseLambda(DynamicLinq.Config, new[] { x }, typeof(bool), $"x {symbol} {literal}"); + + return true; + } + catch (Exception) + { + return false; + } + } + + // ------------------------------------------------------------------ having + + private static Summary Having(object value) => new() + { + GroupBy = new GroupBy + { + Fields = { "Text" }, + AggregateBy = { new AggregateBy { Field = "I", Aggregator = Aggregator.Sumation, Alias = "total" } } + }, + Having = new ConditionGroup + { + Connector = Connector.And, + Conditions = + { + new Condition { Sort = 1, Field = "total", DataType = DataType.Number, Operator = Operator.GreaterThanOrEqual, Values = { value } } + } + } + }; + + [Theory] + [InlineData("1,000")] + [InlineData("NaN")] + [InlineData("+5")] + [InlineData("5-")] + public void A_having_value_the_parser_cannot_read_is_a_format_error(string value) + { + LogicException refusal = Assert.Throws( + () => new List { new() { I = 3 } }.AsQueryable().ToList(Having(value))); + + Assert.Equal(ErrorCode.InvalidFormat, refusal.Message); + } + + [Theory] + [InlineData("de-DE")] + [InlineData("en-US")] + public void A_having_value_is_read_the_same_on_every_host(string culture) => UnderCulture(culture, () => + { + List rows = new() { new Row { I = 3 }, new Row { I = 4 } }; + + Assert.Single(rows.AsQueryable().ToList(Having("6.5")).Data!); + Assert.Empty(rows.AsQueryable().ToList(Having("7.5")).Data!); + Assert.Throws(() => rows.AsQueryable().ToList(Having("6,5"))); + }); +} diff --git a/DynamicWhere.Tests/PageTests.cs b/DynamicWhere.Tests/PageTests.cs index b30fb27..c071526 100644 --- a/DynamicWhere.Tests/PageTests.cs +++ b/DynamicWhere.Tests/PageTests.cs @@ -61,6 +61,21 @@ public void PageCustomersBeyondTheLastRow() => public void HighPageNumberReturnsNothing() => Assert.Empty(Products.Page(Page(999, 10)).ToList()); + /// + /// The offset is a product, and in 32 bits it wraps: a negative offset was the first page again + /// on SQLite and in memory, and an error on SQL Server and PostgreSQL. A page past the last row is + /// an empty page however far past it is. + /// + [Theory] + [InlineData(int.MaxValue, 1000)] + [InlineData(int.MaxValue, 2)] + [InlineData(4_294_968, 1000)] + public void PageNumberWhoseOffsetPassesInt32IsAnEmptyPage(int number, int size) + { + Assert.Empty(Products.Page(Page(number, size)).ToList()); + Assert.Empty(SalesSeed.Products().AsQueryable().Page(Page(number, size)).ToList()); + } + [Fact] public void RejectsPageNumberBelowOne() { diff --git a/DynamicWhere.Tests/PathValidationCacheTests.cs b/DynamicWhere.Tests/PathValidationCacheTests.cs index f7fdc6e..9737b03 100644 --- a/DynamicWhere.Tests/PathValidationCacheTests.cs +++ b/DynamicWhere.Tests/PathValidationCacheTests.cs @@ -25,6 +25,61 @@ public void A_path_that_fails_validation_leaves_no_access_record() Assert.False(CacheDatabase.PropertyPathAccessCount.ContainsKey(key)); } + private sealed class Timed + { + public int Id { get; set; } + } + + /// + /// A last-access time is right to the second. Writing it on every read put every thread querying + /// one entity type in a queue for the same entry's lock, and eviction only asks which entries are + /// oldest. + /// + [Fact] + public void A_read_refreshes_a_last_access_time_only_once_it_is_a_second_old() + { + if (CacheReflection.GetCacheConfigOptions().EvictionStrategy + != DynamicWhere.ex.Optimization.Cache.Enums.CacheEvictionStrategy.LRU) + { + return; + } + + (Type, string) key = (typeof(Timed), "Id"); + + CacheReflection.ValidatePropertyPath(typeof(Timed), "Id"); + + long first = CacheDatabase.PropertyPathAccessTime[key]; + + CacheReflection.ValidatePropertyPath(typeof(Timed), "Id"); + + Assert.Equal(first, CacheDatabase.PropertyPathAccessTime[key]); + + // As though it had last been read two seconds ago. + long stale = first - (2 * CacheDatabase.LruResolutionTicks); + + CacheDatabase.PropertyPathAccessTime[key] = stale; + + CacheReflection.ValidatePropertyPath(typeof(Timed), "Id"); + + Assert.True(CacheDatabase.PropertyPathAccessTime[key] > stale + CacheDatabase.LruResolutionTicks); + } + + /// + /// The configuration in force is a copy nobody outside holds, so a caller editing what they were + /// handed, going in or coming out, changes nothing a lookup reads. + /// + [Fact] + public void The_configuration_a_caller_holds_is_never_the_one_in_force() + { + DynamicWhere.ex.Optimization.Cache.Config.CacheOptions before = CacheReflection.GetCacheConfigOptions(); + + DynamicWhere.ex.Optimization.Cache.Config.CacheOptions handed = CacheReflection.GetCacheConfigOptions(); + + handed.MaxCacheSize = before.MaxCacheSize + 123; + + Assert.Equal(before.MaxCacheSize, CacheReflection.GetCacheConfigOptions().MaxCacheSize); + } + [Fact] public void A_path_that_validates_is_still_tracked() { diff --git a/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs new file mode 100644 index 0000000..103f07e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs @@ -0,0 +1,527 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5, adversarial security review of 3.3.0 at 692dd11. + // + // Round 4's fix: when DwCaps.MaxAuditEvents is reached and the tier hides existence, the + // refusal is raised through Gate.Exception(...DenialFor(feature)...) rather than as + // CapExceeded + SourceOrigin. These probes ask whether that refusal really is the same + // refusal a denied field and an unknown name get, in every clause and both terminals, and + // whether anything else can still tell the three apart. + // + // Everything here drives FilterSanitizer directly with an explicit posture, so no probe + // touches DwPolicy's process-wide state. + // ============================================================================================= + + /// The model the round-5 audit-cap probes gate. + /// + /// EF Core 6 compatible on purpose, so the floor leg runs every probe. + /// + internal class Ac5Staff + { + public int Id { get; set; } + + /// Plain: allowed everywhere, audited nowhere. + public string Code { get; set; } = string.Empty; + + /// Allowed everywhere and audited everywhere: one event per use. + [DwAudit] + public string Tag { get; set; } = string.Empty; + + /// Refused for every feature. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Weighed, so a cost refusal can be told from a structural one. + [DwCost(50)] + public string Heavy { get; set; } = string.Empty; + + /// Confirmable but not searchable. + [DwOperators(Allow = new[] { Operator.Equal })] + public string Badge { get; set; } = string.Empty; + + public Ac5Contact? Contact { get; set; } + } + + /// A nested node, so an audited field can sit behind a navigation. + internal class Ac5Contact + { + [DwAudit] + public string Email { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + } + + /// + /// An audited field on a type with nothing denied, so no projection is synthesized at all and + /// the whole entity comes back. + /// + internal class Ac5Plain + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string Tag { get; set; } = string.Empty; + } + + /// An audited field the type's declared default order names. + [DwEntity(DefaultOrder = "Tag")] + internal class Ac5Ordered + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string Tag { get; set; } = string.Empty; + } + + public class Ac5AuditCapProbes + { + private readonly ITestOutputHelper _out; + + public Ac5AuditCapProbes(ITestOutputHelper output) => _out = output; + + private const string Audited = "Tag"; + private const string Denied = "Secret"; + private const string Missing = "NoSuchColumn"; + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + if (dryRun) + { + context.DryRun = true; + } + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false, int audits = 1) => + new() + { + Tier = tier, + DryRun = dryRun, + Caps = { MinGroupSize = 1, MaxAuditEvents = audits } + }; + + private static Condition On(string field, Operator op = Operator.Equal) => + new() { Sort = 0, Field = field, DataType = DataType.Text, Operator = op, Values = { "x" } }; + + // Every clause names a projection, and names a field nothing audits. A request that sends no + // Selects has one synthesized, and since 3.3.0 the members it returns are recorded as read — + // so leaving Selects out here would spend the buffer before the clause under test is reached. + private static Filter Where(string field, Operator op = Operator.Equal) => + new() + { + ConditionGroup = new ConditionGroup { Conditions = { On(field, op) } }, + Selects = new List { "Code" } + }; + + private static Filter Order(string field) => + new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending } }, + Selects = new List { "Code" } + }; + + private static Filter Select(params string[] fields) => new() { Selects = fields.ToList() }; + + private static Summary Group(string field) => new() + { + GroupBy = new GroupBy { Fields = { field } } + }; + + private static Summary Aggregate(string field) => new() + { + GroupBy = new GroupBy + { + Fields = { "Code" }, + AggregateBy = { new AggregateBy { Field = field, Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Segment Set(string field) => new() + { + Selects = new List { "Code" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup { Conditions = { On(field) } } + } + } + }; + + /// Runs one clause against one caller, so the audit buffer carries across calls. + private static void Run( + object clause, DwPolicyContext context, DwPolicyOptions options, PolicyTrace trace) + { + switch (clause) + { + case Filter filter: + FilterSanitizer.Sanitize(filter, Attributes(), context, options, trace); + + break; + + case Summary summary: + FilterSanitizer.Sanitize(summary, Attributes(), context, options, trace); + + break; + + case Segment segment: + FilterSanitizer.Sanitize(segment, Attributes(), context, options, trace); + + break; + + default: + throw new InvalidOperationException("unknown clause"); + } + } + + /// + /// Fills the caller's audit buffer to capacity, then runs on the + /// same caller so the next audited use overflows it. + /// + private static (Exception? Error, PolicyTrace Trace) WithFullBuffer( + object clause, DwTier tier = DwTier.Strict, bool dryRun = false) + { + DwPolicyOptions options = Options(tier, dryRun); + DwPolicyContext context = Caller(); + + // One audited use fills a one-event buffer. + Run(Where(Audited), context, options, new PolicyTrace(tier, dryRun)); + + Assert.Single(context.PendingAuditEvents); + + PolicyTrace trace = new(tier, dryRun || options.DryRun); + + try + { + Run(clause, context, options, trace); + + return (null, trace); + } + catch (Exception error) + { + return (error, trace); + } + } + + /// Runs a clause on a fresh caller with a roomy buffer. + private static (Exception? Error, PolicyTrace Trace) Plain( + object clause, DwTier tier = DwTier.Strict, bool dryRun = false) + { + DwPolicyOptions options = Options(tier, dryRun, audits: 1000); + DwPolicyContext context = Caller(dryRun); + PolicyTrace trace = new(tier, dryRun); + + try + { + Run(clause, context, options, trace); + + return (null, trace); + } + catch (Exception error) + { + return (error, trace); + } + } + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}" + + $"|msg={refusal.Message}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => error.GetType().Name + }; + + /// + /// The three refusals a caller can tell apart if anything differs: the audit cap on a real + /// audited field, the denial of a real field, and a name matching nothing. + /// + private void AssertThreeAlike(string what, object capped, object denied, object missing) + { + (Exception? cap, PolicyTrace capTrace) = WithFullBuffer(capped); + (Exception? deny, _) = Plain(denied); + (Exception? miss, _) = Plain(missing); + + _out.WriteLine($"--- {what}"); + _out.WriteLine($" audit cap : {Shape(cap)}"); + _out.WriteLine($" denied : {Shape(deny)}"); + _out.WriteLine($" unknown : {Shape(miss)}"); + _out.WriteLine($" trace : {string.Join(" / ", capTrace.Decisions.Select(d => $"{d.FieldPath}:{d.Action}:{d.Reason}"))}"); + + Assert.NotNull(cap); + Assert.NotNull(deny); + Assert.NotNull(miss); + + // The denial and the unknown name are the established pair; the cap has to join them. + Assert.Equal(Shape(deny), Shape(miss)); + Assert.Equal(Shape(deny), Shape(cap)); + + // And the trace still says which refusal it really was. + Assert.Contains( + capTrace.Decisions, + decision => decision.Reason is { } reason && reason.Contains("MaxAuditEvents")); + } + + // ---- 1. the cap refusal is the field refusal, in every clause -------------------------- + + [Fact] + public void Where_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Where", Where(Audited), Where(Denied), Where(Missing)); + + [Fact] + public void Order_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Order", Order(Audited), Order(Denied), Order(Missing)); + + [Fact] + public void Select_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike( + "Select", Select("Code", Audited), Select("Code", Denied), Select("Code", Missing)); + + [Fact] + public void Group_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Group", Group(Audited), Group(Denied), Group(Missing)); + + [Fact] + public void Aggregate_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Aggregate", Aggregate(Audited), Aggregate(Denied), Aggregate(Missing)); + + [Fact] + public void Segment_audit_cap_is_indistinguishable() + => AssertThreeAlike("Segment", Set(Audited), Set(Denied), Set(Missing)); + + [Fact] + public void Nested_audited_field_audit_cap_is_indistinguishable() + => AssertThreeAlike( + "Nested where", + Where("Contact.Email"), + Where("Contact." + Denied), + Where("Contact." + Missing)); + + // ---- 2. the cap still refuses; no record is dropped ------------------------------------- + + [Fact] + public void Cap_refuses_rather_than_dropping_the_record() + { + DwPolicyOptions options = Options(); + DwPolicyContext context = Caller(); + + Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, false)); + + Assert.Single(context.PendingAuditEvents); + + Assert.Throws( + () => Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, false))); + + // Still one: the overflowing use was refused, never silently written and never dropped + // into a query that carried on. + Assert.Single(context.PendingAuditEvents); + } + + // ---- 3. Convenience and a dry run still answer CapExceeded ------------------------------ + + [Fact] + public void Convenience_still_answers_cap_exceeded() + { + (Exception? error, _) = WithFullBuffer(Where(Audited), DwTier.Convenience); + + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine(Shape(refusal)); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal(Audited, refusal.FieldPath); + Assert.Contains("MaxAuditEvents", refusal.SourceOrigin); + } + + [Fact] + public void Strict_dry_run_still_answers_cap_exceeded() + { + DwPolicyOptions options = Options(DwTier.Strict, dryRun: true); + DwPolicyContext context = Caller(); + + Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, true)); + + PolicyException refusal = Assert.Throws( + () => Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, true))); + + _out.WriteLine(Shape(refusal)); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Contains("MaxAuditEvents", refusal.SourceOrigin); + } + + // ---- 4. the cap is reached by uses the caller never wrote -------------------------------- + + // ---- 4b. the other cap that fires before the policy is consulted ------------------------- + + /// + /// MaxNavigationDepth is measured after canonicalization and before any field is + /// gated, so it is the other place a refusal is raised before the caller's policy is read. + /// A real path, a denied one, and a name matching nothing must all answer alike. + /// + [Fact] + public void Navigation_depth_cap_refuses_a_real_path_and_a_missing_one_alike() + { + DwPolicyOptions options = new() + { + Tier = DwTier.Strict, + Caps = { MinGroupSize = 1, MaxNavigationDepth = 1 } + }; + + string Run(string field) + { + try + { + FilterSanitizer.Sanitize( + Where(field), Attributes(), Caller(), options, new PolicyTrace(DwTier.Strict, false)); + + return "OK"; + } + catch (Exception error) + { + return Shape(error); + } + } + + string real = Run("Contact.Phone"); + string audited = Run("Contact.Email"); + string missing = Run("NoSuch.Column"); + + _out.WriteLine($"real deep : {real}"); + _out.WriteLine($"audited deep : {audited}"); + _out.WriteLine($"missing deep : {missing}"); + + Assert.StartsWith("CapExceeded", real); + Assert.Equal(real, audited); + Assert.Equal(real, missing); + } + + // ---- 5. FINDING: a projection the library synthesizes audits nothing --------------------- + + /// + /// A caller who names Tag in Selects is recorded. A caller who names no + /// projection at all receives Tag in every row and is recorded nowhere. + /// + [Fact] + public void Synthesized_projection_records_the_audited_field_it_returns() + { + DwPolicyOptions options = Options(audits: 1000); + + // (a) The caller names the audited field: one Select event. + DwPolicyContext named = Caller(); + + Filter spelled = FilterSanitizer.Sanitize( + Select("Code", Audited), Attributes(), named, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"named selects : {string.Join(",", spelled.Selects ?? new List())}"); + _out.WriteLine($"named events : {named.PendingAuditEvents.Count}" + + $" [{string.Join(",", named.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // (b) The same caller sends no projection. The library synthesizes one. + DwPolicyContext silent = Caller(); + + Filter synthesized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), silent, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"synth selects : {string.Join(",", synthesized.Selects ?? new List())}"); + _out.WriteLine($"synth events : {silent.PendingAuditEvents.Count}" + + $" [{string.Join(",", silent.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // The audited field really is in the projection the caller receives. + Assert.NotNull(synthesized.Selects); + Assert.Contains(Audited, synthesized.Selects!); + + // Naming it is recorded. + Assert.Contains( + named.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); + + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( + silent.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); + } + + /// + /// With nothing denied on the type no projection is synthesized either, so the whole entity + /// comes back — audited field included — and the buffer is empty. + /// + [Fact] + public void Whole_entity_read_records_the_audited_field_it_returns() + { + DwPolicyOptions options = Options(audits: 1000); + DwPolicyContext context = Caller(); + + Filter sanitized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), context, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"selects : {(sanitized.Selects is null ? "" : string.Join(",", sanitized.Selects))}"); + _out.WriteLine($"events : {context.PendingAuditEvents.Count}"); + + // Nothing is denied, so the row comes back whole and carries Tag. + Assert.Null(sanitized.Selects); + + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); + } + + /// + /// The order half of the same question, for contrast: a default-order field the library + /// adds is recorded, which is what the synthesized projection does not do. + /// + [Fact] + public void Default_order_records_the_use_the_library_adds() + { + DwPolicyOptions options = Options(audits: 1000); + DwPolicyContext context = Caller(); + + Filter sanitized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), context, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"orders : {string.Join(",", (sanitized.Orders ?? new List()).Select(o => o.Field))}"); + _out.WriteLine($"events : {string.Join(",", context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + Assert.Contains(sanitized.Orders ?? new List(), o => o.Field == Audited); + + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Order); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs b/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs new file mode 100644 index 0000000..56ab115 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs @@ -0,0 +1,508 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5. What every error code the library can raise still names under Strict, and whether a + // member a subquery builds is answered or refused. + // ============================================================================================= + + /// A generalized field the caller only ever names by its alias. + public class Ac5Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// A field that is filterable, but only with the operator the attribute allows. + public class Ac5Restricted + { + public int Id { get; set; } + + [DwAlias("badge")] + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// The same restriction with nothing else on it, so the policy has one source. + public class Ac5SoleRestricted + { + public int Id { get; set; } + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// Two different members sharing one alias, which no reading can resolve. + public class Ac5Colliding + { + public int Id { get; set; } + + [DwAlias("code")] + public string First { get; set; } = string.Empty; + + [DwAlias("code")] + public string Second { get; set; } = string.Empty; + } + + // ---- the row-shape model ------------------------------------------------------------------- + + public class Ac5Line + { + public int Id { get; set; } + + public int OrdId { get; set; } + + public decimal Price { get; set; } + } + + public class Ac5Ord + { + public int Id { get; set; } + + public decimal Total { get; set; } + + public List Lines { get; set; } = new(); + } + + /// A row node with a getter no database computes, and a field nobody may project. + public class Ac5LineRow + { + public decimal Price { get; set; } + + [DwDenied] + public decimal Cost { get; set; } + + public decimal Doubled => Price * 2m; + } + + /// + /// One node built in place from the entity's own columns, one built by a subquery. + /// + public class Ac5OrdRow + { + public int Id { get; set; } + + public Ac5LineRow Nest { get; set; } = new(); + + public Ac5LineRow? Head { get; set; } + } + + public sealed class Ac5CodeSurfaceProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ac5ShapeDb _db; + + public Ac5CodeSurfaceProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ac5ShapeDb(_connection); + _db.Database.EnsureCreated(); + + Ac5Ord order = new() { Id = 1, Total = 7m }; + + order.Lines.Add(new Ac5Line { Id = 1, Price = 4m }); + order.Lines.Add(new Ac5Line { Id = 2, Price = 6m }); + + _db.Ords.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy(Caller(), Options(tier), Attributes()); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Filter WhereOn( + string field, Operator op = Operator.Equal, DataType type = DataType.Text, string value = "x") => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = field, DataType = type, Operator = op, Values = { value } + } + } + } + }; + + // ========================================================================================= + // FINDING. AmbiguousGroupKey hands back the canonical path of a field named only by alias. + // ========================================================================================= + + [Fact] + public void Ambiguous_group_key_names_the_clause_and_not_the_path_behind_the_alias() + { + Ac5Banded[] rows = + { + new() { Id = 1, Payroll = 100m }, + new() { Id = 2, Payroll = 149m } + }; + + Summary byAlias = new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + Exception? error = Catch(() => Guard(rows.AsQueryable()).ToList(byAlias)); + + _out.WriteLine($"grouped by alias 'band' : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + + // The caller wrote "band" and never wrote "Payroll". Under Strict the refusal names the + // clause: the canonical path is the column behind the alias, and the origin would say + // that its values are transformed. + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// The same disclosure with the shipped k-anonymity floor in force, so it is not an artifact + /// of a deployment that turned the floor off. + /// + [Fact] + public void Ambiguous_group_key_names_the_clause_under_the_default_floor() + { + List rows = new(); + + // Two groups of six — above DwCaps.DefaultMinGroupSize — that round to the same band. + for (int i = 0; i < 6; i++) + { + rows.Add(new Ac5Banded { Id = i + 1, Payroll = 100m }); + rows.Add(new Ac5Banded { Id = i + 100, Payroll = 149m }); + } + + Summary byAlias = new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + DwPolicyOptions shipped = new() { Tier = DwTier.Strict }; + + Exception? error = Catch( + () => rows.AsQueryable().ApplyPolicy(Caller(), shipped, Attributes()).ToList(byAlias)); + + _out.WriteLine($"default floor ({shipped.Caps.MinGroupSize}) : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// The contrast: a field refusal for the same aliased field names nothing at all, which is + /// the rule the refusal above does not follow. + /// + [Fact] + public void A_field_refusal_on_an_aliased_field_names_nothing() + { + Exception? error = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(new Filter { Orders = new List { new() { Field = "band" } } })); + + _out.WriteLine($"ordered by alias 'band' : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + // ========================================================================================= + // OperatorNotAllowed: names the field, the rule and the attribute that restricted it. + // ========================================================================================= + + [Fact] + public void Strict_operator_refusal_names_the_field_but_not_its_source() + { + Exception? aliased = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("badge", Operator.Contains))); + + // The same restriction with no alias beside it, so the policy has exactly one source and + // Gate.Exception would attribute it if anything did. + Exception? sole = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("Serial", Operator.Contains))); + + Exception? unknown = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("NoSuchColumn", Operator.Contains))); + + _out.WriteLine($"restricted, aliased : {Shape(aliased)}"); + _out.WriteLine($"restricted, sole : {Shape(sole)}"); + _out.WriteLine($"unknown name : {Shape(unknown)}"); + + PolicyException refusal = Assert.IsType(aliased); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // Named by the spelling the caller used, which they already know, and with no source. + Assert.Equal("badge", refusal.FieldPath); + + // A single-source policy is the case that would attribute, so it is the one to look at. + PolicyException attributed = Assert.IsType(sole); + + _out.WriteLine($"sole-source origin : {attributed.SourceOrigin ?? "-"}"); + } + + // ========================================================================================= + // AmbiguousFieldName: a real-but-colliding name answers differently from a missing one. + // ========================================================================================= + + [Fact] + public void Strict_ambiguous_name_answers_as_a_missing_one_does() + { + Exception? collides = Catch( + () => Guard(Array.Empty().AsQueryable()).ToList(WhereOn("code"))); + + Exception? missing = Catch( + () => Guard(Array.Empty().AsQueryable()).ToList(WhereOn("NoSuchColumn"))); + + _out.WriteLine($"ambiguous alias : {Shape(collides)}"); + _out.WriteLine($"unknown name : {Shape(missing)}"); + + PolicyException one = Assert.IsType(collides); + PolicyException two = Assert.IsType(missing); + + // A name matching two fields matches at least one, so answering it differently from a + // name matching none would tell a caller their guess named something real. + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, one.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, two.ErrorCode); + Assert.Equal(one.FieldPath, two.FieldPath); + } + + // ========================================================================================= + // Round 4's fix 3: a member a subquery builds records nothing and is left alone. + // ========================================================================================= + + [Fact] + public void A_member_built_in_place_is_refused_and_one_built_by_a_subquery_is_not() + { + IQueryable projected = _db.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total }, + Head = order.Lines.OrderBy(line => line.Id) + .Select(line => new Ac5LineRow { Price = line.Price }) + .FirstOrDefault() + }); + + // What the same query does without the gate in front of it. + Exception? bareNest = Catch( + () => projected.Where(row => row.Nest.Doubled == 1m).ToList()); + + Exception? bareHead = Catch( + () => projected.Where(row => row.Head!.Doubled == 1m).ToList()); + + Exception? guardedNest = Catch( + () => Guard(projected).ToList(WhereOn("Nest.Doubled", Operator.Equal, DataType.Number, "1"))); + + Exception? guardedHead = Catch( + () => Guard(projected).ToList(WhereOn("Head.Doubled", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"unguarded Nest.Doubled : {Shape(bareNest)}"); + _out.WriteLine($"unguarded Head.Doubled : {Shape(bareHead)}"); + _out.WriteLine($"guarded Nest.Doubled : {Shape(guardedNest)}"); + _out.WriteLine($"guarded Head.Doubled : {Shape(guardedHead)}"); + + // A member the projection builds in place is read, so the strict tier refuses the path + // the database cannot compute. + PolicyException nest = Assert.IsType(guardedNest); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, nest.ErrorCode); + Assert.Equal("*", nest.FieldPath); + + // The member a subquery builds is left alone: whatever the bare query does, the guarded + // one does. This assertion records the residual, and must change if it is closed. + Assert.Equal(Shape(bareHead), Shape(guardedHead)); + Assert.IsNotType(guardedHead); + } + + /// + /// The denial half of the same shape: an opaque member is still one the gate narrows or + /// leaves out, so nothing beneath it reaches the caller. + /// + [Fact] + public void A_denied_field_beneath_a_subquery_built_member_does_not_reach_the_caller() + { + IQueryable projected = _db.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total, Cost = order.Total }, + Head = order.Lines.OrderBy(line => line.Id) + .Select(line => new Ac5LineRow { Price = line.Price, Cost = line.Price }) + .FirstOrDefault() + }); + + List rows = Guard(projected).ToList(new Filter()).Data; + + _out.WriteLine($"rows={rows.Count} nestCost={rows[0].Nest?.Cost} " + + $"nestPrice={rows[0].Nest?.Price} head={(rows[0].Head is null ? "" : "present")}"); + + // Whatever the shape could or could not read, the denied value is not in the result. + Assert.Equal(0m, rows[0].Nest?.Cost ?? 0m); + Assert.Equal(0m, rows[0].Head?.Cost ?? 0m); + } + + /// + /// A provider in front of EF Core's own is not EF Core for the purposes of the translation + /// test, and that changes nothing about what the policy denies. + /// + [Fact] + public void A_wrapping_provider_still_enforces_every_denial() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using Ac5ShapeDb wrapped = new(connection, typeof(Ac5PassThroughProvider)); + + wrapped.Database.EnsureCreated(); + + IQueryable projected = wrapped.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total, Cost = order.Total } + }); + + _out.WriteLine($"provider : {projected.Provider.GetType().FullName}"); + + Exception? denied = Catch( + () => Guard(projected).ToList(WhereOn("Nest.Cost", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"guarded Nest.Cost : {Shape(denied)}"); + + PolicyException refusal = Assert.IsType(denied); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// A provider a host puts in front of EF Core's own, built the documented way. + public sealed class Ac5PassThroughProvider : Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider + { + public Ac5PassThroughProvider(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } + + public sealed class Ac5ShapeDb : DbContext + { + private readonly SqliteConnection _connection; + private readonly Type? _replacementProvider; + + public Ac5ShapeDb(SqliteConnection connection, Type? replacementProvider = null) + { + _connection = connection; + _replacementProvider = replacementProvider; + } + + public DbSet Ords => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacementProvider is null) + { + return; + } + + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod( + typeof(Microsoft.EntityFrameworkCore.Query.IAsyncQueryProvider), _replacementProvider) + .Invoke(options, null); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs new file mode 100644 index 0000000..9cf7f2f --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs @@ -0,0 +1,347 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5, adversarial security review of 3.3.0 at 692dd11. + // + // End-to-end probes, through PolicyQueryable and a real database, for the channels round 4's + // fixes did not touch: what a refusal still names under Strict, and what an audited field's + // record says when the caller names no projection. + // ============================================================================================= + + /// An employee whose identifier is audited and whose note is sealed. + public class Ac5Emp + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited for every feature: one event per use. + [DwAudit] + public string NationalId { get; set; } = string.Empty; + + /// Denied outright, so a projection has to be synthesized. + [DwDenied] + public string Note { get; set; } = string.Empty; + } + + /// The same shape with nothing denied, so no projection is synthesized at all. + public class Ac5Open + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + /// A masked identifier, so the transform refusals can be reached. + public class Ac5Masked + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Partial, KeepEnd = 2)] + [DwNoOrder] + public string Email { get; set; } = string.Empty; + } + + /// A scope the caller has to supply themselves. + public class Ac5Scoped + { + public int Id { get; set; } + + [DwRequireWhere] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + public sealed class Ac5LeakProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ac5Db _db; + + public Ac5LeakProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ac5Db(_connection); + _db.Database.EnsureCreated(); + + _db.Emps.Add(new Ac5Emp { Id = 1, Name = "Ada", NationalId = "AAA-111", Note = "founder" }); + _db.Opens.Add(new Ac5Open { Id = 1, Name = "Ada", NationalId = "AAA-111" }); + _db.Masked.Add(new Ac5Masked { Id = 1, Name = "Ada", NationalId = "AAA-111", Email = "ada@x.com" }); + _db.Scoped.Add(new Ac5Scoped { Id = 1, TenantId = 5, Amount = 10m }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, string? salt = null) + { + DwPolicyOptions options = new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + if (salt is not null) + { + options.HashSalt = salt; + } + + return options; + } + + private static PolicyQueryable Guard( + IQueryable source, DwPolicyContext context, DwPolicyOptions options) + where T : class => + source.ApplyPolicy(context, options, Attributes()); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // FINDING. An audited field the caller never names is returned and never recorded. + // ========================================================================================= + + /// + /// The caller sends a filter with no Selects. The library synthesizes one, keeps the + /// audited field in it, and hands back its real value — with nothing written to the audit. + /// + [Fact] + public void An_audited_field_is_recorded_whether_or_not_the_request_names_it() + { + DwPolicyOptions options = Options(); + + // (a) The caller names nothing. + DwPolicyContext silent = Caller(); + + FilterResult whole = Guard(_db.Emps.AsNoTracking(), silent, options).ToList(new Filter()); + + _out.WriteLine($"no projection : value={whole.Data[0].NationalId}" + + $" note='{whole.Data[0].Note}' events={silent.PendingAuditEvents.Count}"); + + // (b) The same caller names the field. + DwPolicyContext named = Caller(); + + FilterResult spelled = Guard(_db.Emps.AsNoTracking(), named, options) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + _out.WriteLine($"named : value={spelled.Data[0].NationalId}" + + $" events={named.PendingAuditEvents.Count}" + + $" [{string.Join(",", named.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // The denial was honoured, so the projection really was synthesized. + Assert.Equal(string.Empty, whole.Data[0].Note); + + // The audited value reached the caller either way. + Assert.Equal("AAA-111", whole.Data[0].NationalId); + Assert.Equal("AAA-111", spelled.Data[0].NationalId); + + // Naming it writes a record. + Assert.Contains( + named.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( + silent.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + } + + /// + /// With nothing denied the row comes back whole, so the audited column is read straight off + /// the table and the buffer is still empty. + /// + [Fact] + public void An_audited_field_a_whole_entity_read_returns_is_recorded() + { + DwPolicyContext context = Caller(); + + FilterResult rows = + Guard(_db.Opens.AsNoTracking(), context, Options()).ToList(new Filter()); + + _out.WriteLine($"value={rows.Data[0].NationalId} events={context.PendingAuditEvents.Count}"); + + Assert.Equal("AAA-111", rows.Data[0].NationalId); + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + } + + /// The convenience tier reads the same way, so the gap is not tier-specific. + [Fact] + public void The_record_is_written_in_both_tiers() + { + DwPolicyContext context = Caller(); + + FilterResult rows = Guard(_db.Opens.AsNoTracking(), context, Options(DwTier.Convenience)) + .ToList(new Filter()); + + Assert.Equal("AAA-111", rows.Data[0].NationalId); + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + } + + // ========================================================================================= + // What a strict refusal still names, across the codes that are not field denials. + // ========================================================================================= + + /// + /// TransformRequiresMaterialization carries every transformed path on the type as its + /// FieldPath, under Strict. + /// + [Fact] + public void Strict_transform_refusal_names_the_clause_and_not_the_masked_fields() + { + DwPolicyContext context = Caller(); + + PolicyQueryable guarded = Guard(_db.Masked.AsNoTracking(), context, Options()); + + Exception? error = Catch(() => guarded.SelectDynamic(new List { "Id" })); + + _out.WriteLine($"SelectDynamic : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.TransformRequiresMaterialization, refusal.ErrorCode); + + // The clause, not the columns: the list was every transformed column on the type, handed + // to a caller who named none of them. + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// MissingHashSalt names the masked field under Strict, where a field refusal + /// names none. + /// + [Fact] + public void Strict_missing_salt_refusal_names_the_clause() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch( + () => Guard(_db.Masked.AsNoTracking(), context, Options()).ToList(new Filter())); + + _out.WriteLine($"no salt : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingHashSalt, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// RequiredFilterMissing names the force-filtered field under Strict and puts + /// it in the origin as well. Documented as deliberate; recorded here so it stays a choice. + /// + [Fact] + public void Strict_required_filter_refusal_names_the_field() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch( + () => Guard(_db.Scoped.AsNoTracking(), context, Options()).ToList(new Filter())); + + _out.WriteLine($"required : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.RequiredFilterMissing, refusal.ErrorCode); + Assert.Equal("TenantId", refusal.FieldPath); + Assert.Contains("TenantId", refusal.SourceOrigin); + } + + /// + /// The salted deployment answers, so the refusal above is a deployment state rather than a + /// standing one. + /// + [Fact] + public void Salted_deployment_answers() + { + DwPolicyContext context = Caller(); + + FilterResult rows = + Guard(_db.Masked.AsNoTracking(), context, Options(salt: "a-long-enough-salt-value")) + .ToList(new Filter()); + + _out.WriteLine($"hashed : {rows.Data[0].NationalId}"); + + Assert.NotEqual("AAA-111", rows.Data[0].NationalId); + } + + public sealed class Ac5Db : DbContext + { + private readonly SqliteConnection _connection; + + public Ac5Db(SqliteConnection connection) => _connection = connection; + + public DbSet Emps => Set(); + + public DbSet Opens => Set(); + + public DbSet Masked => Set(); + + public DbSet Scoped => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs b/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs new file mode 100644 index 0000000..c743337 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs @@ -0,0 +1,328 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, adversarial security review of 3.3.0 at 893cadc. + // + // Reviews round 6's fixes: the audit use recorded for a synthesized projection, AuditPath on the + // four "*" refusals, the blank GroupBy guard, and the alias/rename agreement. + // ============================================================================================= + + /// An audited field the caller may not project, beside one they may. + public class Ar7Audited + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited and refused for projection. + [DwAudit] + [DwNoSelect] + public string NationalId { get; set; } = string.Empty; + } + + /// An audited member a projection cannot assign, beside a denied one. + public class Ar7Uncarried + { + public int Id { get; set; } + + /// Audited, allowed, and read-only — a projection cannot assign it. + [DwAudit] + public string Computed => "computed-" + Id; + + /// Denied, which is what makes a projection be synthesized at all. + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + /// Every member audited and allowed, so nothing is denied and no projection is built. + public class Ar7AllAudited + { + public int Id { get; set; } + + [DwAudit] + public string Name { get; set; } = string.Empty; + } + + /// An audited field named in a where clause and carried by the synthesized projection. + public class Ar7Both + { + public int Id { get; set; } + + [DwAudit] + public string Badge { get; set; } = string.Empty; + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Ar7AuditProbes + { + private readonly ITestOutputHelper _out; + + public Ar7AuditProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = true, + Caps = { MinGroupSize = 1 } + }; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select( + e => $"{e.FieldPath}:{e.Feature}:{e.Effect}:dry={e.DryRun}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // 1. The audit use a synthesized projection records. + // ========================================================================================= + + /// + /// The control: enforced, the denied audited field is not returned and no use is recorded + /// for it. + /// + [Fact] + public void Enforced_records_no_use_for_a_field_the_projection_leaves_out() + { + Ar7Audited[] rows = { new() { Id = 1, Name = "a", NationalId = "AAA-111" } }; + + DwPolicyContext context = Caller(); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}' Name='{got[0].Name}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal(string.Empty, got[0].NationalId); + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// CANDIDATE. In a dry run the row comes back whole, so the audited denied field's value + /// reaches the caller — and the audit loop records only what the projection would have kept, + /// which does not include it. + /// + [Fact] + public void Dry_run_returns_the_audited_denied_value_and_records_no_use() + { + Ar7Audited[] rows = { new() { Id = 1, Name = "a", NationalId = "AAA-111" } }; + + DwPolicyContext context = Caller(dryRun: true); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Strict), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + // What the caller receives. + Assert.Equal("AAA-111", got[0].NationalId); + + // A dry run applies no projection, so what the caller receives is every member — the + // audited denied one included — and every one of them is recorded, with the effect the + // policy decided. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + + // The asymmetry, in the same posture: naming the field records the use, so an empty + // Selects is still one token past [DwAudit] — which is the hole round 6 set out to close. + DwPolicyContext named = Caller(dryRun: true); + + rows.AsQueryable() + .ApplyPolicy(named, Posture(DwTier.Strict), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + _out.WriteLine($"named : {Recorded(named)}"); + + Assert.Contains(named.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// CANDIDATE. An audited member the projection cannot assign is still returned by the + /// narrowed projection's sibling path... or is it? Records what actually happens. + /// + [Fact] + public void An_audited_member_a_projection_cannot_assign() + { + Ar7Uncarried[] rows = { new() { Id = 1, Secret = "s" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => + { + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Computed='{got[0].Computed}' Secret='{got[0].Secret}'"); + }); + + _out.WriteLine($"error : {error?.GetType().Name} {error?.Message}"); + _out.WriteLine($"audit : {Recorded(context)}"); + } + + /// + /// Nothing denied: no projection is built, the row comes back whole, and every audited + /// member is recorded as used. + /// + [Fact] + public void Nothing_denied_records_a_use_for_every_member_returned() + { + Ar7AllAudited[] rows = { new() { Id = 1, Name = "a" } }; + + DwPolicyContext context = Caller(); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Name='{got[0].Name}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Name" && e.Feature == PolicyFeature.Select); + } + + /// + /// A field the request names in a where clause and the synthesized projection also carries + /// records one Where use and one Select use — not two of either. + /// + [Fact] + public void A_field_named_and_projected_records_each_use_once() + { + Ar7Both[] rows = { new() { Id = 1, Badge = "b", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = DynamicWhere.ex.Enums.Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Badge", + DataType = DynamicWhere.ex.Enums.DataType.Text, + Operator = DynamicWhere.ex.Enums.Operator.Equal, + Values = new List { "b" } + } + } + } + }); + + _out.WriteLine($"audit : {Recorded(context)}"); + + int where = context.PendingAuditEvents.Count( + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Where); + int select = context.PendingAuditEvents.Count( + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Select); + + _out.WriteLine($"where={where} select={select}"); + + Assert.Equal(1, where); + Assert.Equal(1, select); + } + + /// + /// The recording runs before the cost check, the scope injection and the required-filter + /// check, so a refused query records uses of fields the caller neither named nor received. + /// + [Fact] + public void A_refused_query_records_uses_of_the_projection_it_never_returned() + { + Ar7Both[] rows = { new() { Id = 1, Badge = "b", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + DwPolicyOptions options = Posture(); + + // Refused after the projection is synthesized and its uses recorded. + options.Caps.MaxQueryCost = 1; + options.Caps.DefaultFieldCost = 1000; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = DynamicWhere.ex.Enums.Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", + DataType = DynamicWhere.ex.Enums.DataType.Number, + Operator = DynamicWhere.ex.Enums.Operator.GreaterThan, + Values = new List { "0" } + } + } + } + })); + + _out.WriteLine($"refused : {(error as PolicyException)?.ErrorCode.ToString() ?? error?.GetType().Name ?? "OK"}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.NotNull(error); + + // The caller received nothing, and the audit says they read Badge. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Select && e.Effect == PolicyEffect.Allow); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs b/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs new file mode 100644 index 0000000..827c796 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs @@ -0,0 +1,256 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A plain type with an alias, so both branches of ResolveName are reachable. + public class Ar7Blank + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("label")] + public string Title { get; set; } = string.Empty; + } + + /// The same shape with no alias at all, which takes the other branch of ResolveName. + public class Ar7Plain + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public sealed class Ar7BlankProbes + { + private readonly ITestOutputHelper _out; + + public Ar7BlankProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException {refusal.ErrorCode} path='{refusal.FieldPath}'", + LogicException logic => $"LogicException {logic.Message}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Ar7Blank[] Rows() => new[] { new Ar7Blank { Id = 1, Name = "a", Title = "t" } }; + + private static Ar7Plain[] Plain() => new[] { new Ar7Plain { Id = 1, Name = "a" } }; + + // ========================================================================================= + // The blank guard: which clauses have one, and what the ones without do. + // ========================================================================================= + + /// The fixed clause: a blank grouping key now fails as validation, not as an argument. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_group_by_key_fails_as_validation(DwTier tier) + { + Exception? guarded = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { " " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + })); + + _out.WriteLine($"{tier} group-by blank : {Shape(guarded)}"); + + Assert.IsType(guarded); + Assert.IsNotType(guarded); + } + + /// + /// CANDIDATE. A blank projection entry has no such guard on either the aliased or the + /// unaliased branch of ResolveName. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_select_entry(DwTier tier) + { + Exception? aliased = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter { Selects = new List { "Id", " " } })); + + Exception? unaliased = Catch(() => Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter { Selects = new List { "Id", " " } })); + + Exception? unguarded = Catch(() => Plain().AsQueryable() + .ToList(new Filter { Selects = new List { "Id", " " } })); + + _out.WriteLine($"{tier} guarded, type with an alias : {Shape(aliased)}"); + _out.WriteLine($"{tier} guarded, type with none : {Shape(unaliased)}"); + _out.WriteLine($" unguarded : {Shape(unguarded)}"); + } + + /// CANDIDATE. The same for a segment's projection. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public async Task A_blank_segment_select_entry(DwTier tier) + { + Exception? guarded = null; + + try + { + await Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToListAsync(new Segment + { + Selects = new List { "Id", " " }, + ConditionSets = new List + { + new() + { + Sort = 0, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, + Values = new List { "0" } + } + } + } + } + } + }); + } + catch (Exception error) + { + guarded = error; + } + + Exception? unguarded = null; + + try + { + await Plain().AsQueryable().ToListAsync(new Segment + { + Selects = new List { "Id", " " }, + ConditionSets = new List + { + new() + { + Sort = 0, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, + Values = new List { "0" } + } + } + } + } + } + }); + } + catch (Exception error) + { + unguarded = error; + } + + _out.WriteLine($"{tier} guarded segment : {Shape(guarded)}"); + _out.WriteLine($" unguarded : {Shape(unguarded)}"); + } + + /// A blank order field, for comparison: guarded there since before round 6. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_order_field_fails_as_validation(DwTier tier) + { + Exception? guarded = Catch(() => Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter + { + Orders = new List { new() { Sort = 0, Field = " ", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"{tier} order blank : {Shape(guarded)}"); + + Assert.IsType(guarded); + } + + /// The guard must not swallow a real name that merely has padding around it. + [Fact] + public void A_padded_real_group_key_is_still_resolved() + { + Exception? error = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { " label " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + })); + + _out.WriteLine($"padded alias : {Shape(error)}"); + + Assert.Null(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs b/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs new file mode 100644 index 0000000..0e3f7de --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs @@ -0,0 +1,202 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A department an employee points at, which nothing includes. + public class Ar7Dept + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + } + + /// An employee whose audited navigation is never loaded. + public class Ar7Employee + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int DeptId { get; set; } + + /// Audited, allowed, and not loaded by a query that does not include it. + [DwAudit] + public Ar7Dept? Dept { get; set; } + } + + /// A staff record whose audited column a source projection may leave unassigned. + public class Ar7Partial + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited and allowed: the question is whether a use is recorded when it is not read. + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + public sealed class Ar7EfContext : DbContext + { + public Ar7EfContext(DbContextOptions options) : base(options) + { + } + + public DbSet Depts => Set(); + + public DbSet Employees => Set(); + + public DbSet Partials => Set(); + } + + public sealed class Ar7EfAuditProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public Ar7EfAuditProbes(ITestOutputHelper output) + { + _out = output; + + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + + using Ar7EfContext seed = new(_options); + + seed.Database.EnsureCreated(); + seed.Depts.Add(new Ar7Dept { Id = 1, Title = "Engineering" }); + seed.Employees.Add(new Ar7Employee { Id = 1, Name = "Ada", DeptId = 1 }); + seed.Partials.Add(new Ar7Partial { Id = 1, Name = "Ada", NationalId = "AAA-111" }); + seed.SaveChanges(); + } + + public void Dispose() => _connection.Dispose(); + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.Effect}")); + + /// + /// CANDIDATE. Nothing is denied on this type, so no projection is synthesized and every + /// member goes into readable — the audited navigation included, although the query + /// never loads it and the caller receives null. + /// + [Fact] + public void An_unloaded_audited_navigation_is_recorded_as_used() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + List got = db.Employees + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Dept is {(got[0].Dept is null ? "null (never loaded)" : "loaded")}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Null(got[0].Dept); + + // Nothing is denied on this type, so no projection is synthesized and the row comes + // back whole — but a navigation nothing loads is not part of it. The audit records what + // the query hands back, not every member the caller may select. + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "Dept"); + } + + /// + /// CANDIDATE. The source already projected, leaving the audited column unassigned, and the + /// guarded read still records a use of it. + /// + [Fact] + public void An_unassigned_audited_column_of_a_projected_source_is_recorded_as_used() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + IQueryable projected = + db.Partials.Select(p => new Ar7Partial { Id = p.Id, Name = p.Name }); + + List got = projected + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}' (source never read the column)"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal(string.Empty, got[0].NationalId); + + // The same, for a column the source projection never assigned: the row carries no value + // for it, so reading it is not something the log has to answer for. + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// What the over-recording costs: the buffer is spent on members the query never read, and + /// MaxAuditEvents fails closed, so a read the caller is entitled to is refused. + /// + [Fact] + public void A_use_recorded_for_a_member_never_read_can_refuse_the_query() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + DwPolicyOptions options = Posture(); + + // Room for nothing beyond the members the query does read. Employees reads Id, Name and + // DeptId; only the unloaded Dept is audited. + options.Caps.MaxAuditEvents = 1; + + List first = db.Employees + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"first read : {first.Count} row(s), audit={Recorded(context)}"); + + Exception? second = null; + + try + { + db.Employees.ApplyPolicy(context, options, Attributes()).ToList(new Filter()); + } + catch (Exception error) + { + second = error; + } + + _out.WriteLine($"second read : {second?.GetType().Name ?? "OK"} " + + $"{(second as DynamicWhere.ex.Exceptions.PolicyException)?.ErrorCode.ToString() ?? string.Empty}"); + + // The second read is answered: nothing was spent on a navigation neither read loaded, + // so the buffer a fail-closed cap watches is not filled by reads that did not happen. + Assert.Null(second); + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "Dept"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs b/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs new file mode 100644 index 0000000..2340597 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Tokens; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A nested branch whose member carries the alias, so the alias names a deep path. + public class Ar7Branch + { + public int Id { get; set; } + + [DwAlias("org")] + public string Name { get; set; } = string.Empty; + } + + /// A division holding the branch, so an alias can name a path two navigations deep. + public class Ar7Division + { + public int Id { get; set; } + + public Ar7Branch? Branch { get; set; } + } + + /// + /// A root whose alias resolves to a path deeper than a lowered navigation cap allows, while a + /// name that matches nothing stays one segment long. + /// + public class Ar7Aliased + { + public int Id { get; set; } + + public Ar7Division? Division { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public sealed class Ar7OracleProbes + { + private readonly ITestOutputHelper _out; + + public Ar7OracleProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Exception? Ask(string name, int depth) + { + DwPolicyOptions options = new() { Tier = DwTier.Strict, AuditRefusals = true }; + + options.Caps.MinGroupSize = 1; + options.Caps.MaxNavigationDepth = depth; + + Ar7Aliased[] rows = { new() { Id = 1, Tag = "t" } }; + + return Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), options, Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = name, Direction = Direction.Ascending } + } + })); + } + + /// + /// CANDIDATE. Under Strict a name that matches nothing and a name that does must be refused + /// alike. The navigation cap is measured on the canonical path, which an alias hides, so a + /// one-token alias standing for a deep path is refused with a different code from a + /// one-token name standing for nothing. + /// + [Fact] + public void An_alias_and_an_unknown_name_are_refused_alike_under_the_navigation_cap() + { + // 'org' stands for Division.Branch.Name, three segments; 'zzz' stands for nothing and + // stays one. Both are one token as the caller writes them. + Exception? alias = Ask("org", depth: 2); + Exception? unknown = Ask("zzz", depth: 2); + Exception? real = Ask("Id", depth: 2); + + _out.WriteLine($"alias 'org' (Division.Branch.Name) : {Shape(alias)}"); + _out.WriteLine($"unknown 'zzz' : {Shape(unknown)}"); + _out.WriteLine($"real 'Id' : {Shape(real)}"); + + Exception? deepAlias = Ask("Division.Branch.Name", depth: 2); + Exception? deepUnknown = Ask("Zzz.Yyy.Xxx", depth: 2); + + _out.WriteLine($"real deep path : {Shape(deepAlias)}"); + _out.WriteLine($"unknown of the same shape : {Shape(deepUnknown)}"); + + // A name that matches nothing and a real one of the SAME WRITTEN SHAPE read alike: + // Unknown() collapses the padding so both count three segments. That half holds. + Assert.Equal( + (deepAlias as PolicyException)?.ErrorCode, + (deepUnknown as PolicyException)?.ErrorCode); + + // A one-token alias is answered the same way. The cap is measured on the canonical path + // the alias stands for, and a name matching nothing stays one segment, so answering with + // the cap's own code would tell the caller their token named something several + // navigations deep. A caller who wrote the path themselves still meets the cap. + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)alias!).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)unknown!).ErrorCode); + } + + /// + /// The cap's origin is returned under Strict. It must not describe anything the caller did + /// not write. + /// + [Fact] + public void The_cap_origin_says_nothing_about_a_name_the_caller_did_not_write() + { + Exception? alias = Ask("org", depth: 2); + Exception? unknown = Ask("zzz", depth: 2); + + _out.WriteLine($"alias : {Shape(alias)}"); + _out.WriteLine($"unknown : {Shape(unknown)}"); + + // It says nothing: the origin used to state the navigation depth of a canonical path + // the caller wrote as one token, while the name matching nothing was given none. + Assert.Null(((PolicyException)alias!).SourceOrigin); + Assert.Null(((PolicyException)unknown!).SourceOrigin); + } + + // ========================================================================================= + // A posture a second host hands over. + // ========================================================================================= + + /// + /// Every value of the posture that decides what a query may do has to be compared, or a + /// second host's configuration is accepted as the same and enforces differently. + /// + [Fact] + public void Every_posture_value_is_compared_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Documented as deliberately uncompared: objects a host builds for itself. + HashSet exempt = new(StringComparer.Ordinal) + { + "TokenVault", "Services", "IsFrozen", "IncludeTraceInResult" + }; + + List uncompared = new(); + + foreach (PropertyInfo property in typeof(DwPolicyOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (exempt.Contains(property.Name)) + { + continue; + } + + uncompared.Add(property.Name); + } + + _out.WriteLine("posture properties compared or exempt must cover: " + + string.Join(", ", uncompared)); + + Assert.NotNull(same); + } + + /// + /// The concrete shape: a second host handing over a posture that differs in any cap is + /// refused rather than accepted, so the caps a query is measured against cannot be swapped. + /// + [Fact] + public void A_cap_that_differs_is_refused_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Two identical postures must read as the same, or every comparison below is vacuously + // "different" and the probe proves nothing. + Assert.True((bool)same.Invoke( + null, + new object?[] { new DwPolicyOptions(), new DwPolicyOptions(), Array.Empty() })!); + + List accepted = new(); + + foreach (PropertyInfo cap in typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType == typeof(int) && p.CanWrite)) + { + DwPolicyOptions inForce = new(); + DwPolicyOptions asked = new(); + + int baseline = (int)cap.GetValue(inForce.Caps)!; + + cap.SetValue(asked.Caps, baseline + 1); + + bool agreed = (bool)same.Invoke( + null, new object?[] { inForce, asked, Array.Empty() })!; + + _out.WriteLine($"{cap.Name,-22} {baseline} vs {baseline + 1} -> same? {agreed}"); + + if (agreed) + { + accepted.Add(cap.Name); + } + } + + Assert.Empty(accepted); + } + + /// The same for every non-cap value of the posture. + [Fact] + public void A_posture_value_that_differs_is_refused_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + List<(string Name, Action Change)> changes = new() + { + ("Tier", o => o.Tier = DwTier.Strict), + ("DryRun", o => o.DryRun = true), + ("IncludeTraceInResult", o => o.IncludeTraceInResult = true), + ("IncludeTraceInResult(false)", o => o.IncludeTraceInResult = false), + ("AuditRefusals", o => o.AuditRefusals = true), + ("HashSalt", o => o.HashSalt = new string('s', 32)), + ("StoreFailure", o => o.StoreFailure = StoreFailureMode.FailClosed), + ("MaxSnapshotAge", o => o.MaxSnapshotAge = TimeSpan.FromHours(3)), + ("RefreshInterval", o => o.RefreshInterval = TimeSpan.FromHours(3)), + ("TokenVault", o => o.TokenVault = new InMemoryTokenVault()), + ("Entities", o => o.Entities.Expose("aliased")) + }; + + List accepted = new(); + + foreach ((string name, Action change) in changes) + { + DwPolicyOptions inForce = new(); + DwPolicyOptions asked = new(); + + change(asked); + + bool agreed = (bool)same.Invoke( + null, new object?[] { inForce, asked, Array.Empty() })!; + + _out.WriteLine($"{name,-22} differs -> same? {agreed}"); + + if (agreed) + { + accepted.Add(name); + } + } + + _out.WriteLine("accepted although different: " + (accepted.Count == 0 ? "-" : string.Join(", ", accepted))); + + // TokenVault is documented as deliberately uncompared; IncludeTraceInResult is compared + // by the value that applies, and true is the Convenience tier's own answer. + Assert.Equal(new[] { "IncludeTraceInResult", "TokenVault" }, accepted.OrderBy(a => a, StringComparer.Ordinal).ToArray()); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs b/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs new file mode 100644 index 0000000..44878a2 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs @@ -0,0 +1,339 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A nested node, so a path can be made deep enough to trip the navigation cap. + public class Ar7Node + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public Ar7Node? Child { get; set; } + } + + /// A root whose child chain gives a long path. + public class Ar7Deep + { + public int Id { get; set; } + + public Ar7Node? Node { get; set; } + } + + /// A tenant-scoped type whose scope reads an ambient value. + public class Ar7Scoped + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, ContextValue = "TenantId")] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + /// A type demanding the caller supply the scope, under a public name. + public class Ar7Demanding + { + public int Id { get; set; } + + [DwAlias("org")] + [DwRequireWhere] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + /// Several audited members, so the audit cap can be tripped by any one of them. + public class Ar7ManyAudited + { + public int Id { get; set; } + + [DwAudit] + public string A { get; set; } = string.Empty; + + [DwAudit] + public string B { get; set; } = string.Empty; + + [DwAudit] + public string C { get; set; } = string.Empty; + } + + public sealed class Ar7RefusalProbes + { + private readonly ITestOutputHelper _out; + + public Ar7RefusalProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false, int tenant = -1) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + if (tenant >= 0) + { + context.WithValue("TenantId", tenant); + } + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture( + DwTier tier = DwTier.Strict, bool dryRun = false, Action? caps = null) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun, AuditRefusals = true }; + + options.Caps.MinGroupSize = 1; + + caps?.Invoke(options.Caps); + + return options; + } + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // AuditPath on the four refusals that report "*" under Strict. + // ========================================================================================= + + /// A navigation-depth cap: "*" to the caller, the real path to the audit. + [Fact] + public void The_navigation_depth_cap_keeps_the_path_for_the_audit() + { + Ar7Deep[] rows = { new() { Id = 1 } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxNavigationDepth = 2), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Node.Child.Label", Direction = Direction.Ascending } + } + })); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Node.Child.Label"); + } + + /// A structural cap concerns no field, and records "*" honestly. + [Fact] + public void A_structural_cap_records_the_clause() + { + Ar7Deep[] rows = { new() { Id = 1 } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxOrderFields = 1), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Id", Direction = Direction.Ascending }, + new() { Sort = 1, Field = "Node", Direction = Direction.Ascending } + } + })); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal("*", Assert.IsType(error).FieldPath); + Assert.All(context.PendingAuditEvents, e => Assert.Equal("*", e.FieldPath)); + } + + /// A forced predicate the context cannot supply: "*" out, the column to the audit. + [Fact] + public void A_missing_context_value_keeps_the_scope_column_for_the_audit() + { + Ar7Scoped[] rows = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingContextValue, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "TenantId"); + } + + /// A missing required filter names the alias out and the canonical path in. + [Fact] + public void A_missing_required_filter_names_the_alias_out_and_the_path_in() + { + Ar7Demanding[] rows = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.RequiredFilterMissing, refusal.ErrorCode); + Assert.Equal("org", refusal.FieldPath); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "TenantId"); + } + + /// + /// The audit cap: the refusal must be the ordinary field denial under Strict, identical + /// whichever member tripped it, and the audit must still say which one that was. + /// + [Fact] + public void The_audit_cap_refuses_identically_whichever_member_trips_it() + { + Ar7ManyAudited[] rows = { new() { Id = 1, A = "a", B = "b", C = "c" } }; + + List shapes = new(); + + for (int room = 1; room < 4; room++) + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxAuditEvents = room), Attributes()) + .ToList(new Filter())); + + shapes.Add(Shape(error)); + + _out.WriteLine($"room={room} : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + } + + // Every refusal that fires reads alike: no field, no origin, no rule. So the cap cannot + // be turned into an oracle by watching which member tripped it. + Assert.Single(shapes.Where(shape => shape != "OK").Distinct()); + } + + // ========================================================================================= + // The dry run each of the four reads. + // ========================================================================================= + + /// A per-context dry run alone suppresses every one of the four refusals. + [Fact] + public void A_per_context_dry_run_suppresses_all_four() + { + Ar7Deep[] deep = { new() { Id = 1, Node = new Ar7Node { Id = 2, Label = "n", Child = new Ar7Node { Id = 3, Label = "c" } } } }; + Ar7Scoped[] scoped = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7Demanding[] demanding = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7ManyAudited[] audited = { new() { Id = 1, A = "a", B = "b", C = "c" } }; + + Exception? cap = Catch(() => deep.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(caps: c => c.MaxNavigationDepth = 2), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Node.Child.Label", Direction = Direction.Ascending } + } + })); + + Exception? context = Catch(() => scoped.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(), Attributes()) + .ToList(new Filter())); + + Exception? required = Catch(() => demanding.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(), Attributes()) + .ToList(new Filter())); + + // The audit cap is the one that fails closed even in a dry run, by design. + Exception? auditCap = Catch(() => audited.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(caps: c => c.MaxAuditEvents = 1), Attributes()) + .ToList(new Filter())); + + _out.WriteLine("(the audit cap is documented to fail closed even in a dry run)"); + + _out.WriteLine($"navigation cap : {Shape(cap)}"); + _out.WriteLine($"context value : {Shape(context)}"); + _out.WriteLine($"required filter: {Shape(required)}"); + _out.WriteLine($"audit cap : {Shape(auditCap)}"); + + Assert.Null(cap); + Assert.Null(context); + Assert.Null(required); + } + + /// The global switch alone does the same. + [Fact] + public void A_global_dry_run_suppresses_all_four() + { + Ar7Scoped[] scoped = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7Demanding[] demanding = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + Exception? context = Catch(() => scoped.AsQueryable() + .ApplyPolicy(Caller(), Posture(dryRun: true), Attributes()) + .ToList(new Filter())); + + Exception? required = Catch(() => demanding.AsQueryable() + .ApplyPolicy(Caller(), Posture(dryRun: true), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"context value : {Shape(context)}"); + _out.WriteLine($"required filter: {Shape(required)}"); + + Assert.Null(context); + Assert.Null(required); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs b/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs new file mode 100644 index 0000000..1b3324e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs @@ -0,0 +1,173 @@ +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- the exact shapes that make the startup scan's alias check ambiguous ----------------- + + /// Two members of one name in one class, which C# allows and the core folds together. + public class Ar7TwoCases + { + public int Id { get; set; } + + public string Info { get; set; } = string.Empty; + + public string INFO { get; set; } = string.Empty; + + [DwAlias("info")] + public string Label { get; set; } = string.Empty; + } + + /// A base and a derived member of the same name, hidden with new. + public class Ar7NewBase + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ar7NewDerived : Ar7NewBase + { + public new string Tag { get; set; } = string.Empty; + + [DwAlias("tag")] + public string Label { get; set; } = string.Empty; + } + + /// A base and a derived member differing only in case. + public class Ar7CaseBase + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ar7CaseDerived : Ar7CaseBase + { + public string tag { get; set; } = string.Empty; + + [DwAlias("TAG")] + public string Label { get; set; } = string.Empty; + } + + /// A sound model carrying a real error, to show what the abort costs. + public class Ar7AlsoBroken + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Nickname { get; set; } = string.Empty; + } + + public sealed class Ar7ScanProbes + { + private readonly ITestOutputHelper _out; + + public Ar7ScanProbes(ITestOutputHelper output) => _out = output; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private string Scan(params Type[] types) + { + PolicyModelReport? report = null; + + Exception? error = Catch(() => report = PolicyModelValidator.Inspect(types)); + + if (error is not null) + { + return $"RAISED {error.GetType().Name}: {error.Message}"; + } + + return report!.Errors.Count == 0 && report.Warnings.Count == 0 + ? "clean" + : string.Join(" | ", report.Errors.Concat(report.Warnings)); + } + + /// CANDIDATE. Two members of one name in one class abort the whole scan. + [Fact] + public void Two_members_of_one_name_in_one_class() + { + string outcome = Scan(typeof(Ar7TwoCases)); + + _out.WriteLine("Ar7TwoCases : " + outcome); + + // The scan reports; it never throws. Asking the type for one property by name with + // IgnoreCase raised AmbiguousMatchException here, because two members whose names differ + // only in case are two matches. + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// A plain new shadow of the same name: reflection prefers the derived one. + [Fact] + public void A_new_shadow_of_the_same_name_is_fine() + { + string outcome = Scan(typeof(Ar7NewDerived)); + + _out.WriteLine("Ar7NewDerived : " + outcome); + + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// CANDIDATE. A base and derived member differing only in case. + [Fact] + public void A_base_and_derived_member_differing_in_case() + { + string outcome = Scan(typeof(Ar7CaseDerived)); + + _out.WriteLine("Ar7CaseDerived: " + outcome); + + // The same across a base and a derived declaration. + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// + /// The cost of the abort: one unscannable type takes every other type's errors with it, so a + /// startup gate that was going to refuse the deployment instead dies on a reflection call. + /// + [Fact] + public void One_unscannable_type_hides_every_other_types_errors() + { + string alone = Scan(typeof(Ar7AlsoBroken)); + string together = Scan(typeof(Ar7TwoCases), typeof(Ar7AlsoBroken)); + + _out.WriteLine("alone : " + alone); + _out.WriteLine("together : " + together); + + Assert.Contains("alias 'Name'", alone, StringComparison.Ordinal); + + // Scanned beside it, the real error is still reported: no type can take another type's + // errors down with it, because the scan no longer raises anything. + Assert.Contains("alias 'Name'", together, StringComparison.Ordinal); + } + + /// The same through the throwing façade a deployment actually calls. + [Fact] + public void ValidateModel_reports_rather_than_raising_a_reflection_error() + { + Exception? error = Catch( + () => DwPolicy.ValidateModel(new DwPolicyOptions(), new[] { typeof(Ar7TwoCases) })); + + _out.WriteLine($"ValidateModel : {error?.GetType().Name ?? "-"} {error?.Message.Split('\n')[0]}"); + + // The documented startup gate refuses the deployment with the model's errors, rather + // than raising a reflection error out of a method that returns a report. + Assert.IsType(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs b/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs new file mode 100644 index 0000000..a0211d2 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs @@ -0,0 +1,347 @@ +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- alias / rename models ------------------------------------------------------------- + + /// A base declaring a member the derived type re-declares in another case. + public class Ar7ShadowBase + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + /// + /// The derived type declares secret, differing from the base's Secret only in + /// case, and puts an alias spelled the same on a third member. + /// + public class Ar7Shadow : Ar7ShadowBase + { + public string secret { get; set; } = string.Empty; + + [DwAlias("secret")] + public string Label { get; set; } = string.Empty; + } + + /// An alias spelled exactly like another member of the same type. + public class Ar7AliasShadowsMember + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Nickname { get; set; } = string.Empty; + } + + /// An alias spelled like another member but in a different case. + public class Ar7AliasShadowsCase + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("name")] + public string Nickname { get; set; } = string.Empty; + } + + /// An alias on a denied field, to see what a rename can carry out. + public class Ar7AliasOnDenied + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("code")] + [DwNoSelect] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Ar7SurfaceProbes + { + private readonly ITestOutputHelper _out; + + public Ar7SurfaceProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // AuditPath must not reach a caller. + // ========================================================================================= + + /// + /// AuditPath is internal; nothing public on the exception exposes it and a serializer + /// cannot see it. + /// + [Fact] + public void AuditPath_is_not_on_the_public_surface_and_does_not_serialize() + { + PropertyInfo[] published = typeof(PolicyException) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + + _out.WriteLine("public properties: " + string.Join(", ", published.Select(p => p.Name))); + + Assert.DoesNotContain(published, p => p.Name == "AuditPath"); + + PolicyException refusal = + new(PolicyErrorCode.FieldDeniedForSelect, "*", PolicyFeature.Select, DwTier.Strict) + { + AuditPath = "NationalId", + SourceOrigin = null + }; + + string json = JsonSerializer.Serialize(refusal); + string text = refusal.ToString(); + string message = refusal.Message; + + _out.WriteLine("json : " + json); + _out.WriteLine("message : " + message); + + Assert.DoesNotContain("NationalId", json, StringComparison.Ordinal); + Assert.DoesNotContain("NationalId", message, StringComparison.Ordinal); + Assert.DoesNotContain("NationalId", text, StringComparison.Ordinal); + } + + /// Every strict refusal reachable from a guarded read, and what it names. + [Fact] + public void Strict_refusals_name_no_field_the_caller_did_not_write() + { + Ar7AliasOnDenied[] rows = { new() { Id = 1, Name = "a", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "code" } })); + + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine($"code={refusal.ErrorCode} path='{refusal.FieldPath}' rule={refusal.RuleId ?? "-"} origin={refusal.SourceOrigin ?? "-"}"); + _out.WriteLine("audit : " + string.Join("; ", context.PendingAuditEvents.Select(e => e.FieldPath))); + + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + + // The audit keeps the canonical path, not the alias the caller wrote. + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Secret"); + } + + // ========================================================================================= + // The inbound and outbound alias rules. + // ========================================================================================= + + /// The startup scan reports an alias spelled exactly like another member. + [Fact] + public void The_scan_reports_an_alias_spelled_like_a_member() + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7AliasShadowsMember) }); + + foreach (string error in report.Errors) + { + _out.WriteLine("error : " + error); + } + + Assert.Contains(report.Errors, e => e.Contains("'Name'", StringComparison.Ordinal)); + } + + /// And one differing only in case, which is how the core compares names. + [Fact] + public void The_scan_reports_an_alias_spelled_like_a_member_in_another_case() + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7AliasShadowsCase) }); + + foreach (string error in report.Errors) + { + _out.WriteLine("error : " + error); + } + + Assert.Contains(report.Errors, e => e.Contains("'name'", StringComparison.Ordinal)); + } + + /// + /// CANDIDATE. The scan asks the type for the shadowed member with IgnoreCase, and a type + /// that declares two members whose names differ only in case has two matches. + /// + [Fact] + public void The_scan_survives_a_type_with_two_members_of_one_name() + { + Exception? error = Catch(() => + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7Shadow) }); + + foreach (string message in report.Errors) + { + _out.WriteLine("error : " + message); + } + + foreach (string message in report.Warnings) + { + _out.WriteLine("warning : " + message); + } + }); + + _out.WriteLine($"raised : {error?.GetType().Name ?? "-"} {error?.Message}"); + + // It does: the scan walks the type's own properties for a name that matches the alias, + // rather than asking reflection for one property by a name two members answer to, which + // raised AmbiguousMatchException out of a method that returns a report. Ar7ScanProbes + // reduces that to its two smallest shapes. + Assert.Null(error); + } + + /// + /// A query on the same type: the alias 'secret' could mean the aliased member or either + /// real member, and the answer must not hand the denied value out under the alias. + /// + [Fact] + public void A_shadowing_alias_cannot_carry_a_denied_value_out() + { + Ar7Shadow[] rows = { new() { Id = 1, Secret = "DENIED", secret = "lower", Label = "L" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => + { + var got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()).Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + }); + + _out.WriteLine($"raised : {error?.GetType().Name ?? "-"} {error?.Message}"); + + // The denied value does not leave. What does leave is a column literally named 'secret' + // holding Label's value, while both real members of that name are left out — the shape + // the startup scan is meant to report and cannot, because it aborts on this very type. + Assert.Null(error); + } + + /// + /// The outbound rename must not put a column under a name the row already carries, and must + /// not carry a denied value out under the alias. + /// + [Fact] + public void A_rename_does_not_land_on_a_name_the_row_already_carries() + { + Ar7AliasShadowsCase[] rows = { new() { Id = 1, Name = "real", Nickname = "nick" } }; + + // The inbound half: naming the colliding name at all is refused. + Exception? named = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Id", "Name" } })); + + _out.WriteLine("naming the collision : " + + ((named as PolicyException)?.ErrorCode.ToString() ?? named?.GetType().Name ?? "OK")); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, Assert.IsType(named).ErrorCode); + + // The outbound half: a row carrying 'Name' must not have 'Nickname' renamed onto it. + var got = rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Id", "Nickname" } }) + .Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + + IDictionary columns = (IDictionary)got[0]; + + _out.WriteLine("columns : " + string.Join(", ", columns.Keys)); + + Assert.Contains(columns, c => Equals(c.Value, "nick")); + } + + /// The alias of a deny-select field must not appear in the result at all. + [Fact] + public void A_deny_select_field_does_not_reach_the_caller_under_its_alias() + { + Ar7AliasOnDenied[] rows = { new() { Id = 1, Name = "a", Secret = "SECRET" } }; + + DwPolicyContext context = Caller(); + + var got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()).Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + + Assert.DoesNotContain("SECRET", JsonSerializer.Serialize(got), StringComparison.Ordinal); + } + + /// The unguarded shape of a blank grouping key, for parity with the guarded one. + [Fact] + public void A_blank_group_key_fails_alike_guarded_and_not() + { + Ar7AliasShadowsCase[] rows = { new() { Id = 1, Name = "a", Nickname = "n" } }; + + Summary Blank() => new() + { + GroupBy = new GroupBy + { + Fields = new List { " " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + }; + + Exception? unguarded = Catch(() => rows.AsQueryable().ToList(Blank())); + + Exception? guarded = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToList(Blank())); + + _out.WriteLine($"unguarded : {unguarded?.GetType().Name} {unguarded?.Message.Split('\n')[0]}"); + _out.WriteLine($"guarded : {guarded?.GetType().Name} {guarded?.Message.Split('\n')[0]}"); + + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs new file mode 100644 index 0000000..b22de7f --- /dev/null +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -0,0 +1,482 @@ +using System.Reflection; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A second DwPolicy.Configure asking for the posture already in force. + /// + /// + /// An integration suite starts many hosts over one composition root, and each one runs the + /// registration again. Refusing the second host made every such suite write its own + /// IsConfigured check, which is a check-then-act two hosts can both pass — so the package + /// answers it, inside the same lock that does the configuring. + /// + /// The posture in this process is whatever configured it first, so every case here builds its + /// candidate from DwPolicy.Options rather than from a fixed set of values. A case that + /// expects a refusal changes nothing: the refusal happens before anything is installed. + /// + /// + public sealed class ConfigureOnceTests + { + public ConfigureOnceTests() => Ensure(); + + /// + /// Configures the assembly's one posture, which every suite here asks for. + /// + /// + /// Calling it from each test is the feature under test: before 3.3.0 only the first caller + /// could do this, and the cases below all read what is in force rather than assuming a + /// posture of their own. + /// + private static void Ensure() => PolicyBootstrap.Ensure(); + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + [Fact] + public void The_same_posture_configured_again_changes_nothing() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(Copy()); + + Assert.Same(inForce, DwPolicy.Options); + Assert.True(DwPolicy.IsConfigured); + } + + [Fact] + public void The_posture_handed_to_a_second_call_is_frozen_rather_than_left_settable() + { + DwPolicyOptions second = Copy(); + + DwPolicy.Configure(second); + + Assert.True(second.IsFrozen); + Assert.Throws(() => second.Tier = DwTier.Convenience); + } + + [Fact] + public void A_different_tier_is_still_refused() + { + DwPolicyOptions different = Copy(); + + different.Tier = DwPolicy.Options.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict; + + Refused(different); + } + + [Fact] + public void A_different_cap_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Caps.MaxPageSize = DwPolicy.Options.Caps.MaxPageSize - 1; + + Refused(different); + } + + [Fact] + public void Writing_the_group_floor_the_default_already_holds_is_the_same_posture() + { + // The floor that applies, not whether somebody wrote it down. The documented appsettings + // sample writes "MinGroupSize": 5, so a host binding it and a host on the defaults + // enforce the same floor — and refusing the second is refusing the case this is for. + DwPolicyOptions different = Copy(); + + different.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, different.Caps.IsMinGroupSizeSet); + Assert.Equal(DwPolicy.Options.Caps.MinGroupSize, different.Caps.MinGroupSize); + + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(different); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void Writing_the_trace_flag_the_tier_already_answers_is_the_same_posture() + { + // The value that applies, not whether somebody wrote it down. IncludeTraceInResult + // defaults to the tier's own answer, so a host writing that answer out and a host + // leaving it null hand a caller the same result. + DwPolicyOptions different = Copy(); + + different.IncludeTraceInResult = DwPolicy.Options.IncludeTraceInResult + ?? DwPolicy.Options.Tier == DwTier.Convenience; + + Assert.NotEqual(DwPolicy.Options.IncludeTraceInResult, different.IncludeTraceInResult); + + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(different); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void A_trace_flag_that_answers_differently_is_refused() + { + DwPolicyOptions different = Copy(); + + different.IncludeTraceInResult = !(DwPolicy.Options.IncludeTraceInResult + ?? DwPolicy.Options.Tier == DwTier.Convenience); + + Refused(different); + } + + [Fact] + public void A_different_group_floor_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize == 7 ? 8 : 7; + + Refused(different); + } + + [Fact] + public void A_different_dry_run_flag_is_refused() + { + DwPolicyOptions different = Copy(); + + different.DryRun = !DwPolicy.Options.DryRun; + + Refused(different); + } + + [Fact] + public void A_different_refusal_audit_flag_is_refused() + { + DwPolicyOptions different = Copy(); + + different.AuditRefusals = !DwPolicy.Options.AuditRefusals; + + Refused(different); + } + + [Fact] + public void A_different_snapshot_age_is_refused() + { + DwPolicyOptions different = Copy(); + + different.MaxSnapshotAge = DwPolicy.Options.MaxSnapshotAge + TimeSpan.FromMinutes(1); + + Refused(different); + } + + [Fact] + public void A_different_refresh_interval_is_refused() + { + DwPolicyOptions different = Copy(); + + different.RefreshInterval = DwPolicy.Options.RefreshInterval + TimeSpan.FromSeconds(1); + + Refused(different); + } + + [Fact] + public void A_different_store_failure_mode_is_refused() + { + DwPolicyOptions different = Copy(); + + different.StoreFailure = DwPolicy.Options.StoreFailure == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed; + + Refused(different); + } + + [Fact] + public void A_different_hash_salt_is_refused() + { + DwPolicyOptions different = Copy(); + + different.HashSalt = new string('s', DwPolicyOptions.MinimumHashSaltLength + 1); + + Refused(different); + } + + [Fact] + public void A_catalogue_exposing_one_more_type_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Entities.Expose("configure-once-only-here"); + + Refused(different); + } + + [Fact] + public void A_catalogue_exposing_a_type_under_another_name_is_refused() + { + // The floor leg runs this suite alone, where nothing has exposed anything, so the + // difference has to be made rather than found. + DwPolicyOptions different = Copy(); + + if (DwPolicy.Options.Entities.IsEmpty) + { + different.Entities.Expose("under-one-name"); + + Refused(different); + + return; + } + + KeyValuePair first = DwPolicy.Options.Entities.Entities.First(); + + different = new DwPolicyOptions { Tier = DwPolicy.Options.Tier }; + + foreach (KeyValuePair exposed in DwPolicy.Options.Entities.Entities) + { + different.Entities.Expose( + exposed.Key, exposed.Key == first.Key ? $"{exposed.Value}-renamed" : exposed.Value); + } + + Refused(different); + } + + [Fact] + public void One_more_policy_source_is_refused() + { + Refused(Copy(), new FakePolicyProvider()); + } + + [Fact] + public async Task Many_hosts_starting_at_once_all_get_through() + { + // The race the package now answers: two hosts reading IsConfigured as false and both + // configuring. Nothing here takes a lock of its own, which is the point. + Task[] hosts = Enumerable + .Range(0, 16) + .Select(_ => Task.Run(() => DwPolicy.Configure(Copy()))) + .ToArray(); + + await Task.WhenAll(hosts); + + Assert.True(DwPolicy.IsConfigured); + } + + [Fact] + public void A_second_registration_hands_the_container_the_posture_in_force() + { + IServiceCollection services = new ServiceCollection(); + IConfiguration section = new ConfigurationBuilder().Build().GetSection("DynamicWhere:Policies"); + + services.AddDwPolicies(section, options => + { + options.Tier = DwPolicy.Options.Tier; + + foreach (KeyValuePair exposed in DwPolicy.Options.Entities.Entities) + { + options.Entities.Expose(exposed.Key, exposed.Value); + } + }); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Same(DwPolicy.Options, provider.GetRequiredService()); + } + + [Fact] + public void The_posture_in_force_handed_back_with_a_source_beside_it_is_refused() + { + // The instance carries the same values by definition and says nothing about the sources. + // Answering "same posture" here would drop the source: the resolver is never rebuilt, the + // source is never consulted, and every rule in it quietly does not apply. + PolicyResolver before = DwPolicy.Resolver; + + InvalidOperationException refusal = Assert.Throws( + () => DwPolicy.Configure(DwPolicy.Options, new FakePolicyProvider())); + + Assert.Contains("already configured", refusal.Message); + Assert.Same(before, DwPolicy.Resolver); + } + + [Fact] + public void The_posture_in_force_handed_back_with_nothing_beside_it_is_the_same_posture() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(inForce); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void A_catalogue_answering_to_one_more_name_for_the_same_type_is_refused() + { + // Entities reports the last name a type was exposed under; both stay resolvable. Two + // catalogues reporting the same pairs can still answer differently to an admin request. + DwPolicyOptions different = Copy(); + + if (DwPolicy.Options.Entities.IsEmpty) + { + different.Entities.Expose("only-here"); + + Refused(different); + + return; + } + + KeyValuePair first = DwPolicy.Options.Entities.Entities.First(); + + // Exposed again under an extra name, then back under the one in force, so Entities + // matches pair for pair and only the name map differs. + different.Entities.Expose(first.Key, $"{first.Value}-also"); + different.Entities.Expose(first.Key, first.Value); + + Assert.Equal(DwPolicy.Options.Entities.Entities.Count, different.Entities.Entities.Count); + Assert.Equal(first.Value, different.Entities.NameOf(first.Key)); + + Refused(different); + } + + /// + /// Every settable value on the posture and on the caps decides whether a second call is the + /// same posture, and the two that do not are named here on purpose. + /// + /// + /// Written by reflection rather than by hand: a value added to DwPolicyOptions or + /// DwCaps later and forgotten in the comparison would let a second host run with a + /// posture it did not ask for, silently, which is the one failure this feature can cause. A + /// new property fails this test until somebody decides which column it belongs in. + /// + [Theory] + [InlineData(typeof(DwPolicyOptions))] + [InlineData(typeof(DwCaps))] + public void Every_value_on_the_posture_is_compared(Type declaring) + { + // The objects a host builds for itself. A second host builds its own, so comparing them + // by reference would refuse every second call; they stay as the first call left them. + HashSet notCompared = new() { nameof(DwPolicyOptions.TokenVault), nameof(DwPolicyOptions.Services) }; + + List settable = declaring + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetSetMethod() is not null) + .Select(property => property.Name) + .ToList(); + + Assert.NotEmpty(settable); + + foreach (string name in settable) + { + if (notCompared.Remove(name)) + { + continue; + } + + DwPolicyOptions different = Copy(); + object target = declaring == typeof(DwCaps) ? different.Caps : different; + PropertyInfo property = declaring.GetProperty(name)!; + + // The trace flag is compared by the value that applies, and it defaults to the + // tier's own answer, so what proves it is compared is a value answering differently. + // Writing the default down is the case above. + object? replacement = name == nameof(DwPolicyOptions.IncludeTraceInResult) + ? !(DwPolicy.Options.IncludeTraceInResult ?? DwPolicy.Options.Tier == DwTier.Convenience) + : Other(property.GetValue(target), property.PropertyType); + + property.SetValue(target, replacement); + + DwPolicyOptions inForce = DwPolicy.Options; + + Assert.Throws(() => DwPolicy.Configure(different)); + Assert.Same(inForce, DwPolicy.Options); + } + + // Both names belong to the posture, so nothing may be left over once it has been walked. + // A stale exclusion would otherwise sit here hiding a property nobody compares. + if (declaring == typeof(DwPolicyOptions)) + { + Assert.Empty(notCompared); + } + } + + /// A value of the same type that is not the one held. + private static object? Other(object? held, Type type) => type switch + { + _ when type == typeof(bool) => !(bool)held!, + _ when type == typeof(bool?) => held is true ? false : true, + _ when type == typeof(int) => (int)held! + 1, + _ when type == typeof(TimeSpan) => (TimeSpan)held! + TimeSpan.FromMinutes(1), + _ when type == typeof(string) => new string('x', DwPolicyOptions.MinimumHashSaltLength + 2), + _ when type == typeof(DwTier) => (DwTier)held! == DwTier.Strict ? DwTier.Convenience : DwTier.Strict, + _ when type == typeof(StoreFailureMode) => (StoreFailureMode)held! == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed, + _ => throw new InvalidOperationException( + $"No second value is defined for {type.Name}. Add one, and decide whether the comparison covers it.") + }; + + private static void Refused(DwPolicyOptions different, params IDwPolicyProvider[] providers) + { + DwPolicyOptions inForce = DwPolicy.Options; + + InvalidOperationException refusal = + Assert.Throws(() => DwPolicy.Configure(different, providers)); + + Assert.Contains("already configured", refusal.Message); + Assert.Same(inForce, DwPolicy.Options); + } + } + + /// A type nothing else exposes, so exposing it is a difference by itself. + public sealed class ConfigureOnceOnlyHere + { + public int Id { get; set; } + } +} diff --git a/DynamicWhere.Tests/Policies/DocsReview33Probe.cs b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs new file mode 100644 index 0000000..9b0b9b0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs @@ -0,0 +1,1065 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// A docs-against-code probe for the 3.3.0 claims. Nothing here is a fix; every fact reads what the +// code does and prints it, so the report can quote an outcome rather than an inference. + +namespace DynamicWhere.Tests.Policies +{ + // ---- a model with a converted column, a date, a nullable and an unmapped getter -------------- + + public class QpTag + { + public string Value { get; set; } = string.Empty; + + /// A getter over the one column the converter stores. + public bool IsEmpty => string.IsNullOrWhiteSpace(Value); + } + + public class QpTicket + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + + public int? Score { get; set; } + + /// Stored through a value converter, so the model maps it as one column. + public QpTag Tag { get; set; } = new(); + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Unmapped: a getter over two columns of the entity itself. + public string Display => $"{Code}:{Id}"; + } + + public sealed class QpContext : DbContext + { + private readonly SqliteConnection _connection; + + public QpContext(SqliteConnection connection) => _connection = connection; + + public DbSet Tickets => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(ticket => ticket.Tag) + .HasConversion(tag => tag.Value, value => new QpTag { Value = value }); + + model.Entity().Ignore(ticket => ticket.Display); + } + } + + // ---- default-order shapes -------------------------------------------------------------------- + + [DwEntity(DefaultOrder = "Code desc")] + public class QpCtorRow + { + public QpCtorRow() + { + } + + /// A constructor with arguments. Which member each one sets is not recorded. + public QpCtorRow(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + [DwEntity(DefaultOrder = "Code desc")] + public class QpPlainRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + [DwEntity(DefaultOrder = "Code desc")] + public class QpTicketRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + /// A catalogue-comparison stand-in, exposed nowhere else. + public sealed class QpOnlyHere + { + } + + public sealed class QpAlsoOnlyHere + { + } + + public sealed class QpProviderA : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + public sealed class QpProviderB : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + // ============================================================================================= + // 1. The source table, row by row, and the Selects exemption. + // ============================================================================================= + + public sealed class QpSourceTableProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly QpContext _db; + + public QpSourceTableProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new QpContext(_connection); + _db.Database.EnsureCreated(); + _db.Tickets.Add(new QpTicket + { + Code = "alpha", + CreatedAt = new DateTime(2026, 3, 4), + Score = 7, + Tag = new QpTag { Value = "red" }, + Secret = "s" + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private void Row(string label, IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + bool? answer = (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + + _out.WriteLine($"{label,-52} Expresses(\"{path}\") = {(answer is null ? "null (left alone)" : answer.ToString())}"); + } + + // ---- every row of the table, read straight off RowShape ----------------------------------- + + [Fact] + public void The_source_table_row_by_row() + { + Row("an entity", _db.Tickets, "Tag.IsEmpty"); + Row("an entity, unmapped getter on itself", _db.Tickets, "Display"); + Row("an entity, a framework member (Length)", _db.Tickets, "Code.Length"); + Row("an entity, a framework member (Year)", _db.Tickets, "CreatedAt.Year"); + Row("an entity, a framework member (HasValue)", _db.Tickets, "Score.HasValue"); + Row("beneath a converted column", _db.Tickets, "Tag.Value"); + + Row("rows in memory", new[] { new QpTicket() }.AsQueryable(), "Tag.IsEmpty"); + Row("a source the library cannot read", + new ZyOwnProvider(new[] { new QpTicket() }.AsQueryable()), "Tag.IsEmpty"); + } + + /// Beneath a column the converter decides, so the policy leaves it alone either way. + [Fact] + public void Beneath_a_column_is_left_alone_and_behaves_as_it_does_unguarded() + { + Exception? unguarded = Record.Exception( + () => _db.Tickets.Where(ticket => ticket.Tag.IsEmpty == false).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Tag.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"converted column: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + Assert.IsNotType(guarded); + } + + // ---- the framework members the text names, guarded and unguarded -------------------------- + + [Fact] + public void Length_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.Code.Length == 5).ToList()); + + FilterResult guarded = Guard(_db.Tickets, DwTier.Strict) + .ToList(Where("Code.Length", "5", DataType.Number)); + + _out.WriteLine($"Length: unguarded=1 guarded={guarded.Data.Count}"); + + Assert.Single(guarded.Data); + } + + [Fact] + public void Year_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.CreatedAt.Year == 2026).ToList()); + + FilterResult guarded = Guard(_db.Tickets, DwTier.Strict) + .ToList(Where("CreatedAt.Year", "2026", DataType.Number)); + + _out.WriteLine($"Year: unguarded=1 guarded={guarded.Data.Count}"); + + Assert.Single(guarded.Data); + } + + [Fact] + public void HasValue_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.Score.HasValue).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Score.HasValue", "true", DataType.Boolean))); + + _out.WriteLine($"HasValue: unguarded=1 guarded={guarded?.GetType().Name ?? "ran"} {guarded?.Message}"); + + Assert.IsNotType(guarded); + Assert.Null(guarded); + } + + // ---- Selects, both tiers, filter and segment ----------------------------------------------- + + [Fact] + public void Selects_is_exempt_under_strict() + { + FilterResult result = Guard(_db.Tickets, DwTier.Strict) + .ToList(new Filter { Selects = new List { "Id", "Display" } }); + + _out.WriteLine($"strict Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public void Selects_is_exempt_under_convenience() + { + FilterResult result = Guard(_db.Tickets, DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Id", "Display" } }); + + _out.WriteLine($"convenience Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public async Task Selects_is_exempt_inside_a_segment() + { + SegmentResult result = await Guard(_db.Tickets, DwTier.Strict).ToListAsync(new Segment + { + Selects = new List { "Id", "Display" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha" } } } + } + } + } + }); + + _out.WriteLine($"segment Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public void A_denied_field_named_in_Selects_is_still_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"denied field in Selects: {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + [Fact] + public void An_unknown_name_in_Selects_is_still_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "NoSuchColumn" } })); + + _out.WriteLine($"unknown name in Selects: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + // ---- which clauses refuse ------------------------------------------------------------------- + + [Fact] + public void An_aggregated_field_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "N", Aggregator = Aggregator.Count }, + new() { Alias = "D", Field = "Display", Aggregator = Aggregator.Maximum } + } + } + })); + + _out.WriteLine($"aggregated field: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForAggregate, refusal.ErrorCode); + } + + [Fact] + public void The_composed_clauses_each_report_their_own_answer() + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + Exception? where = Record.Exception(() => guarded.Where(new Condition + { + Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } + })); + + Exception? order = Record.Exception( + () => guarded.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending })); + + Exception? select = Record.Exception(() => guarded.Select(new List { "Id", "Display" })); + + Exception? page = Record.Exception(() => guarded.Page(new PageBy { PageNumber = 1, PageSize = 10 })); + + _out.WriteLine($"composed .Where -> {Name(where)}"); + _out.WriteLine($"composed .Order -> {Name(order)}"); + _out.WriteLine($"composed .Select -> {Name(select)}"); + _out.WriteLine($"composed .Page -> {Name(page)}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, ((PolicyException)where!).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)order!).ErrorCode); + Assert.Null(select); + Assert.Null(page); + + static string Name(Exception? error) => + error is PolicyException policy + ? $"{policy.GetType().Name} {policy.ErrorCode}" + : error?.GetType().Name ?? "no refusal"; + } + + // ---- LastTrace on every terminal ------------------------------------------------------------ + + [Fact] + public async Task LastTrace_is_readable_after_a_refusal_on_every_terminal() + { + Filter filter = Where("Display", "alpha:1"); + + Report("ToList(Filter)", g => g.ToList(filter)); + await ReportAsync("ToListAsync(Filter)", g => g.ToListAsync(Where("Display", "alpha:1"))); + Report("ToListDynamic(Filter)", g => g.ToListDynamic(Where("Display", "alpha:1"))); + await ReportAsync("ToListAsyncDynamic(Filter)", g => g.ToListAsyncDynamic(Where("Display", "alpha:1"))); + + Report("ToList(Summary)", g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + await ReportAsync("ToListAsync(Summary)", g => g.ToListAsync(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + await ReportAsync("ToListAsync(Segment)", g => g.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } } } + } + } + } + })); + + Report("composed .Where", g => g.Where(new Condition + { + Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } + })); + + Report("composed .Order", g => g.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending })); + + Report("composed .Group", g => g.Group(new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + })); + + void Report(string label, Action> call) + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + Assert.ThrowsAny(() => call(guarded)); + + Describe(label, guarded.LastTrace); + } + + async Task ReportAsync(string label, Func, Task> call) + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + await Assert.ThrowsAnyAsync(() => call(guarded)); + + Describe(label, guarded.LastTrace); + } + + void Describe(string label, PolicyTrace? trace) + { + string reason = trace?.Decisions + .Where(decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")) + .Select(decision => decision.FieldPath) + .FirstOrDefault() ?? "(no 'cannot compute' decision)"; + + _out.WriteLine($"{label,-28} LastTrace={(trace is null ? "null" : "set")} reason on: {reason}"); + + Assert.NotNull(trace); + } + } + } + + // ============================================================================================= + // 2. DefaultOrder: the four shapes the corrected remarks name. + // ============================================================================================= + + public sealed class QpDefaultOrderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly QpContext _db; + + public QpDefaultOrderProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new QpContext(_connection); + _db.Database.EnsureCreated(); + + // Inserted B, C, A: none of the expected orders is the insertion order. + foreach (string code in new[] { "B", "C", "A" }) + { + _db.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + [Fact] + public void A_constructor_with_arguments_and_an_initializer_still_takes_the_default() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpCtorRow(ticket.Id, ticket.Code) { Code = ticket.Code }); + + string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"ctor with args + initializer: {order}"); + + Assert.Equal("C,B,A", order); + } + + [Fact] + public void A_projection_with_no_initializer_at_all_stays_in_its_own_order() + { + IQueryable rows = _db.Tickets.Select(ticket => new QpCtorRow(ticket.Id, ticket.Code)); + + string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"ctor with args, no initializer: {order}"); + + Assert.NotEqual("C,B,A", order); + } + + [Fact] + public void A_filter_carrying_Selects_is_ordered_as_any_other_filter_is() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code }); + + string order = string.Join( + ",", + Guard(rows).ToList(new Filter { Selects = new List { "Id", "Code" } }) + .Data.Select(row => row.Code)); + + _out.WriteLine($"filter carrying Selects: {order}"); + + Assert.Equal("C,B,A", order); + } + + [Fact] + public void A_Select_composed_on_the_guarded_handle_leaves_the_chain_unordered() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code }); + + string order = string.Join( + ",", + Guard(rows).Select(new List { "Id", "Code" }) + .ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"composed .Select then ToList(new Filter()): {order}"); + + Assert.NotEqual("C,B,A", order); + } + } + + // ============================================================================================= + // 3. Posture comparison, property by property. + // ============================================================================================= + + public sealed class QpPostureProbe + { + private readonly ITestOutputHelper _out; + + public QpPostureProbe(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static readonly MethodInfo ProviderKinds = typeof(DwPolicy) + .GetMethod("ProviderTypes", BindingFlags.Static | BindingFlags.NonPublic)!; + + /// The comparison key a list of sources reduces to, which is what a second call is compared by. + private static Type[] Kinds(params IDwPolicyProvider[] providers) => + (Type[])ProviderKinds.Invoke(null, new object?[] { providers })!; + + private static bool Same(DwPolicyOptions asked, params IDwPolicyProvider[] providers) => + (bool)SamePosture.Invoke(null, new object?[] { DwPolicy.Options, asked, providers })!; + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in Caps()) + { + if (cap.Name == "MinGroupSize" && !inForce.Caps.IsMinGroupSizeSet) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private static PropertyInfo[] Caps() => typeof(DwCaps) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.CanWrite && property.PropertyType == typeof(int)) + .OrderBy(property => property.Name) + .ToArray(); + + // ---- the options, one property at a time ---------------------------------------------------- + + [Fact] + public void An_identical_posture_is_the_same_posture() + { + Assert.True(Same(Copy())); + } + + [Fact] + public void Every_option_the_text_lists_as_compared_really_is() + { + Check("Tier", o => o.Tier = o.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict); + Check("DryRun", o => o.DryRun = !o.DryRun); + // Compared by the value that applies: the flag defaults to the tier's own answer, so what + // has to be refused is a value answering differently, not a value written down. + Check("IncludeTraceInResult", o => o.IncludeTraceInResult = + !(o.IncludeTraceInResult ?? o.Tier == DwTier.Convenience)); + Check("AuditRefusals", o => o.AuditRefusals = !o.AuditRefusals); + Check("HashSalt", o => o.HashSalt = "a-salt-long-enough-for-the-minimum"); + Check("StoreFailure", o => o.StoreFailure = + o.StoreFailure == StoreFailureMode.FailClosed ? StoreFailureMode.LastKnownGood : StoreFailureMode.FailClosed); + Check("MaxSnapshotAge", o => o.MaxSnapshotAge = o.MaxSnapshotAge + TimeSpan.FromMinutes(1)); + Check("RefreshInterval", o => o.RefreshInterval = o.RefreshInterval + TimeSpan.FromSeconds(1)); + + void Check(string label, Action change) + { + DwPolicyOptions asked = Copy(); + + change(asked); + + bool same = Same(asked); + + _out.WriteLine($"{label,-22} differs -> SamePosture = {same}"); + + Assert.False(same); + } + } + + [Fact] + public void The_two_objects_the_text_lists_as_not_compared_really_are_not() + { + DwPolicyOptions vault = Copy(); + vault.TokenVault = new QpVault(); + + DwPolicyOptions services = Copy(); + services.Services = new QpServices(); + + _out.WriteLine($"TokenVault differs -> SamePosture = {Same(vault)}"); + _out.WriteLine($"Services differs -> SamePosture = {Same(services)}"); + + Assert.True(Same(vault)); + Assert.True(Same(services)); + } + + // ---- every cap, one at a time --------------------------------------------------------------- + + [Fact] + public void Every_cap_value_is_compared() + { + PropertyInfo[] caps = Caps(); + + _out.WriteLine($"settable int caps on DwCaps: {caps.Length} -> {string.Join(", ", caps.Select(c => c.Name))}"); + + foreach (PropertyInfo cap in caps) + { + DwPolicyOptions asked = Copy(); + + int inForce = (int)cap.GetValue(DwPolicy.Options.Caps)!; + + // Every cap setter refuses below one, so move up rather than down. + cap.SetValue(asked.Caps, inForce + 1); + + bool same = Same(asked); + + _out.WriteLine($" {cap.Name,-22} {inForce} -> {inForce + 1}: SamePosture = {same}"); + + Assert.False(same); + } + } + + [Fact] + public void IsMinGroupSizeSet_is_not_compared() + { + DwPolicyOptions asked = Copy(); + + asked.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + _out.WriteLine( + $"in force: MinGroupSize={DwPolicy.Options.Caps.MinGroupSize} set={DwPolicy.Options.Caps.IsMinGroupSizeSet}; " + + $"asked: MinGroupSize={asked.Caps.MinGroupSize} set={asked.Caps.IsMinGroupSizeSet}; " + + $"SamePosture = {Same(asked)}"); + + Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, asked.Caps.IsMinGroupSizeSet); + Assert.True(Same(asked)); + } + + [Fact] + public void The_default_MinGroupSize_is_five_and_unset_reads_as_five() + { + DwCaps caps = new(); + + _out.WriteLine($"fresh DwCaps: MinGroupSize={caps.MinGroupSize} IsMinGroupSizeSet={caps.IsMinGroupSizeSet} DefaultMinGroupSize={DwCaps.DefaultMinGroupSize}"); + + Assert.Equal(5, caps.MinGroupSize); + Assert.False(caps.IsMinGroupSizeSet); + Assert.Equal(5, DwCaps.DefaultMinGroupSize); + } + + // ---- the catalogue, every name --------------------------------------------------------------- + + [Fact] + public void The_catalogue_is_compared_by_every_name_it_answers_to() + { + MethodInfo sameAs = typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!; + + bool Ask(DwEntityCatalog left, DwEntityCatalog right) => + (bool)sameAs.Invoke(left, new object[] { right })!; + + DwEntityCatalog one = new(); + one.Expose("only"); + + DwEntityCatalog same = new(); + same.Expose("only"); + + DwEntityCatalog extraName = new(); + extraName.Expose("also"); + extraName.Expose("only"); + + DwEntityCatalog otherName = new(); + otherName.Expose("different"); + + DwEntityCatalog extraType = new(); + extraType.Expose("only"); + extraType.Expose("second"); + + _out.WriteLine($"identical -> {Ask(one, same)}"); + _out.WriteLine($"Entities reports the same pair, one extra resolvable name -> {Ask(one, extraName)}"); + _out.WriteLine($" (its Entities count: {one.Entities.Count} vs {extraName.Entities.Count}; " + + $"NameOf: {one.NameOf(typeof(QpOnlyHere))} vs {extraName.NameOf(typeof(QpOnlyHere))})"); + _out.WriteLine($"a different public name -> {Ask(one, otherName)}"); + _out.WriteLine($"one more type -> {Ask(one, extraType)}"); + + Assert.True(Ask(one, same)); + Assert.Equal(one.Entities.Count, extraName.Entities.Count); + Assert.Equal(one.NameOf(typeof(QpOnlyHere)), extraName.NameOf(typeof(QpOnlyHere))); + Assert.False(Ask(one, extraName)); + Assert.False(Ask(one, otherName)); + Assert.False(Ask(one, extraType)); + } + + // ---- the providers ---------------------------------------------------------------------------- + + [Fact] + public void The_provider_rules_hold_type_by_type() + { + // Read through the comparison key rather than by driving DwPolicy's static list: that + // list is process-wide, and a test holding it hostage refuses every suite configuring + // the assembly's posture in parallel. + Type[] inForce = Kinds(new QpProviderA(), new QpProviderB()); + + bool Ask(params IDwPolicyProvider[] asked) => inForce.SequenceEqual(Kinds(asked)); + + bool sameKinds = Ask(new QpProviderA(), new QpProviderB()); + bool reordered = Ask(new QpProviderB(), new QpProviderA()); + bool dropped = Ask(new QpProviderA()); + bool added = Ask(new QpProviderA(), new QpProviderB(), new QpProviderA()); + bool attributeIgnored = Ask(new AttributePolicyProvider(), new QpProviderA(), new QpProviderB()); + bool none = Ask(); + + _out.WriteLine($"in force = [QpProviderA, QpProviderB]"); + _out.WriteLine($" same kinds, new instances -> {sameKinds}"); + _out.WriteLine($" reordered -> {reordered}"); + _out.WriteLine($" one dropped -> {dropped}"); + _out.WriteLine($" one added -> {added}"); + _out.WriteLine($" AttributePolicyProvider prefixed -> {attributeIgnored}"); + _out.WriteLine($" none supplied -> {none}"); + + Assert.True(sameKinds); + Assert.False(reordered); + Assert.False(dropped); + Assert.False(added); + Assert.True(attributeIgnored); + Assert.False(none); + } + + [Fact] + public void Handing_the_posture_in_force_back_with_a_source_beside_it_is_refused() + { + InvalidOperationException refusal = Assert.Throws( + () => DwPolicy.Configure(DwPolicy.Options, new QpProviderA())); + + _out.WriteLine($"Configure(DwPolicy.Options, provider) -> {refusal.GetType().Name}"); + + Assert.Same(DwPolicy.Options, DwPolicy.Options); + } + + [Fact] + public void Handing_the_posture_in_force_back_alone_is_a_no_op() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(DwPolicy.Options); + + _out.WriteLine($"Configure(DwPolicy.Options) -> same instance still in force: {ReferenceEquals(inForce, DwPolicy.Options)}"); + + Assert.Same(inForce, DwPolicy.Options); + } + + private sealed class QpVault : DynamicWhere.ex.Policies.Tokens.IDwTokenVault + { + public string GetOrCreate(string scope, string value) => value; + } + + private sealed class QpServices : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + } + + // ============================================================================================= + // 4. Clone, branch by branch. + // ============================================================================================= + + public sealed class QpCloneProbe + { + private readonly ITestOutputHelper _out; + + public QpCloneProbe(ITestOutputHelper output) => _out = output; + + [Fact] + public void Clone_is_public_on_all_three() + { + foreach (Type type in new[] { typeof(Filter), typeof(Segment), typeof(Summary) }) + { + MethodInfo? clone = type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public); + + _out.WriteLine($"{type.Name}.Clone: {(clone is null ? "not public" : $"public, returns {clone.ReturnType.Name}")}"); + + Assert.NotNull(clone); + Assert.Equal(type, clone!.ReturnType); + } + } + + [Fact] + public void A_filter_clone_shares_no_branch() + { + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } }, + SubConditionGroups = { new ConditionGroup { Sort = 1, Conditions = { new Condition { Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { "1" } } } } } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + Assert.NotSame(filter.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Assert.NotSame(filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0].Conditions[0], copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + Assert.NotSame(filter.Selects, copy.Selects); + Assert.NotSame(filter.Orders, copy.Orders); + Assert.NotSame(filter.Orders![0], copy.Orders![0]); + Assert.NotSame(filter.Page, copy.Page); + + copy.Page!.PageNumber = 2; + copy.ConditionGroup.Conditions[0].Field = "Other"; + copy.Selects!.Add("Code"); + + _out.WriteLine($"filter: original page={filter.Page!.PageNumber} field={filter.ConditionGroup.Conditions[0].Field} selects={filter.Selects!.Count}"); + + Assert.Equal(1, filter.Page.PageNumber); + Assert.Equal("Code", filter.ConditionGroup.Conditions[0].Field); + Assert.Single(filter.Selects); + } + + [Fact] + public void A_null_branch_stays_null() + { + Filter filter = new(); + Filter copy = filter.Clone(); + + _out.WriteLine($"filter nulls kept: group={copy.ConditionGroup is null} selects={copy.Selects is null} orders={copy.Orders is null} page={copy.Page is null}"); + + Assert.Null(copy.ConditionGroup); + Assert.Null(copy.Selects); + Assert.Null(copy.Orders); + Assert.Null(copy.Page); + + Summary summary = new(); + Summary summaryCopy = summary.Clone(); + + _out.WriteLine($"summary nulls kept: group={summaryCopy.ConditionGroup is null} groupBy={summaryCopy.GroupBy is null} having={summaryCopy.Having is null} orders={summaryCopy.Orders is null} page={summaryCopy.Page is null}"); + + Assert.Null(summaryCopy.ConditionGroup); + Assert.Null(summaryCopy.GroupBy); + Assert.Null(summaryCopy.Having); + Assert.Null(summaryCopy.Orders); + Assert.Null(summaryCopy.Page); + + Segment segment = new() { Orders = null }; + Segment segmentCopy = segment.Clone(); + + _out.WriteLine($"segment nulls kept: selects={segmentCopy.Selects is null} orders={segmentCopy.Orders is null} page={segmentCopy.Page is null}"); + + Assert.Null(segmentCopy.Selects); + Assert.Null(segmentCopy.Orders); + Assert.Null(segmentCopy.Page); + } + + [Fact] + public void A_segment_clone_gives_each_set_its_own_condition_group() + { + ConditionGroup shared = new() + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } } + }; + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet { Sort = 1, ConditionGroup = shared }, + new ConditionSet { Sort = 2, Intersection = Intersection.Union, ConditionGroup = shared } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Segment copy = segment.Clone(); + + _out.WriteLine( + "segment: sets share one group before the clone: " + + $"{ReferenceEquals(segment.ConditionSets[0].ConditionGroup, segment.ConditionSets[1].ConditionGroup)}; after: " + + $"{ReferenceEquals(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup)}"); + + Assert.NotSame(segment.ConditionSets, copy.ConditionSets); + Assert.NotSame(segment.ConditionSets[0], copy.ConditionSets[0]); + Assert.NotSame(segment.ConditionSets[0].ConditionGroup, copy.ConditionSets[0].ConditionGroup); + Assert.NotSame(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup); + Assert.NotSame(segment.Selects, copy.Selects); + Assert.NotSame(segment.Orders, copy.Orders); + Assert.NotSame(segment.Orders![0], copy.Orders![0]); + Assert.NotSame(segment.Page, copy.Page); + } + + [Fact] + public void A_summary_clone_reaches_the_group_by_the_aggregates_and_the_having() + { + Summary summary = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } } + }, + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + }, + Having = new ConditionGroup + { + Conditions = { new Condition { Field = "N", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } } + }, + Orders = new List { new() { Field = "N", Direction = Direction.Descending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Summary copy = summary.Clone(); + + Assert.NotSame(summary.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(summary.GroupBy, copy.GroupBy); + Assert.NotSame(summary.GroupBy!.Fields, copy.GroupBy!.Fields); + Assert.NotSame(summary.GroupBy.AggregateBy, copy.GroupBy.AggregateBy); + Assert.NotSame(summary.GroupBy.AggregateBy[0], copy.GroupBy.AggregateBy[0]); + Assert.NotSame(summary.Having, copy.Having); + Assert.NotSame(summary.Having!.Conditions[0], copy.Having!.Conditions[0]); + Assert.NotSame(summary.Orders, copy.Orders); + Assert.NotSame(summary.Orders![0], copy.Orders![0]); + Assert.NotSame(summary.Page, copy.Page); + + copy.GroupBy.AggregateBy[0].Alias = "Changed"; + copy.Having.Conditions[0].Field = "Changed"; + + _out.WriteLine($"summary: original alias={summary.GroupBy.AggregateBy[0].Alias} having field={summary.Having.Conditions[0].Field}"); + + Assert.Equal("N", summary.GroupBy.AggregateBy[0].Alias); + Assert.Equal("N", summary.Having.Conditions[0].Field); + } + + [Fact] + public void A_segment_whose_ConditionSets_is_null_is_what_Clone_gives_back() + { + Segment segment = new() { ConditionSets = null! }; + + Segment copy = segment.Clone(); + + _out.WriteLine($"Segment.Clone with null ConditionSets -> {(copy.ConditionSets is null ? "null" : "empty list")}"); + + Assert.Null(copy.ConditionSets); + } + } +} diff --git a/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs new file mode 100644 index 0000000..a3bb9d8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs @@ -0,0 +1,251 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // What the entity row of the source table is actually read from, plus the group floor. + // ============================================================================================= + + public sealed class QpDerivedAndFloorProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _zyConnection; + private readonly ZyContext _zy; + private readonly SqliteConnection _qpConnection; + private readonly QpContext _qp; + + public QpDerivedAndFloorProbe(ITestOutputHelper output) + { + _out = output; + + _zyConnection = new SqliteConnection("DataSource=:memory:"); + _zyConnection.Open(); + _zy = new ZyContext(_zyConnection); + _zy.Database.EnsureCreated(); + _zy.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin" } }); + _zy.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); + _zy.SaveChanges(); + _zy.ChangeTracker.Clear(); + + _qpConnection = new SqliteConnection("DataSource=:memory:"); + _qpConnection.Open(); + _qp = new QpContext(_qpConnection); + _qp.Database.EnsureCreated(); + + foreach (string code in new[] { "a", "b", "c" }) + { + _qp.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() }); + } + + _qp.SaveChanges(); + _qp.ChangeTracker.Clear(); + } + + public void Dispose() + { + _zy.Dispose(); + _zyConnection.Dispose(); + _qp.Dispose(); + _qpConnection.Dispose(); + } + + private static bool? Ask(IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + } + + private static string Show(bool? answer) => answer is null ? "null (left alone)" : answer.ToString()!; + + private static PolicyQueryable Guard(IQueryable source, DwPolicyOptions options) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + /// + /// The one claim two tables disagree on: whether a derived type's columns count as members + /// the queried type can produce. + /// + [Fact] + public void The_entity_row_reads_the_queried_types_own_model_not_a_derived_types() + { + bool derivedMaps = _zy.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence") is not null; + bool baseMaps = _zy.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence") is not null; + + bool? throughBase = Ask(_zy.Parties, "Licence"); + bool? throughDerived = Ask(_zy.Set(), "Licence"); + + _out.WriteLine($"model maps Licence: on ZyMerchant = {derivedMaps}, on ZyParty = {baseMaps}"); + _out.WriteLine($"Expresses(\"Licence\") on DbSet = {Show(throughBase)}"); + _out.WriteLine($"Expresses(\"Licence\") on Set() = {Show(throughDerived)}"); + + Assert.True(derivedMaps); + Assert.False(baseMaps); + Assert.False(throughBase); + Assert.True(throughDerived); + } + + /// The projected rows of the table, read straight off the shape. + [Fact] + public void The_projected_rows_of_the_table() + { + IQueryable built = _zy.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable copied = + _zy.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name }); + + IQueryable another = new ZyOwnProvider( + new[] { new ZyRole { Id = 1, Code = "admin", Name = new ZyLocalizedText() } } + .AsQueryable() + .Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = new ZyLocalizedText { En = role.Name.En } })); + + _out.WriteLine($"a Select that builds the row Expresses(\"Name.IsEmpty\") = {Show(Ask(built, "Name.IsEmpty"))}"); + _out.WriteLine($" ... and what it does assign Expresses(\"Name.En\") = {Show(Ask(built, "Name.En"))}"); + _out.WriteLine($"a Select that copies the member Expresses(\"Name.IsEmpty\") = {Show(Ask(copied, "Name.IsEmpty"))}"); + _out.WriteLine($"a projection another provider ran Expresses(\"Name.IsEmpty\") = {Show(Ask(another, "Name.IsEmpty"))}"); + _out.WriteLine($"an entity, an owned type's getter Expresses(\"Name.IsEmpty\") = {Show(Ask(_zy.Roles, "Name.IsEmpty"))}"); + + Assert.False(Ask(built, "Name.IsEmpty")); + Assert.True(Ask(built, "Name.En")); + Assert.False(Ask(copied, "Name.IsEmpty")); + Assert.Null(Ask(another, "Name.IsEmpty")); + Assert.False(Ask(_zy.Roles, "Name.IsEmpty")); + } + + /// The exact wording the docs quote for the trace. + [Fact] + public void The_trace_reason_is_worded_as_the_docs_quote_it() + { + PolicyQueryable guarded = Guard(_zy.Roles, new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny(() => guarded.ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } } + } + })); + + string reason = guarded.LastTrace!.Decisions + .Select(decision => decision.Reason) + .First(text => text is not null && text.Contains("cannot compute"))!; + + _out.WriteLine($"trace reason: {reason}"); + + Assert.Equal( + "the member exists on the type and the query cannot compute it, so it is refused as an unknown name is", + reason); + } + + /// LastTrace lands on the handle the method was called on, not the one it returns. + [Fact] + public void LastTrace_lands_on_the_handle_the_method_was_called_on() + { + PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Strict }); + + PolicyQueryable returned = guarded.Where(new Condition + { + Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } + }); + + _out.WriteLine( + $"called-on handle LastTrace = {(guarded.LastTrace is null ? "null" : "set")}; " + + $"returned handle LastTrace = {(returned.LastTrace is null ? "null" : "set")}"); + + Assert.NotNull(guarded.LastTrace); + Assert.Null(returned.LastTrace); + } + + /// The floor the three pages now lead with: on at 5, silent, and never unguarded. + [Fact] + public void The_group_floor_is_on_at_five_and_silent() + { + static Summary Ask() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + SummaryResult unguarded = _qp.Tickets.ToList(Ask()); + + SummaryResult floored = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }).ToList(Ask()); + + SummaryResult unfloored = Guard( + _qp.Tickets, + new DwPolicyOptions { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }).ToList(Ask()); + + _out.WriteLine( + $"3 groups of 1 row each -> unguarded={unguarded.Data.Count} " + + $"guarded(default)={floored.Data.Count} guarded(MinGroupSize=1)={unfloored.Data.Count}"); + _out.WriteLine($"guarded(default): TotalCount={floored.TotalCount}, trace on result={(floored.Policy is null ? "null" : "set")}"); + + Assert.Equal(3, unguarded.Data.Count); + Assert.Empty(floored.Data); + Assert.Equal(3, unfloored.Data.Count); + } + + /// + /// Whether the composable pair is floored too, which decides whether a page describing them + /// without the floor is a gap. + /// + [Fact] + public void The_composable_Group_and_Summary_are_floored_the_same_way() + { + static GroupBy Grouping() => new() + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + }; + + static Summary Asked() => new() { GroupBy = Grouping() }; + + int unguardedGroup = _qp.Tickets.Group(Grouping()).Cast().Count(); + + PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }); + int guardedGroup = guarded.Group(Grouping()).Cast().Count(); + + int unguardedSummary = _qp.Tickets.Summary(Asked()).Cast().Count(); + + PolicyQueryable guardedTwo = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }); + int guardedSummary = guardedTwo.Summary(Asked()).Cast().Count(); + + _out.WriteLine($"composable .Group -> unguarded={unguardedGroup} guarded(default floor)={guardedGroup}"); + _out.WriteLine($"composable .Summary -> unguarded={unguardedSummary} guarded(default floor)={guardedSummary}"); + + Assert.Equal(3, unguardedGroup); + Assert.Equal(3, unguardedSummary); + Assert.Equal(0, guardedGroup); + Assert.Equal(0, guardedSummary); + } + } +} diff --git a/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs b/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs new file mode 100644 index 0000000..1cc73e8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs @@ -0,0 +1,1036 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// Round three of the docs-against-code probe for 3.3.0. Every fact here is read by running the +// library, never by reading it: each test prints what happened so the report can quote an outcome. +// Nothing here fixes anything. + +namespace DynamicWhere.Tests.Policies +{ + // ---- a model whose computed member and whose mapped member are both audited ------------------ + + public class DrcText + { + public string Ar { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.All)] + public string En { get; set; } = string.Empty; + + /// Audited too, so a refusal that recorded a use would show here. + [DwAudit(PolicyFeature.All)] + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class DrcRole + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DrcText Name { get; set; } = new(); + } + + public sealed class DrcContext : DbContext + { + private readonly SqliteConnection _connection; + + public DrcContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity().OwnsOne(role => role.Name); + } + + /// A value whose text answers differently on every read. + public sealed class DrcDrifting + { + private int _reads; + + public int Reads => _reads; + + public override string ToString() => (++_reads).ToString(); + } + + public sealed class DrcSink : IDwAuditSink + { + public List Events { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + Events.Add(auditEvent); + + return default; + } + } + + public sealed class DrcTypeOne + { + } + + // ============================================================================================= + // 1. The rows of the source table that no test covers: a provider standing in front of EF Core. + // ============================================================================================= + + public sealed class DrcProviderInFrontProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcProviderInFrontProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + internal static bool? Ask(IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + } + + internal static string Show(bool? answer) => answer is null ? "null (left alone)" : answer.ToString()!; + + internal static PolicyQueryable Guard(IQueryable source, DwTier tier) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// + /// The table's row "a provider in front of EF Core", over an entity. The wrapper leaves the EF + /// Core query root in the expression, so the model is readable; the provider is not EF Core's. + /// + [Fact] + public void A_provider_in_front_of_EF_Core_over_an_entity_is_left_alone() + { + IQueryable wrapped = new ZyOwnProvider(_db.Roles); + + bool? bare = Ask(_db.Roles, "Name.IsEmpty"); + bool? front = Ask(wrapped, "Name.IsEmpty"); + + _out.WriteLine($"DbSet Expresses(\"Name.IsEmpty\") = {Show(bare)}"); + _out.WriteLine($"a provider in front of it, entity Expresses(\"Name.IsEmpty\") = {Show(front)}"); + + Exception? guarded = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? unguarded = Record.Exception(() => _db.Roles.Where(role => role.Name.IsEmpty).ToList()); + + _out.WriteLine($" guarded -> {guarded?.GetType().Name ?? "ran"}"); + _out.WriteLine($" unguarded-> {unguarded?.GetType().Name ?? "ran"}"); + + Assert.False(bare); + Assert.Null(front); + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + } + + /// The table's row "a projection a provider that is not EF Core's ran", EF-backed. + [Fact] + public void A_provider_in_front_of_EF_Core_over_a_projection_is_left_alone() + { + IQueryable built = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable front = new ZyOwnProvider(built); + + _out.WriteLine($"EF Core's own projection Expresses(\"Name.IsEmpty\") = {Show(Ask(built, "Name.IsEmpty"))}"); + _out.WriteLine($"the same behind another provider Expresses(\"Name.IsEmpty\") = {Show(Ask(front, "Name.IsEmpty"))}"); + + Assert.False(Ask(built, "Name.IsEmpty")); + Assert.Null(Ask(front, "Name.IsEmpty")); + } + + /// What the library matches on, so the report can say which providers answer which way. + [Fact] + public void EF_Core_s_own_provider_is_what_decides() + { + MethodInfo owns = typeof(RowShape).GetMethod("EfCoreOwns", BindingFlags.Static | BindingFlags.NonPublic)!; + + IQueryable efCore = _db.Roles; + + bool ef = (bool)owns.Invoke(null, new object[] { efCore.Provider })!; + bool wrapper = (bool)owns.Invoke(null, new object[] { new ZyOwnProvider(efCore).Provider })!; + bool memory = (bool)owns.Invoke(null, new object[] { new[] { new ZyRole() }.AsQueryable().Provider })!; + + _out.WriteLine($"EF Core's own provider -> {ef} ({efCore.Provider.GetType().FullName})"); + _out.WriteLine($"a provider in front -> {wrapper}"); + _out.WriteLine($"EnumerableQuery -> {memory}"); + + Assert.True(ef); + Assert.False(wrapper); + Assert.False(memory); + } + } + + // ============================================================================================= + // 2. Every clause the text lists, and the two the text lists inside a Segment. + // ============================================================================================= + + public sealed class DrcClauseProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcClauseProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private PolicyQueryable Guarded(DwTier tier = DwTier.Strict) => + DrcProviderInFrontProbe.Guard(_db.Roles, tier); + + private static ConditionSet Set(int sort, string field, Intersection? intersection = null) => new() + { + Sort = sort, + Intersection = intersection, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, + DataType = field.EndsWith("IsEmpty", StringComparison.Ordinal) ? DataType.Boolean : DataType.Text, + Operator = Operator.Equal, + Values = { field.EndsWith("IsEmpty", StringComparison.Ordinal) ? "false" : "Admin" } + } + } + } + }; + + [Fact] + public void The_five_clauses_the_text_names_each_refuse() + { + Report("a filter", Record.Exception( + () => Guarded().ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))), + PolicyErrorCode.FieldDeniedForWhere); + + Report("an order", Record.Exception(() => Guarded().ToList(new Filter + { + Orders = new List { new() { Sort = 1, Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })), PolicyErrorCode.FieldDeniedForOrder); + + Report("a grouping key", Record.Exception(() => Guarded().ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })), PolicyErrorCode.FieldDeniedForGroup); + + Report("an aggregated field", Record.Exception(() => Guarded().ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "N", Aggregator = Aggregator.Count }, + new() { Alias = "M", Field = "Name.IsEmpty", Aggregator = Aggregator.Maximum } + } + } + })), PolicyErrorCode.FieldDeniedForAggregate); + + void Report(string label, Exception? error, PolicyErrorCode expected) + { + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine($"{label,-22} -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(expected, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + } + + [Fact] + public async Task A_filter_inside_a_Segment_is_refused() + { + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => Guarded().ToListAsync(new Segment { ConditionSets = { Set(1, "Name.IsEmpty") } })); + + _out.WriteLine($"a filter inside a Segment -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public async Task An_order_inside_a_Segment_is_refused() + { + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => Guarded().ToListAsync(new Segment + { + ConditionSets = { Set(1, "Name.En") }, + Orders = new List { new() { Sort = 1, Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"an order inside a Segment -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// The composable pair reaches the same refusal the terminals do. + [Fact] + public void The_composable_Group_and_Summary_refuse_it_too() + { + Exception? group = Record.Exception(() => Guarded().Group(new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + })); + + Exception? summary = Record.Exception(() => Guarded().Summary(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"composable Group -> {Show(group)}"); + _out.WriteLine($"composable Summary -> {Show(summary)}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, Assert.IsType(group).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, Assert.IsType(summary).ErrorCode); + + static string Show(Exception? error) => + error is PolicyException policy ? $"{policy.ErrorCode} FieldPath={policy.FieldPath}" : error?.GetType().Name ?? "ran"; + } + + /// + /// The aggregation entry DOC.md leaves without the floor note: a guarded in-memory summary. + /// + [Fact] + public void An_in_memory_summary_reached_through_ApplyPolicy_is_floored_too() + { + List rows = new(); + + for (int i = 0; i < 7; i++) + { + rows.Add(new ZyRole { Id = i + 1, Code = i < 5 ? "big" : "small", Name = new ZyLocalizedText() }); + } + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + SummaryResult unguarded = rows.ToList(summary); + + SummaryResult guarded = rows + .AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(summary.Clone()); + + _out.WriteLine($"in-memory summary: unguarded {unguarded.Data.Count} group(s), guarded {guarded.Data.Count}"); + + Assert.Equal(2, unguarded.Data.Count); + Assert.Single(guarded.Data); + } + + /// Selects is the exemption, in a Filter and in a Segment, in both tiers. + [Fact] + public async Task Selects_stays_exempt_everywhere() + { + FilterResult strict = Guarded().ToList(new Filter { Selects = new List { "Id", "Name.IsEmpty" } }); + FilterResult easy = Guarded(DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Id", "Name.IsEmpty" } }); + + SegmentResult segment = await Guarded().ToListAsync(new Segment + { + Selects = new List { "Id", "Name.IsEmpty" }, + ConditionSets = { Set(1, "Name.En") } + }); + + _out.WriteLine($"Selects: strict={strict.Data.Count} convenience={easy.Data.Count} segment={segment.Data.Count}"); + + Assert.Single(strict.Data); + Assert.Single(easy.Data); + Assert.Single(segment.Data); + } + } + + // ============================================================================================= + // 3. Both branches of a conditional, including the core's own typed Select. + // ============================================================================================= + + public sealed class DrcConditionalProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcConditionalProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// The claim in every document: "the core's typed Select null-guards every nested node it + /// builds, and both branches of that guard are read, so composing Select and then + /// filtering refuses exactly what the bare handle refuses." + /// + [Fact] + public void The_cores_own_typed_Select_composed_then_filtered_refuses_what_the_bare_handle_refuses() + { + IQueryable projected = _db.Roles.Select(new List { "Id", "Code", "Name.En" }); + + _out.WriteLine("the core's typed Select builds:"); + _out.WriteLine(" " + projected.Expression.ToString()); + _out.WriteLine($" Expresses(\"Name.IsEmpty\") = {DrcProviderInFrontProbe.Show(DrcProviderInFrontProbe.Ask(projected, "Name.IsEmpty"))}"); + _out.WriteLine($" Expresses(\"Name.En\") = {DrcProviderInFrontProbe.Show(DrcProviderInFrontProbe.Ask(projected, "Name.En"))}"); + + Exception? bare = Record.Exception(() => DrcProviderInFrontProbe + .Guard(_db.Roles, DwTier.Strict) + .ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? composed = Record.Exception(() => DrcProviderInFrontProbe + .Guard(projected, DwTier.Strict) + .ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($" bare handle refuses with {Name(bare)}"); + _out.WriteLine($" composed Select refuses {Name(composed)}"); + + Assert.IsType(bare); + Assert.IsType(composed); + Assert.Equal(((PolicyException)bare!).ErrorCode, ((PolicyException)composed!).ErrorCode); + + static string Name(Exception? error) => + error is PolicyException policy ? $"{policy.ErrorCode}" : error?.GetType().Name ?? "no refusal"; + } + + /// A conditional written by hand, each branch a nested initializer. + [Fact] + public void A_conditional_whose_branches_are_both_initializers_is_read_on_both() + { + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = role.Code == "admin" + ? new ZyLocalizedText { En = role.Name.En } + : new ZyLocalizedText { En = role.Name.En, Ar = role.Name.Ar } + }); + + bool? isEmpty = DrcProviderInFrontProbe.Ask(rows, "Name.IsEmpty"); + bool? en = DrcProviderInFrontProbe.Ask(rows, "Name.En"); + bool? ar = DrcProviderInFrontProbe.Ask(rows, "Name.Ar"); + + _out.WriteLine($"two initializer branches: IsEmpty={DrcProviderInFrontProbe.Show(isEmpty)} " + + $"En={DrcProviderInFrontProbe.Show(en)} Ar={DrcProviderInFrontProbe.Show(ar)} (union of the branches)"); + + Assert.False(isEmpty); + Assert.True(en); + Assert.True(ar); + } + } + + // ============================================================================================= + // 4. The audit: the refusal raises no [DwAudit] event, and AuditRefusals records it. + // ============================================================================================= + + public sealed class DrcAuditProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly DrcContext _db; + + public DrcAuditProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new DrcContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new DrcRole { Code = "admin", Name = new DrcText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private (DwPolicyContext Context, PolicyQueryable Query) Guard(DwPolicyOptions options) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + return (context, _db.Roles.ApplyPolicy( + context, options, new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }))); + } + + [Fact] + public void An_audited_member_that_answers_records_a_use() + { + (DwPolicyContext context, PolicyQueryable query) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + query.ToList(DrcProviderInFrontProbe.Where("Name.En", "Admin")); + + _out.WriteLine($"filter on the audited Name.En -> {context.PendingAuditEvents.Count} event(s): " + + string.Join(", ", context.PendingAuditEvents.Select(e => $"{e.FieldPath}/{e.Feature}/{e.Effect}"))); + + Assert.NotEmpty(context.PendingAuditEvents); + } + + /// The claim: the refusal raises no [DwAudit] event, as an unknown name raises none. + [Fact] + public void The_refusal_raises_no_DwAudit_event() + { + (DwPolicyContext computed, PolicyQueryable one) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny( + () => one.ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + (DwPolicyContext unknown, PolicyQueryable two) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny(() => two.ToList(DrcProviderInFrontProbe.Where("NoSuchMember", "x"))); + + _out.WriteLine($"a path the query cannot compute -> {computed.PendingAuditEvents.Count} [DwAudit] event(s)"); + _out.WriteLine($"a name that matches nothing -> {unknown.PendingAuditEvents.Count} [DwAudit] event(s)"); + + Assert.Empty(computed.PendingAuditEvents); + Assert.Empty(unknown.PendingAuditEvents); + } + + /// The other half of the same sentence: AuditRefusals records it, and so does the trace. + [Fact] + public void AuditRefusals_records_it_and_so_does_the_trace() + { + (DwPolicyContext context, PolicyQueryable query) = + Guard(new DwPolicyOptions { Tier = DwTier.Strict, AuditRefusals = true }); + + Assert.ThrowsAny( + () => query.ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"AuditRefusals on -> {context.PendingAuditEvents.Count} event(s): " + + string.Join(", ", context.PendingAuditEvents.Select(e => $"{e.FieldPath}/{e.ErrorCode}"))); + + foreach (PolicyDecision decision in query.LastTrace!.Decisions) + { + _out.WriteLine($" trace: {decision.FieldPath} {decision.Feature} {decision.Action} — {decision.Reason}"); + } + + Assert.Single(context.PendingAuditEvents); + Assert.Contains( + query.LastTrace!.Decisions, + decision => decision.Reason is not null + && decision.Reason.Contains("the member exists on the type and the query cannot compute it", + StringComparison.Ordinal)); + } + } + + // ============================================================================================= + // 5. A simulation has no source, so it cannot refuse such a path at all. + // ============================================================================================= + + public sealed class DrcSimulationProbe + { + private readonly ITestOutputHelper _out; + + public DrcSimulationProbe(ITestOutputHelper output) => _out = output; + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + [Fact] + public void A_simulation_cannot_refuse_a_path_no_database_can_compute() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation computed = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation denied = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("Name", "x"), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation unknown = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("NoSuchMember", "x"), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + _out.WriteLine($"simulate Name.IsEmpty -> WouldRun={computed.WouldRun} refusal={computed.Refusal?.ErrorCode.ToString() ?? "none"}"); + _out.WriteLine($"simulate Name (denied) -> WouldRun={denied.WouldRun} refusal={denied.Refusal?.ErrorCode.ToString() ?? "none"}"); + _out.WriteLine($"simulate NoSuchMember -> WouldRun={unknown.WouldRun} refusal={unknown.Refusal?.ErrorCode.ToString() ?? "none"}"); + + Assert.True(computed.WouldRun); + Assert.False(denied.WouldRun); + Assert.False(unknown.WouldRun); + } + + /// A Segment and a Summary simulate the same way, so the claim holds for every clause. + [Fact] + public void A_simulated_segment_and_summary_run_it_too() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation segment = PolicySimulator.Simulate( + new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Name.IsEmpty", + DataType = DataType.Boolean, + Operator = Operator.Equal, + Values = { "false" } + } + } + } + } + } + }, + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation summary = PolicySimulator.Simulate( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }, + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + _out.WriteLine($"simulate a Segment naming it -> WouldRun={segment.WouldRun}"); + _out.WriteLine($"simulate a Summary naming it -> WouldRun={summary.WouldRun}"); + + Assert.True(segment.WouldRun); + Assert.True(summary.WouldRun); + } + } + + // ============================================================================================= + // 6. The posture comparison: the two sentences round two added. + // ============================================================================================= + + public sealed class DrcPostureProbe + { + private readonly ITestOutputHelper _out; + + public DrcPostureProbe(ITestOutputHelper output) => _out = output; + + private static readonly MethodInfo SameAs = typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static bool Ask(DwEntityCatalog left, DwEntityCatalog right) => + (bool)SameAs.Invoke(left, new object[] { right })!; + + /// + /// "A type exposed under two names is reported under the last one, so two catalogues resolving + /// every name alike are still refused when the order differs." + /// + [Fact] + public void A_type_exposed_under_two_names_in_a_different_order_is_refused() + { + DwEntityCatalog first = new(); + first.Expose("alpha"); + first.Expose("beta"); + + DwEntityCatalog second = new(); + second.Expose("beta"); + second.Expose("alpha"); + + _out.WriteLine($"first resolves alpha -> {first.Resolve("alpha")?.Name}, beta -> {first.Resolve("beta")?.Name}, reported as '{first.NameOf(typeof(DrcTypeOne))}'"); + _out.WriteLine($"second resolves alpha -> {second.Resolve("alpha")?.Name}, beta -> {second.Resolve("beta")?.Name}, reported as '{second.NameOf(typeof(DrcTypeOne))}'"); + _out.WriteLine($"SameAs -> {Ask(first, second)}"); + + Assert.Equal(first.Resolve("alpha"), second.Resolve("alpha")); + Assert.Equal(first.Resolve("beta"), second.Resolve("beta")); + Assert.NotEqual(first.NameOf(typeof(DrcTypeOne)), second.NameOf(typeof(DrcTypeOne))); + Assert.False(Ask(first, second)); + } + + /// + /// "IncludeTraceInResult is compared by the value that applies": under a tier whose own + /// answer is the written one, the two postures are the same. + /// + [Fact] + public void IncludeTraceInResult_is_compared_by_the_value_that_applies() + { + foreach (DwTier tier in new[] { DwTier.Convenience, DwTier.Strict }) + { + bool tierAnswer = tier == DwTier.Convenience; + + DwPolicyOptions unset = Posture(tier, null); + DwPolicyOptions written = Posture(tier, tierAnswer); + DwPolicyOptions opposite = Posture(tier, !tierAnswer); + + bool sameWhenWritten = Compare(unset, written); + bool sameWhenOpposite = Compare(unset, opposite); + + _out.WriteLine($"{tier}: null vs {tierAnswer} (the tier's own answer) -> {sameWhenWritten}; " + + $"null vs {!tierAnswer} -> {sameWhenOpposite}"); + + Assert.True(sameWhenWritten); + Assert.False(sameWhenOpposite); + } + + static DwPolicyOptions Posture(DwTier tier, bool? trace) => + new() { Tier = tier, IncludeTraceInResult = trace }; + } + + /// The cap half of the same rule, on the one cap with a default of its own. + [Fact] + public void Every_cap_is_compared_by_the_value_that_applies() + { + DwPolicyOptions unset = new() { Tier = DwTier.Strict }; + DwPolicyOptions written = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize } }; + DwPolicyOptions higher = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize + 1 } }; + + _out.WriteLine($"unset MinGroupSize reads {unset.Caps.MinGroupSize} (IsMinGroupSizeSet={unset.Caps.IsMinGroupSizeSet}); " + + $"written reads {written.Caps.MinGroupSize} (IsMinGroupSizeSet={written.Caps.IsMinGroupSizeSet})"); + _out.WriteLine($"unset vs written -> {Compare(unset, written)}"); + _out.WriteLine($"unset vs {higher.Caps.MinGroupSize} -> {Compare(unset, higher)}"); + + Assert.True(Compare(unset, written)); + Assert.False(Compare(unset, higher)); + } + + /// + /// The one cap whose applied value is not the value written down, beside + /// MinGroupSize: DefaultPageSize is applied as MaxPageSize when it is + /// larger, so two postures that page a caller identically are still told apart. + /// + [Fact] + public void DefaultPageSize_above_MaxPageSize_is_compared_as_written_not_as_applied() + { + DwPolicyOptions inForce = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 100 } + }; + + DwPolicyOptions asked = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 1000 } + }; + + _out.WriteLine($"MaxPageSize {inForce.Caps.MaxPageSize}: DefaultPageSize " + + $"{inForce.Caps.DefaultPageSize} vs {asked.Caps.DefaultPageSize} " + + $"— both page a caller at {Math.Min(asked.Caps.DefaultPageSize, asked.Caps.MaxPageSize)}"); + _out.WriteLine($"SamePosture -> {Compare(inForce, asked)}"); + + Assert.False(Compare(inForce, asked)); + } + + /// Compares two postures the way Configure does, without configuring anything. + private static bool Compare(DwPolicyOptions inForce, DwPolicyOptions asked) => + (bool)SamePosture.Invoke(null, new object?[] { inForce, asked, Array.Empty() })!; + } + + // ============================================================================================= + // 7. Clone's contract: a new Values list holding the caller's own objects, on all three shapes. + // ============================================================================================= + + public sealed class DrcCloneProbe + { + private readonly ITestOutputHelper _out; + + public DrcCloneProbe(ITestOutputHelper output) => _out = output; + + private static Condition Sample(object value) => new() + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { value } + }; + + [Fact] + public void Every_shape_gives_a_new_Values_list_holding_the_callers_own_objects() + { + object value = new DrcDrifting(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } } + }; + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } } + } + } + }; + + Summary summary = new() + { + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } }, + Having = new ConditionGroup { Conditions = { Sample(value) } }, + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + Filter filterCopy = filter.Clone(); + Segment segmentCopy = segment.Clone(); + Summary summaryCopy = summary.Clone(); + + Check("Filter", filter.ConditionGroup!.Conditions[0], filterCopy.ConditionGroup!.Conditions[0]); + Check("Segment", segment.ConditionSets[0].ConditionGroup.Conditions[0], + segmentCopy.ConditionSets[0].ConditionGroup.Conditions[0]); + Check("Summary/where", summary.ConditionGroup!.Conditions[0], summaryCopy.ConditionGroup!.Conditions[0]); + Check("Summary/having", summary.Having!.Conditions[0], summaryCopy.Having!.Conditions[0]); + + // The clauses beside the condition tree. + Assert.NotSame(segment.ConditionSets[0], segmentCopy.ConditionSets[0]); + Assert.Equal(Intersection.Union, segmentCopy.ConditionSets[0].Intersection); + Assert.NotSame(summary.GroupBy, summaryCopy.GroupBy); + Assert.NotSame(summary.GroupBy!.AggregateBy[0], summaryCopy.GroupBy!.AggregateBy[0]); + + void Check(string label, Condition original, Condition copy) + { + _out.WriteLine($"{label,-16} condition new={!ReferenceEquals(original, copy)} " + + $"Values list new={!ReferenceEquals(original.Values, copy.Values)} " + + $"element shared={ReferenceEquals(original.Values[0], copy.Values[0])}"); + + Assert.NotSame(original, copy); + Assert.NotSame(original.Values, copy.Values); + Assert.Same(original.Values[0], copy.Values[0]); + } + } + + [Fact] + public void Clone_is_public_on_the_three_shapes_and_internal_on_the_parts() + { + foreach (Type type in new[] { typeof(Filter), typeof(Segment), typeof(Summary) }) + { + MethodInfo? clone = type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public); + + _out.WriteLine($"{type.Name,-8} public Clone -> {(clone is null ? "absent" : clone.ReturnType.Name)}"); + + Assert.NotNull(clone); + Assert.Equal(type, clone!.ReturnType); + } + + foreach (Type type in new[] + { + typeof(ConditionGroup), typeof(Condition), typeof(ConditionSet), + typeof(GroupBy), typeof(AggregateBy), typeof(OrderBy), typeof(PageBy) + }) + { + _out.WriteLine($"{type.Name,-16} public Clone -> " + + $"{(type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public) is null ? "absent" : "present")}"); + } + } + } + + // ============================================================================================= + // 8. A condition's values are read more than once. + // ============================================================================================= + + public sealed class DrcValueReadProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcValueReadProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + + foreach (string code in new[] { "1", "2", "3" }) + { + _db.Roles.Add(new ZyRole { Code = code, Name = new ZyLocalizedText { En = code } }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// "A value is read once to validate its format and again to build the predicate … one whose + /// ToString() answers differently each time is validated as one value and queried as + /// another." + /// + [Fact] + public void A_value_is_read_more_than_once_so_an_unstable_one_is_queried_as_another() + { + DrcDrifting drifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { drifting } + } + } + } + }; + + FilterResult result = _db.Roles.ToList(filter, getQueryString: true); + + _out.WriteLine($"the value was read {drifting.Reads} time(s)"); + _out.WriteLine($"rows: {string.Join(", ", result.Data.Select(role => role.Code))}"); + _out.WriteLine($"SQL contains '= 2': {result.QueryString?.Contains("2", StringComparison.Ordinal)}"); + + Assert.True(drifting.Reads > 1, $"read {drifting.Reads} time(s)"); + + // Validated as the first reading, queried as a later one. + Assert.Single(result.Data); + Assert.NotEqual("1", result.Data[0].Code); + } + + /// No policy decision reads a value's content — only how many there are. + [Fact] + public void A_guarded_query_decides_nothing_from_a_value() + { + DrcDrifting drifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { drifting } + } + } + } + }; + + FilterResult result = DrcProviderInFrontProbe.Guard(_db.Roles, DwTier.Strict).ToList(filter); + + _out.WriteLine($"guarded: the value was read {drifting.Reads} time(s), {result.Data.Count} row(s) came back"); + + Assert.True(drifting.Reads > 1); + } + } +} diff --git a/DynamicWhere.Tests/Policies/DocsReview34Probe.cs b/DynamicWhere.Tests/Policies/DocsReview34Probe.cs new file mode 100644 index 0000000..ce6006d --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview34Probe.cs @@ -0,0 +1,867 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +// Round four of the docs-against-code probe for 3.3.0. Every fact here is read by running the +// library, never by reading it: each test prints what happened so the report can quote an outcome. +// Nothing here fixes anything, and nothing here drives DwPolicy's static fields. + +namespace DynamicWhere.Tests.Policies +{ + // ---- models ------------------------------------------------------------------------------- + + /// An entity with two owned members of the same type, so a conditional can copy two. + public class Rd4Role + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ZyLocalizedText Name { get; set; } = new(); + + public ZyLocalizedText Alt { get; set; } = new(); + } + + /// The row a caller projects before the guard sees it. + public class Rd4Row + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ZyLocalizedText? Name { get; set; } + } + + /// A chain, so an initializer can be nested to any depth the probe asks for. + public class Rd4Node + { + public string Value { get; set; } = string.Empty; + + public Rd4Node? Next { get; set; } + + /// A getter over the level's own column: no database computes it. + public bool IsEmpty => Value.Length == 0; + } + + /// A class stored in one column through a value converter. + public class Rd4Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "IQD"; + + public bool IsZero => Amount == 0m; + } + + public class Rd4Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + /// One column, converted. Beneath it the converter decides. + public Rd4Money Total { get; set; } = new(); + } + + public sealed class Rd4Context : DbContext + { + private readonly SqliteConnection _connection; + + public Rd4Context(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Alt); + + model.Entity().Property(order => order.Total).HasConversion( + money => money.Amount.ToString(CultureInfo.InvariantCulture), + text => new Rd4Money { Amount = decimal.Parse(text, CultureInfo.InvariantCulture) }); + } + } + + // ---- the two providers the corrected paragraph tells apart ---------------------------------- + + /// Rewrites ZyLocalizedText.IsEmpty into the two columns beneath it. + /// + /// The rewrite a member-translator plugin or a replaced query preprocessor performs, expressed + /// where a test can drive it: whatever runs it, the member becomes something SQLite computes. + /// + internal sealed class Rd4EmptyRewriter : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == nameof(ZyLocalizedText.IsEmpty) + && node.Member.DeclaringType == typeof(ZyLocalizedText) + && node.Expression is not null) + { + Expression instance = Visit(node.Expression)!; + + return Expression.AndAlso( + Expression.Equal( + Expression.Property(instance, nameof(ZyLocalizedText.Ar)), + Expression.Constant(string.Empty)), + Expression.Equal( + Expression.Property(instance, nameof(ZyLocalizedText.En)), + Expression.Constant(string.Empty))); + } + + return base.VisitMember(node); + } + } + + /// + /// A rewrite inside EF Core's own pipeline: the provider handed to the query is + /// EntityQueryProvider itself, and the rewrite happens under it. + /// + /// + /// This is what a member-translator plugin and a replaced query preprocessor look like from + /// outside: EF Core's own provider is still the one on the queryable, so the library's name + /// comparison still matches and the member is still refused — even though the query now runs. + /// + internal sealed class Rd4RewritingCompiler : IQueryCompiler + { + private readonly IQueryCompiler _inner; + + public Rd4RewritingCompiler(IQueryCompiler inner) => _inner = inner; + + public TResult Execute(Expression query) => + _inner.Execute(new Rd4EmptyRewriter().Visit(query)!); + + public TResult ExecuteAsync(Expression query, CancellationToken cancellationToken = default) => + _inner.ExecuteAsync(new Rd4EmptyRewriter().Visit(query)!, cancellationToken); + + public Func CreateCompiledQuery(Expression query) => + _inner.CreateCompiledQuery(new Rd4EmptyRewriter().Visit(query)!); + + public Func CreateCompiledAsyncQuery(Expression query) => + _inner.CreateCompiledAsyncQuery(new Rd4EmptyRewriter().Visit(query)!); + } + + /// A provider built by deriving from EF Core's own, rewriting the same member. + internal sealed class Rd4DerivedProvider : EntityQueryProvider + { + public Rd4DerivedProvider(IQueryCompiler compiler) : base(compiler) + { + } + + public override IQueryable CreateQuery(Expression expression) => + base.CreateQuery(new Rd4EmptyRewriter().Visit(expression)!); + + public override object? Execute(Expression expression) => + base.Execute(new Rd4EmptyRewriter().Visit(expression)!); + + public override TResult Execute(Expression expression) => + base.Execute(new Rd4EmptyRewriter().Visit(expression)!); + } + + // ---- shared plumbing ------------------------------------------------------------------------ + + internal static class Rd4 + { + private static readonly MethodInfo OfMethod = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + + private static readonly MethodInfo ExpressesMethod = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly MethodInfo OwnsMethod = typeof(RowShape) + .GetMethod("EfCoreOwns", BindingFlags.Static | BindingFlags.NonPublic)!; + + /// What the shape of a source says about a path: true, false, or "cannot say". + internal static bool? Ask(IQueryable source, string path) + { + object shape = OfMethod.MakeGenericMethod(source.ElementType).Invoke(null, new object[] { source })!; + + return (bool?)ExpressesMethod.Invoke(shape, new object[] { path }); + } + + internal static bool EfCoreOwns(IQueryProvider provider) => + (bool)OwnsMethod.Invoke(null, new object[] { provider })!; + + internal static string Show(bool? answer) => answer switch + { + null => "null (left alone)", + true => "true (nothing to refuse)", + _ => "false (refused)" + }; + + /// A guarded handle that reads no process-wide state. + internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value, DataType type) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// What a call did: the rows it returned, or the exception it raised. + internal static string Outcome(Func call) + { + try + { + return $"ran, {call()} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED PolicyException({refusal.Message})"; + } + catch (LogicException invalid) + { + return $"invalid LogicException({invalid.Message})"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + } + + // ============================================================================================= + // 1. Which provider the rule belongs to: EF Core's own TYPE, and a rewrite under it. + // ============================================================================================= + + public sealed class Rd4ProviderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + private readonly IQueryCompiler _compiler; + + public Rd4ProviderProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rd4Role + { + Code = "admin", + Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" }, + Alt = new ZyLocalizedText { Ar = string.Empty, En = string.Empty } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + _compiler = (IQueryCompiler)((IInfrastructure)_db).Instance + .GetService(typeof(IQueryCompiler))!; + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// "A rewrite inside EF Core's own pipeline — a member-translator plugin, a replaced + /// query preprocessor — leaves EF Core's own provider in place, so a member it computes + /// without a mapping is refused with the rest." + /// + [Fact] + public void A_rewrite_inside_EF_Cores_own_pipeline_leaves_the_provider_in_place_and_is_refused() + { + EntityQueryProvider provider = new(new Rd4RewritingCompiler(_compiler)); + + IQueryable rewritten = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + _out.WriteLine($"provider type = {rewritten.Provider.GetType().FullName}"); + _out.WriteLine($"EfCoreOwns = {Rd4.EfCoreOwns(rewritten.Provider)}"); + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(rewritten, "Name.IsEmpty"))}"); + + // The rewrite makes the member something SQLite computes, so the unguarded query runs. + string unguarded = Rd4.Outcome(() => rewritten.Where(role => role.Name.IsEmpty).ToList().Count); + string alt = Rd4.Outcome(() => rewritten.Where(role => role.Alt.IsEmpty).ToList().Count); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(rewritten).ToList(Rd4.Where("Name.IsEmpty", "false", DataType.Boolean)).Data.Count); + + _out.WriteLine($" unguarded Name.IsEmpty -> {unguarded}"); + _out.WriteLine($" unguarded Alt.IsEmpty -> {alt}"); + _out.WriteLine($" guarded Name.IsEmpty -> {guarded}"); + + Assert.True(Rd4.EfCoreOwns(rewritten.Provider)); + Assert.False(Rd4.Ask(rewritten, "Name.IsEmpty")); + Assert.StartsWith("ran,", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + /// + /// "one built by deriving from EF Core's provider rewrites in the same way and is left alone + /// too" — over an entity. + /// + [Fact] + public void A_provider_deriving_from_EF_Cores_own_is_left_alone_over_an_entity() + { + Rd4DerivedProvider provider = new(_compiler); + + IQueryable derived = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + _out.WriteLine($"provider type = {derived.Provider.GetType().FullName}"); + _out.WriteLine($"derives from = {derived.Provider.GetType().BaseType?.FullName}"); + _out.WriteLine($"EntityQueryProvider itself derives from {typeof(EntityQueryProvider).BaseType?.FullName} " + + $"(EF Core {typeof(EntityQueryProvider).Assembly.GetName().Version})"); + _out.WriteLine($"EfCoreOwns = {Rd4.EfCoreOwns(derived.Provider)}"); + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(derived, "Name.IsEmpty"))}"); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(derived).ToList(Rd4.Where("Name.IsEmpty", "false", DataType.Boolean)).Data.Count); + + _out.WriteLine($" guarded Name.IsEmpty -> {guarded}"); + + Assert.Equal(typeof(EntityQueryProvider), derived.Provider.GetType().BaseType); + + // "EF Core's own derives from object in every version, so nothing real is lost by the + // exact test" — read from the assembly this leg runs against, both legs. + Assert.Equal(typeof(object), typeof(EntityQueryProvider).BaseType); + + Assert.False(Rd4.EfCoreOwns(derived.Provider)); + Assert.Null(Rd4.Ask(derived, "Name.IsEmpty")); + Assert.DoesNotContain("REFUSED", guarded); + } + + /// The same sentence, over a projection. + [Fact] + public void A_provider_deriving_from_EF_Cores_own_is_left_alone_over_a_projection() + { + Rd4DerivedProvider provider = new(_compiler); + + IQueryable derived = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + IQueryable builtOnEfCore = _db.Roles.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable builtOnDerived = derived.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + _out.WriteLine($"EF Core's own provider, projection -> {Rd4.Show(Rd4.Ask(builtOnEfCore, "Name.IsEmpty"))}"); + _out.WriteLine($"derived provider, same projection -> {Rd4.Show(Rd4.Ask(builtOnDerived, "Name.IsEmpty"))}"); + _out.WriteLine($" the derived one's provider = {builtOnDerived.Provider.GetType().FullName}"); + + Assert.False(Rd4.Ask(builtOnEfCore, "Name.IsEmpty")); + Assert.Null(Rd4.Ask(builtOnDerived, "Name.IsEmpty")); + } + + /// The wrapping half of the same sentence, over an entity and over a projection. + [Fact] + public void A_provider_wrapping_EF_Core_is_left_alone_over_both() + { + IQueryable entity = new ZyOwnProvider(_db.Roles); + + IQueryable projection = new ZyOwnProvider( + _db.Roles.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + })); + + _out.WriteLine($"wrapper over an entity -> {Rd4.Show(Rd4.Ask(entity, "Name.IsEmpty"))}"); + _out.WriteLine($"wrapper over a projection -> {Rd4.Show(Rd4.Ask(projection, "Name.IsEmpty"))}"); + + Assert.Null(Rd4.Ask(entity, "Name.IsEmpty")); + Assert.Null(Rd4.Ask(projection, "Name.IsEmpty")); + } + + /// + /// "[DbFunction] was removed as an example because it maps a method, which no field + /// path can name." A path is resolved through properties, so a method never reaches the + /// refusal at all — it fails name resolution first, in both tiers. + /// + [Fact] + public void No_field_path_can_name_a_method() + { + // Three methods on the row's own types: an instance method, a method on a member's type, + // and a static one of the kind [DbFunction] maps. + foreach (string path in new[] { "ToString", "Name.ToString", "Code.Trim", "Name.GetHashCode" }) + { + string strict = Rd4.Outcome( + () => Rd4.Guard(_db.Roles).ToList(Rd4.Where(path, "x", DataType.Text)).Data.Count); + + string convenience = Rd4.Outcome( + () => Rd4.Guard(_db.Roles, DwTier.Convenience) + .ToList(Rd4.Where(path, "x", DataType.Text)).Data.Count); + + _out.WriteLine($"{path,-20} strict -> {strict}"); + _out.WriteLine($"{path,-20} conv. -> {convenience}"); + + // Whatever the tier answers, the answer is a name answer, never the compute refusal: + // the shape is never even asked, because the path resolves to no member. + Assert.DoesNotContain("cannot compute", strict); + Assert.DoesNotContain("cannot compute", convenience); + Assert.Contains("ConditionMustHasValidFieldName", convenience); + } + + // And the property beside them, for contrast: that one the shape does answer for. + _out.WriteLine($"Name.IsEmpty (a property) Expresses -> {Rd4.Show(Rd4.Ask(_db.Roles, "Name.IsEmpty"))}"); + + Assert.False(Rd4.Ask(_db.Roles, "Name.IsEmpty")); + } + } + + // ============================================================================================= + // 2. How far a projection is read: every assignment kind, and where the reading stops. + // ============================================================================================= + + public sealed class Rd4ProjectionProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + + private static readonly ZyLocalizedText Captured = new() { Ar = "a", En = "e" }; + + public Rd4ProjectionProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static ZyLocalizedText Make(string code) => new() { Ar = code, En = code }; + + /// + /// The two lists the text gives: the assignments a projection is read through, and the ones + /// that leave the member alone. One row per shape, printed, so the report can quote it. + /// + [Fact] + public void Every_assignment_kind_the_text_lists() + { + List<(string Shape, string Expected, bool? Answer)> rows = new(); + + void Row(string shape, string expected, IQueryable source) => + rows.Add((shape, expected, Rd4.Ask(source, "Name.IsEmpty"))); + + Row("a nested initializer", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a member copied from the entity", "read", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = r.Name })); + + Row("a value built and left empty", "read", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = new ZyLocalizedText() })); + + // ROUND-4 FINDING. Every document lists "a null" beside the four forms above, as one of + // the assignments a projection is read through. On its own it is not: ReadValue answers + // "I could read that" and records nothing, so the member has no entry and the shape says + // "cannot say". A null only carries its weight as one branch of a conditional, where the + // other branch is what records the members. The row below is expected against the code, + // not against the text, and the text is what the report names. + Row("a null, on its own", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = null })); + + Row("a conditional: built | built", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, + Name = r.Code == "x" + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a conditional: null | built", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, + Name = r.Code == "x" ? null : new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a conditional: copied | copied, one member", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? r.Name : r.Name + })); + + Row("a method call", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = Make(r.Code) })); + + Row("a captured value", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = Captured })); + + Row("a subquery", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = _db.Roles.Select(other => other.Name).FirstOrDefault() + })); + + Row("two branches building it two ways", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? r.Name : r.Alt + })); + + Row("a conditional: built | copied", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? new ZyLocalizedText() : r.Name + })); + + foreach ((string shape, string expected, bool? answer) in rows) + { + _out.WriteLine($"{shape,-45} code says {Rd4.Show(answer)} (expected {expected})"); + } + + // Said plainly, so the finding is an outcome rather than a reading of the code: the four + // forms the text lists beside it are read, and a bare null is not. + _out.WriteLine(string.Empty); + _out.WriteLine("the text's list: nested initializer, copied member, built-and-left-empty, A NULL, " + + "and a conditional over those"); + _out.WriteLine($" Name = new ZyLocalizedText {{ … }} -> {Rd4.Show(rows[0].Answer)}"); + _out.WriteLine($" Name = r.Name -> {Rd4.Show(rows[1].Answer)}"); + _out.WriteLine($" Name = new ZyLocalizedText() -> {Rd4.Show(rows[2].Answer)}"); + _out.WriteLine($" Name = null -> {Rd4.Show(rows[3].Answer)} <-- the text says this one is read"); + _out.WriteLine($" Name = c ? null : new … {{ … }} -> {Rd4.Show(rows[5].Answer)}"); + + Assert.Null(rows[3].Answer); + + // The text's first list: these are the ones a projection is read through. + foreach ((string shape, string expected, bool? answer) in rows.Where(r => r.Expected == "read")) + { + Assert.True(answer == false, $"{shape}: expected refused, got {Rd4.Show(answer)}"); + } + + // The text's second list: these leave the member alone. + foreach ((string shape, string expected, bool? answer) in rows.Where(r => r.Expected == "left alone")) + { + Assert.True(answer is null, $"{shape}: expected left alone, got {Rd4.Show(answer)}"); + } + } + + /// + /// "Past MaxComplexDepth — eight levels — it stops reading and stops speaking." + /// Nests one initializer inside another and reports the last depth the shape answers for. + /// + [Fact] + public void The_depth_at_which_a_nested_initializer_stops_being_read() + { + int cap = (int)typeof(RowShape) + .GetField("MaxComplexDepth", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + + _out.WriteLine($"RowShape.MaxComplexDepth = {cap}"); + + int? lastSpoken = null; + int? firstSilent = null; + + for (int nesting = 0; nesting <= cap + 3; nesting++) + { + IQueryable projected = _db.Roles.Select(Chain(nesting)); + + string prefix = string.Concat(Enumerable.Repeat("Next.", nesting)); + + bool? computable = Rd4.Ask(projected, prefix + "IsEmpty"); + bool? assigned = Rd4.Ask(projected, prefix + "Value"); + + _out.WriteLine($"{nesting,2} nested initializer(s): " + + $"\"{prefix}Value\" = {Rd4.Show(assigned)} " + + $"\"{prefix}IsEmpty\" = {Rd4.Show(computable)}"); + + if (computable == false) + { + lastSpoken = nesting; + } + else if (firstSilent is null) + { + firstSilent = nesting; + } + } + + _out.WriteLine($"last level the shape refuses at = {lastSpoken}; first level it stops speaking at = {firstSilent}"); + + Assert.NotNull(lastSpoken); + Assert.NotNull(firstSilent); + Assert.Equal(lastSpoken + 1, firstSilent); + Assert.Equal(cap, lastSpoken); + } + + /// Builds r => new Rd4Node { Value = r.Code, Next = new Rd4Node { … } }. + private static Expression> Chain(int nesting) + { + ParameterExpression role = Expression.Parameter(typeof(Rd4Role), "r"); + MemberExpression code = Expression.Property(role, nameof(Rd4Role.Code)); + + MemberInitExpression body = Level(code, null); + + for (int i = 0; i < nesting; i++) + { + body = Level(code, body); + } + + return Expression.Lambda>(body, role); + } + + private static MemberInitExpression Level(Expression value, Expression? next) + { + List bindings = new() + { + Expression.Bind(typeof(Rd4Node).GetProperty(nameof(Rd4Node.Value))!, value) + }; + + if (next is not null) + { + bindings.Add(Expression.Bind(typeof(Rd4Node).GetProperty(nameof(Rd4Node.Next))!, next)); + } + + return Expression.MemberInit(Expression.New(typeof(Rd4Node)), bindings); + } + } + + // ============================================================================================= + // 3. "Left alone" is not a promise the path runs. + // ============================================================================================= + + public sealed class Rd4LeftAloneProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + + public Rd4LeftAloneProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rd4Role + { + Code = "admin", + Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" }, + Alt = new ZyLocalizedText() + }); + _db.Orders.Add(new Rd4Order { Code = "A-1", Total = new Rd4Money { Amount = 12m } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// "which for rows in memory … means it runs and returns rows". + [Fact] + public void Rows_in_memory_run_and_return_rows() + { + List rows = new() + { + new Rd4Role { Id = 1, Code = "a", Name = new ZyLocalizedText { Ar = "م", En = "A" } }, + new Rd4Role { Id = 2, Code = "b", Name = new ZyLocalizedText() } + }; + + IQueryable memory = rows.AsQueryable(); + + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(memory, "Name.IsEmpty"))}"); + + FilterResult? result = null; + + string outcome = Rd4.Outcome(() => + { + result = Rd4.Guard(memory).ToList(Rd4.Where("Name.IsEmpty", "true", DataType.Boolean)); + + return result.Data.Count; + }); + + _out.WriteLine($" guarded Name.IsEmpty -> {outcome}"); + + Assert.Null(Rd4.Ask(memory, "Name.IsEmpty")); + Assert.StartsWith("ran,", outcome); + Assert.Single(result!.Data); + } + + /// "…and a framework member means it runs and returns rows". + [Fact] + public void A_framework_member_runs_and_returns_rows() + { + _out.WriteLine($"Expresses(\"Code.Length\") = {Rd4.Show(Rd4.Ask(_db.Roles, "Code.Length"))}"); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(_db.Roles).ToList(Rd4.Where("Code.Length", "5", DataType.Number)).Data.Count); + + string unguarded = Rd4.Outcome(() => _db.Roles.Where(role => role.Code.Length == 5).ToList().Count); + + _out.WriteLine($" guarded -> {guarded}"); + _out.WriteLine($" unguarded -> {unguarded}"); + + Assert.Null(Rd4.Ask(_db.Roles, "Code.Length")); + Assert.StartsWith("ran,", guarded); + Assert.Equal(unguarded, guarded); + } + + /// + /// "…and beneath a converted column means the provider decides." The path is left alone, and + /// what happens next is not the policy's answer to give. + /// + [Fact] + public void Beneath_a_converted_column_the_provider_decides() + { + _out.WriteLine("Total is one column, converted to " + + $"{_db.Model.FindEntityType(typeof(Rd4Order))!.FindProperty(nameof(Rd4Order.Total))!.GetValueConverter()!.ProviderClrType.Name}"); + _out.WriteLine($"Expresses(\"Total.Amount\") = {Rd4.Show(Rd4.Ask(_db.Orders, "Total.Amount"))}"); + _out.WriteLine($"Expresses(\"Total.IsZero\") = {Rd4.Show(Rd4.Ask(_db.Orders, "Total.IsZero"))}"); + + string guardedAmount = Rd4.Outcome( + () => Rd4.Guard(_db.Orders).ToList(Rd4.Where("Total.Amount", "12", DataType.Number)).Data.Count); + + string unguardedAmount = Rd4.Outcome( + () => _db.Orders.Where(order => order.Total.Amount == 12m).ToList().Count); + + _out.WriteLine($" guarded Total.Amount -> {guardedAmount}"); + _out.WriteLine($" unguarded Total.Amount -> {unguardedAmount}"); + + // Left alone by the policy... + Assert.Null(Rd4.Ask(_db.Orders, "Total.Amount")); + Assert.Null(Rd4.Ask(_db.Orders, "Total.IsZero")); + + // ...and the provider still decides, which here is a throw rather than rows. + Assert.DoesNotContain("REFUSED", guardedAmount); + Assert.Equal(unguardedAmount, guardedAmount); + Assert.StartsWith("threw", guardedAmount); + } + } + + // ============================================================================================= + // 4. The posture comparison: which values are compared by what applies, and which as written. + // ============================================================================================= + + public sealed class Rd4CapProbe + { + private readonly ITestOutputHelper _out; + + public Rd4CapProbe(ITestOutputHelper output) => _out = output; + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static bool Compare(DwPolicyOptions inForce, DwPolicyOptions asked) => + (bool)SamePosture.Invoke(null, new object?[] { inForce, asked, Array.Empty() })!; + + /// + /// Walks every cap: for each, whether two postures differing only in that cap are told apart, + /// and whether the value the enforcement path applies is the value written down. + /// + [Fact] + public void Only_the_group_floor_is_compared_by_the_value_that_applies() + { + // The floor: unset and written-as-the-default are the same posture. + DwPolicyOptions floorUnset = new() { Tier = DwTier.Strict }; + DwPolicyOptions floorWritten = new() + { + Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize } + }; + + bool floorSame = Compare(floorUnset, floorWritten); + + _out.WriteLine($"MinGroupSize unset({floorUnset.Caps.MinGroupSize}) vs written({floorWritten.Caps.MinGroupSize})" + + $" -> same posture = {floorSame}"); + + // DefaultPageSize: two postures that page a caller identically, written differently. + DwPolicyOptions pageLow = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 100 } + }; + + DwPolicyOptions pageHigh = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 5000 } + }; + + bool pageSame = Compare(pageLow, pageHigh); + + int appliedLow = Math.Min(pageLow.Caps.DefaultPageSize, pageLow.Caps.MaxPageSize); + int appliedHigh = Math.Min(pageHigh.Caps.DefaultPageSize, pageHigh.Caps.MaxPageSize); + + _out.WriteLine($"DefaultPageSize written {pageLow.Caps.DefaultPageSize} vs {pageHigh.Caps.DefaultPageSize}, " + + $"both applied as {appliedLow}/{appliedHigh} -> same posture = {pageSame}"); + + Assert.True(floorSame, "the group floor is compared by the value that applies"); + Assert.Equal(appliedLow, appliedHigh); + Assert.False(pageSame, "DefaultPageSize is compared as written, not by the value that applies"); + } + + /// Proves the same sentence end to end: the sanitizer pages both postures alike. + [Fact] + public void The_two_page_size_postures_page_a_caller_identically() + { + List rows = Enumerable.Range(1, 250) + .Select(i => new Rd4Role { Id = i, Code = $"c{i}" }) + .ToList(); + + foreach (int written in new[] { 100, 5000 }) + { + DwPolicyOptions options = new() + { + Tier = DwTier.Convenience, Caps = { MaxPageSize = 100, DefaultPageSize = written } + }; + + FilterResult result = rows.AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + _out.WriteLine($"DefaultPageSize written as {written,5} -> the caller got {result.Data.Count} row(s)"); + + Assert.Equal(100, result.Data.Count); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs new file mode 100644 index 0000000..7ffdc6d --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs @@ -0,0 +1,889 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5. Documentation against code for 3.3.0. + // + // Each probe answers one written claim by running it. Nothing here drives DwPolicy's static + // fields: every guarded call is the three-argument ApplyPolicy, which takes its own posture. + // ============================================================================================= + + public sealed class Dv5DocsProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _watchedConnection; + private readonly Dv5WatchedContext _watched; + private readonly SqliteConnection _orderConnection; + private readonly Dv5Context _orders; + + public Dv5DocsProbes(ITestOutputHelper output) + { + _out = output; + + _watchedConnection = new SqliteConnection("DataSource=:memory:"); + _watchedConnection.Open(); + _watched = new Dv5WatchedContext(_watchedConnection); + _watched.Database.EnsureCreated(); + _watched.Rows.Add(new Dv5Watched { First = "a", Second = "b", Amount = 3, Sealed = "s" }); + _watched.SaveChanges(); + _watched.ChangeTracker.Clear(); + + _orderConnection = new SqliteConnection("DataSource=:memory:"); + _orderConnection.Open(); + _orders = new Dv5Context(_orderConnection); + _orders.Database.EnsureCreated(); + _orders.Orders.Add(new Dv5Order + { + Code = "AB123", + Qty = 2, + Total = new Dv5Money { Amount = 10m, Currency = "USD" }, + Lines = { new Dv5Line { Price = 4m } } + }); + _orders.SaveChanges(); + _orders.ChangeTracker.Clear(); + } + + public void Dispose() + { + _watched.Dispose(); + _watchedConnection.Dispose(); + _orders.Dispose(); + _orderConnection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------------ + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + /// A posture whose audit buffer holds exactly one event. + private static DwPolicyOptions OneSlot(DwTier tier, bool dryRun = false) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun }; + + options.Caps.MaxAuditEvents = 1; + options.Caps.MinGroupSize = 1; + + return options; + } + + private static Condition On(string field, DataType type = DataType.Text) => new() + { + Field = field, + DataType = type, + Operator = Operator.Equal, + Values = { Value(type) } + }; + + private static string Value(DataType type) => type switch + { + DataType.Number => "1", + DataType.Boolean => "true", + _ => "x" + }; + + /// What a call answered with, in a form two calls can be compared by. + private static string Outcome(Func run) + { + try + { + run(); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"PolicyException {refusal.ErrorCode}" + + $" path={refusal.FieldPath}" + + $" feature={refusal.Feature}" + + $" origin={refusal.SourceOrigin ?? ""}" + + $" rule={refusal.RuleId ?? ""}"; + } + catch (LogicException logic) + { + return "LogicException " + logic.Message; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + // ========================================================================================= + // Dv5-A. The audit cap under Strict raises the clause's own field refusal. + // + // Claim: "under Strict outside a dry run, a query that exhausts the audit buffer is refused + // with the clause's own field refusal — same code, FieldPath "*", no SourceOrigin." + // ========================================================================================= + + [Fact] + public void Dv5_A_The_audit_cap_raises_the_clauses_own_field_refusal_under_Strict() + { + (string What, PolicyErrorCode Expected, Func Run)[] clauses = + { + ("Where", PolicyErrorCode.FieldDeniedForWhere, WhereTwice), + ("Selects", PolicyErrorCode.FieldDeniedForSelect, SelectTwice), + ("Orders", PolicyErrorCode.FieldDeniedForOrder, OrderTwice), + ("GroupBy", PolicyErrorCode.FieldDeniedForGroup, GroupTwice), + ("AggregateBy", PolicyErrorCode.FieldDeniedForAggregate, GroupThenAggregate), + ("Segment", PolicyErrorCode.FieldDeniedForSegment, SegmentTwice) + }; + + List wrong = new(); + + foreach ((string what, PolicyErrorCode expected, Func run) in clauses) + { + string strict = run(OneSlot(DwTier.Strict)); + + _out.WriteLine($"{what,-12} strict -> {strict}"); + + string want = $"PolicyException {expected} path=* feature="; + + if (!strict.StartsWith(want, StringComparison.Ordinal)) + { + wrong.Add($"{what}: {strict}"); + } + + if (!strict.Contains("origin=", StringComparison.Ordinal) + || !strict.Contains("rule=", StringComparison.Ordinal)) + { + wrong.Add($"{what} carried an origin or a rule: {strict}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-B. Convenience and a dry run still answer CapExceeded. + // ========================================================================================= + + [Fact] + public void Dv5_B_Convenience_and_a_dry_run_still_answer_CapExceeded() + { + string convenience = WhereTwice(OneSlot(DwTier.Convenience)); + string dryRun = WhereTwice(OneSlot(DwTier.Strict, dryRun: true)); + string strict = WhereTwice(OneSlot(DwTier.Strict)); + + _out.WriteLine($"Convenience -> {convenience}"); + _out.WriteLine($"Strict + dry run -> {dryRun}"); + _out.WriteLine($"Strict -> {strict}"); + + Assert.StartsWith("PolicyException CapExceeded", convenience, StringComparison.Ordinal); + Assert.Contains("MaxAuditEvents", convenience, StringComparison.Ordinal); + Assert.Contains("path=Second", convenience, StringComparison.Ordinal); + + Assert.StartsWith("PolicyException CapExceeded", dryRun, StringComparison.Ordinal); + Assert.Contains("MaxAuditEvents", dryRun, StringComparison.Ordinal); + + // The request fails in every posture: the buffer still fails closed. + Assert.StartsWith("PolicyException", strict, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-C. Under Strict the cap cannot be told from a denied field or from a name matching + // nothing, and the trace still records which refusal it really was. + // ========================================================================================= + + [Fact] + public void Dv5_C_The_three_refusals_are_alike_and_the_trace_keeps_the_real_reason() + { + DwPolicyOptions options = OneSlot(DwTier.Strict); + + string cap = WhereTwice(options); + string denied = WhereOn(options, "First", "Sealed"); + string unknown = WhereOn(options, "First", "Zzzzz"); + + _out.WriteLine($"audit cap reached -> {cap}"); + _out.WriteLine($"a denied field -> {denied}"); + _out.WriteLine($"a name matching nothing -> {unknown}"); + + Assert.Equal(denied, cap); + Assert.Equal(unknown, cap); + + // The trace names the real reason, and LastTrace is readable after the refusal. + PolicyQueryable guarded = + _watched.Rows.ApplyPolicy(Caller(), options, Resolver()); + + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + filter.ConditionGroup.Conditions.Add(On("First")); + filter.ConditionGroup.Conditions.Add(On("Second")); + + Assert.Throws(() => guarded.ToList(filter)); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + + foreach (PolicyDecision decision in trace!.Decisions) + { + _out.WriteLine($"trace: {decision.FieldPath} {decision.Feature} {decision.Action} {decision.Reason}"); + } + + Assert.Contains( + trace.Decisions, + d => d.FieldPath == "Second" + && d.Action == PolicyAction.Denied + && (d.Reason ?? string.Empty).Contains("MaxAuditEvents", StringComparison.Ordinal)); + } + + // ========================================================================================= + // Dv5-D. Which provider the uncomputable-path refusal belongs to. + // + // Claim: "EF Core's own provider type, from EF Core's own assembly; every other provider is + // left alone, a host's own through ReplaceService included. A + // rewrite inside EF Core's own pipeline leaves EF Core's provider in place, so such a + // member is refused." + // ========================================================================================= + + [Fact] + public void Dv5_D_The_refusal_belongs_to_EF_Cores_own_provider_from_EF_Cores_own_assembly() + { + Type own = _orders.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"plain context provider = {own.FullName} @ {own.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", own.FullName); + Assert.Equal("Microsoft.EntityFrameworkCore", own.Assembly.GetName().Name); + + // EF Core's own provider: the unmapped getter and the owned type's getter are refused. + string slug = GuardedWhere(_orders.Orders, "Slug"); + string zero = GuardedWhere(_orders.Orders, "Total.IsZero"); + + _out.WriteLine($"EF Core's own Slug -> {slug}"); + _out.WriteLine($"EF Core's own Total.IsZero -> {zero}"); + + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", slug, StringComparison.Ordinal); + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", zero, StringComparison.Ordinal); + + // A host's own provider, registered through ReplaceService: left alone. The guarded + // query does exactly what the unguarded one does. + using SqliteConnection replacedConnection = new("DataSource=:memory:"); + + replacedConnection.Open(); + + using Dv5Context replaced = new(replacedConnection, typeof(Dv5PassThroughProvider)); + + replaced.Database.EnsureCreated(); + replaced.Orders.Add(new Dv5Order { Code = "AB123", Total = new Dv5Money { Amount = 10m } }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + Type host = replaced.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"replaced provider = {host.FullName} @ {host.Assembly.GetName().Name}"); + + Assert.NotEqual("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", host.FullName); + + string hostRaw = Outcome(() => replaced.Orders.Where(o => o.Slug == "AB123-1").ToList()); + string hostGuarded = GuardedWhere(replaced.Orders, "Slug"); + + _out.WriteLine($"replaced provider Slug unguarded -> {hostRaw}"); + _out.WriteLine($"replaced provider Slug guarded -> {hostGuarded}"); + + Assert.Equal(hostRaw, hostGuarded); + Assert.DoesNotContain("FieldDeniedForWhere", hostGuarded, StringComparison.Ordinal); + + // A rewrite inside EF Core's own pipeline leaves EF Core's own provider in front of it, + // so the member is refused with the rest. + using SqliteConnection insideConnection = new("DataSource=:memory:"); + + insideConnection.Open(); + + using Dv5Context inside = new(insideConnection, replacePreprocessor: true); + + inside.Database.EnsureCreated(); + inside.Orders.Add(new Dv5Order { Code = "AB123", Total = new Dv5Money { Amount = 10m } }); + inside.SaveChanges(); + inside.ChangeTracker.Clear(); + + Type stillOwn = inside.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"pipeline rewrite provider = {stillOwn.FullName} @ {stillOwn.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", stillOwn.FullName); + + string insideGuarded = GuardedWhere(inside.Orders, "Slug"); + + _out.WriteLine($"pipeline rewrite Slug guarded -> {insideGuarded}"); + + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", insideGuarded, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-E. Which projected rows the refusal reads. + // + // Claim: "A projection that does not build its rows with an object initializer — an + // anonymous type, a constructor with arguments — has no member refused here. A member + // assigned from a subquery is left alone." + // ========================================================================================= + + [Fact] + public void Dv5_E_A_row_no_object_initializer_builds_has_no_member_refused() + { + List wrong = new(); + + // 1. An anonymous type. Nothing says which member each value sets. + var anonymous = _orders.Orders.Select(o => new { o.Id, o.Total }); + + Report("anonymous row, Total.IsZero", RowShape.Of(anonymous).Expresses("Total.IsZero")); + + if (RowShape.Of(anonymous).Expresses("Total.IsZero") == false) + { + wrong.Add("an anonymous row had a member refused"); + } + + // 2. A constructor with arguments, and no initializer at all. + IQueryable built = _orders.Orders.Select(o => new Dv5Row(o.Id)); + + foreach (string path in new[] { "Nest.Blank", "Money.IsZero", "Code" }) + { + bool? answer = RowShape.Of(built).Expresses(path); + + Report($"new Dv5Row(o.Id), {path}", answer); + + if (answer == false) + { + wrong.Add($"a constructor-built row had {path} refused"); + } + } + + string builtRaw = Outcome(() => + _orders.Orders.Select(o => new Dv5Row(o.Id)).Where(r => r.Nest.Blank).ToList()); + string builtGuarded = GuardedWhere(built, "Nest.Blank", DataType.Boolean); + + _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank unguarded -> {builtRaw}"); + _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank guarded -> {builtGuarded}"); + + if (builtRaw != builtGuarded) + { + wrong.Add($"guarded and unguarded differ: {builtRaw} vs {builtGuarded}"); + } + + // 3. A constructor with arguments that DOES carry an object initializer. Recorded, and + // reported, because the text reads two ways. + IQueryable mixed = _orders.Orders.Select(o => new Dv5Row(o.Id) + { + Nest = new Dv5Nest { A = o.Code } + }); + + bool? mixedNested = RowShape.Of(mixed).Expresses("Nest.Blank"); + bool? mixedTop = RowShape.Of(mixed).Expresses("Money"); + + Report("new Dv5Row(o.Id) { Nest = new Dv5Nest { A = … } }, Nest.Blank", mixedNested); + Report("new Dv5Row(o.Id) { Nest = new Dv5Nest { A = … } }, Money", mixedTop); + + _out.WriteLine(mixedNested == false + ? "NOTE: a row built by a constructor WITH an initializer does have a member refused" + : "a row built by a constructor with an initializer has no member refused"); + + // 4. A member assigned from a subquery is left alone, and one copied from the entity is + // not — which is the contrast the sentence draws. + IQueryable subquery = _orders.Orders.Select(o => new Dv5Row + { + Id = o.Id, + Lines = o.Lines.Select(l => new Dv5LineRow { Id = l.Id, Price = l.Price }).ToList() + }); + + bool? beneathSubquery = RowShape.Of(subquery).Expresses("Lines.Price"); + + Report("Lines = o.Lines.Select(…).ToList(), Lines.Price", beneathSubquery); + + if (beneathSubquery is not null) + { + wrong.Add($"a member assigned from a subquery answered {beneathSubquery}"); + } + + IQueryable copied = _orders.Orders.Select(o => new Dv5Row + { + Id = o.Id, + Money = o.Total + }); + + bool? beneathCopy = RowShape.Of(copied).Expresses("Money.IsZero"); + + Report("Money = o.Total, Money.IsZero", beneathCopy); + + if (beneathCopy != false) + { + wrong.Add($"a member copied from the entity answered {beneathCopy} rather than false"); + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-F. The compute refusal raises no [DwAudit] event, and "left alone" is not a promise + // that the path runs. + // ========================================================================================= + + [Fact] + public void Dv5_F_The_compute_refusal_records_no_audit_event() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + // Control: an audited field the query does name records exactly one event. + DwPolicyContext allowed = Caller(); + + _orders.Orders + .ApplyPolicy(allowed, options, Resolver()) + .ToList(Where(On("Code"))); + + _out.WriteLine($"an audited field named -> {allowed.PendingAuditEvents.Count} event(s):" + + $" {string.Join(",", allowed.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + // Two uses of the one field: the caller filtered on it, and the row the request named no + // projection for carries it back, which since 3.3.0 is recorded as the read it is. + Assert.Equal(2, allowed.PendingAuditEvents.Count); + Assert.Contains(allowed.PendingAuditEvents, e => e.Feature == PolicyFeature.Where); + Assert.Contains(allowed.PendingAuditEvents, e => e.Feature == PolicyFeature.Select); + + // The refusal of a path the query cannot compute records none. + DwPolicyContext refused = Caller(); + + Assert.Throws(() => + _orders.Orders + .ApplyPolicy(refused, options, Resolver()) + .ToList(Where(On("Slug")))); + + _out.WriteLine($"a path the query cannot compute -> {refused.PendingAuditEvents.Count} event(s):" + + $" {string.Join(",", refused.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + // No event names the refused path. The request's own projection is recorded as it is for + // any other request — a use is what the request would have read — but the refusal itself + // raises none, as an unknown name raises none. + Assert.DoesNotContain(refused.PendingAuditEvents, e => e.FieldPath.Contains("IsZero")); + + // And a name matching nothing records none either, which is the comparison the text draws. + DwPolicyContext unknown = Caller(); + + Assert.Throws(() => + _orders.Orders + .ApplyPolicy(unknown, options, Resolver()) + .ToList(Where(On("Zzzzz")))); + + _out.WriteLine($"a name matching nothing -> {unknown.PendingAuditEvents.Count} event(s)"); + + Assert.DoesNotContain(unknown.PendingAuditEvents, e => e.FieldPath.Contains("Zzzzz")); + } + + // ========================================================================================= + // Dv5-G. Convenience and a dry run fail exactly as the unguarded query does on a path the + // query cannot compute: the refusal is the strict tier's alone. + // ========================================================================================= + + [Fact] + public void Dv5_G_Convenience_and_a_dry_run_fail_as_the_unguarded_query_does() + { + string raw = Outcome(() => _orders.Orders.Where(o => o.Slug == "AB123-1").ToList()); + string convenience = GuardedWhere(_orders.Orders, "Slug", DataType.Text, DwTier.Convenience); + string dryRun = GuardedWhere(_orders.Orders, "Slug", DataType.Text, DwTier.Strict, dryRun: true); + string strict = GuardedWhere(_orders.Orders, "Slug"); + + _out.WriteLine($"unguarded -> {raw}"); + _out.WriteLine($"Convenience -> {convenience}"); + _out.WriteLine($"Strict + dry run -> {dryRun}"); + _out.WriteLine($"Strict -> {strict}"); + + Assert.Equal(raw, convenience); + Assert.Equal(raw, dryRun); + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", strict, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-H. Selects is not one of the clauses the database has to compute. + // ========================================================================================= + + [Fact] + public void Dv5_H_Selects_naming_an_uncomputable_member_still_returns_its_value() + { + Filter filter = new() { Selects = new List { "Id", "Slug" } }; + + string strict = Outcome(() => _orders.Orders + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = DwTier.Strict }, Resolver()) + .ToList(filter)); + + _out.WriteLine($"Selects = [Id, Slug] under Strict -> {strict}"); + + Assert.Equal("OK", strict); + } + + // ========================================================================================= + // Dv5-I. The clauses the compute refusal reaches: "a filter, an order, a grouping key, an + // aggregated field, and a filter or an order inside a Segment" — and not Selects. + // ========================================================================================= + + [Fact] + public void Dv5_I_The_compute_refusal_reaches_every_clause_the_database_computes() + { + DwPolicyOptions strict = new() { Tier = DwTier.Strict }; + + strict.Caps.MinGroupSize = 1; + + (string What, PolicyErrorCode Expected, Func Run)[] clauses = + { + ("a filter", PolicyErrorCode.FieldDeniedForWhere, + () => Guarded(strict, g => g.ToList(Where(On("Slug"))))), + ("an order", PolicyErrorCode.FieldDeniedForOrder, + () => Guarded(strict, g => g.ToList(new Filter + { + Orders = new List { new() { Sort = 1, Field = "Slug" } } + }))), + ("a grouping key", PolicyErrorCode.FieldDeniedForGroup, + () => Guarded(strict, g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Slug" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + }))), + ("an aggregated field", PolicyErrorCode.FieldDeniedForAggregate, + () => Guarded(strict, g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Field = "Slug", Alias = "s", Aggregator = Aggregator.Maximum } + } + } + }))), + ("a filter inside a Segment", PolicyErrorCode.FieldDeniedForSegment, + () => Guarded(strict, g => g.ToListAsync(OneSet(On("Slug"))).GetAwaiter().GetResult())) + }; + + List wrong = new(); + + foreach ((string what, PolicyErrorCode expected, Func run) in clauses) + { + string answer = run(); + + _out.WriteLine($"{what,-28} -> {answer}"); + + if (!answer.StartsWith($"PolicyException {expected} path=*", StringComparison.Ordinal)) + { + wrong.Add($"{what}: {answer}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-J. A simulation has no source, so it cannot refuse a path the query cannot compute. + // ========================================================================================= + + [Fact] + public void Dv5_J_A_simulation_shows_an_uncomputable_path_running() + { + PolicySimulation simulated = PolicySimulator.Simulate( + Where(On("Slug")), + Caller(), + new DwPolicyOptions { Tier = DwTier.Strict }, + Resolver()); + + _out.WriteLine($"simulated WouldRun = {simulated.WouldRun}"); + _out.WriteLine($"simulated refusal = {simulated.Refusal?.ErrorCode.ToString() ?? ""}"); + _out.WriteLine($"the same request guarded -> {GuardedWhere(_orders.Orders, "Slug")}"); + + Assert.True(simulated.WouldRun); + Assert.Null(simulated.Refusal); + } + + // ========================================================================================= + // Dv5-K. A shadow property is nameable by no clause, guarded or not. + // ========================================================================================= + + [Fact] + public void Dv5_K_A_shadow_property_is_nameable_by_no_clause() + { + // Dv5Line.OrderId is a real CLR member; the shadow property here is the foreign key EF + // Core creates for Dv5Order.Lines when no CLR member carries it. Read the model for one. + List shadow = _orders.Model + .FindEntityType(typeof(Dv5Line))! + .GetProperties() + .Where(p => p.IsShadowProperty()) + .Select(p => p.Name) + .ToList(); + + _out.WriteLine("shadow properties on Dv5Line: " + (shadow.Count == 0 ? "" : string.Join(", ", shadow))); + + Assert.NotEmpty(shadow); + + DwPolicyOptions strict = new() { Tier = DwTier.Strict }; + + foreach (string name in shadow) + { + string guarded = Outcome(() => _orders.Lines + .ApplyPolicy(Caller(), strict, Resolver()) + .ToList(Where(On(name, DataType.Number)))); + + string convenience = Outcome(() => _orders.Lines + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = DwTier.Convenience }, Resolver()) + .ToList(Where(On(name, DataType.Number)))); + + string unguarded = Outcome(() => DynamicWhere.ex.Source.Extension + .Where(_orders.Lines.AsQueryable(), On(name, DataType.Number)) + .ToList()); + + _out.WriteLine($"shadow '{name}' strict -> {guarded}"); + _out.WriteLine($"shadow '{name}' convenience -> {convenience}"); + _out.WriteLine($"shadow '{name}' unguarded -> {unguarded}"); + + Assert.StartsWith("LogicException", unguarded, StringComparison.Ordinal); + Assert.StartsWith("LogicException", convenience, StringComparison.Ordinal); + } + } + + // ========================================================================================= + // Dv5-L. Clone()'s contract: every node new, the values a condition carries the caller's own + // objects in a new list. + // ========================================================================================= + + [Fact] + public void Dv5_L_Clone_is_a_deep_copy_that_shares_only_the_values_themselves() + { + object value = new Dv5Box("x"); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { value } + } + }, + SubConditionGroups = { new ConditionGroup { Sort = 2 } } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 1, Field = "Id" } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + _out.WriteLine($"filter same reference = {ReferenceEquals(filter, copy)}"); + _out.WriteLine($"condition group same reference = {ReferenceEquals(filter.ConditionGroup, copy.ConditionGroup)}"); + _out.WriteLine($"condition same reference = {ReferenceEquals(filter.ConditionGroup!.Conditions[0], copy.ConditionGroup!.Conditions[0])}"); + _out.WriteLine($"sub-group same reference = {ReferenceEquals(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0])}"); + _out.WriteLine($"values LIST same reference = {ReferenceEquals(filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values)}"); + _out.WriteLine($"one VALUE same reference = {ReferenceEquals(filter.ConditionGroup.Conditions[0].Values[0], copy.ConditionGroup.Conditions[0].Values[0])}"); + _out.WriteLine($"selects same reference = {ReferenceEquals(filter.Selects, copy.Selects)}"); + _out.WriteLine($"order same reference = {ReferenceEquals(filter.Orders![0], copy.Orders![0])}"); + _out.WriteLine($"page same reference = {ReferenceEquals(filter.Page, copy.Page)}"); + + Assert.NotSame(filter, copy); + Assert.NotSame(filter.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Assert.NotSame(filter.Selects, copy.Selects); + Assert.NotSame(filter.Orders[0], copy.Orders[0]); + Assert.NotSame(filter.Page, copy.Page); + + // A new list, holding the caller's own objects: both requests read the same value. + Assert.NotSame(filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Assert.Same(value, copy.ConditionGroup.Conditions[0].Values[0]); + } + + // ========================================================================================= + // Dv5-M. The group floor applies to a summary read through the IEnumerable overload. + // ========================================================================================= + + [Fact] + public void Dv5_M_The_group_floor_applies_to_a_summary_read_in_memory() + { + List rows = new(); + + // Five rows under "big", two under "small": only the first group clears a floor of five. + for (int i = 0; i < 5; i++) + { + rows.Add(new Dv5Watched { Id = i + 1, First = "big", Second = "b", Amount = 1 }); + } + + for (int i = 0; i < 2; i++) + { + rows.Add(new Dv5Watched { Id = 100 + i, First = "small", Second = "b", Amount = 1 }); + } + + DwPolicyOptions floored = new() { Tier = DwTier.Convenience }; + DwPolicyOptions off = new() { Tier = DwTier.Convenience }; + + off.Caps.MinGroupSize = 1; + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "First" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + }; + + // ApplyPolicy(IEnumerable, context) is AsQueryable() plus the same pipeline + // (PolicyExtensions.cs), and only the one-argument form reads DwPolicy's statics, so the + // posture is handed in here instead. + int withFloor = ((IEnumerable)rows).AsQueryable() + .ApplyPolicy(Caller(), floored, Resolver()) + .ToList(summary.Clone()) + .Data!.Count; + + int withoutFloor = ((IEnumerable)rows).AsQueryable() + .ApplyPolicy(Caller(), off, Resolver()) + .ToList(summary.Clone()) + .Data!.Count; + + _out.WriteLine($"in memory, default floor (5) -> {withFloor} group(s)"); + _out.WriteLine($"in memory, floor off (1) -> {withoutFloor} group(s)"); + + Assert.Equal(1, withFloor); + Assert.Equal(2, withoutFloor); + } + + private static Segment OneSet(params Condition[] conditions) + { + Segment segment = new(); + ConditionSet set = new() { Sort = 1, ConditionGroup = new ConditionGroup() }; + + foreach (Condition condition in conditions) + { + set.ConditionGroup.Conditions.Add(condition); + } + + segment.ConditionSets.Add(set); + + return segment; + } + + private string Guarded(DwPolicyOptions options, Func, object?> run) => + Outcome(() => run(_orders.Orders.ApplyPolicy(Caller(), options, Resolver()))); + + // ---- the clauses the cap probes run -------------------------------------------------------- + + private static Filter Where(params Condition[] conditions) + { + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + foreach (Condition condition in conditions) + { + filter.ConditionGroup.Conditions.Add(condition); + } + + return filter; + } + + private string WhereTwice(DwPolicyOptions options) => WhereOn(options, "First", "Second"); + + private string WhereOn(DwPolicyOptions options, params string[] fields) + { + Condition[] conditions = Array.ConvertAll(fields, f => On(f)); + + return Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(Where(conditions))); + } + + private string SelectTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Filter { Selects = new List { "First", "Second" } })); + + private string OrderTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 1, Field = "First" }, + new() { Sort = 2, Field = "Second" } + } + })); + + private string GroupTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "First", "Second" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + })); + + private string GroupThenAggregate(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "First" }, + AggregateBy = new List + { + new() { Field = "Amount", Alias = "s", Aggregator = Aggregator.Sumation } + } + } + })); + + private string SegmentTwice(DwPolicyOptions options) + { + Segment segment = new(); + ConditionSet set = new() { Sort = 1, ConditionGroup = new ConditionGroup() }; + + set.ConditionGroup.Conditions.Add(On("First")); + set.ConditionGroup.Conditions.Add(On("Second")); + segment.ConditionSets.Add(set); + + return Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToListAsync(segment).GetAwaiter().GetResult()); + } + + private string GuardedWhere( + IQueryable source, + string field, + DataType type = DataType.Text, + DwTier tier = DwTier.Strict, + bool dryRun = false) + where T : class => + Outcome(() => source + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = tier, DryRun = dryRun }, Resolver()) + .ToList(Where(On(field, type)))); + + private void Report(string what, bool? answer) => + _out.WriteLine($"{what,-62} Expresses = {(answer is null ? "null (left alone)" : answer.ToString())}"); + } +} diff --git a/DynamicWhere.Tests/Policies/Dv5Model.cs b/DynamicWhere.Tests/Policies/Dv5Model.cs new file mode 100644 index 0000000..5eed60a --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dv5Model.cs @@ -0,0 +1,209 @@ +using System.ComponentModel.DataAnnotations.Schema; +using DynamicWhere.ex.Policies.Attributes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5 (documentation against code). The model the Dv5 probes query. + // + // EF Core 6 compatible on purpose, so the floor leg runs every probe: no complex properties, + // no ToJson, no primitive collections, no DateOnly/TimeOnly, no compiled model. + // ============================================================================================= + + /// An owned type with a getter over two of its own columns: no database computes it. + public class Dv5Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + public bool IsZero => Amount == 0m; + } + + public class Dv5Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + } + + public class Dv5Order + { + public int Id { get; set; } + + /// Audited, so a refusal that records no event can be told from one that does. + [DwAudit] + public string Code { get; set; } = string.Empty; + + public int Qty { get; set; } + + public Dv5Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + /// Unmapped: a getter over two columns, which no database computes. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into ------------------------------------------------------- + + public class Dv5Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public bool Blank => A.Length == 0; + } + + public class Dv5LineRow + { + public int Id { get; set; } + + public decimal Price { get; set; } + } + + public class Dv5Row + { + public Dv5Row() + { + } + + /// The constructor the ctor-with-arguments probes call. + public Dv5Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Dv5Nest Nest { get; set; } = new(); + + public Dv5Money Money { get; set; } = new(); + + public List Lines { get; set; } = new(); + } + + /// A value a condition carries, so a clone's sharing of it can be seen by reference. + public sealed class Dv5Box + { + public Dv5Box(string text) => Text = text; + + public string Text { get; } + + public override string ToString() => Text; + } + + // ---- the audited entity ---------------------------------------------------------------------- + + /// Two audited fields, so one use fills a one-slot buffer and the next hits the cap. + public class Dv5Watched + { + public int Id { get; set; } + + [DwAudit] + public string First { get; set; } = string.Empty; + + [DwAudit] + public string Second { get; set; } = string.Empty; + + [DwAudit] + public int Amount { get; set; } + + /// Not audited, and denied for filtering: the refusal a real denial gives. + [DwDeny(DynamicWhere.ex.Policies.Enums.PolicyFeature.Where)] + public string Sealed { get; set; } = string.Empty; + } + + public sealed class Dv5WatchedContext : DbContext + { + private readonly SqliteConnection _connection; + + public Dv5WatchedContext(SqliteConnection connection) => _connection = connection; + + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class Dv5Context : DbContext + { + private readonly SqliteConnection _connection; + private readonly Type? _replacedProvider; + private readonly bool _replacePreprocessor; + + public Dv5Context( + SqliteConnection connection, + Type? replacedProvider = null, + bool replacePreprocessor = false) + { + _connection = connection; + _replacedProvider = replacedProvider; + _replacePreprocessor = replacePreprocessor; + } + + public DbSet Orders => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacedProvider is not null) + { + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod(typeof(IAsyncQueryProvider), _replacedProvider) + .Invoke(options, null); + } + + if (_replacePreprocessor) + { + // A rewrite *inside* EF Core's own pipeline: the query is preprocessed by a type of + // the host's, and EF Core's own provider stays in front of it. + options.ReplaceService(); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + } + } + + /// A host's own preprocessor factory, registered inside EF Core's pipeline. + public sealed class Dv5PreprocessorFactory + : Microsoft.EntityFrameworkCore.Query.Internal.RelationalQueryTranslationPreprocessorFactory + { + public Dv5PreprocessorFactory( + QueryTranslationPreprocessorDependencies dependencies, + RelationalQueryTranslationPreprocessorDependencies relationalDependencies) + : base(dependencies, relationalDependencies) + { + } + } + + /// + /// A plain pass-through built the way a host builds one: derived from EF Core's own provider, + /// registered through ReplaceService<IAsyncQueryProvider, …>, rewriting nothing. + /// + public sealed class Dv5PassThroughProvider : Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider + { + public Dv5PassThroughProvider(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs b/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs new file mode 100644 index 0000000..889c0da --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs @@ -0,0 +1,297 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 1: "[DwAudit] records a read the request did not name: the members a projection the + // caller never named hands back are recorded for Select, one event per query rather than per + // row, only for a field the attribute names." + // + // Checked against every document that states it, and against llms.txt section 22, which is the + // one place that still states the opposite. + // ============================================================================================= + + /// Nothing denied, one audited member, so the row comes back whole. + public class Dx7Whole + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited for everything. + [DwAudit] + public string Email { get; set; } = string.Empty; + } + + /// One member audited for Where alone, so a Select read must not record it. + public class Dx7WhereAudited + { + public int Id { get; set; } + + [DwAudit(PolicyFeature.Where)] + public string Email { get; set; } = string.Empty; + } + + /// + /// A denied member forces a projection, and an audited member the projection cannot keep. + /// + /// + /// Computed has no setter, so a synthesized projection cannot assign it and leaves it + /// out — while a dry run, which synthesizes nothing, hands the whole row back with it in. + /// + public class Dx7Uncarried + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + [DwAudit] + public string Kept { get; set; } = string.Empty; + + /// Readable, audited, and not assignable by a projection. + [DwAudit] + public string Computed => "derived"; + } + + public sealed class Dx7AuditReadProbes + { + private readonly ITestOutputHelper _out; + + public Dx7AuditReadProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Convenience, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + private static PolicyQueryable Guarded(T[] rows, DwPolicyContext context, DwPolicyOptions options) + where T : class => + rows.AsQueryable().ApplyPolicy(context, options, Attributes()); + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing)" + : string.Join("; ", context.PendingAuditEvents.Select(Describe)); + + private static string Describe(DwAuditEvent e) => + $"{e.FieldPath}:{e.Feature}:{e.Effect}:dry={e.DryRun}"; + + // ========================================================================================= + // The claim itself, and the one document that still denies it. + // ========================================================================================= + + /// + /// A request that names no projection records every audited member it hands back, for + /// Select. + /// + /// + /// llms.txt section 22 lists "a projection the library synthesized" under "Nothing is + /// recorded for". This is that case, and something is recorded. + /// + [Fact] + public void A_request_naming_no_projection_records_the_audited_member_for_select() + { + DwPolicyContext context = Caller(); + + Guarded(new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"no Selects, nothing denied : {Recorded(context)}"); + + DwAuditEvent recorded = Assert.Single(context.PendingAuditEvents); + + Assert.Equal("Email", recorded.FieldPath); + Assert.Equal(PolicyFeature.Select, recorded.Feature); + Assert.Equal(PolicyEffect.Allow, recorded.Effect); + Assert.Null(recorded.ErrorCode); + } + + /// One event per query, whatever the row count. + [Fact] + public void One_event_per_query_not_one_per_row() + { + Dx7Whole[] rows = + { + new() { Id = 1, Email = "a@b" }, + new() { Id = 2, Email = "c@d" }, + new() { Id = 3, Email = "e@f" }, + new() { Id = 4, Email = "g@h" }, + new() { Id = 5, Email = "i@j" } + }; + + DwPolicyContext context = Caller(); + + List returned = Guarded(rows, context, Options()).ToList(new Filter()).Data; + + _out.WriteLine($"{returned.Count} rows returned, events: {Recorded(context)}"); + + Assert.Equal(5, returned.Count); + Assert.Single(context.PendingAuditEvents); + } + + /// Only a member the attribute names, and only for a feature it names. + [Fact] + public void Only_a_field_the_attribute_names_for_a_feature_it_names() + { + DwPolicyContext whole = Caller(); + + Guarded(new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } }, whole, Options()) + .ToList(new Filter()); + + _out.WriteLine($"audited-for-All : {Recorded(whole)}"); + + // Id and Name are handed back too, and neither carries the attribute. + Assert.Equal(new[] { "Email" }, whole.PendingAuditEvents.Select(e => e.FieldPath).ToArray()); + + DwPolicyContext narrowed = Caller(); + + Guarded(new[] { new Dx7WhereAudited { Id = 1, Email = "a@b" } }, narrowed, Options()) + .ToList(new Filter()); + + _out.WriteLine($"audited-for-Where: {Recorded(narrowed)}"); + + // The member is read, but the attribute names Where, so the read is not in its set. + Assert.Empty(narrowed.PendingAuditEvents); + } + + // ========================================================================================= + // What the documents promise for the two halves of the rule. + // ========================================================================================= + + /// + /// Where a projection IS built, the members it keeps are recorded and the ones it leaves + /// out are not. + /// + [Fact] + public void Where_a_projection_is_built_it_records_what_the_projection_keeps() + { + DwPolicyContext context = Caller(); + + Guarded( + new[] { new Dx7Uncarried { Id = 1, Secret = "s", Kept = "k" } }, + context, + Options()) + .ToList(new Filter()); + + _out.WriteLine($"projection built, enforced : {Recorded(context)}"); + + // Kept is assigned by the projection; Computed has no setter, so the projection cannot + // assign it and the caller never receives it. + Assert.Equal(new[] { "Kept" }, context.PendingAuditEvents.Select(e => e.FieldPath).ToArray()); + } + + /// + /// FINDING candidate. A dry run synthesizes nothing, so the whole row is handed back — but + /// the audit records only what the projection it did not build would have kept. + /// + /// + /// Every document states the rule in two halves: "what the synthesized projection keeps + /// where one is built, and every member the caller may select where none is, since the row + /// then comes back whole". A dry run is the second half — nothing is built and the row comes + /// back whole — and the code takes the first. + /// + [Fact] + public void A_dry_run_records_every_member_it_hands_back() + { + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + List rows = Guarded( + new[] { new Dx7Uncarried { Id = 1, Secret = "s", Kept = "k" } }, + context, + options) + .ToList(new Filter()) + .Data; + + string[] paths = context.PendingAuditEvents.Select(e => e.FieldPath).ToArray(); + + _out.WriteLine($"[{leg}] rows[0].Secret = '{rows[0].Secret}', " + + $"rows[0].Computed = '{rows[0].Computed}'"); + _out.WriteLine($"[{leg}] recorded: {Recorded(context)}"); + + // The row came back whole: the denied member is still on it, so nothing was + // projected and every member reached the caller. + Assert.Equal("s", rows[0].Secret); + + // A dry run builds no projection, whatever is denied, so the whole row is what the + // caller read — and both audited members it handed back are recorded. + Assert.Contains("Kept", paths); + Assert.Contains("Computed", paths); + } + } + + /// + /// The same type with nothing denied, for contrast: no projection is built, and then every + /// member the caller may select IS recorded, Computed included. + /// + /// + /// This is what pins the previous probe as a divergence rather than a rule about read-only + /// members: the member is recordable, and a dry run is the one state that drops it. + /// + [Fact] + public void With_nothing_denied_a_read_only_audited_member_is_recorded() + { + DwPolicyContext context = Caller(); + + Guarded(new[] { new Dx7Readable { Id = 1, Kept = "k" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"nothing denied, read-only member : {Recorded(context)}"); + + Assert.Contains("Computed", context.PendingAuditEvents.Select(e => e.FieldPath)); + } + + /// A dry run of a query that denies nothing records both, as it always did. + [Fact] + public void A_dry_run_with_nothing_denied_records_both_members() + { + DwPolicyContext context = Caller(dryRun: true); + + Guarded(new[] { new Dx7Readable { Id = 1, Kept = "k" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"dry run, nothing denied : {Recorded(context)}"); + + Assert.Contains("Computed", context.PendingAuditEvents.Select(e => e.FieldPath)); + Assert.All(context.PendingAuditEvents, e => Assert.True(e.DryRun)); + } + } + + /// The uncarried type with the denial removed. + public class Dx7Readable + { + public int Id { get; set; } + + [DwAudit] + public string Kept { get; set; } = string.Empty; + + [DwAudit] + public string Computed => "derived"; + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs b/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs new file mode 100644 index 0000000..8fec7c4 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs @@ -0,0 +1,376 @@ +using System.Dynamic; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 3: "A blank name fails a guarded query exactly as it fails an unguarded one, in every + // clause." (DOC.md, breaking-changes page) + // Claim 4: "An alias spelled like another member of the same type is a ValidateModel error, and + // a generated row keeps both columns under their own names." + // + // Round 6 covered where / order / select / group / segment-select. The clauses below are the + // ones it did not: an aggregated field, a Having condition, a summary's own Orders, and a + // segment's Orders — each named in llms.txt's "Checks by shape" table as a place a blank name + // is checked. + // ============================================================================================= + + /// A plain type, so only the blank name decides what happens. + public class Dx7Plain + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + /// An alias spelled exactly like another member of the same type. + public class Dx7Shadowing + { + public int Id { get; set; } + + /// Answers to the name Code, which already carries. + [DwAlias("Code")] + public string Reference { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } + + /// The same collision, spelled in another case. + public class Dx7ShadowingByCase + { + public int Id { get; set; } + + [DwAlias("code")] + public string Reference { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } + + /// An alias that shadows nothing, as the control. + public class Dx7Aliased + { + public int Id { get; set; } + + [DwAlias("reference")] + public string Number { get; set; } = string.Empty; + } + + public sealed class Dx7BlankAndAliasProbes + { + private readonly ITestOutputHelper _out; + + public Dx7BlankAndAliasProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException|{refusal.ErrorCode}|path={refusal.FieldPath}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}|{error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ---- the clauses round 6 did not cover --------------------------------------------------- + + private static object AggregateField(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = name, Alias = "total", Aggregator = Aggregator.Sumation } + } + } + }; + + private static object SummaryOrder(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } + }; + + private static object Having(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + }; + + private static object SegmentOrder(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } }, + Selects = new List { "Id" } + }; + + private static object SegmentCondition(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Selects = new List { "Id" } + }; + + private static readonly (string Clause, Func Build)[] Clauses = + { + ("aggregate-field", AggregateField), + ("summary-order", SummaryOrder), + ("having", Having), + ("segment-order", SegmentOrder), + ("segment-condition", SegmentCondition) + }; + + private static void Execute(IQueryable source, object request) + { + switch (request) + { + case Filter filter: + source.ToListDynamic(filter); + break; + + case Summary summary: + source.ToList(summary); + break; + + case Segment segment: + source.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + } + + private static void Execute(PolicyQueryable guarded, object request) + { + switch (request) + { + case Filter filter: + guarded.ToListDynamic(filter); + break; + + case Summary summary: + guarded.ToList(summary); + break; + + case Segment segment: + guarded.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + } + + // ========================================================================================= + // Claim 3, widened. + // ========================================================================================= + + [Fact] + public void A_blank_name_fails_alike_in_the_clauses_round_six_did_not_cover() + { + List diverged = new(); + + foreach (string blank in new[] { string.Empty, " ", "\t" }) + { + foreach ((string clause, Func build) in Clauses) + { + Exception? bare = Catch(() => + Execute(Array.Empty().AsQueryable(), build(blank))); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Exception? guarded = Catch(() => Execute( + Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(tier), Attributes()), + build(blank))); + + _out.WriteLine( + $"[{clause}/{tier}] blank='{blank.Replace("\t", "\\t")}' " + + $"guarded={Shape(guarded)} unguarded={Shape(bare)}"); + + if (!string.Equals(Shape(guarded), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"{tier}/{clause}/'{blank}': {Shape(guarded)} vs {Shape(bare)}"); + } + } + } + } + + foreach (string line in diverged) + { + _out.WriteLine($"DIVERGED {line}"); + } + + Assert.Empty(diverged); + } + + // ========================================================================================= + // Claim 4, both halves. + // ========================================================================================= + + [Fact] + public void An_alias_spelled_like_another_member_is_a_validate_model_error() + { + foreach (Type type in new[] { typeof(Dx7Shadowing), typeof(Dx7ShadowingByCase) }) + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { type }); + + _out.WriteLine($"[{type.Name}] valid={report.IsValid}"); + + foreach (string error in report.Errors) + { + _out.WriteLine($" error: {error}"); + } + + Assert.False(report.IsValid); + Assert.Contains(report.Errors, e => e.Contains("Reference") && e.Contains("Code")); + } + + // The control: an alias that shadows nothing is still fine. + PolicyModelReport clean = PolicyModelValidator.Inspect(new[] { typeof(Dx7Aliased) }); + + _out.WriteLine($"[Dx7Aliased] valid={clean.IsValid}"); + + Assert.True(clean.IsValid); + } + + /// + /// A generated row keeps both columns under their own names, for a deployment that never + /// ran the scan. + /// + [Fact] + public void A_generated_row_keeps_both_columns_under_their_own_names() + { + foreach (Type type in new[] { typeof(Dx7Shadowing), typeof(Dx7ShadowingByCase) }) + { + IDictionary row = type == typeof(Dx7Shadowing) + ? Dynamic(new[] { new Dx7Shadowing { Id = 1, Reference = "R-1", Code = "C-1" } }) + : Dynamic(new[] { new Dx7ShadowingByCase { Id = 1, Reference = "R-1", Code = "C-1" } }); + + _out.WriteLine( + $"[{type.Name}] columns: {string.Join(", ", row.Select(c => $"{c.Key}={c.Value}"))}"); + + // Both values survive, each under the name of the member that holds it. + Assert.Equal("R-1", row["Reference"]); + Assert.Equal("C-1", row["Code"]); + } + } + + /// The control: an alias that shadows nothing still renames the column. + [Fact] + public void An_alias_that_shadows_nothing_still_renames_the_column() + { + IDictionary row = + Dynamic(new[] { new Dx7Aliased { Id = 1, Number = "N-1" } }); + + _out.WriteLine($"[Dx7Aliased] columns: {string.Join(", ", row.Select(c => $"{c.Key}={c.Value}"))}"); + + Assert.Equal("N-1", row["reference"]); + Assert.False(row.ContainsKey("Number")); + } + + private static IDictionary Dynamic(T[] rows) where T : class + { + DwPolicyOptions options = new() { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }; + + dynamic first = rows.AsQueryable() + .ApplyPolicy(Caller(), options, Attributes()) + .ToListDynamic(new Filter()) + .Data[0]; + + return first is ExpandoObject expando + ? (IDictionary)expando + : ((object)first).GetType() + .GetProperties() + .ToDictionary(p => p.Name, p => (object?)p.GetValue((object)first)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs b/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs new file mode 100644 index 0000000..7c8a7a7 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs @@ -0,0 +1,128 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.AspNetCore; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // The AspNetCore package's own 3.3.0 release note says "No API or behaviour change in this + // package", while the core's 3.3.0 note ends the [DwAudit] paragraph with "raise the cap or + // drain per request with app.UseDwPolicyAudit()". This measures what that package's surface + // actually does with a request that names no projection. + // ============================================================================================= + + public sealed class Dx7MiddlewareSurfaceProbes + { + private readonly ITestOutputHelper _out; + + public Dx7MiddlewareSurfaceProbes(ITestOutputHelper output) => _out = output; + + private sealed class Recorder : IDwAuditSink + { + internal List Written { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + Written.Add(auditEvent); + + return default; + } + } + + private sealed class Recording : ILogger + { + internal List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } + + /// + /// Runs a guarded query that names no projection, then drains the context the way the + /// middleware does for a request. + /// + private static async Task<(List Written, List<(LogLevel Level, string Message)> Logged)> + RequestWithNoSelects(bool registerSink) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } } + .AsQueryable() + .ApplyPolicy( + context, + new DwPolicyOptions { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + Recorder sink = new(); + ServiceCollection services = new(); + + if (registerSink) + { + services.AddSingleton(sink); + } + + Recording logger = new(); + DefaultHttpContext http = new() { RequestServices = services.BuildServiceProvider() }; + + http.Features.Set(context); + + await new DwPolicyAuditMiddleware(_ => Task.CompletedTask, logger).InvokeAsync(http); + + return (sink.Written, logger.Entries); + } + + /// + /// A request that names no projection now reaches the sink this package drains to. + /// + [Fact] + public async Task A_request_naming_no_projection_reaches_the_sink_this_package_drains_to() + { + (List written, _) = await RequestWithNoSelects(registerSink: true); + + _out.WriteLine($"written: {string.Join("; ", written.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + DwAuditEvent recorded = Assert.Single(written); + + Assert.Equal("Email", recorded.FieldPath); + Assert.Equal(PolicyFeature.Select, recorded.Feature); + } + + /// + /// And the same request, on a deployment with no sink, now trips the middleware's warning + /// where it recorded nothing to warn about before. + /// + [Fact] + public async Task The_same_request_with_no_sink_now_trips_the_middleware_warning() + { + (_, List<(LogLevel Level, string Message)> logged) = await RequestWithNoSelects(registerSink: false); + + foreach ((LogLevel level, string message) in logged) + { + _out.WriteLine($"{level}: {message}"); + } + + Assert.Contains(logged, entry => entry.Level == LogLevel.Warning); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs b/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs new file mode 100644 index 0000000..905a26e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs @@ -0,0 +1,462 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 2: "The four refusals that report FieldPath "*" under Strict: AmbiguousGroupKey, + // MissingHashSalt, MissingTokenVault carry no SourceOrigin; TransformRequiresMaterialization + // keeps an origin naming the method, and its list is every transformed column, not every masked + // one. An ambiguous name is refused as an unknown name is. All four honour a dry run declared by + // either switch, and all four still record the real field through AuditRefusals." + // ============================================================================================= + + /// A generalized column a caller names only by its alias. + public class Dx7Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// Hashed, so the missing-salt refusal is reachable. + public class Dx7Hashed + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// Tokenized, so the missing-vault refusal is reachable. + public class Dx7Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// + /// One column of each transform kind, so the materialization refusal's list can be read for + /// what it actually contains. + /// + public class Dx7EveryTransform + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + [DwNoOrder] + public string Masked { get; set; } = string.Empty; + + [DwGeneralize(GeneralizeMode.Round, Step = 10)] + [DwNoOrder] + public decimal Generalized { get; set; } + + [DwTruncate(3)] + [DwNoOrder] + public string Truncated { get; set; } = string.Empty; + + [DwFormat("0.00")] + [DwNoOrder] + public decimal Formatted { get; set; } + + /// Nothing at all, so the list is not simply "every member". + public string Plain { get; set; } = string.Empty; + } + + /// Two members answering to one public name. + public class Dx7Ambiguous + { + public int Id { get; set; } + + [DwAlias("label")] + public string First { get; set; } = string.Empty; + + [DwAlias("label")] + public string Second { get; set; } = string.Empty; + } + + public sealed class Dx7RefusalShapeProbes + { + private readonly ITestOutputHelper _out; + + public Dx7RefusalShapeProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options( + DwTier tier = DwTier.Strict, bool dryRun = false, bool auditRefusals = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = auditRefusals, + Caps = { MinGroupSize = 1 } + }; + + private static PolicyException Refusal(Action run) + { + try + { + run(); + } + catch (PolicyException refusal) + { + return refusal; + } + + throw new Xunit.Sdk.XunitException("the request was not refused"); + } + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static string Shape(PolicyException r) => + $"{r.ErrorCode}|path={r.FieldPath}|feature={r.Feature}|origin={r.SourceOrigin ?? "-"}"; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + // ---- the four requests ------------------------------------------------------------------- + + private static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + private static void GroupKeyCollision(DwPolicyContext context, DwPolicyOptions options) => + new[] + { + new Dx7Banded { Id = 1, Payroll = 100m }, + new Dx7Banded { Id = 2, Payroll = 149m } + } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(ByBand()); + + private static void HashWithNoSalt(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7Hashed { Id = 1, NationalId = "x" } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + private static void TokenizeWithNoVault(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7Tokenized { Id = 1, NationalId = "x" } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + private static void Unmaterialized(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7EveryTransform { Id = 1 } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .SelectDynamic(new List { "Id" }); + + private static readonly (string Name, PolicyErrorCode Code, Action Run)[] Four = + { + ("AmbiguousGroupKey", PolicyErrorCode.AmbiguousGroupKey, GroupKeyCollision), + ("MissingHashSalt", PolicyErrorCode.MissingHashSalt, HashWithNoSalt), + ("MissingTokenVault", PolicyErrorCode.MissingTokenVault, TokenizeWithNoVault), + ("TransformRequiresMaterialization", PolicyErrorCode.TransformRequiresMaterialization, Unmaterialized) + }; + + // ========================================================================================= + // Under Strict: "*", and an origin only on the transform refusal. + // ========================================================================================= + + [Fact] + public void All_four_report_star_under_strict_and_only_one_keeps_an_origin() + { + foreach ((string name, PolicyErrorCode code, Action run) in Four) + { + DwPolicyContext context = Caller(); + PolicyException refusal = Refusal(() => run(context, Options())); + + _out.WriteLine($"[strict] {name}: {Shape(refusal)}"); + + Assert.Equal(code, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + + if (code == PolicyErrorCode.TransformRequiresMaterialization) + { + // Kept, and it names the method and what to call instead, never a field. + Assert.NotNull(refusal.SourceOrigin); + Assert.Contains(nameof(PolicyQueryable.SelectDynamic), refusal.SourceOrigin!); + Assert.Contains("ToListDynamic", refusal.SourceOrigin!); + Assert.DoesNotContain("Masked", refusal.SourceOrigin!); + } + else + { + Assert.Null(refusal.SourceOrigin); + } + } + } + + /// + /// The transform refusal's list is every transformed column, not every masked one. + /// + /// + /// Read on the convenience tier, where the list is the caller-facing path, and again from + /// the strict refusal's audit path, where it is what reaches a sink. + /// + [Fact] + public void The_transform_refusal_lists_every_transformed_column_not_every_masked_one() + { + DwPolicyContext lenient = Caller(); + PolicyException open = Refusal(() => Unmaterialized(lenient, Options(DwTier.Convenience))); + + _out.WriteLine($"[convenience] path = {open.FieldPath}"); + + foreach (string column in new[] { "Masked", "Generalized", "Truncated", "Formatted" }) + { + Assert.Contains(column, open.FieldPath); + } + + // A column with no transform at all is not in the list. + Assert.DoesNotContain("Plain", open.FieldPath); + + DwPolicyContext strict = Caller(); + + Assert.Throws(() => Unmaterialized(strict, Options(auditRefusals: true))); + + _out.WriteLine($"[strict, audited] {Recorded(strict)}"); + + string audited = Assert.Single(strict.PendingAuditEvents).FieldPath; + + foreach (string column in new[] { "Masked", "Generalized", "Truncated", "Formatted" }) + { + Assert.Contains(column, audited); + } + } + + // ========================================================================================= + // A dry run, declared by either switch, names the field again. + // ========================================================================================= + + [Fact] + public void All_four_honour_a_dry_run_declared_by_either_switch() + { + foreach ((string name, PolicyErrorCode code, Action run) in Four) + { + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + PolicyException refusal = Refusal(() => run(context, options)); + + _out.WriteLine($"[dry:{leg}] {name}: {Shape(refusal)}"); + + Assert.Equal(code, refusal.ErrorCode); + + // Named again, exactly as the convenience tier names it. + Assert.NotEqual("*", refusal.FieldPath); + Assert.NotEmpty(refusal.FieldPath); + } + } + } + + // ========================================================================================= + // AuditRefusals keeps the real field for all four. + // ========================================================================================= + + [Fact] + public void All_four_record_the_real_field_through_audit_refusals() + { + (string Name, string Expected)[] expected = + { + ("AmbiguousGroupKey", "Payroll"), + ("MissingHashSalt", "NationalId"), + ("MissingTokenVault", "NationalId"), + ("TransformRequiresMaterialization", "Masked") + }; + + for (int i = 0; i < Four.Length; i++) + { + DwPolicyContext context = Caller(); + + Assert.Throws(() => Four[i].Run(context, Options(auditRefusals: true))); + + _out.WriteLine($"[audited] {Four[i].Name}: {Recorded(context)}"); + + string path = Assert.Single(context.PendingAuditEvents).FieldPath; + + Assert.NotEqual("*", path); + Assert.Contains(expected[i].Expected, path); + } + } + + // ========================================================================================= + // An ambiguous name is refused as an unknown name is. + // ========================================================================================= + + [Fact] + public void An_ambiguous_name_is_refused_as_an_unknown_name_is_under_strict() + { + static Filter Named(string field) => new() + { + Selects = new List { "Id" }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + static PolicyException Ask(DwPolicyContext context, DwPolicyOptions options, string field) => + Refusal(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(Named(field))); + + PolicyException ambiguous = Ask(Caller(), Options(), "label"); + PolicyException unknown = Ask(Caller(), Options(), "NoSuchColumn"); + + _out.WriteLine($"[strict] ambiguous : {Shape(ambiguous)}"); + _out.WriteLine($"[strict] unknown : {Shape(unknown)}"); + + // The same refusal, indistinguishable at the caller. + Assert.Equal(unknown.ErrorCode, ambiguous.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, ambiguous.ErrorCode); + Assert.Equal("*", ambiguous.FieldPath); + Assert.Null(ambiguous.SourceOrigin); + Assert.Null(ambiguous.RuleId); + + // Convenience still says what it is. + PolicyException open = Ask(Caller(), Options(DwTier.Convenience), "label"); + + _out.WriteLine($"[convenience] ambiguous : {Shape(open)}"); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, open.ErrorCode); + + // And a dry run, either switch, says what it is too. + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + PolicyException dry = Ask(context, options, "label"); + + _out.WriteLine($"[dry:{leg}] ambiguous : {Shape(dry)}"); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, dry.ErrorCode); + } + } + + /// What an ambiguous name records, once it is refused as an unknown name is. + [Fact] + public void An_ambiguous_name_records_the_name_the_caller_wrote() + { + DwPolicyContext context = Caller(); + + Assert.Throws(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Options(auditRefusals: true), Attributes()) + .ToList(new Filter + { + Selects = new List { "Id" }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "label", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + })); + + _out.WriteLine($"[audited] ambiguous : {Recorded(context)}"); + + // Recorded as the caller sent it, as a name matching nothing is — not as either of the + // two canonical paths it could have meant. + Assert.Equal("label", Assert.Single(context.PendingAuditEvents).FieldPath); + } + + /// The trace keeps the ambiguity for the operator, as every document promises. + [Fact] + public void The_trace_keeps_the_ambiguity_under_strict() + { + PolicyQueryable guarded = Array.Empty() + .AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()); + + Exception? error = Catch(() => guarded.ToList(new Filter + { + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 0, Field = "label", Direction = Direction.Ascending } } + })); + + Assert.IsType(error); + + string reasons = string.Join( + " | ", + guarded.LastTrace!.Decisions.Select(d => $"{d.FieldPath}:{d.Action}:{d.Reason}")); + + _out.WriteLine($"trace: {reasons}"); + + Assert.Contains("First", reasons); + Assert.Contains("Second", reasons); + } + } +} diff --git a/DynamicWhere.Tests/Policies/EfStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/EfStoreConformanceTests.cs index 75c090d..0f45b02 100644 --- a/DynamicWhere.Tests/Policies/EfStoreConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/EfStoreConformanceTests.cs @@ -52,7 +52,7 @@ protected override IDwPolicyWritableStore CreateStore(Func? resol public sealed class PostgresStoreConformanceTests : PolicyStoreConformanceTests, IAsyncLifetime { private readonly PostgreSqlContainer _server = - new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); + new PostgreSqlBuilder("postgres:16-alpine").Build(); private Func? _contexts; diff --git a/DynamicWhere.Tests/Policies/EfTokenVaultConformanceTests.cs b/DynamicWhere.Tests/Policies/EfTokenVaultConformanceTests.cs index 3fecc3a..d675936 100644 --- a/DynamicWhere.Tests/Policies/EfTokenVaultConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/EfTokenVaultConformanceTests.cs @@ -1,5 +1,6 @@ using DynamicWhere.ex.Policies.EntityFrameworkCore; using DynamicWhere.ex.Policies.Tokens; +using Microsoft.EntityFrameworkCore; namespace DynamicWhere.Tests.Policies; @@ -21,3 +22,115 @@ public sealed class SqliteTokenVaultConformanceTests : DurableTokenVaultConforma /// Closes the database, which is what destroys it. public void Dispose() => _database.Dispose(); } + +/// +/// The durable suite against a vault that holds a key, plus what the key is for and what it must not +/// break: the tokens an unkeyed vault already issued. +/// +public sealed class KeyedSqliteTokenVaultConformanceTests : DurableTokenVaultConformanceTests, IDisposable +{ + private static readonly byte[] Key = Enumerable.Range(1, 32).Select(i => (byte)i).ToArray(); + private static readonly byte[] Other = Enumerable.Range(101, 32).Select(i => (byte)i).ToArray(); + + private readonly SqlitePolicyDatabase _database = new(); + + /// + protected override IDwTokenVault CreateVault() => new EfTokenVault(() => _database.Create(), Key); + + /// Stops the database. + public void Dispose() => _database.Dispose(); + + private List StoredKeys() + { + using DwPolicyDbContext db = _database.Create(); + + return db.Set().Select(row => row.Key).ToList().OrderBy(key => key, StringComparer.Ordinal).ToList(); + } + + [Fact] + public void The_table_holds_no_plain_digest_of_the_value() + { + CreateVault().GetOrCreate(Scope, "07701234567"); + + string stored = Assert.Single(StoredKeys()); + + Assert.Equal(DwToken.KeyFor(Scope, "07701234567", Key), stored); + Assert.StartsWith(DwToken.KeyedPrefix, stored, StringComparison.Ordinal); + Assert.NotEqual(DwToken.KeyFor(Scope, "07701234567"), stored); + } + + /// A deployment that adds a key keeps every token it has handed out. + [Fact] + public void A_value_keeps_the_token_an_unkeyed_vault_gave_it() + { + string issued = new EfTokenVault(() => _database.Create()).GetOrCreate(Scope, "AAA-000123"); + + Assert.Equal(issued, CreateVault().GetOrCreate(Scope, "AAA-000123")); + + // Both rows, until the vault is told every instance holds the key: one still running without + // it reads the unkeyed row, and must go on finding the same token there. + Assert.Equal( + new[] { DwToken.KeyFor(Scope, "AAA-000123"), DwToken.KeyFor(Scope, "AAA-000123", Key) }.OrderBy(key => key, StringComparer.Ordinal), + StoredKeys()); + + Assert.Equal(issued, new EfTokenVault(() => _database.Create()).GetOrCreate(Scope, "AAA-000123")); + } + + [Fact] + public void Retiring_deletes_the_unkeyed_row_and_keeps_the_token() + { + string issued = new EfTokenVault(() => _database.Create()).GetOrCreate(Scope, "AAA-000123"); + + EfTokenVault retiring = new(() => _database.Create(), Key, retireUnkeyed: true); + + Assert.Equal(issued, retiring.GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(DwToken.KeyFor(Scope, "AAA-000123", Key), Assert.Single(StoredKeys())); + + // A cold vault finds it under the key alone. + Assert.Equal(issued, CreateVault().GetOrCreate(Scope, "AAA-000123")); + } + + /// + /// The order a deployment does it in: every instance takes the key first, and only then is the vault + /// told to retire. By then the active values already have their keyed rows, and those are the + /// unkeyed rows that most need to go. + /// + [Fact] + public void Retiring_reaches_a_value_adopted_before_retiring_began() + { + string issued = new EfTokenVault(() => _database.Create()).GetOrCreate(Scope, "AAA-000123"); + + Assert.Equal(issued, CreateVault().GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(2, StoredKeys().Count); + + Assert.Equal(issued, new EfTokenVault(() => _database.Create(), Key, retireUnkeyed: true).GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(DwToken.KeyFor(Scope, "AAA-000123", Key), Assert.Single(StoredKeys())); + } + + [Fact] + public void A_value_with_no_unkeyed_row_is_not_given_one() + { + new EfTokenVault(() => _database.Create(), Key, retireUnkeyed: true).GetOrCreate(Scope, "BBB-1"); + CreateVault().GetOrCreate(Scope, "BBB-2"); + + Assert.All(StoredKeys(), key => Assert.StartsWith(DwToken.KeyedPrefix, key, StringComparison.Ordinal)); + } + + /// Stated as a test because it is the hazard of changing a key: every value is met for the first time again. + [Fact] + public void Another_key_is_another_vault() + { + string first = CreateVault().GetOrCreate(Scope, "AAA-000123"); + string second = new EfTokenVault(() => _database.Create(), Other).GetOrCreate(Scope, "AAA-000123"); + + Assert.NotEqual(first, second); + } + + [Fact] + public void A_key_that_is_absent_or_short_is_refused() + { + Assert.Throws(() => new EfTokenVault(() => _database.Create(), null!)); + Assert.Throws(() => new EfTokenVault(() => _database.Create(), new byte[8])); + Assert.Throws(() => new EfTokenVault(null!, Key)); + } +} diff --git a/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs b/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs new file mode 100644 index 0000000..91e7ba4 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs @@ -0,0 +1,658 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Tokens; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// Round six of the docs-against-code probe for 3.3.0. Every claim is read by running the library. +// Nothing here fixes anything, and nothing here drives DwPolicy's static fields, by reflection or +// otherwise: every handle is built from its own DwPolicyOptions and its own resolver. + +namespace DynamicWhere.Tests.Policies +{ + // ---- models --------------------------------------------------------------------------------- + + /// + /// A type whose transforms are NOT all masks: one truncation, one generalization, one mask. + /// + /// + /// The point the README and the security page make is that the refusal listed "every masked + /// column". If a truncated-only and a generalized-only column appear in the list too, the list + /// is of transformed columns, which is what DOC.md, the release notes and the breaking-changes + /// page say. + /// + internal class Hx6Staffer + { + public int Id { get; set; } + + public string Department { get; set; } = string.Empty; + + /// Shortened, never masked. + [DwTruncate(3)] + public string Note { get; set; } = string.Empty; + + /// Rounded, never masked. + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + public decimal Salary { get; set; } + + /// The one column that really is masked. + [DwMask(MaskStrategy.Full)] + public string NationalId { get; set; } = string.Empty; + } + + /// Two rows whose keys collide once rounded, so the grouping key is ambiguous. + internal class Hx6Banded + { + public int Id { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + public decimal Band { get; set; } + } + + /// A hashed column with no salt configured, and a tokenized one with no vault. + internal class Hx6Secret + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + public string Hashed { get; set; } = string.Empty; + } + + internal class Hx6Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + public string Ticket { get; set; } = string.Empty; + } + + /// An audited field, recorded for every feature. + internal class Hx6Audited + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + /// A line of an order, with a getter no database computes. + public class Hx6Line + { + public int Id { get; set; } + + public string Sku { get; set; } = string.Empty; + + public int Hx6OrderId { get; set; } + + /// Computed in memory from the column beside it. + public bool IsBlank => Sku.Length == 0; + } + + public class Hx6Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public List Lines { get; set; } = new(); + } + + /// The row a caller projects before the guard sees it. + public class Hx6Row + { + public int Id { get; set; } + + public List Lines { get; set; } = new(); + } + + /// The same row, but its lines are rebuilt by a subquery of its own. + public class Hx6LineRow + { + public string Sku { get; set; } = string.Empty; + + public bool IsBlank => Sku.Length == 0; + } + + public class Hx6BuiltRow + { + public int Id { get; set; } + + public List Lines { get; set; } = new(); + } + + public sealed class Hx6Context : DbContext + { + private readonly SqliteConnection _connection; + + public Hx6Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + // ---- shared plumbing -------------------------------------------------------------------------- + + internal static class Hx6 + { + private static readonly MethodInfo OfMethod = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + + private static readonly MethodInfo ExpressesMethod = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!; + + /// What the shape of a source says about a path: true, false, or "cannot say". + internal static bool? Ask(IQueryable source, string path) + { + object shape = OfMethod.MakeGenericMethod(source.ElementType).Invoke(null, new object[] { source })!; + + return (bool?)ExpressesMethod.Invoke(shape, new object[] { path }); + } + + internal static string Show(bool? answer) => answer switch + { + null => "null (left alone)", + true => "true (nothing to refuse)", + _ => "false (refused)" + }; + + internal static DwPolicyOptions Options( + DwTier tier, + bool dryRun = false, + string? salt = null, + IDwTokenVault? vault = null, + int? floor = null) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun }; + + if (floor is { } size) + { + // The shipped floor is 5, which removes a group of one before anything collides. + options.Caps.MinGroupSize = size; + } + + if (salt is not null) + { + options.HashSalt = salt; + } + + if (vault is not null) + { + options.TokenVault = vault; + } + + options.Freeze(); + + return options; + } + + internal static PolicyQueryable Guard( + IQueryable source, DwPolicyOptions options, DwPolicyContext? context = null) + where T : class => + source.ApplyPolicy( + context ?? new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, + DataType = DataType.Text, + Operator = Operator.Equal, + Values = { value } + } + } + } + }; + + internal static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + internal static string Origin(PolicyException error) => + error.SourceOrigin is null ? "" : $"\"{error.SourceOrigin}\""; + } + + // ---- 1. the four refusals that name the clause ------------------------------------------------ + + /// + /// Whether each of the four really reports "*" and no origin under Strict. + /// + public class Hx6NamesTheClauseProbe + { + private readonly ITestOutputHelper _out; + + public Hx6NamesTheClauseProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Staffer[] Staff() => new[] + { + new Hx6Staffer { Id = 1, Department = "Ops", Note = "abcdef", Salary = 120m, NationalId = "A1" } + }; + + private static Hx6Banded[] Banded() => new[] + { + new Hx6Banded { Id = 1, Band = 100m }, + new Hx6Banded { Id = 2, Band = 149m } + }; + + /// + /// TransformRequiresMaterialization under Strict: the field path, and the origin. + /// + [Fact] + public void Transform_requires_materialization_reports_star_and_an_origin() + { + PolicyQueryable guarded = + Hx6.Guard(Staff().AsQueryable(), Hx6.Options(DwTier.Strict, salt: new string('s', 16))); + + PolicyException error = Assert.Throws( + () => guarded.SelectDynamic(new List { "Id" })); + + _out.WriteLine($"code = {error.ErrorCode}"); + _out.WriteLine($"FieldPath = \"{error.FieldPath}\""); + _out.WriteLine($"origin = {Hx6.Origin(error)}"); + + Assert.Equal(PolicyErrorCode.TransformRequiresMaterialization, error.ErrorCode); + Assert.Equal("*", error.FieldPath); + + // The documents that say "the three report '*' and no origin" are wrong about this one. + Assert.NotNull(error.SourceOrigin); + } + + /// + /// And the other three really do carry no origin, so the difference is this one refusal. + /// + [Fact] + public void The_other_three_report_star_with_no_origin() + { + PolicyException group = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Strict, floor: 1)).ToList(Hx6.ByBand())); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict)) + .ToList(new Filter())); + + PolicyException vault = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Tokenized { Id = 1, Ticket = "T-1" } }.AsQueryable(), + Hx6.Options(DwTier.Strict)) + .ToList(new Filter())); + + foreach (PolicyException error in new[] { group, salt, vault }) + { + _out.WriteLine($"{error.ErrorCode,-34} FieldPath=\"{error.FieldPath}\" origin={Hx6.Origin(error)}"); + + Assert.Equal("*", error.FieldPath); + Assert.Null(error.SourceOrigin); + } + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, group.ErrorCode); + Assert.Equal(PolicyErrorCode.MissingHashSalt, salt.ErrorCode); + Assert.Equal(PolicyErrorCode.MissingTokenVault, vault.ErrorCode); + } + + /// + /// Convenience is unchanged: each names the field, and the list is of transformed columns. + /// + [Fact] + public void Convenience_still_names_the_field() + { + PolicyException listed = Assert.Throws( + () => Hx6.Guard(Staff().AsQueryable(), Hx6.Options(DwTier.Convenience, salt: new string('s', 16))) + .SelectDynamic(new List { "Id" })); + + PolicyException group = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Convenience, floor: 1)).ToList(Hx6.ByBand())); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Convenience)) + .ToList(new Filter())); + + _out.WriteLine($"TransformRequiresMaterialization FieldPath = \"{listed.FieldPath}\""); + _out.WriteLine($"AmbiguousGroupKey FieldPath = \"{group.FieldPath}\" origin={Hx6.Origin(group)}"); + _out.WriteLine($"MissingHashSalt FieldPath = \"{salt.FieldPath}\""); + + // Every transformed column, not every masked one: Note is truncated and Salary rounded. + Assert.Contains("Note", listed.FieldPath); + Assert.Contains("Salary", listed.FieldPath); + Assert.Contains("NationalId", listed.FieldPath); + + Assert.Equal("Band", group.FieldPath); + Assert.Equal("Hashed", salt.FieldPath); + } + + /// + /// The global dry run is unchanged — and so, the documents say, is "a dry run". The + /// per-context one is the other half of what the library calls a dry run everywhere else. + /// + [Fact] + public void A_dry_run_names_the_field_whichever_switch_declares_it() + { + PolicyException global = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Strict, dryRun: true, floor: 1)) + .ToList(Hx6.ByBand())); + + PolicyException percall = Assert.Throws( + () => Hx6.Guard( + Banded().AsQueryable(), + Hx6.Options(DwTier.Strict, floor: 1), + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1")) + .ToList(Hx6.ByBand())); + + _out.WriteLine($"options.DryRun = true -> FieldPath=\"{global.FieldPath}\" origin={Hx6.Origin(global)}"); + _out.WriteLine($"context.DryRun = true -> FieldPath=\"{percall.FieldPath}\" origin={Hx6.Origin(percall)}"); + + Assert.Equal("Band", global.FieldPath); + Assert.NotNull(global.SourceOrigin); + + // A dry run is either switch, the posture's or the caller's, as it is everywhere else in + // the layer: a canary subject previewing a posture must not meet the hidden refusal. + Assert.Equal("Band", percall.FieldPath); + Assert.NotNull(percall.SourceOrigin); + } + + /// + /// The per-context dry run is honoured by the refusal the same page describes one section + /// earlier, so the two are not consistent with each other. + /// + [Fact] + public void A_context_dry_run_is_honoured_by_the_unknown_name_gate() + { + // Strict, but the caller's own context is the canary: the gate stops hiding existence, + // so an unknown name fails as it would unguarded rather than as a field refusal. + LogicException unguarded = Assert.Throws( + () => Hx6.Guard( + Staff().AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16)), + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1")) + .ToList(Hx6.Where("NoSuchField", "x"))); + + _out.WriteLine($"context dry run, unknown name -> {unguarded.GetType().Name}: {unguarded.Message}"); + + // Without it, the same name is a field refusal naming nothing. + PolicyException hidden = Assert.Throws( + () => Hx6.Guard( + Staff().AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16))) + .ToList(Hx6.Where("NoSuchField", "x"))); + + _out.WriteLine($"no dry run, unknown name -> {hidden.ErrorCode} \"{hidden.FieldPath}\""); + } + } + + // ---- 2. an ambiguous name ----------------------------------------------------------------------- + + /// A type with one alias pointing at two different members. + internal class Hx6Ambiguous + { + public int Id { get; set; } + + [DwAlias("ref")] + public string Primary { get; set; } = string.Empty; + + [DwAlias("ref")] + public string Secondary { get; set; } = string.Empty; + } + + public class Hx6AmbiguousNameProbe + { + private readonly ITestOutputHelper _out; + + public Hx6AmbiguousNameProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Ambiguous[] Rows() => new[] + { + new Hx6Ambiguous { Id = 1, Primary = "a", Secondary = "b" } + }; + + /// + /// Under Strict the ambiguous name is refused as an unknown name is, and the trace names the + /// fields it matched. + /// + [Fact] + public void An_ambiguous_name_is_refused_as_an_unknown_name_is() + { + PolicyQueryable guarded = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict)); + + PolicyException ambiguous = Assert.Throws( + () => guarded.ToList(Hx6.Where("ref", "a"))); + + PolicyQueryable second = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict)); + + PolicyException unknown = Assert.Throws( + () => second.ToList(Hx6.Where("NoSuchField", "a"))); + + _out.WriteLine($"ambiguous: {ambiguous.ErrorCode} \"{ambiguous.FieldPath}\" origin={Hx6.Origin(ambiguous)}"); + _out.WriteLine($"unknown : {unknown.ErrorCode} \"{unknown.FieldPath}\" origin={Hx6.Origin(unknown)}"); + + Assert.Equal(unknown.ErrorCode, ambiguous.ErrorCode); + Assert.Equal(unknown.FieldPath, ambiguous.FieldPath); + Assert.Equal(unknown.SourceOrigin, ambiguous.SourceOrigin); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + + foreach (PolicyDecision decision in trace!.Decisions) + { + _out.WriteLine($" trace: {decision.FieldPath} {decision.Action} — {decision.Reason}"); + } + + Assert.Contains( + trace.Decisions, + decision => decision.Reason is not null + && decision.Reason.Contains("Primary") + && decision.Reason.Contains("Secondary")); + } + + /// Convenience still answers AmbiguousFieldName. + [Fact] + public void Convenience_still_answers_ambiguous_field_name() + { + PolicyException error = Assert.Throws( + () => Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Convenience)) + .ToList(Hx6.Where("ref", "a"))); + + _out.WriteLine($"{error.ErrorCode} \"{error.FieldPath}\""); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, error.ErrorCode); + } + } + + // ---- 3. what [DwAudit] records ------------------------------------------------------------------ + + public class Hx6AuditProbe + { + private readonly ITestOutputHelper _out; + + public Hx6AuditProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Audited[] Rows() => new[] + { + new Hx6Audited { Id = 1, Code = "c1", NationalId = "A-1" } + }; + + /// + /// A request that sends no Selects reads the audited field and records nothing. + /// + [Fact] + public void A_request_with_no_selects_reads_the_field_and_records_it() + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + FilterResult result = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), context).ToList(new Filter()); + + _out.WriteLine($"rows = {result.Data.Count}"); + _out.WriteLine($"NationalId = \"{result.Data[0].NationalId}\""); + _out.WriteLine($"audit events = {context.PendingAuditEvents.Count}"); + + Assert.Equal("A-1", result.Data[0].NationalId); + + // The caller receives the value, so the log says so: a use is what the request reads, + // not only what it spells out. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + } + + /// Naming the field records it, which is the documented way to be recorded. + [Fact] + public void Naming_the_field_records_it() + { + DwPolicyContext named = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), named) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + DwPolicyContext filtered = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), filtered) + .ToList(Hx6.Where("NationalId", "A-1")); + + foreach (DwAuditEvent recorded in named.PendingAuditEvents) + { + _out.WriteLine($"Selects -> {recorded.FieldPath} {recorded.Feature}"); + } + + foreach (DwAuditEvent recorded in filtered.PendingAuditEvents) + { + _out.WriteLine($"Where -> {recorded.FieldPath} {recorded.Feature}"); + } + + Assert.Contains(named.PendingAuditEvents, e => e.FieldPath == "NationalId"); + Assert.Contains(filtered.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + } + + // ---- 4. the member a sequence operator hides ---------------------------------------------------- + + public class Hx6SequenceOperatorProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Hx6Context _db; + + public Hx6SequenceOperatorProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("Filename=:memory:"); + _connection.Open(); + _db = new Hx6Context(_connection); + _db.Database.EnsureCreated(); + + _db.Orders.Add(new Hx6Order + { + Id = 1, + Code = "o1", + Lines = { new Hx6Line { Id = 1, Sku = "s1" } } + }); + + _db.SaveChanges(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// Each of the three shapes the documents name, and the entity for contrast. + /// + [Fact] + public void A_member_assigned_through_a_sequence_operator_is_left_alone() + { + IQueryable plain = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.ToList() }); + + IQueryable filtered = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.Where(l => l.Sku != "").ToList() }); + + IQueryable built = _db.Orders + .Select(o => new Hx6BuiltRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new Hx6LineRow { Sku = l.Sku }).ToList() + }); + + bool? plainAnswer = Hx6.Ask(plain, "Lines.IsBlank"); + bool? filteredAnswer = Hx6.Ask(filtered, "Lines.IsBlank"); + bool? builtAnswer = Hx6.Ask(built, "Lines.IsBlank"); + bool? entityAnswer = Hx6.Ask(_db.Orders, "Lines.IsBlank"); + + _out.WriteLine($"Lines = o.Lines.ToList() -> {Hx6.Show(plainAnswer)}"); + _out.WriteLine($"Lines = o.Lines.Where(...).ToList() -> {Hx6.Show(filteredAnswer)}"); + _out.WriteLine($"Lines = o.Lines.Select(l => new ...).ToList() -> {Hx6.Show(builtAnswer)}"); + _out.WriteLine($"the entity itself -> {Hx6.Show(entityAnswer)}"); + + Assert.Null(plainAnswer); + Assert.Null(filteredAnswer); + Assert.Null(builtAnswer); + + // The entity is the contrast: there the member is refused. + Assert.False(entityAnswer); + } + + /// + /// End to end: the entity refuses the path, the projection with the sequence operator does + /// not — it reaches the provider and fails there, as an unguarded query does. + /// + [Fact] + public void The_entity_refuses_and_the_projection_reaches_the_provider() + { + PolicyException refused = Assert.Throws( + () => Hx6.Guard(_db.Orders, Hx6.Options(DwTier.Strict)) + .ToList(Hx6.Where("Lines.IsBlank", "true"))); + + _out.WriteLine($"entity : {refused.ErrorCode} \"{refused.FieldPath}\""); + + IQueryable projected = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.ToList() }); + + Exception passed = Assert.ThrowsAny( + () => Hx6.Guard(projected, Hx6.Options(DwTier.Strict)) + .ToList(Hx6.Where("Lines.IsBlank", "true"))); + + _out.WriteLine($"projection : {passed.GetType().Name}"); + + Assert.IsNotType(passed); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs b/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs new file mode 100644 index 0000000..a458e90 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs @@ -0,0 +1,111 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// Which dry run each refusal honours. The library has two — the global switch and the caller's + /// own, whose union is what every other refusal calls "a dry run" — and the documents say only + /// "a dry run" for all of them. + /// + public class Hx6DryRunReachProbe + { + private readonly ITestOutputHelper _out; + + public Hx6DryRunReachProbe(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Canary() => + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1"); + + /// + /// The audit cap (breaking point 33) honours the caller's own dry run, because it asks the + /// gate. The four of point 34 ask the options directly. + /// + [Fact] + public void A_context_dry_run_is_honoured_by_the_audit_cap_and_by_the_four() + { + DwPolicyOptions capped = new() { Tier = DwTier.Strict }; + + capped.Caps.MaxAuditEvents = 1; + capped.Freeze(); + + Filter request = new() + { + Selects = new List { "Id", "NationalId", "Code" }, + Orders = new List { new() { Field = "NationalId", Direction = Direction.Ascending } } + }; + + PolicyException cap = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Audited { Id = 1, Code = "c", NationalId = "A-1" } }.AsQueryable(), + capped, + Canary()) + .ToList(request)); + + _out.WriteLine($"audit cap, context dry run -> {cap.ErrorCode} \"{cap.FieldPath}\" origin={Hx6.Origin(cap)}"); + + PolicyException transform = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Staffer { Id = 1, Note = "abcdef" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16)), + Canary()) + .SelectDynamic(new List { "Id" })); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict), + Canary()) + .ToList(new Filter())); + + _out.WriteLine($"TRM, context dry run -> {transform.ErrorCode} \"{transform.FieldPath}\""); + _out.WriteLine($"salt, context dry run -> {salt.ErrorCode} \"{salt.FieldPath}\""); + + // A canary subject previews the posture without meeting the refusals it hides, which is + // what a dry run is for — and a dry run is either switch, the posture's or the caller's. + Assert.Equal(PolicyErrorCode.CapExceeded, cap.ErrorCode); + Assert.NotEqual("*", transform.FieldPath); + Assert.NotEqual("*", salt.FieldPath); + } + + /// + /// The global switch is the one the four read, and there they do name the field, which is + /// the half of "a dry run is unchanged" that holds. + /// + [Fact] + public void The_global_dry_run_still_names_the_field_for_all_four() + { + PolicyException transform = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Staffer { Id = 1, Note = "abcdef" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true, salt: new string('s', 16))) + .SelectDynamic(new List { "Id" })); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true)) + .ToList(new Filter())); + + PolicyException vault = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Tokenized { Id = 1, Ticket = "T-1" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true)) + .ToList(new Filter())); + + _out.WriteLine($"TRM, global dry run -> \"{transform.FieldPath}\""); + _out.WriteLine($"salt, global dry run -> \"{salt.FieldPath}\""); + _out.WriteLine($"vault, global dry run -> \"{vault.FieldPath}\""); + + Assert.Equal("Note, Salary, NationalId", transform.FieldPath); + Assert.Equal("Hashed", salt.FieldPath); + Assert.Equal("Ticket", vault.FieldPath); + } + } +} diff --git a/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs b/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs index 24a2640..c31abbd 100644 --- a/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs +++ b/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs @@ -164,7 +164,12 @@ public async Task Without_a_type_resolver_the_upsert_is_accepted_and_the_rule_is public async Task A_watch_yields_the_version_on_every_write() { using InMemoryPolicyStore store = new(); - using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(10)); + + // Generous on purpose, and for the same reason the store conformance watch is: the reader + // runs on the thread pool, and a suite of three thousand tests can leave it waiting behind + // work that has nothing to do with this store. Only a watch that never reports waits this + // long. + using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(60)); IAsyncEnumerable watch = store.WatchAsync(cancel.Token)!; @@ -193,7 +198,10 @@ public async Task A_watch_yields_the_version_on_every_write() { await store.UpsertAsync(Rule(field: "Notes"), cancel.Token); - await Task.Delay(10, cancel.Token); + // Waits on the reader itself rather than on the clock, so the loop ends as soon as the + // watch has reported twice and does not go on writing while the reader waits to be + // scheduled. + await Task.WhenAny(reader, Task.Delay(10, cancel.Token)); } await reader; diff --git a/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs b/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs new file mode 100644 index 0000000..40f1092 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs @@ -0,0 +1,351 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- complex types, which only EF Core 8 has --------------------------------------------------------- + + /// A complex type: its members are columns of the owner's table, and it has a getter too. + public class OcxMoney + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + /// A getter over two columns: no database can answer it. + public bool IsFree => Amount == 0m; + } + + /// A complex type nested inside another one. + public class OcxAddress + { + public string City { get; set; } = string.Empty; + + public OcxPostCode Post { get; set; } = new(); + + public string Full => $"{City} {Post.Code}"; + } + + public class OcxPostCode + { + public string Code { get; set; } = string.Empty; + + public bool IsBlank => string.IsNullOrEmpty(Code); + } + + public class OcxInvoice + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + public OcxMoney Total { get; set; } = new(); + + public OcxAddress ShipTo { get; set; } = new(); + } + + /// A row a caller projects out of the invoice, complex members copied whole. + public class OcxRow + { + public int Id { get; set; } + + public OcxMoney Total { get; set; } = new(); + + public OcxAddress ShipTo { get; set; } = new(); + } + + public sealed class OcxContext : DbContext + { + private readonly SqliteConnection _connection; + + public OcxContext(SqliteConnection connection) => _connection = connection; + + public DbSet Invoices => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(invoice => invoice.Total); + model.Entity().ComplexProperty( + invoice => invoice.ShipTo, ship => ship.ComplexProperty(address => address.Post)); + } + } + + /// + /// EF Core 8 shapes the floor leg has no API for: complex properties, which + /// RowShape.ExpressesInComplex walks entirely through reflection, and the provider + /// identity of a second relational provider and a pooled factory. + /// + public sealed class Ob4ComplexProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly OcxContext _db; + private readonly List _findings = new(); + + public Ob4ComplexProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new OcxContext(_connection); + _db.Database.EnsureCreated(); + _db.Invoices.Add(new OcxInvoice + { + Number = "INV-1", + Total = new OcxMoney { Amount = 10m, Currency = "IQD" }, + ShipTo = new OcxAddress { City = "Baghdad", Post = new OcxPostCode { Code = "10001" } } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, Operator = Operator.Equal, Values = { value } + } + } + } + }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-60} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Ob4-H. Complex properties: the reflection walk must find every column, or a member that + // maps to one is refused as if it were a getter. + // ========================================================================================= + + [Fact] + public void Ob4_H_A_complex_types_columns_are_found_by_the_reflection_walk() + { + Case("H1 column of a complex property", + Raw(() => _db.Invoices.Where(i => i.Total.Currency == "IQD").ToList()), + Guarded(_db.Invoices, "Total.Currency", DataType.Text, "IQD")); + + Case("H2 numeric column of a complex property", + Raw(() => _db.Invoices.Where(i => i.Total.Amount == 10m).ToList()), + Guarded(_db.Invoices, "Total.Amount", DataType.Number, "10")); + + Case("H3 column of a NESTED complex property", + Raw(() => _db.Invoices.Where(i => i.ShipTo.Post.Code == "10001").ToList()), + Guarded(_db.Invoices, "ShipTo.Post.Code", DataType.Text, "10001")); + + Case("H4 column of the outer complex property", + Raw(() => _db.Invoices.Where(i => i.ShipTo.City == "Baghdad").ToList()), + Guarded(_db.Invoices, "ShipTo.City", DataType.Text, "Baghdad")); + + Case("H5 framework member of a complex type's column", + Raw(() => _db.Invoices.Where(i => i.Total.Currency.Length == 3).ToList()), + Guarded(_db.Invoices, "Total.Currency.Length", DataType.Number, "3")); + + Case("H6 the complex member itself, named whole", + Raw(() => _db.Invoices.Where(i => i.Number == "INV-1").ToList()), + Guarded(_db.Invoices, "Number", DataType.Text, "INV-1")); + + // Getters on a complex type: the refusal is the point, and the unguarded query fails. + Case("H7 getter on a complex type (the refusal is right here)", + Raw(() => _db.Invoices.Where(i => i.Total.IsFree).ToList()), + Guarded(_db.Invoices, "Total.IsFree", DataType.Boolean, "false")); + + Case("H8 getter on a nested complex type (the refusal is right here)", + Raw(() => _db.Invoices.Where(i => i.ShipTo.Post.IsBlank).ToList()), + Guarded(_db.Invoices, "ShipTo.Post.IsBlank", DataType.Boolean, "false")); + + Done(); + } + + [Fact] + public void Ob4_H_A_projection_copying_a_complex_member_reads_it_from_the_model() + { + IQueryable projected = _db.Invoices.Select(invoice => new OcxRow + { + Id = invoice.Id, + Total = invoice.Total, + ShipTo = invoice.ShipTo + }); + + Case("H9 complex member copied whole, a column beneath it", + Raw(() => _db.Invoices.Select(i => new OcxRow { Id = i.Id, Total = i.Total, ShipTo = i.ShipTo }) + .Where(r => r.Total.Currency == "IQD").ToList()), + Guarded(projected, "Total.Currency", DataType.Text, "IQD")); + + Case("H10 complex member copied whole, a nested column beneath it", + Raw(() => _db.Invoices.Select(i => new OcxRow { Id = i.Id, Total = i.Total, ShipTo = i.ShipTo }) + .Where(r => r.ShipTo.Post.Code == "10001").ToList()), + Guarded(projected, "ShipTo.Post.Code", DataType.Text, "10001")); + + IQueryable rebuilt = _db.Invoices.Select(invoice => new OcxRow + { + Id = invoice.Id, + Total = new OcxMoney { Amount = invoice.Total.Amount, Currency = invoice.Total.Currency } + }); + + Case("H11 complex member rebuilt member by member", + Raw(() => _db.Invoices + .Select(i => new OcxRow + { + Id = i.Id, + Total = new OcxMoney { Amount = i.Total.Amount, Currency = i.Total.Currency } + }) + .Where(r => r.Total.Currency == "IQD").ToList()), + Guarded(rebuilt, "Total.Currency", DataType.Text, "IQD")); + + Done(); + } + + // ========================================================================================= + // Ob4-I. A second relational provider, and a pooled factory: the exact provider comparison + // has to hold for every one of them. + // ========================================================================================= + + private const string EfCoreProvider = "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + [Fact] + public void Ob4_I_A_second_provider_and_a_pooled_factory_carry_EF_Cores_own_provider() + { + List lost = new(); + + void Check(string name, IQueryProvider provider) + { + bool exact = provider.GetType().FullName == EfCoreProvider; + + _out.WriteLine($"{name,-34} {provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {provider.GetType().FullName}"); + } + } + + // Npgsql: a relational provider other than SQLite. No server is contacted — the provider + // a DbSet hands out is decided when the model is built. + using OcxNpgsqlContext npgsql = new(); + + Check("Npgsql", ((IQueryable)npgsql.Invoices).Provider); + Check("Npgsql + FromSqlRaw", npgsql.Invoices.FromSqlRaw("SELECT 1").Provider); + + // A pooled factory, which is how a host with AddPooledDbContextFactory gets its context. + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=none") + .Options; + + PooledDbContextFactory factory = new(options); + + using OcxNpgsqlContext pooled = factory.CreateDbContext(); + + Check("pooled context", ((IQueryable)pooled.Invoices).Provider); + + using OcxNpgsqlContext pooledAgain = factory.CreateDbContext(); + + Check("pooled context, reused", ((IQueryable)pooledAgain.Invoices).Provider); + + Check("SQLite", ((IQueryable)_db.Invoices).Provider); + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// A Npgsql-backed context. Nothing connects; only the model and the provider are read. + public sealed class OcxNpgsqlContext : DbContext + { + public OcxNpgsqlContext() + { + } + + public OcxNpgsqlContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Invoices => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (!options.IsConfigured) + { + options.UseNpgsql("Host=localhost;Database=none"); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(invoice => invoice.Total); + model.Entity().ComplexProperty( + invoice => invoice.ShipTo, ship => ship.ComplexProperty(address => address.Post)); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs new file mode 100644 index 0000000..71bd353 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs @@ -0,0 +1,873 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Reflection.Metadata; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- model ------------------------------------------------------------------------------------------- + + /// A shared kernel type: two columns and a getter over them. + public class OvbText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class OvbCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// An unmapped getter on the customer. + public string Handle => Name.ToLowerInvariant(); + + public List Orders { get; set; } = new(); + } + + public class OvbLine + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public OvbOrder Order { get; set; } = null!; + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// + /// An unmapped getter on the LINE ENTITY whose NAME a projected element row also declares. + /// The projected row assigns it from a column, so the query computes it; the entity cannot. + /// + public string Label => $"{Sku}#{Qty}"; + + /// An unmapped getter whose name a projected row also uses for a customer. + public string Name => Sku; + } + + public class OvbOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + /// Mapped as a computed column. + public decimal Doubled { get; set; } + + /// Owned; its getter is the member no database can compute. + public OvbText Title { get; set; } = new(); + + /// A column whose value a converter builds, so what is beneath it is the converter's. + public OvbText Badge { get; set; } = new(); + + public int CustomerId { get; set; } + + public OvbCustomer Customer { get; set; } = null!; + + public List Lines { get; set; } = new(); + + /// Unmapped getter on the order. + public string Display => $"{Code}/{Id}"; + } + + /// TPH. + public class OvbParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class OvbVendor : OvbParty + { + public string? Vat { get; set; } + } + + // ---- the rows a caller projects ---------------------------------------------------------------------- + + /// The element of a projected collection. Label collides with the entity's getter. + public class OvbLineRow + { + /// The entity declares this name and maps nothing for it. + public string Label { get; set; } = string.Empty; + + /// The entity maps a column of this name. + public int Qty { get; set; } + + /// The entity does not declare this name at all. + public string Note { get; set; } = string.Empty; + } + + public class OvbNest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + } + + public class OvbRow + { + public OvbRow() + { + } + + public OvbRow(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Tag { get; set; } = string.Empty; + + public OvbText Title { get; set; } = new(); + + public OvbNest Nest { get; set; } = new(); + + /// A projected collection of projected rows. + public List Lines { get; set; } = new(); + + /// The entity's own rows, copied whole. + public List Raw { get; set; } = new(); + + /// A customer read out of a collection, so the recorded source is the collection. + public OvbCustomer? Customer { get; set; } + } + + public sealed class OvbContext : DbContext + { + private readonly SqliteConnection _connection; + + public OvbContext(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection).AddInterceptors(new OvbInterceptor()); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + + // A column a converter builds: what is beneath it is the application's code to decide. + model.Entity().Property(order => order.Badge).HasConversion( + new ValueConverter( + text => text.En, + stored => new OvbText { En = stored, Ar = stored })); + + model.Entity().Ignore(customer => customer.Handle); + + model.Entity().Ignore(line => line.Label); + model.Entity().Ignore(line => line.Name); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + } + } + + /// An interceptor, to confirm one does not change which provider the queryable carries. + public sealed class OvbInterceptor : DbCommandInterceptor + { + } + + /// + /// Round 4 of the over-blocking review of 3.3.0's strict refusal: each shape run guarded and + /// unguarded, so a query that ran in 3.2.0 and still runs unguarded is visible when it is refused. + /// + public sealed class Ob4OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly OvbContext _db; + private readonly List _findings = new(); + + public Ob4OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new OvbContext(_connection); + _db.Database.EnsureCreated(); + + OvbCustomer customer = new() { Name = "Acme" }; + + OvbOrder order = new() + { + Customer = customer, + Code = "AB123", + Amount = 10m, + Title = new OvbText { Ar = "AR", En = "EN" }, + Badge = new OvbText { En = "gold" } + }; + + order.Lines.Add(new OvbLine { Sku = "S-1", Qty = 2 }); + order.Lines.Add(new OvbLine { Sku = "S-2", Qty = 4 }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new OvbVendor { Kind = "vendor", Vat = "V-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// Records a probe, and flags it when the guard refuses what the same query runs. + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-62} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Ob4-A. EfCoreOwns is an EXACT type-name comparison now. Every ordinary EF Core shape must + // still carry exactly that provider, or its refusal is silently lost. + // ========================================================================================= + + private const string EfCoreProvider = "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + /// The exact comparison the shipped EfCoreOwns performs. + private static bool ExactlyEfCore(IQueryProvider provider) => provider.GetType().FullName == EfCoreProvider; + + [Fact] + public void Ob4_A_Every_ordinary_EF_Core_shape_carries_exactly_EF_Cores_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("DbSet", _db.Orders), + ("Set()", _db.Set()), + ("Where", _db.Orders.Where(o => o.Id > 0)), + ("AsNoTracking", _db.Orders.AsNoTracking()), + ("AsNoTrackingWithIdentityResolution", _db.Orders.AsNoTrackingWithIdentityResolution()), + ("AsTracking", _db.Orders.AsTracking()), + ("Include", _db.Orders.Include(o => o.Customer)), + ("Include+ThenInclude", _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Lines)), + ("AsSplitQuery", _db.Orders.Include(o => o.Lines).AsSplitQuery()), + ("AsSingleQuery", _db.Orders.Include(o => o.Lines).AsSingleQuery()), + ("IgnoreQueryFilters", _db.Orders.IgnoreQueryFilters()), + ("IgnoreAutoIncludes", _db.Orders.IgnoreAutoIncludes()), + ("TagWith", _db.Orders.TagWith("t")), + ("TagWithCallSite", _db.Orders.TagWithCallSite()), + ("FromSqlRaw", _db.Orders.FromSqlRaw("SELECT * FROM \"Orders\"")), + ("FromSqlInterpolated", _db.Orders.FromSqlInterpolated($"SELECT * FROM \"Orders\"")), + ("Select(row type)", _db.Orders.Select(o => new OvbRow { Id = o.Id })), + ("Select(anonymous)", _db.Orders.Select(o => new { o.Id, o.Code })), + ("Select(navigation)", _db.Orders.Select(o => o.Customer)), + ("OfType", _db.Parties.OfType()), + ("Cast", _db.Parties.Cast()), + ("Distinct", _db.Orders.Distinct()), + ("OrderBy+Skip+Take", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5)), + ("SelectMany", _db.Orders.SelectMany(o => o.Lines)), + ("GroupBy+Select", _db.Orders.GroupBy(o => o.CustomerId).Select(g => new OvbRow { Id = g.Key })), + ("Join", _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, (o, c) => o)), + ("Concat", _db.Orders.Concat(_db.Orders)), + ("Union", _db.Orders.Union(_db.Orders)) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + bool exact = ExactlyEfCore(source.Provider); + + _out.WriteLine($"{name,-38} {source.Provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {source.Provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + [Fact] + public void Ob4_A_A_pooled_context_a_factory_and_an_interceptor_carry_it_too() + { + // A pooled context and one built by a factory are ordinary DbContexts with a different + // lifetime; an interceptor sits under the provider, not in front of it. Each must still + // hand out exactly EF Core's own provider, or every query on them loses the refusal. + ServiceCollectionStandIn services = new(_connection); + + List lost = new(); + + foreach ((string name, IQueryProvider provider) in services.Providers()) + { + bool exact = ExactlyEfCore(provider); + + _out.WriteLine($"{name,-38} {provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// Builds the context shapes a host builds, without needing a service container. + private sealed class ServiceCollectionStandIn + { + private readonly SqliteConnection _connection; + + internal ServiceCollectionStandIn(SqliteConnection connection) => _connection = connection; + + internal IEnumerable<(string Name, IQueryProvider Provider)> Providers() + { + using OvbContext plain = new(_connection); + + yield return ("plain context", ((IQueryable)plain.Orders).Provider); + + // A second context over the same connection is what a factory hands out. + using OvbContext fromFactory = new(_connection); + + yield return ("factory-built context", ((IQueryable)fromFactory.Orders).Provider); + + // The interceptor registered in OnConfiguring is already in force on both. + yield return ("intercepted context", plain.Orders.AsNoTracking().Provider); + + using OvbInMemoryContext inMemory = new(); + + yield return ("EF Core in-memory provider", ((IQueryable)inMemory.Orders).Provider); + } + } + + [Fact] + public void Ob4_A_EF_Cores_provider_is_that_exact_type_in_every_version_it_supports() + { + // The exact comparison only holds if no EF Core version renames the type, namespaces it + // differently, or hands out a subclass. Read straight from the assemblies. + string root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages", "microsoft.entityframeworkcore"); + + if (!Directory.Exists(root)) + { + _out.WriteLine("no package cache on this machine; nothing to read."); + + return; + } + + List wrong = new(); + int seen = 0; + + foreach (string version in Directory.GetDirectories(root).OrderBy(name => name)) + { + string? assembly = Directory + .GetFiles(version, "Microsoft.EntityFrameworkCore.dll", SearchOption.AllDirectories) + .FirstOrDefault(path => path.Contains("net", StringComparison.OrdinalIgnoreCase)); + + if (assembly is null) + { + continue; + } + + try + { + using FileStream file = File.OpenRead(assembly); + using System.Reflection.PortableExecutable.PEReader reader = new(file); + + System.Reflection.Metadata.MetadataReader metadata = reader.GetMetadataReader(); + + bool found = false; + + foreach (System.Reflection.Metadata.TypeDefinitionHandle handle in metadata.TypeDefinitions) + { + System.Reflection.Metadata.TypeDefinition definition = metadata.GetTypeDefinition(handle); + + if (metadata.GetString(definition.Name) != "EntityQueryProvider") + { + continue; + } + + found = true; + seen++; + + string space = metadata.GetString(definition.Namespace); + string baseName = definition.BaseType.Kind switch + { + System.Reflection.Metadata.HandleKind.TypeReference => metadata.GetString( + metadata.GetTypeReference( + (System.Reflection.Metadata.TypeReferenceHandle)definition.BaseType).Name), + System.Reflection.Metadata.HandleKind.TypeDefinition => metadata.GetString( + metadata.GetTypeDefinition( + (System.Reflection.Metadata.TypeDefinitionHandle)definition.BaseType).Name), + _ => "?" + }; + + _out.WriteLine( + $"{Path.GetFileName(version),-10} {space}.EntityQueryProvider : {baseName}"); + + if ($"{space}.EntityQueryProvider" != EfCoreProvider) + { + wrong.Add($"{version}: {space}.EntityQueryProvider"); + } + + if (baseName != "Object") + { + wrong.Add($"{version}: derives from {baseName}, so an exact match is not enough"); + } + } + + if (!found) + { + _out.WriteLine($"{Path.GetFileName(version),-10} no EntityQueryProvider in this package"); + } + } + catch (Exception failure) + { + _out.WriteLine($"{Path.GetFileName(version),-10} unreadable: {failure.GetType().Name}"); + } + } + + _out.WriteLine($"versions read: {seen}"); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Ob4-B. A projected collection of projected rows. MemberChain strips ToList/Where/Select, + // so the shape records the member as COPIED FROM THE ENTITY NAVIGATION and then reads + // the rest of the path out of the ENTITY's model — not out of the row the subquery + // actually builds. + // ========================================================================================= + + [Fact] + public void Ob4_B_A_projected_collection_is_read_out_of_the_entity_not_the_projected_row() + { + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }).ToList() + }); + + Case("B1 element member the subquery assigns, name the entity also declares", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(projected, "Lines.Label", DataType.Text, "S-1")); + + Case("B2 element member both the row and the entity map (control)", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Qty == 2)).ToList()), + Guarded(projected, "Lines.Qty", DataType.Number, "2")); + + // The third case of the triangulation. The only thing that changes between B1, B2 and + // B2b is whether the ENTITY's element type declares the name and whether it maps it: + // maps it -> allowed (B2, Qty) + // declares, unmapped -> REFUSED (B1, Label) + // does not declare -> allowed (B2b, Note) + // which is the model of OvbLine being read, not the OvbLineRow the subquery builds. + IQueryable noted = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Note = line.Sku, Qty = line.Qty }).ToList() + }); + + Case("B2b element member the entity does not declare at all", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Note = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Note == "S-1")).ToList()), + Guarded(noted, "Lines.Note", DataType.Text, "S-1")); + + // The same shape with a filtered include's operators in front of the Select, which is how + // a caller writes it when the collection is narrowed. + IQueryable filtered = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines + .Where(line => line.Qty > 0) + .Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }) + .ToList() + }); + + Case("B3 the same, with the subquery filtered first", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Where(l => l.Qty > 0) + .Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(filtered, "Lines.Label", DataType.Text, "S-1")); + + // The entity's own rows copied whole: here the entity IS what the member holds, so the + // refusal is right and the unguarded query fails the same way. + IQueryable copiedWhole = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Raw = order.Lines.ToList() + }); + + Case("B4 the entity's own rows copied whole (the refusal is right here)", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Raw = o.Lines.ToList() }) + .Where(r => r.Raw.Any(l => l.Label == "S-1")).ToList()), + Guarded(copiedWhole, "Raw.Label", DataType.Text, "S-1")); + + Done(); + } + + [Fact] + public void Ob4_B_A_member_read_out_of_a_collection_is_read_against_the_elements_model() + { + // Customer is assigned from a subquery over Lines, so MemberChain records ("Customer", + // Order, "Lines") and the rest of the path is walked in OvbLine — a type that has nothing + // to do with what the member holds. + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Customer = order.Lines.Select(line => line.Order.Customer).FirstOrDefault() + }); + + Case("B5 customer read out of a line subquery, filtered on a column of the customer", + Raw(() => _db.Orders + .Select(o => new OvbRow { Id = o.Id, Customer = o.Lines.Select(l => l.Order.Customer).FirstOrDefault() }) + .Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(projected, "Customer.Name", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // Ob4-C. Binding shapes the initializer reader skips: a member-member binding and a list + // binding record the NAME but nothing beneath it. + // ========================================================================================= + + [Fact] + public void Ob4_C_A_member_member_binding_is_left_alone() + { + IQueryable nested = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Nest = { A = order.Code } + }); + + Case("C1 member-member binding, the member it sets", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Nest = { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nested, "Nest.A", DataType.Text, "AB123")); + + Case("C2 member-member binding, the sibling it does not set", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Nest = { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nested, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // Ob4-D. The entity branch's remaining mapping shapes, on both EF Core legs. + // ========================================================================================= + + [Fact] + public void Ob4_D_The_entity_branch_leaves_every_mapped_shape_alone() + { + Case("D1 computed column", + Raw(() => _db.Orders.Where(o => o.Doubled == 20m).ToList()), + Guarded(_db.Orders, "Doubled", DataType.Number, "20")); + + Case("D2 a member beneath a converted column", + Raw(() => _db.Orders.Where(o => o.Badge.En == "gold").ToList()), + Guarded(_db.Orders, "Badge.En", DataType.Text, "gold")); + + Case("D3 a getter beneath a converted column", + Raw(() => _db.Orders.Where(o => o.Badge.IsEmpty).ToList()), + Guarded(_db.Orders, "Badge.IsEmpty", DataType.Boolean, "false")); + + Case("D4 a column of an owned type", + Raw(() => _db.Orders.Where(o => o.Title.En == "EN").ToList()), + Guarded(_db.Orders, "Title.En", DataType.Text, "EN")); + + Case("D5 a framework member of a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + Case("D6 a column across a reference navigation", + Raw(() => _db.Orders.Where(o => o.Customer.Name == "Acme").ToList()), + Guarded(_db.Orders, "Customer.Name", DataType.Text, "Acme")); + + Case("D7 a column across a collection navigation", + Raw(() => _db.Orders.Where(o => o.Lines.Any(l => l.Sku == "S-1")).ToList()), + Guarded(_db.Orders, "Lines.Sku", DataType.Text, "S-1")); + + Case("D8 a TPH subtype column through its own set", + Raw(() => _db.Parties.OfType().Where(v => v.Vat == "V-1").ToList()), + Guarded(_db.Parties.OfType(), "Vat", DataType.Text, "V-1")); + + Case("D9 a mapped column of the root (control)", + Raw(() => _db.Orders.Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + // A shadow property has no CLR member, so the name never reaches the new walk: it is an + // unknown name, refused the way it was before this branch. The unguarded form has to go + // through EF.Property, which is not a path a caller can write. + string shadow = Guarded(_db.Orders, "Tenant", DataType.Text, "t1"); + + _out.WriteLine($"D14 a shadow property, named directly unguarded=(EF.Property only) guarded={shadow}"); + + // Documented limit, and no regression: a caller's path names CLR members, so a shadow + // property is a name that matches nothing, refused as one, on this branch and before it. + // The unguarded form has to go through EF.Property, which is not a path a caller writes. + Assert.StartsWith("REFUSED", shadow); + + Done(); + } + + [Fact] + public void Ob4_D_The_entity_branch_over_a_set_operation_and_a_reshape() + { + Case("D10 Concat of two entity queries", + Raw(() => _db.Orders.Concat(_db.Orders).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Concat(_db.Orders), "Code", DataType.Text, "AB123")); + + Case("D11 rows reached through a navigation", + Raw(() => _db.Orders.Select(o => o.Customer).Where(c => c.Name == "Acme").ToList()), + Guarded(_db.Orders.Select(o => o.Customer), "Name", DataType.Text, "Acme")); + + Case("D12 rows reached by SelectMany", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Sku == "S-1").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Sku", DataType.Text, "S-1")); + + Case("D13 a getter on rows reached by SelectMany", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Label == "S-1#2").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Label", DataType.Text, "S-1#2")); + + Done(); + } + + // ========================================================================================= + // Ob4-E. Members of one projected row read from another projected row's members. + // ========================================================================================= + + [Fact] + public void Ob4_E_A_second_projection_over_the_first() + { + IQueryable once = _db.Orders.Select(order => new OvbRow { Id = order.Id, Code = order.Code }); + IQueryable twice = once.Select(row => new OvbRow { Id = row.Id, Tag = row.Code }); + + Case("E1 member of the second projection, assigned from the first", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Tag == "AB123").ToList()), + Guarded(twice, "Tag", DataType.Text, "AB123")); + + Case("E2 a framework member beneath it", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Tag.Length == 5).ToList()), + Guarded(twice, "Tag.Length", DataType.Number, "5")); + + Case("E3 a member the second projection leaves out", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Code == "AB123").ToList()), + Guarded(twice, "Code", DataType.Text, "AB123")); + + Done(); + } + + // ========================================================================================= + // Ob4-F. Anonymous rows and a constructor with arguments: neither knows which member each + // value sets, so neither may refuse anything. + // ========================================================================================= + + [Fact] + public void Ob4_F_A_constructor_with_arguments_and_the_bindings_beside_it() + { + IQueryable built = _db.Orders.Select(order => new OvbRow(order.Id) { Code = order.Code }); + + Case("F1 member the initializer beside the constructor sets", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(built, "Code", DataType.Text, "AB123")); + + Case("F2 member only the constructor could have set", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Id == 1).ToList()), + Guarded(built, "Id", DataType.Number, "1")); + + Case("F3 member neither sets", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Tag == "x").ToList()), + Guarded(built, "Tag", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // Ob4-G. The projection clause is exempt, so nothing above takes back a Select that worked. + // ========================================================================================= + + [Fact] + public void Ob4_G_A_projection_naming_the_refused_path_still_returns_it() + { + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }).ToList() + }); + + string selected; + + try + { + FilterResult result = Guard(projected).ToList(new Filter + { + Selects = new List { "Id", "Lines.Label" } + }); + + selected = $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + selected = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + selected = failure.GetType().Name; + } + + Case("G1 Selects naming the path Where refuses", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }).ToList()), + selected); + + Done(); + } + } + + /// EF Core's own in-memory provider, which is still EF Core's provider. + public sealed class OvbInMemoryContext : DbContext + { + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseInMemoryDatabase("ob4-" + Guid.NewGuid().ToString("N")); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Ignore(order => order.Badge); + model.Entity().Ignore(order => order.Doubled); + model.Entity().Ignore(customer => customer.Handle); + model.Entity().Ignore(line => line.Label); + model.Entity().Ignore(line => line.Name); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs new file mode 100644 index 0000000..7856c45 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs @@ -0,0 +1,742 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- entity shapes ------------------------------------------------------------------------ + + /// Several audited members, one denied member, one owned member with an audited leaf. + public class Ov7Aud + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Email { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Phone { get; set; } = string.Empty; + + [DwAudit] + public string Ssn { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public DateTime Dob { get; set; } + + public Ov7Own Own { get; set; } = new(); + } + + public class Ov7Own + { + public string Street { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Zip { get; set; } = string.Empty; + } + + /// Same shape, plus a member denied for select so a projection is synthesized. + public class Ov7AudDenied + { + public int Id { get; set; } + + [DwAudit(PolicyFeature.Select)] + public string Email { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Phone { get; set; } = string.Empty; + + [DwNoSelect] + public decimal Salary { get; set; } + } + + /// Nothing audited at all. + public class Ov7Plain + { + public int Id { get; set; } + + public string City { get; set; } = string.Empty; + + public int Capacity { get; set; } + } + + /// An alias that differs from its own member only in case. + public class Ov7Case + { + public int Id { get; set; } + + [DwAlias("total")] + public decimal Total { get; set; } + + public string Note { get; set; } = string.Empty; + } + + /// A chain: A answers to B, and B answers to C. No two names collide in the output. + public class Ov7Chain + { + public int Id { get; set; } + + [DwAlias("B")] + public string A { get; set; } = string.Empty; + + [DwAlias("C")] + public string B { get; set; } = string.Empty; + } + + /// An ordinary alias, as a control. + public class Ov7Ref + { + public int Id { get; set; } + + [DwAlias("reference")] + public string Number { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + /// An alias naming a member the group-size column would have taken. + public class Ov7Floor + { + public int Id { get; set; } + + public string Bucket { get; set; } = string.Empty; + + [DwAlias("Bucket2")] + public decimal Amount { get; set; } + } + + // ---- validator-only shapes (never queried) -------------------------------------------------- + + public class Ov7ShadowBase + { + public object Value { get; set; } = string.Empty; + } + + /// A member hidden with new, and an alias naming it: reflection sees the name twice. + public class Ov7ShadowDerived : Ov7ShadowBase + { + public new string Value { get; set; } = string.Empty; + + [DwAlias("Value")] + public string Other { get; set; } = string.Empty; + } + + /// Two members differing only in case, and an alias naming one of them. + public class Ov7DualCase + { + public string Name { get; set; } = string.Empty; + + public string name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Other { get; set; } = string.Empty; + } + + /// An alias naming another member of the same type: the collision the round-6 rule adds. + public class Ov7Shadow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAlias("Code")] + public string Reference { get; set; } = string.Empty; + } + + public sealed class Ov7Context : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7Context(SqliteConnection connection) => _connection = connection; + + public DbSet Auds => Set(); + + public DbSet Denied => Set(); + + public DbSet Plains => Set(); + + public DbSet Cases => Set(); + + public DbSet Chains => Set(); + + public DbSet Refs => Set(); + + public DbSet Floors => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity().OwnsOne(a => a.Own); + } + + /// + /// Round 7 of the over-blocking hunt: what rounds 5 and 6 changed, measured against 3.2.0. + /// + public sealed class Ov7OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7Context _db; + + public Ov7OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7Context(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 5; i++) + { + _db.Auds.Add(new Ov7Aud + { + Name = $"n{i}", + Email = $"e{i}@x", + Phone = $"p{i}", + Ssn = $"s{i}", + Dob = new DateTime(1990, 1, 1).AddDays(i), + Own = new Ov7Own { Street = $"st{i}", Zip = $"z{i}" } + }); + + _db.Denied.Add(new Ov7AudDenied { Email = $"e{i}@x", Phone = $"p{i}", Salary = i * 100 }); + _db.Plains.Add(new Ov7Plain { City = $"c{i}", Capacity = i }); + _db.Cases.Add(new Ov7Case { Total = i * 10, Note = $"note{i}" }); + _db.Chains.Add(new Ov7Chain { A = $"a{i}", B = $"b{i}" }); + _db.Refs.Add(new Ov7Ref { Number = $"NUM{i}", Amount = i }); + _db.Floors.Add(new Ov7Floor { Bucket = i <= 3 ? "x" : "y", Amount = i }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Ctx() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guard( + IQueryable source, DwPolicyContext context, DwTier tier = DwTier.Strict, int auditCap = 10_000) + where T : class + { + DwPolicyOptions options = new() { Tier = tier }; + options.Caps.MinGroupSize = 1; + options.Caps.MaxAuditEvents = auditCap; + + return source.ApplyPolicy(context, options, Resolver()); + } + + private static string Outcome(Func run) + { + try + { + return $"OK({run()})"; + } + catch (PolicyException refusal) + { + return $"POLICY({refusal.ErrorCode}/{refusal.FieldPath})"; + } + catch (LogicException logic) + { + return $"LOGIC({logic.Message})"; + } + catch (Exception other) + { + return other.GetType().Name; + } + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7 {probe} = {value}"); + + // ---- 1. what [DwAudit] now records ------------------------------------------------------- + + [Fact] + public void Ov7_Audit_Counts() + { + // Five rows in the table: the count must not move with them. + DwPolicyContext whole = Ctx(); + Say("a1.no-selects.5rows", Outcome(() => Guard(_db.Auds, whole).ToList(new Filter()).Data.Count)); + Say("a1.events", whole.PendingAuditEvents.Count.ToString()); + Say("a1.paths", string.Join("|", whole.PendingAuditEvents.Select(e => e.FieldPath + ":" + e.Feature))); + + // One row only, same request. + DwPolicyContext one = Ctx(); + Filter justOne = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.Equal, Values = { "1" } + } + } + } + }; + Say("a2.one-row", Outcome(() => Guard(_db.Auds, one).ToList(justOne).Data.Count)); + Say("a2.events", one.PendingAuditEvents.Count.ToString()); + + // Nothing audited on the type. + DwPolicyContext plain = Ctx(); + Say("a3.nothing-audited", Outcome(() => Guard(_db.Plains, plain).ToList(new Filter()).Data.Count)); + Say("a3.events", plain.PendingAuditEvents.Count.ToString()); + + // A projection is synthesized because something is denied. + DwPolicyContext denied = Ctx(); + Say("a4.synthesized", Outcome(() => Guard(_db.Denied, denied).ToList(new Filter()).Data.Count)); + Say("a4.events", denied.PendingAuditEvents.Count.ToString()); + Say("a4.paths", string.Join("|", denied.PendingAuditEvents.Select(e => e.FieldPath))); + + // The caller names a projection themselves: only what they named. + DwPolicyContext named = Ctx(); + Filter spelled = new() { Selects = new List { "Email" } }; + Say("a5.named-selects", Outcome(() => Guard(_db.Auds, named).ToList(spelled).Data.Count)); + Say("a5.events", named.PendingAuditEvents.Count.ToString()); + + // Composed clauses must not multiply the recording. + DwPolicyContext composed = Ctx(); + Say("a6.composed", Outcome(() => + Guard(_db.Auds, composed) + .Where(new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + }) + .Order(new OrderBy { Field = "Id", Direction = Direction.Ascending, Sort = 1 }) + .ToList(new Filter()).Data.Count)); + Say("a6.events", composed.PendingAuditEvents.Count.ToString()); + + // A segment. + DwPolicyContext segment = Ctx(); + Segment seg = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + }, + new ConditionSet + { + Sort = 2, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.LessThan, Values = { "99" } + } + } + } + } + } + }; + Say("a7.segment-2sets", Outcome(() => + Guard(_db.Auds, segment).ToListAsync(seg).GetAwaiter().GetResult().Data.Count)); + Say("a7.events", segment.PendingAuditEvents.Count.ToString()); + + // A summary. + DwPolicyContext summary = Ctx(); + Summary sum = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }; + Say("a8.summary", Outcome(() => Guard(_db.Auds, summary).ToList(sum).Data.Count)); + Say("a8.events", summary.PendingAuditEvents.Count.ToString()); + + // Two terminals on one context: the buffer accumulates per query. + DwPolicyContext twice = Ctx(); + Guard(_db.Auds, twice).ToList(new Filter()); + Guard(_db.Auds, twice).ToList(new Filter()); + Say("a9.two-queries.events", twice.PendingAuditEvents.Count.ToString()); + + // The cap, at exactly what one query now costs and at one less. + DwPolicyContext capped = Ctx(); + Say("a10.cap-4", Outcome(() => Guard(_db.Auds, capped, auditCap: 4).ToList(new Filter()).Data.Count)); + DwPolicyContext capped5 = Ctx(); + Say("a10.cap-5", Outcome(() => Guard(_db.Auds, capped5, auditCap: 5).ToList(new Filter()).Data.Count)); + + // Convenience tier at the same cap: the refusal's own shape. + DwPolicyContext conv = Ctx(); + Say("a11.cap-4-convenience", Outcome(() => + Guard(_db.Auds, conv, DwTier.Convenience, auditCap: 4).ToList(new Filter()).Data.Count)); + + // ToListDynamic, the other filter terminal. + DwPolicyContext dyn = Ctx(); + Say("a12.dynamic", Outcome(() => Guard(_db.Auds, dyn).ToListDynamic(new Filter()).Data.Count)); + Say("a12.events", dyn.PendingAuditEvents.Count.ToString()); + } + + // ---- 2. a blank name, in every clause ---------------------------------------------------- + + [Fact] + public void Ov7_Blank_Names() + { + string[] names = { "", " ", "\t", ".", "A.", ".A", "A..B", "Name.", "..", "Name..Name" }; + + foreach (string name in names) + { + string label = name.Replace("\t", "\\t"); + + Say($"b.group[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn(name)).Data.Count)); + Say($"b.group[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn(name)).Data.Count)); + + Say($"b.aggfield[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryAgg(name)).Data.Count)); + Say($"b.aggfield[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryAgg(name)).Data.Count)); + + Say($"b.where[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(WhereOn(name)).Data.Count)); + Say($"b.where[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(WhereOn(name)).Data.Count)); + + Say($"b.order[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(OrderOn(name)).Data.Count)); + Say($"b.order[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(OrderOn(name)).Data.Count)); + + Say($"b.select[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToListDynamic(SelectOn(name)).Data.Count)); + Say($"b.select[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToListDynamic(SelectOn(name)).Data.Count)); + + Say($"b.segsel[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToListAsync(SegmentSelect(name)).GetAwaiter().GetResult().Data.Count)); + Say($"b.segsel[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToListAsync(SegmentSelect(name)).GetAwaiter().GetResult().Data.Count)); + } + + // A legitimate key still resolves, guarded and unguarded. + Say("b.legit.group.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("Name")).Data.Count)); + Say("b.legit.group.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn("Name")).Data.Count)); + Say("b.legit.group.owned.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("Own.Street")).Data.Count)); + Say("b.legit.group.owned.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn("Own.Street")).Data.Count)); + Say("b.legit.group.case.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("nAmE")).Data.Count)); + Say("b.legit.group.spaces.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn(" Name ")).Data.Count)); + Say("b.legit.group.spaces.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn(" Name ")).Data.Count)); + + // The same, on an aliased type, which takes the other branch of ResolveName. + Say("b.alias.group.guarded", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(SummaryOn("reference")).Data.Count)); + Say("b.alias.group.blank.guarded", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(SummaryOn(" ")).Data.Count)); + Say("b.alias.group.blank.unguarded", Outcome(() => + _db.Refs.AsQueryable().ToList(SummaryOn(" ")).Data.Count)); + } + + private static Summary SummaryOn(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Summary SummaryAgg(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Field = field, Alias = "n" } + } + } + }; + + private static Filter WhereOn(string field) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + private static Filter OrderOn(string field) => new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending, Sort = 1 } } + }; + + private static Filter SelectOn(string field) => new() { Selects = new List { field } }; + + private static Segment SegmentSelect(string field) => new() + { + Selects = new List { field }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + } + } + }; + + // ---- 3. an alias spelled like another member --------------------------------------------- + + [Fact] + public void Ov7_Alias_Inbound_And_Out() + { + // The startup scan. + foreach (Type type in new[] + { + typeof(Ov7Case), typeof(Ov7Chain), typeof(Ov7Ref), typeof(Ov7Shadow), + typeof(Ov7ShadowDerived), typeof(Ov7DualCase), typeof(Ov7Floor) + }) + { + string verdict; + + try + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { type }); + verdict = report.IsValid + ? "VALID" + : "ERRORS:" + string.Join(" ;; ", report.Errors); + } + catch (Exception failure) + { + verdict = "THREW:" + failure.GetType().Name + ":" + failure.Message; + } + + Say($"c.inspect[{type.Name}]", verdict); + + // The scan reports; it never throws. A type reflection sees two properties on under + // one name — a base member hidden with new, two spelled in different cases — used to + // take an AmbiguousMatchException out of the scan, so a host calling ValidateModel + // at startup failed to start with no report at all. + Assert.DoesNotContain("THREW", verdict); + } + + // Plain reflection, for the same question the new rule asks. + foreach (Type type in new[] { typeof(Ov7ShadowDerived), typeof(Ov7DualCase) }) + { + string verdict; + + try + { + PropertyInfo? found = type.GetProperty( + "Value" == type.Name ? "Value" : type == typeof(Ov7DualCase) ? "Name" : "Value", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + verdict = found?.DeclaringType?.Name + "." + found?.Name; + } + catch (Exception failure) + { + verdict = "THREW:" + failure.GetType().Name; + } + + Say($"c.getproperty[{type.Name}]", verdict); + } + + // Outbound: an alias that differs from its member only in case. + string spelled = Columns(() => + Guard(_db.Cases, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Total", "Note" } })); + + string wholeRow = Columns(() => Guard(_db.Cases, Ctx()).ToListDynamic(new Filter())); + + Say("c.out.case", spelled); + Say("c.out.case.nosel", wholeRow); + + // A column does not collide with itself. The rule that stops a rename landing on a name + // the row already carries reads a case-insensitive map, so an alias spelling its own + // member differently looked like a shadow and the public name stopped being emitted. + Assert.Contains("total", spelled); + Assert.Contains("total", wholeRow); + + // Outbound: the chain. Both renames land on free names. + Say("c.out.chain", Columns(() => + Guard(_db.Chains, Ctx()).ToListDynamic( + new Filter { Selects = new List { "A", "B" } }))); + Say("c.out.chain.onlyA", Columns(() => + Guard(_db.Chains, Ctx()).ToListDynamic( + new Filter { Selects = new List { "A" } }))); + + // Outbound control: an ordinary alias. + Say("c.out.ref", Columns(() => + Guard(_db.Refs, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Number", "Amount" } }))); + + // Outbound: an alias naming a member the row also carries. + Say("c.out.shadowlike", Columns(() => + Guard(_db.Floors, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Bucket", "Amount" } }))); + + // Inbound: a caller may still write either spelling. + Say("c.in.case.alias", Outcome(() => + Guard(_db.Cases, Ctx()).ToList(WhereNum("total", "10")).Data.Count)); + Say("c.in.case.member", Outcome(() => + Guard(_db.Cases, Ctx()).ToList(WhereNum("Total", "10")).Data.Count)); + Say("c.in.chain.B", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("B", "b1")).Data.Count)); + Say("c.in.chain.C", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("C", "b1")).Data.Count)); + Say("c.in.chain.A", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("A", "a1")).Data.Count)); + Say("c.in.ref.alias", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(WhereText("reference", "NUM1")).Data.Count)); + + // A summary, whose rows are generated and whose keys are renamed too. + Say("c.out.summary.case", SummaryColumns(() => + Guard(_db.Cases, Ctx()).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Total" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }))); + } + + private static Filter WhereNum(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Number, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + private static Filter WhereText(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + private static string Columns(Func> run) + { + try + { + FilterResult result = run(); + + if (result.Data.Count == 0) + { + return "EMPTY"; + } + + return string.Join(",", Names(result.Data[0]!)); + } + catch (Exception failure) + { + return failure.GetType().Name + ":" + failure.Message; + } + } + + private static string SummaryColumns(Func run) + { + try + { + SummaryResult result = run(); + + if (result.Data.Count == 0) + { + return "EMPTY"; + } + + return string.Join(",", Names(result.Data[0]!)); + } + catch (Exception failure) + { + return failure.GetType().Name + ":" + failure.Message; + } + } + + private static IEnumerable Names(object row) => + row is IDictionary expando + ? expando.Keys.OrderBy(k => k, StringComparer.Ordinal) + : row.GetType().GetProperties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal); + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs b/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs new file mode 100644 index 0000000..0741809 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs @@ -0,0 +1,327 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A hashed member with no salt supplied: the MissingHashSalt refusal. + public class Ov7Hashed + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Hash)] + [DwDeny(PolicyFeature.Order)] + public string Secret { get; set; } = string.Empty; + } + + /// A tokenized member with no vault supplied: the MissingTokenVault refusal. + public class Ov7Tokenized + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Tokenize)] + [DwDeny(PolicyFeature.Order)] + public string Secret { get; set; } = string.Empty; + } + + /// A generalized key, so two groups can collide once transformed. + public class Ov7Grouped + { + public int Id { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 100, AllowAggregate = true, MinGroupSize = 1)] + [DwDeny(PolicyFeature.Order)] + public int Bracket { get; set; } + + public decimal Amount { get; set; } + } + + /// Several audited members beneath an owned member. + public class Ov7Nested + { + public int Id { get; set; } + + [DwNoSelect] + public decimal Hidden { get; set; } + + public Ov7NestedOwn Own { get; set; } = new(); + } + + public class Ov7NestedOwn + { + [DwAudit(PolicyFeature.Select)] + public string Zip { get; set; } = string.Empty; + + public string Street { get; set; } = string.Empty; + } + + public sealed class Ov7RefusalContext : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7RefusalContext(SqliteConnection connection) => _connection = connection; + + public DbSet Hashed => Set(); + + public DbSet Tokenized => Set(); + + public DbSet Grouped => Set(); + + public DbSet Nested => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().OwnsOne(n => n.Own); + } + + /// + /// Round 7: the four refusals' dry-run reading and audit path, the audit cap's own refusal, and + /// what an audited member beneath an owned member records. + /// + public sealed class Ov7RefusalProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7RefusalContext _db; + + public Ov7RefusalProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7RefusalContext(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 4; i++) + { + _db.Hashed.Add(new Ov7Hashed { Name = $"n{i}", Secret = $"s{i}" }); + _db.Tokenized.Add(new Ov7Tokenized { Name = $"n{i}", Secret = $"s{i}" }); + + // 10 and 20 fall in one bucket of 100; 150 and 199 in another. + _db.Grouped.Add(new Ov7Grouped { Bracket = i <= 2 ? i * 10 : 100 + (i * 20), Amount = i }); + _db.Nested.Add(new Ov7Nested + { + Hidden = i, + Own = new Ov7NestedOwn { Zip = $"z{i}", Street = $"st{i}" } + }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7R {probe} = {value}"); + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Opts(DwTier tier, bool optionDry, int auditCap = 10_000) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = optionDry, AuditRefusals = true }; + options.Caps.MinGroupSize = 1; + options.Caps.MaxAuditEvents = auditCap; + + return options; + } + + /// Runs one posture and reports the refusal's shape plus what the refusal audit stored. + private string Shape(DwTier tier, bool optionDry, bool contextDry, Func, int> _, + Func run) + { + DwPolicyContext context = new DwPolicyContext() + .WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = contextDry; + + DwPolicyOptions options = Opts(tier, optionDry); + + string outcome; + + try + { + outcome = $"OK({run(context, options)})"; + } + catch (PolicyException refusal) + { + outcome = $"{refusal.ErrorCode}/field='{refusal.FieldPath}'/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name; + } + + string audited = string.Join("|", context.PendingAuditEvents + .Where(e => e.ErrorCode is not null) + .Select(e => e.FieldPath)); + + return $"{outcome} ;; auditPath='{audited}'"; + } + + private static readonly (string Label, DwTier Tier, bool OptionDry, bool ContextDry)[] Postures = + { + ("strict", DwTier.Strict, false, false), + ("strict+optionDry", DwTier.Strict, true, false), + ("strict+contextDry", DwTier.Strict, false, true), + ("convenience", DwTier.Convenience, false, false) + }; + + // ---- 4. the four refusals ------------------------------------------------------------ + + [Fact] + public void Ov7_Four_Refusals() + { + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in Postures) + { + // 1. MissingHashSalt: no salt on the options. + Say($"d1.hashsalt[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Hashed.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count)); + + // 2. MissingTokenVault: no vault on the options. + Say($"d2.tokenvault[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Tokenized.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count)); + + // 3. TransformRequiresMaterialization: a transformed type handed back as a query. + Say($"d3.materialize[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + { + IQueryable handed = _db.Hashed.AsQueryable().ApplyPolicy(context, options, Resolver()) + .FilterDynamic(new Filter()); + + return handed is null ? 0 : 1; + })); + + // 4. Two groups colliding once the key is generalized. + Say($"d4.collision[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Grouped.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Bracket" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Sumation, Field = "Amount", Alias = "total" } + } + } + }).Data.Count)); + } + } + + // ---- 1b. the audit cap's own refusal, and a nested audited member ---------------------- + + [Fact] + public void Ov7_Audit_Cap_And_Nested() + { + // Four audited members on Ov7Aud. Walk the cap down through the count. + foreach (int cap in new[] { 1, 2, 3, 4, 5 }) + { + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in Postures) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = contextDry; + + DwPolicyOptions options = Opts(tier, optionDry, cap); + + string outcome; + + try + { + using SqliteConnection side = new("DataSource=:memory:"); + side.Open(); + using Ov7Context db = new(side); + db.Database.EnsureCreated(); + db.Auds.Add(new Ov7Aud + { + Name = "n", Email = "e", Phone = "p", Ssn = "s", + Dob = new DateTime(1990, 1, 1), Own = new Ov7Own { Street = "st", Zip = "z" } + }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + int count = db.Auds.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count; + outcome = $"OK({count})"; + } + catch (PolicyException refusal) + { + outcome = + $"{refusal.ErrorCode}/field='{refusal.FieldPath}'/feature={refusal.Feature}" + + $"/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name; + } + + Say($"e.cap{cap}[{label}]", outcome); + } + } + + // An audited member beneath an owned member, with nothing denied: the row comes back whole. + DwPolicyContext whole = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + FilterResult? wholeResult = null; + + try + { + wholeResult = _db.Nested.AsQueryable() + .ApplyPolicy(whole, Opts(DwTier.Strict, false), Resolver()) + .ToList(new Filter()); + } + catch (Exception failure) + { + Say("f.nested.whole", failure.GetType().Name); + } + + if (wholeResult is not null) + { + Say("f.nested.whole.zipReturned", + wholeResult.Data.Count > 0 ? wholeResult.Data[0].Own.Zip : "NONE"); + Say("f.nested.whole.events", whole.PendingAuditEvents.Count.ToString()); + Say("f.nested.whole.paths", + string.Join("|", whole.PendingAuditEvents.Select(e => e.FieldPath))); + + // An audited column of an owned type is read by whoever receives the object holding + // it, named or not: a member kept whole hands back everything inside it. + Assert.Contains(whole.PendingAuditEvents, e => e.FieldPath == "Own.Zip"); + } + + // The caller names the nested path themselves. + DwPolicyContext named = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + try + { + _db.Nested.AsQueryable().ApplyPolicy(named, Opts(DwTier.Strict, false), Resolver()) + .ToListDynamic(new Filter { Selects = new List { "Own.Zip" } }); + Say("f.nested.named.events", named.PendingAuditEvents.Count.ToString()); + Say("f.nested.named.paths", + string.Join("|", named.PendingAuditEvents.Select(e => e.FieldPath))); + } + catch (Exception failure) + { + Say("f.nested.named", failure.GetType().Name + ":" + failure.Message); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs b/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs new file mode 100644 index 0000000..a046883 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs @@ -0,0 +1,264 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A realistically audited record: twelve audited columns, a navigation, a collection. + public class Ov7Wide + { + public int Id { get; set; } + + public string Reference { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A1 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A2 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A3 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A4 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A5 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A6 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A7 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A8 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A9 { get; set; } = string.Empty; + + [DwAudit] public string A10 { get; set; } = string.Empty; + + [DwAudit] public string A11 { get; set; } = string.Empty; + + [DwAudit] public string A12 { get; set; } = string.Empty; + + public List Lines { get; set; } = new(); + + /// An audited navigation. Nothing includes it, so no query loads it. + [DwAudit(PolicyFeature.Select)] + public Ov7Side? Side { get; set; } + } + + public class Ov7Side + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ov7WideLine + { + public int Id { get; set; } + + public int Ov7WideId { get; set; } + + [DwAudit(PolicyFeature.Select)] + public string Detail { get; set; } = string.Empty; + } + + /// A masked key, so two groups collide once the values are starred out. + public class Ov7Collide + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + [DwDeny(PolicyFeature.Order)] + public string Label { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + public sealed class Ov7ScaleContext : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7ScaleContext(SqliteConnection connection) => _connection = connection; + + public DbSet Wides => Set(); + + public DbSet WideLines => Set(); + + public DbSet Collides => Set(); + + public DbSet Sides => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// Round 7: how many events a realistic request now costs, and the fourth refusal. + /// + public sealed class Ov7ScaleProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7ScaleContext _db; + + public Ov7ScaleProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7ScaleContext(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 50; i++) + { + Ov7Wide wide = new() + { + Reference = $"R{i}", + A1 = "a", A2 = "b", A3 = "c", A4 = "d", A5 = "e", A6 = "f", + A7 = "g", A8 = "h", A9 = "i", A10 = "j", A11 = "k", A12 = "l" + }; + + wide.Lines.Add(new Ov7WideLine { Detail = $"d{i}a" }); + wide.Lines.Add(new Ov7WideLine { Detail = $"d{i}b" }); + _db.Wides.Add(wide); + + // Two distinct labels of the same length mask to the same run of stars. + _db.Collides.Add(new Ov7Collide { Label = i % 2 == 0 ? "aaaa" : "bbbb", Amount = i }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7S {probe} = {value}"); + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Ctx(bool dry = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = dry; + + return context; + } + + private static DwPolicyOptions Opts(DwTier tier = DwTier.Strict, bool dry = false) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dry, AuditRefusals = true }; + options.Caps.MinGroupSize = 1; + + return options; + } + + [Fact] + public void Ov7_Scale_And_Collision() + { + // Fifty rows, twelve audited columns, no projection named. + DwPolicyContext wide = Ctx(); + int rows = _db.Wides.AsQueryable().ApplyPolicy(wide, Opts(), Resolver()) + .ToList(new Filter()).Data.Count; + Say("g1.wide.rows", rows.ToString()); + Say("g1.wide.events", wide.PendingAuditEvents.Count.ToString()); + + // The same request with the navigation loaded, which is what an Include would do. + DwPolicyContext included = Ctx(); + List loaded = _db.Wides.Include(w => w.Lines) + .ApplyPolicy(included, Opts(), Resolver()) + .ToList(new Filter()).Data; + int withLines = loaded.Count; + Say("g2.included.rows", withLines.ToString()); + Say("g2.included.detailReturned", + withLines > 0 && loaded[0].Lines.Count > 0 ? loaded[0].Lines[0].Detail : "NONE"); + Say("g2.included.events", included.PendingAuditEvents.Count.ToString()); + Say("g2.included.paths", + string.Join("|", included.PendingAuditEvents.Select(e => e.FieldPath))); + + // Ten terminals on one long-lived context: the multiplier per request. + DwPolicyContext many = Ctx(); + + for (int i = 0; i < 10; i++) + { + _db.Wides.AsQueryable().ApplyPolicy(many, Opts(), Resolver()).ToList(new Filter()); + } + + Say("g3.ten-queries.events", many.PendingAuditEvents.Count.ToString()); + + // Side is audited and nothing loads it: the query returns null for it every time. + DwPolicyContext side = Ctx(); + List unloaded = _db.Wides.AsQueryable().ApplyPolicy(side, Opts(), Resolver()) + .ToList(new Filter()).Data; + Say("g5.navigation.loaded", + unloaded.Count > 0 ? (unloaded[0].Side is null ? "null" : "loaded") : "NONE"); + Say("g5.navigation.recorded", + side.PendingAuditEvents.Any(e => e.FieldPath == "Side") ? "yes" : "no"); + Say("g5.navigation.events", side.PendingAuditEvents.Count.ToString()); + + // Nothing loads it, so the caller receives null for it: not a read, and not an event + // counted against a cap that refuses rather than dropping a record. + Assert.DoesNotContain(side.PendingAuditEvents, e => e.FieldPath == "Side"); + + // The fourth refusal: two groups sharing a key once masked. + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in + new[] + { + ("strict", DwTier.Strict, false, false), + ("strict+optionDry", DwTier.Strict, true, false), + ("strict+contextDry", DwTier.Strict, false, true), + ("convenience", DwTier.Convenience, false, false) + }) + { + DwPolicyContext context = Ctx(contextDry); + string outcome; + + try + { + int groups = _db.Collides.AsQueryable() + .ApplyPolicy(context, Opts(tier, optionDry), Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Label" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }).Data.Count; + outcome = $"OK({groups})"; + } + catch (PolicyException refusal) + { + outcome = + $"{refusal.ErrorCode}/field='{refusal.FieldPath}'" + + $"/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name + ":" + + (other.InnerException?.GetType().Name ?? "-") + ":" + + (other.InnerException?.Message ?? other.Message); + } + + string audited = string.Join("|", context.PendingAuditEvents + .Where(e => e.ErrorCode is not null) + .Select(e => e.FieldPath)); + + Say($"g4.collision[{label}]", $"{outcome} ;; auditPath='{audited}'"); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/PolicyAdminControllerTests.cs b/DynamicWhere.Tests/Policies/PolicyAdminControllerTests.cs index 86696d8..26d2bd8 100644 --- a/DynamicWhere.Tests/Policies/PolicyAdminControllerTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyAdminControllerTests.cs @@ -32,7 +32,7 @@ namespace DynamicWhere.Tests.Policies; public sealed class PolicyAdminControllerTests : IAsyncLifetime { private readonly PostgreSqlContainer _server = - new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); + new PostgreSqlBuilder("postgres:16-alpine").Build(); private IHost? _host; diff --git a/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs b/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs index b595b60..22a0fe2 100644 --- a/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs @@ -137,6 +137,46 @@ public async Task What_the_request_recorded_reaches_the_sink() Assert.Equal(new[] { "Salary", "NationalId" }, sink.Written.Select(e => e.FieldPath)); } + /// A sink that stops when it is told to, as one writing through a database does. + private sealed class Obedient : IDwAuditSink + { + internal List Written { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + Written.Add(auditEvent); + + return default; + } + } + + /// + /// A caller who hangs up does not take the record of what they read with them. + /// + /// + /// The drain was handed the request's own abort token. A client that closed the connection, once + /// the rows had started to arrive or the moment they had, cancelled the write that follows the + /// response: the sink threw, the middleware logged it, and the events went with the context. An + /// audited read with nothing written down, at the price of closing a socket. + /// + [Fact] + public async Task A_caller_who_disconnects_does_not_cancel_the_record_of_what_they_read() + { + Obedient sink = new(); + Recording log = new(); + + HttpContext http = Request(Services(sink), Event(), Event("NationalId")); + + http.RequestAborted = new CancellationToken(canceled: true); + + await new DwPolicyAuditMiddleware(_ => Task.CompletedTask, log).InvokeAsync(http); + + Assert.Equal(new[] { "Salary", "NationalId" }, sink.Written.Select(e => e.FieldPath)); + Assert.Empty(log.Entries); + } + /// /// A request that threw still writes what it did before it threw. /// diff --git a/DynamicWhere.Tests/Policies/PolicyAuditTests.cs b/DynamicWhere.Tests/Policies/PolicyAuditTests.cs index be87c44..3c7edbf 100644 --- a/DynamicWhere.Tests/Policies/PolicyAuditTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyAuditTests.cs @@ -2,6 +2,7 @@ using DynamicWhere.ex.Classes.Core; using DynamicWhere.ex.Enums; using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; using DynamicWhere.ex.Policies.Audit; using DynamicWhere.ex.Policies.Config; using DynamicWhere.ex.Policies.Context; @@ -64,9 +65,18 @@ private static PolicyQueryable Query( options ?? Options(), new PolicyResolver(new[] { new AttributePolicyProvider() })); + /// + /// A filter naming one condition, and a projection naming one unaudited field. + /// + /// + /// The projection is deliberate. A request that names none has one synthesized, and since 3.3.0 + /// the members it returns are recorded as read — so a bare filter here would record the fields + /// the row carries back as well as the one the test is about. + /// private static Filter Where(string field, DataType type = DataType.Text) => new() { + Selects = new List { "Name" }, ConditionGroup = new ConditionGroup { Sort = 1, @@ -199,18 +209,83 @@ public void Two_references_to_one_field_are_recorded_twice() } /// - /// The library resolving a policy on its own behalf is not an access by the caller. Charging a - /// synthesized projection to the audit log would record every field of the type as read on - /// every query that named none. + /// A projection the caller never named still hands them the members it keeps, and an audited + /// field among them is a field they read. /// + /// + /// Until 3.3.0 only a field the request spelled out was recorded, which left an empty + /// Selects as one token past the control: the same value, returned, with nothing written + /// down. Only an audited field produces an event, and one per query rather than one per row, so + /// a type with nothing audited still records nothing here. + /// [Fact] - public void A_synthesized_projection_records_nothing() + public void A_synthesized_projection_records_what_it_returns() { DwPolicyContext context = new(); Query(context).ToList(new Filter()); - Assert.Empty(context.PendingAuditEvents); + // Both audited columns come back in the row, so both are read: Salary is audited for every + // feature and Email for Select alone. + Assert.Equal(2, context.PendingAuditEvents.Count); + Assert.All(context.PendingAuditEvents, e => Assert.Equal(PolicyFeature.Select, e.Feature)); + Assert.All(context.PendingAuditEvents, e => Assert.Equal(PolicyEffect.Allow, e.Effect)); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Salary"); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Email"); + } + + /// + /// A member a projection could not have kept is still read when the row comes back whole. + /// + /// + /// Nothing is denied on this type, so no projection is built and the caller receives the row as + /// it is — a getter with no setter included, which a projection would have had to leave out. + /// Recording only what a projection would have kept would lose exactly those members. + /// + [Fact] + public void A_whole_row_read_records_an_audited_member_no_projection_could_keep() + { + DwPolicyContext context = new(); + + Array.Empty() + .AsQueryable() + .ApplyPolicy(context, Options(), new PolicyResolver(new[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "Reference" && e.Feature == PolicyFeature.Select); + } + + /// A type whose audited member a projection cannot assign. + private class AuditedGetter + { + public int Id { get; set; } + + [DwAudit] + public string Reference => "r-" + Id; + } + + /// The same read, spelled out by the caller, is the same one record. + [Fact] + public void Naming_the_field_records_the_same_read_once() + { + DwPolicyContext named = new(); + + Query(named).ToList(new Filter { Selects = new List { "Email" } }); + + DwPolicyContext silent = new(); + + Query(silent).ToList(new Filter()); + + DwAuditEvent spelled = Assert.Single(named.PendingAuditEvents); + + Assert.Equal("Email", spelled.FieldPath); + + // The same read, reached the other way, is recorded the same way. + Assert.Contains( + silent.PendingAuditEvents, + e => e.FieldPath == spelled.FieldPath && e.Feature == spelled.Feature && e.Effect == spelled.Effect); } // ---- the drain ----------------------------------------------------------------------------- diff --git a/DynamicWhere.Tests/Policies/PolicyBootstrap.cs b/DynamicWhere.Tests/Policies/PolicyBootstrap.cs new file mode 100644 index 0000000..d48dc8f --- /dev/null +++ b/DynamicWhere.Tests/Policies/PolicyBootstrap.cs @@ -0,0 +1,41 @@ +using DynamicWhere.ex.Policies.Config; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// The one posture this test assembly configures, whichever suite asks for it first. + /// + /// + /// DwPolicy is process-wide, so a suite that configures a posture of its own decides what + /// every other suite in the run sees. Since 3.3.0 a second call asking for the same posture is a + /// no-op, which is what lets several suites call this without a lock — but only while they all + /// ask for the same posture, which is what this type is for. + /// + /// The demo API's Employee is exposed by name rather than by reference: the floor leg + /// drops the API project along with the rest of the EF Core 8 graph, and the endpoint suites + /// that need the type are dropped with it. + /// + /// + internal static class PolicyBootstrap + { + /// The name the endpoint suites address Staff by. + internal const string StaffName = "staff"; + + /// Configures the posture, or confirms the one already in force is it. + internal static void Ensure() + { + DwPolicyOptions options = new(); + + options.Entities + .Expose(StaffName) + .Expose(); + + if (Type.GetType("DynamicWhere.API.Models.Employee, DynamicWhere.API") is { } employee) + { + options.Entities.Expose(employee, "Employee"); + } + + DwPolicy.Configure(options); + } + } +} diff --git a/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs b/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs index 7efd234..d36b63f 100644 --- a/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs @@ -39,38 +39,24 @@ public sealed class PolicyEndpointCollection /// internal static class PolicyEndpointHost { - internal const string Entity = "staff"; - - private static readonly object Bootstrap = new(); + internal const string Entity = PolicyBootstrap.StaffName; /// - /// Configures the process-wide policy once, because the endpoints read DwPolicy rather - /// than taking a posture per call — which is the whole point of that type: a posture a caller - /// can forget to pass at one call site is not a posture. + /// Configures the process-wide policy, because the endpoints read DwPolicy rather than + /// taking a posture per call — which is the whole point of that type: a posture a caller can + /// forget to pass at one call site is not a posture. /// - internal static void Configure() - { - lock (Bootstrap) - { - if (DwPolicy.IsConfigured) - { - return; - } - - DwPolicyOptions options = new(); - - // Employee belongs to the demo API rather than to this suite, and is exposed here - // because DwPolicy.Configure is refused after the first call: a second bootstrap for - // PolicyAdminControllerTests could not exist, and whichever suite ran first would - // decide what the other could resolve. - options.Entities - .Expose(Entity) - .Expose() - .Expose("Employee"); - - DwPolicy.Configure(options); - } - } + /// + /// No lock and no IsConfigured check. Since 3.3.0 a second call asking for the posture + /// already in force is a no-op, so every test that needs the endpoints can call this and the + /// package settles the race. Before that, this method held the check-then-act every integration + /// suite had to write for itself. + /// + /// Employee belongs to the demo API rather than to this suite, and is exposed here because the + /// posture must be identical whichever suite calls first. + /// + /// + internal static void Configure() => PolicyBootstrap.Ensure(); /// Signs every request in as whoever the test asked for. internal sealed class StubAuth : AuthenticationHandler diff --git a/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs b/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs index 1806d87..1c61dd6 100644 --- a/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs @@ -87,7 +87,7 @@ private static PolicyTrace Walk(IEnumerable rows, IReadOnlyCollection { PolicyTrace trace = new(DwTier.Convenience, dryRun: false); - GraphWalker.Apply(rows, PolicyFor(), projected, Caller(), Options(), trace); + GraphWalker.Apply(rows, typeof(T), PolicyFor(), projected, Caller(), Options(), trace); return trace; } diff --git a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs index c4c80ac..988b92e 100644 --- a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs @@ -171,7 +171,11 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll() long before = await store.GetVersionAsync(default); - using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(10)); + // Generous on purpose. The watch runs on the thread pool, and a suite of two thousand tests + // can leave it waiting behind work that has nothing to do with this store; a budget tight + // enough to catch a real hang is also tight enough to fail a healthy run under load. Only a + // store that never reports a change waits this long. + using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(60)); IAsyncEnumerable? watch = store.WatchAsync(cancel.Token); @@ -200,7 +204,9 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll() { await store.UpsertAsync(Rule(), cancel.Token); - await Task.Delay(25, cancel.Token); + // Waits on the watch itself rather than on the clock, so the loop ends as soon as the + // store reports and does not go on writing while the reader waits to be scheduled. + await Task.WhenAny(observed, Task.Delay(25, cancel.Token)); } Assert.True(await observed > before); diff --git a/DynamicWhere.Tests/Policies/PostgresTokenVaultConformanceTests.cs b/DynamicWhere.Tests/Policies/PostgresTokenVaultConformanceTests.cs index 027a76e..efbcc3a 100644 --- a/DynamicWhere.Tests/Policies/PostgresTokenVaultConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/PostgresTokenVaultConformanceTests.cs @@ -22,7 +22,7 @@ public sealed class PostgresTokenVaultConformanceTests : DurableTokenVaultConformanceTests, IAsyncLifetime { private readonly PostgreSqlContainer _server = - new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); + new PostgreSqlBuilder("postgres:16-alpine").Build(); private Func? _contexts; @@ -48,4 +48,40 @@ public async Task InitializeAsync() /// protected override IDwTokenVault CreateVault() => new EfTokenVault(_contexts!); + + /// + /// Many vaults holding the key, meeting at once a value an unkeyed vault already tokenized, all + /// hand back that token, and one of them retiring the unkeyed row under the others does not + /// disturb it. + /// + /// + /// On the leg with a real server behind it, for the reason the unkeyed race gives: SQLite in memory + /// is one connection and cannot contend. + /// + [Fact] + public void Many_keyed_callers_racing_for_a_value_all_get_the_token_it_already_had() + { + byte[] key = Enumerable.Range(1, 32).Select(i => (byte)i).ToArray(); + + string issued = new EfTokenVault(_contexts!).GetOrCreate(Scope, "RACE-000123"); + + EfTokenVault[] vaults = Enumerable.Range(0, 8) + .Select(i => new EfTokenVault(_contexts!, key, retireUnkeyed: i % 2 == 0)).ToArray(); + + string[] tokens = new string[vaults.Length]; + + Parallel.For(0, vaults.Length, i => tokens[i] = vaults[i].GetOrCreate(Scope, "RACE-000123")); + + Assert.All(tokens, token => Assert.Equal(issued, token)); + + Parallel.For(0, vaults.Length, i => tokens[i] = vaults[i].GetOrCreate(Scope, "RACE-NEW")); + + Assert.Single(tokens.Distinct(StringComparer.Ordinal)); + + using DbContext db = _contexts!(); + + Assert.DoesNotContain( + db.Set().Select(row => row.Key).ToList(), + stored => stored == DwToken.KeyFor(Scope, "RACE-000123")); + } } diff --git a/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs b/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs new file mode 100644 index 0000000..019b238 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs @@ -0,0 +1,242 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// An owned type stored as a JSON document. + public class P6Json + { + public string City { get; set; } = string.Empty; + + public string Street { get; set; } = string.Empty; + + /// A getter over two members of the document. + public string Full => $"{Street}, {City}"; + } + + /// A complex type: its members are columns of the owner's table. + public class P6Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + public bool IsFree => Amount == 0m; + } + + public class P6Doc + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + /// A JSON column. + public P6Json Where { get; set; } = new(); + + /// A complex property. + public P6Money Total { get; set; } = new(); + + /// A primitive collection: one column holding a list of scalars. + public List Tags { get; set; } = new(); + } + + public sealed class P6MappingContext : DbContext + { + private readonly SqliteConnection _connection; + + public P6MappingContext(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(doc => doc.Where, json => json.ToJson()); + model.Entity().ComplexProperty(doc => doc.Total); + model.Entity().PrimitiveCollection(doc => doc.Tags); + } + } + + /// + /// Round 6, the mapping shapes only EF Core 8 has: a JSON column, a complex property and a + /// primitive collection, each run guarded and unguarded. + /// + public sealed class Pr6MappingProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly P6MappingContext _db; + private readonly List _findings = new(); + + public Pr6MappingProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new P6MappingContext(_connection); + _db.Database.EnsureCreated(); + _db.Docs.Add(new P6Doc + { + Number = "D-1", + Where = new P6Json { City = "Baghdad", Street = "Al Rasheed" }, + Total = new P6Money { Amount = 10m, Currency = "IQD" }, + Tags = new List { "red", "blue" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, Operator = Operator.Equal, Values = { value } + } + } + } + }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// The same request through the library with no policy attached at all. + private static string Unguarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = source.ToList(Clause(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static Filter Clause(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-58} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + [Fact] + public void Pr6_F_The_three_mapping_shapes_only_EF_Core_8_has() + { + Case("F01 a member of a JSON column", + Raw(() => _db.Docs.Where(d => d.Where.City == "Baghdad").ToList()), + Guarded(_db.Docs, "Where.City", DataType.Text, "Baghdad")); + + Case("F02 a second member of the same JSON column", + Raw(() => _db.Docs.Where(d => d.Where.Street == "Al Rasheed").ToList()), + Guarded(_db.Docs, "Where.Street", DataType.Text, "Al Rasheed")); + + Case("F03 a getter over two members of a JSON column (refusal is right)", + Raw(() => _db.Docs.Where(d => d.Where.Full == "Al Rasheed, Baghdad").ToList()), + Guarded(_db.Docs, "Where.Full", DataType.Text, "Al Rasheed, Baghdad")); + + Case("F04 a column of a complex property", + Raw(() => _db.Docs.Where(d => d.Total.Currency == "IQD").ToList()), + Guarded(_db.Docs, "Total.Currency", DataType.Text, "IQD")); + + Case("F05 a numeric column of a complex property", + Raw(() => _db.Docs.Where(d => d.Total.Amount == 10m).ToList()), + Guarded(_db.Docs, "Total.Amount", DataType.Number, "10")); + + Case("F06 a getter on a complex type (refusal is right)", + Raw(() => _db.Docs.Where(d => d.Total.IsFree).ToList()), + Guarded(_db.Docs, "Total.IsFree", DataType.Boolean, "false")); + + // Printed, not flagged: the library refuses Tags.Count with no policy attached either + // (F09), so the hand-written LINQ is not a control the guard can be held to. + _out.WriteLine("F07 a primitive collection's Count, hand-written LINQ unguarded=" + + Raw(() => _db.Docs.Where(d => d.Tags.Count == 2).ToList()) + + " guarded=" + Guarded(_db.Docs, "Tags.Count", DataType.Number, "2")); + + // The control that matters: the same field through the library with no policy at all. + Case("F09 Tags.Count through the library, unguarded", + Unguarded(_db.Docs, "Tags.Count", DataType.Number, "2"), + Guarded(_db.Docs, "Tags.Count", DataType.Number, "2")); + + Case("F08 a plain column beside all three", + Raw(() => _db.Docs.Where(d => d.Number == "D-1").ToList()), + Guarded(_db.Docs, "Number", DataType.Text, "D-1")); + + Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs new file mode 100644 index 0000000..1058d35 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs @@ -0,0 +1,773 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- entity shapes ---------------------------------------------------------------------------------- + + /// Owned, with a getter over two columns that no database can compute. + public class P6Text + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class P6Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Unmapped getter. + public string Handle => Name.ToLowerInvariant(); + + /// Table splitting: this lives in the customer's own table. + public P6Detail Detail { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class P6Detail + { + public int Id { get; set; } + + public string Notes { get; set; } = string.Empty; + } + + public class P6Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public P6Order Order { get; set; } = null!; + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// Unmapped getter whose name a projected element row also declares. + public string Label => $"{Sku}#{Qty}"; + } + + public class P6Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public int? Bonus { get; set; } + + /// A computed column. + public decimal Doubled { get; set; } + + /// Owned. + public P6Text Title { get; set; } = new(); + + /// A column a value converter builds. + public P6Text Badge { get; set; } = new(); + + public int CustomerId { get; set; } + + public P6Customer Customer { get; set; } = null!; + + public List Lines { get; set; } = new(); + + /// Unmapped getter. + public string Display => $"{Code}/{Id}"; + } + + /// TPH. + public class P6Party + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class P6Vendor : P6Party + { + public string? Vat { get; set; } + } + + /// TPT. + public class P6Animal + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class P6Dog : P6Animal + { + public string? Breed { get; set; } + } + + /// Keyless. + public class P6Stat + { + public string Bucket { get; set; } = string.Empty; + + public int Total { get; set; } + } + + // ---- the rows a caller projects --------------------------------------------------------------------- + + public class P6Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + } + + public class P6LineRow + { + /// The entity declares this name and maps nothing for it. + public string Label { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// A name the entity does not declare at all. + public string Note { get; set; } = string.Empty; + } + + public class P6Row + { + public P6Row() + { + } + + public P6Row(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Tag { get; set; } = string.Empty; + + public string Flag { get; set; } = string.Empty; + + public int Count { get; set; } + + public decimal Total { get; set; } + + public long Wide { get; set; } + + public P6Text Title { get; set; } = new(); + + public P6Nest Nest { get; set; } = new(); + + /// A projected collection of projected rows. + public List Lines { get; set; } = new(); + + /// The entity's own rows, copied whole. + public List Raw { get; set; } = new(); + + public P6Customer? Customer { get; set; } + + public P6Line? One { get; set; } + } + + public sealed class P6Context : DbContext + { + private readonly SqliteConnection _connection; + + public P6Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + public DbSet Parties => Set(); + + public DbSet Animals => Set(); + + public DbSet Stats => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + model.Entity().Property(order => order.Badge).HasConversion( + new ValueConverter( + text => text.En, + stored => new P6Text { En = stored, Ar = stored })); + + model.Entity().Ignore(customer => customer.Handle); + + // Table splitting: the detail shares the customer's table. + model.Entity().ToTable("P6Customers"); + model.Entity().HasOne(customer => customer.Detail).WithOne() + .HasForeignKey(detail => detail.Id); + model.Entity().ToTable("P6Customers"); + + model.Entity().Ignore(line => line.Label); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + + // TPT: each type its own table. + model.Entity().ToTable("P6Animals"); + model.Entity().ToTable("P6Dogs"); + + model.Entity().HasNoKey().ToTable("P6Stats"); + } + } + + /// + /// Round 6 of the over-blocking hunt: every entity shape and every projection shape, run guarded + /// and unguarded, so a query that ran in 3.2.0 and still runs unguarded is visible when the guard + /// refuses it. + /// + public sealed class Pr6OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly P6Context _db; + private readonly List _findings = new(); + + public Pr6OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new P6Context(_connection); + _db.Database.EnsureCreated(); + + P6Customer customer = new() + { + Name = "Acme", + Detail = new P6Detail { Notes = "vip" } + }; + + P6Order order = new() + { + Customer = customer, + Code = "AB123", + Amount = 10m, + Bonus = 3, + Title = new P6Text { Ar = "AR", En = "EN" }, + Badge = new P6Text { En = "gold" } + }; + + order.Lines.Add(new P6Line { Sku = "S-1", Qty = 2 }); + order.Lines.Add(new P6Line { Sku = "S-2", Qty = 4 }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new P6Vendor { Kind = "vendor", Vat = "V-1" }); + _db.Animals.Add(new P6Dog { Name = "Rex", Breed = "collie" }); + _db.SaveChanges(); + + // Keyless rows cannot be tracked, so the one row goes in directly. + _db.Database.ExecuteSqlRaw("INSERT INTO P6Stats (Bucket, Total) VALUES ('b1', 7)"); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// A projection of the named field, which the tier exempts from the computed check. + private static string GuardedSelect(IQueryable source, string field) where T : class + { + try + { + FilterResult result = Guard(source) + .ToListDynamic(new Filter { Selects = new List { field } }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// The same request through the library with no policy attached at all. + private static string Unguarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = source.ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-64} unguarded={unguarded,-24} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Pr6-D. Entity shapes. + // ========================================================================================= + + [Fact] + public void Pr6_D_Every_mapped_entity_shape_is_left_alone() + { + Case("D01 plain column", + Raw(() => _db.Orders.Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + Case("D02 owned type's column", + Raw(() => _db.Orders.Where(o => o.Title.En == "EN").ToList()), + Guarded(_db.Orders, "Title.En", DataType.Text, "EN")); + + Case("D03 getter on an owned type (refusal is right)", + Raw(() => _db.Orders.Where(o => o.Title.IsEmpty).ToList()), + Guarded(_db.Orders, "Title.IsEmpty", DataType.Boolean, "false")); + + Case("D04 computed column", + Raw(() => _db.Orders.Where(o => o.Doubled == 20m).ToList()), + Guarded(_db.Orders, "Doubled", DataType.Number, "20")); + + Case("D05 a column a converter builds", + Raw(() => _db.Orders.Where(o => o.Badge.En == "gold").ToList()), + Guarded(_db.Orders, "Badge.En", DataType.Text, "gold")); + + Case("D06 nullable column", + Raw(() => _db.Orders.Where(o => o.Bonus == 3).ToList()), + Guarded(_db.Orders, "Bonus", DataType.Number, "3")); + + Case("D07 navigation to the principal", + Raw(() => _db.Orders.Where(o => o.Customer.Name == "Acme").ToList()), + Guarded(_db.Orders, "Customer.Name", DataType.Text, "Acme")); + + Case("D08 a framework member of a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + Case("D09 table splitting: the dependent's column", + Raw(() => _db.Customers.Where(c => c.Detail.Notes == "vip").ToList()), + Guarded(_db.Customers, "Detail.Notes", DataType.Text, "vip")); + + Case("D10 TPH: the base type's column", + Raw(() => _db.Parties.Where(p => p.Kind == "vendor").ToList()), + Guarded(_db.Parties, "Kind", DataType.Text, "vendor")); + + Case("D11 TPH: a subtype's own column, queried on the subtype", + Raw(() => _db.Parties.OfType().Where(v => v.Vat == "V-1").ToList()), + Guarded(_db.Parties.OfType(), "Vat", DataType.Text, "V-1")); + + Case("D12 TPT: the base type's column", + Raw(() => _db.Animals.Where(a => a.Name == "Rex").ToList()), + Guarded(_db.Animals, "Name", DataType.Text, "Rex")); + + Case("D13 TPT: a subtype's own column, queried on the subtype", + Raw(() => _db.Animals.OfType().Where(d => d.Breed == "collie").ToList()), + Guarded(_db.Animals.OfType(), "Breed", DataType.Text, "collie")); + + Case("D14 keyless entity", + Raw(() => _db.Stats.Where(s => s.Bucket == "b1").ToList()), + Guarded(_db.Stats, "Bucket", DataType.Text, "b1")); + + // Printed, not flagged: EF.Property is not something a Filter can express, so the + // comparison that means anything is D19 below. + _out.WriteLine("D15 shadow property, hand-written LINQ unguarded=" + + Raw(() => _db.Orders.Where(o => EF.Property(o, "Tenant") == "t1").ToList()) + + " guarded=" + Guarded(_db.Orders, "Tenant", DataType.Text, "t1")); + + Case("D16 an unmapped getter on the entity (refusal is right)", + Raw(() => _db.Orders.Where(o => o.Display == "AB123/1").ToList()), + Guarded(_db.Orders, "Display", DataType.Text, "AB123/1")); + + Case("D17 a collection's element column", + Raw(() => _db.Orders.Where(o => o.Lines.Any(l => l.Sku == "S-1")).ToList()), + Guarded(_db.Lines, "Sku", DataType.Text, "S-1")); + + Case("D18 a navigation back to the principal from the dependent", + Raw(() => _db.Lines.Where(l => l.Order.Code == "AB123").ToList()), + Guarded(_db.Lines, "Order.Code", DataType.Text, "AB123")); + + // The control that matters for a shadow property: no Filter a caller writes can name it, + // guarded or not, because it is not a member of the type. + Case("D19 the shadow property through the library, unguarded", + Unguarded(_db.Orders, "Tenant", DataType.Text, "t1"), + Guarded(_db.Orders, "Tenant", DataType.Text, "t1")); + + Case("D20 a mapped column through the library, unguarded", + Unguarded(_db.Orders, "Code", DataType.Text, "AB123"), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + Done(); + } + + // ========================================================================================= + // Pr6-E. Projection shapes: a member of a projected row is read out of the row, and the + // projection clause itself is exempt from what the provider can compute. + // ========================================================================================= + + [Fact] + public void Pr6_E_A_row_built_by_a_subquery_of_every_operator() + { + IQueryable rows = _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code, + Count = order.Lines.Count(), + Total = order.Lines.Sum(line => line.Qty), + Wide = order.Lines.Max(line => line.Qty), + Tag = order.Lines.OrderBy(line => line.Id).Select(line => line.Sku).FirstOrDefault() ?? "none", + Flag = order.Lines.Any(line => line.Qty > 3) ? "big" : "small", + Lines = order.Lines + .Where(line => line.Qty > 0) + .OrderBy(line => line.Id) + .Select(line => new P6LineRow { Label = line.Sku, Qty = line.Qty, Note = "n" }) + .ToList(), + Raw = order.Lines.ToList(), + One = order.Lines.OrderBy(line => line.Id).FirstOrDefault(), + Customer = order.Customer + }); + + Case("E01 Count() subquery", + Raw(() => Shape().Where(r => r.Count == 2).ToList()), + Guarded(rows, "Count", DataType.Number, "2")); + + Case("E02 Sum() subquery", + Raw(() => Shape().Where(r => r.Total == 6m).ToList()), + Guarded(rows, "Total", DataType.Number, "6")); + + Case("E03 Max() subquery", + Raw(() => Shape().Where(r => r.Wide == 4L).ToList()), + Guarded(rows, "Wide", DataType.Number, "4")); + + Case("E04 OrderBy/Select/FirstOrDefault with ??", + Raw(() => Shape().Where(r => r.Tag == "S-1").ToList()), + Guarded(rows, "Tag", DataType.Text, "S-1")); + + Case("E05 Any() inside a conditional", + Raw(() => Shape().Where(r => r.Flag == "big").ToList()), + Guarded(rows, "Flag", DataType.Text, "big")); + + Case("E06 a member of a projected collection of projected rows", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Qty == 2)).ToList()), + Guarded(rows, "Lines.Qty", DataType.Number, "2")); + + Case("E07 a member the element row assigns and the entity only computes", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(rows, "Lines.Label", DataType.Text, "S-1")); + + Case("E08 a member only the element row declares", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Note == "n")).ToList()), + Guarded(rows, "Lines.Note", DataType.Text, "n")); + + Case("E09 the entity's own rows copied whole", + Raw(() => Shape().Where(r => r.Raw.Any(l => l.Sku == "S-1")).ToList()), + Guarded(rows, "Raw.Sku", DataType.Text, "S-1")); + + Case("E10 a single entity read out of a collection", + Raw(() => Shape().Where(r => r.One!.Sku == "S-1").ToList()), + Guarded(rows, "One.Sku", DataType.Text, "S-1")); + + Case("E11 a navigation copied whole", + Raw(() => Shape().Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(rows, "Customer.Name", DataType.Text, "Acme")); + + Case("E12 a plain copied column", + Raw(() => Shape().Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Done(); + + IQueryable Shape() => _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code, + Count = order.Lines.Count(), + Total = order.Lines.Sum(line => line.Qty), + Wide = order.Lines.Max(line => line.Qty), + Tag = order.Lines.OrderBy(line => line.Id).Select(line => line.Sku).FirstOrDefault() ?? "none", + Flag = order.Lines.Any(line => line.Qty > 3) ? "big" : "small", + Lines = order.Lines + .Where(line => line.Qty > 0) + .OrderBy(line => line.Id) + .Select(line => new P6LineRow { Label = line.Sku, Qty = line.Qty, Note = "n" }) + .ToList(), + Raw = order.Lines.ToList(), + One = order.Lines.OrderBy(line => line.Id).FirstOrDefault(), + Customer = order.Customer + }); + } + + [Fact] + public void Pr6_E_Casts_conditionals_method_calls_and_nested_initializers() + { + IQueryable rows = _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code.ToUpper(), + Tag = order.Code.Substring(0, 2), + Flag = order.Bonus == null ? "none" : "some", + Total = (decimal)order.Amount, + Wide = (long)order.Id, + Title = new P6Text { Ar = order.Title.Ar, En = order.Title.En }, + Nest = new P6Nest { A = order.Code, B = order.Customer.Name } + }); + + Case("E20 a method call on a column", + Raw(() => Shape().Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Case("E21 Substring", + Raw(() => Shape().Where(r => r.Tag == "AB").ToList()), + Guarded(rows, "Tag", DataType.Text, "AB")); + + Case("E22 a conditional over a null check", + Raw(() => Shape().Where(r => r.Flag == "some").ToList()), + Guarded(rows, "Flag", DataType.Text, "some")); + + Case("E23 a cast", + Raw(() => Shape().Where(r => r.Total == 10m).ToList()), + Guarded(rows, "Total", DataType.Number, "10")); + + Case("E24 a widening cast", + Raw(() => Shape().Where(r => r.Wide == 1L).ToList()), + Guarded(rows, "Wide", DataType.Number, "1")); + + Case("E25 a nested initializer rebuilt member by member", + Raw(() => Shape().Where(r => r.Title.En == "EN").ToList()), + Guarded(rows, "Title.En", DataType.Text, "EN")); + + Case("E26 a nested initializer of a type the entity has no member for", + Raw(() => Shape().Where(r => r.Nest.A == "AB123").ToList()), + Guarded(rows, "Nest.A", DataType.Text, "AB123")); + + Case("E27 a nested member read from a navigation", + Raw(() => Shape().Where(r => r.Nest.B == "Acme").ToList()), + Guarded(rows, "Nest.B", DataType.Text, "Acme")); + + Done(); + + IQueryable Shape() => _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code.ToUpper(), + Tag = order.Code.Substring(0, 2), + Flag = order.Bonus == null ? "none" : "some", + Total = (decimal)order.Amount, + Wide = (long)order.Id, + Title = new P6Text { Ar = order.Title.Ar, En = order.Title.En }, + Nest = new P6Nest { A = order.Code, B = order.Customer.Name } + }); + } + + [Fact] + public void Pr6_E_A_constructor_with_arguments_and_a_second_projection_over_the_first() + { + IQueryable built = _db.Orders.Select(order => new P6Row(order.Id, order.Code) + { + Tag = order.Title.En + }); + + Case("E30 a member the constructor sets", + Raw(() => _db.Orders.Select(o => new P6Row(o.Id, o.Code) { Tag = o.Title.En }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(built, "Code", DataType.Text, "AB123")); + + Case("E31 a binding beside the constructor", + Raw(() => _db.Orders.Select(o => new P6Row(o.Id, o.Code) { Tag = o.Title.En }) + .Where(r => r.Tag == "EN").ToList()), + Guarded(built, "Tag", DataType.Text, "EN")); + + // Members of one projected row read from another projected row's members. + IQueryable twice = _db.Orders + .Select(order => new P6Row { Id = order.Id, Code = order.Code, Tag = order.Title.En }) + .Select(row => new P6Row { Id = row.Id, Code = row.Tag, Tag = row.Code }); + + Case("E32 a second projection over the first", + Raw(() => _db.Orders + .Select(o => new P6Row { Id = o.Id, Code = o.Code, Tag = o.Title.En }) + .Select(r => new P6Row { Id = r.Id, Code = r.Tag, Tag = r.Code }) + .Where(r => r.Code == "EN").ToList()), + Guarded(twice, "Code", DataType.Text, "EN")); + + Case("E33 the swapped member of the second projection", + Raw(() => _db.Orders + .Select(o => new P6Row { Id = o.Id, Code = o.Code, Tag = o.Title.En }) + .Select(r => new P6Row { Id = r.Id, Code = r.Tag, Tag = r.Code }) + .Where(r => r.Tag == "AB123").ToList()), + Guarded(twice, "Tag", DataType.Text, "AB123")); + + // An anonymous row: nothing can say which member each value sets. + var anonymous = _db.Orders.Select(order => new { order.Id, order.Code, Name = order.Customer.Name }); + + Case("E34 an anonymous row", + Raw(() => _db.Orders.Select(o => new { o.Id, o.Code, Name = o.Customer.Name }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(anonymous, "Code", DataType.Text, "AB123")); + + Case("E35 an anonymous row's renamed member", + Raw(() => _db.Orders.Select(o => new { o.Id, o.Code, Name = o.Customer.Name }) + .Where(r => r.Name == "Acme").ToList()), + Guarded(anonymous, "Name", DataType.Text, "Acme")); + + Done(); + } + + [Fact] + public void Pr6_E_A_projection_clause_still_returns_what_no_database_can_compute() + { + Case("E40 select a getter over two owned columns", + Raw(() => _db.Orders.Select(o => new { o.Title.IsEmpty }).ToList()), + GuardedSelect(_db.Orders, "Title.IsEmpty")); + + Case("E41 select an unmapped getter on the entity", + Raw(() => _db.Orders.Select(o => new { o.Display }).ToList()), + GuardedSelect(_db.Orders, "Display")); + + Case("E42 select a plain column", + Raw(() => _db.Orders.Select(o => new { o.Code }).ToList()), + GuardedSelect(_db.Orders, "Code")); + + Case("E43 select a computed column", + Raw(() => _db.Orders.Select(o => new { o.Doubled }).ToList()), + GuardedSelect(_db.Orders, "Doubled")); + + Case("E44 select through a navigation", + Raw(() => _db.Orders.Select(o => new { o.Customer.Name }).ToList()), + GuardedSelect(_db.Orders, "Customer.Name")); + + Done(); + } + + [Fact] + public void Pr6_E_A_source_composed_before_the_guard() + { + Case("E50 a filtered source", + Raw(() => _db.Orders.Where(o => o.Amount > 0m).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Where(o => o.Amount > 0m), "Code", DataType.Text, "AB123")); + + Case("E51 an ordered source", + Raw(() => _db.Orders.OrderBy(o => o.Id).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.OrderBy(o => o.Id), "Code", DataType.Text, "AB123")); + + Case("E52 a source that took a page", + Raw(() => _db.Orders.OrderBy(o => o.Id).Take(10).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.OrderBy(o => o.Id).Take(10), "Code", DataType.Text, "AB123")); + + Case("E53 a distinct source", + Raw(() => _db.Orders.Distinct().Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Distinct(), "Code", DataType.Text, "AB123")); + + Case("E54 a set operation", + Raw(() => _db.Orders.Where(o => o.Id > 0).Union(_db.Orders.Where(o => o.Id < 0)) + .Where(o => o.Code == "AB123").ToList()), + Guarded( + _db.Orders.Where(o => o.Id > 0).Union(_db.Orders.Where(o => o.Id < 0)), + "Code", DataType.Text, "AB123")); + + Case("E55 SelectMany over a collection", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Sku == "S-1").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Sku", DataType.Text, "S-1")); + + Case("E56 an included navigation", + Raw(() => _db.Orders.Include(o => o.Lines).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Include(o => o.Lines), "Code", DataType.Text, "AB123")); + + Done(); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs new file mode 100644 index 0000000..a8985d0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs @@ -0,0 +1,528 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- models ----------------------------------------------------------------------------------------- + + /// A row of a generated summary, whose two key columns the probe sets by hand. + public class Pr6KeyRow + { + public string? K1 { get; set; } + + public string? K2 { get; set; } + + public int N { get; set; } + } + + /// A transformer that returns what it was given, so the composite key is the probe's own text. + public sealed class Pr6Identity : IValueTransformer + { + public object? Transform(object? value, DwTransformContext context) => value; + } + + /// Aliases that resolve, and aliases that collide. + public class Pr6Aliased + { + public int Id { get; set; } + + /// A name nothing else answers to: it must still resolve. + [DwAlias("Handle")] + public string Login { get; set; } = string.Empty; + + /// An alias spelled exactly like its own path: one candidate, not two. + [DwAlias("Same")] + public string Same { get; set; } = string.Empty; + + /// An alias that is also a real property of the type. + [DwAlias("Code")] + public string Serial { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + + /// Two members sharing one alias. + [DwAlias("Ref")] + public string RefA { get; set; } = string.Empty; + + [DwAlias("Ref")] + public string RefB { get; set; } = string.Empty; + } + + /// One alias on a type that appears inside its own navigation graph. + public class Pr6Node + { + public int Id { get; set; } + + [DwAlias("Tag")] + public string Label { get; set; } = string.Empty; + + public Pr6Node? Parent { get; set; } + + public List Children { get; set; } = new(); + } + + /// A type whose values are transformed, which is what makes a query unmaterializable. + public class Pr6Masked + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Secret { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Full)] + public string Other { get; set; } = string.Empty; + } + + /// + /// Round 6: the four refusals the fifth review changed, and the two literals it rewrote. + /// + /// + /// Every probe prints its answer and every probe runs on 3.2.0 unchanged, so the two outputs are + /// diffed rather than asserted against a remembered expectation. + /// + public sealed class Pr6PrecisionProbes + { + private readonly ITestOutputHelper _out; + + public Pr6PrecisionProbes(ITestOutputHelper output) => _out = output; + + // Built from code points, never from an escape, so nothing between here and the file can + // decode one into the character it names. + private static readonly string Nul = ((char)0).ToString(); + + private static readonly string Unit = ((char)31).ToString(); + + private static DwPolicyContext Caller(bool dryRun = false) => + new DwPolicyContext { DryRun = dryRun }.WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + private void Line(string probe, string answer) => _out.WriteLine($"{probe,-58} {answer}"); + + // ========================================================================================= + // Pr6-A. The composite grouping key. Two escapes replaced a raw NUL and a raw unit + // separator: every collision and every non-collision below pins both to the exact + // code point, because each case turns on one of them. + // ========================================================================================= + + private static TypePolicy KeyPolicy() => new( + new Dictionary>(StringComparer.OrdinalIgnoreCase), + Array.Empty(), + new Dictionary>(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["K1"] = new ValueTransform(mutate: new MutateStage(typeof(Pr6Identity))), + ["K2"] = new ValueTransform(mutate: new MutateStage(typeof(Pr6Identity))) + }); + + /// Runs the summary transformer over rows whose key columns the probe chose. + private static string Keys( + (string? K1, string? K2)[] rows, + DwTier tier = DwTier.Strict, + bool optionsDryRun = false, + bool contextDryRun = false) + { + SummaryResult result = new(); + + foreach ((string? k1, string? k2) in rows) + { + result.Data.Add(new Pr6KeyRow { K1 = k1, K2 = k2, N = 1 }); + } + + Summary summary = new() + { + GroupBy = new GroupBy { Fields = { "K1", "K2" } } + }; + + DwPolicyOptions options = Options(tier, optionsDryRun); + + try + { + ResultTransformer.Summary( + result, summary, KeyPolicy(), Caller(contextDryRun), options, + new PolicyTrace(tier, optionsDryRun || contextDryRun)); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath}|origin=" + + (refusal.SourceOrigin is null ? "null" : "set") + ")"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_A_The_composite_key_is_the_same_bytes_it_always_was() + { + Line("A01 two plain keys that differ", Keys(new (string?, string?)[] { ("a", "b"), ("c", "d") })); + Line("A02 two plain keys that match", Keys(new (string?, string?)[] { ("a", "b"), ("a", "b") })); + Line("A03 one row only", Keys(new (string?, string?)[] { ("a", "b") })); + Line("A04 no rows", Keys(Array.Empty<(string?, string?)>())); + + // The separator. These collide if and only if the joiner is exactly U+001F. + Line("A05 separator straddled by a value", + Keys(new (string?, string?)[] { ("a" + Unit + "b", "c"), ("a", "b" + Unit + "c") })); + Line("A06 separator at both ends", + Keys(new (string?, string?)[] { (Unit, string.Empty), (string.Empty, Unit) })); + Line("A07 a value that is only separators", + Keys(new (string?, string?)[] { (Unit + Unit, "x"), (Unit, Unit + "x") })); + + // The null sentinel. These collide if and only if the sentinel is exactly one U+0000. + Line("A08 null against a literal NUL", Keys(new (string?, string?)[] { (null, "c"), (Nul, "c") })); + Line("A09 null on both keys against NUL on both", + Keys(new (string?, string?)[] { (null, null), (Nul, Nul) })); + Line("A10 empty string is not the null sentinel", + Keys(new (string?, string?)[] { (string.Empty, "c"), (null, "c") })); + Line("A11 empty string is not a literal NUL", + Keys(new (string?, string?)[] { (string.Empty, "c"), (Nul, "c") })); + Line("A12 two nulls in the same column", + Keys(new (string?, string?)[] { (null, "c"), (null, "d") })); + + // Both characters inside the values at once. + Line("A13 NUL and separator inside the values", + Keys(new (string?, string?)[] { (Nul + Unit, "y"), (Nul, Unit + "y") })); + Line("A14 NUL and separator, not colliding", + Keys(new (string?, string?)[] { (Nul + Unit, "y"), (Unit + Nul, "y") })); + + // Detection order: the third row is the one that repeats. + Line("A15 collision only between rows two and three", + Keys(new (string?, string?)[] { ("a", "b"), ("c", "d"), ("c", "d") })); + Line("A16 three rows, none repeating", + Keys(new (string?, string?)[] { ("a", "b"), ("c", "d"), ("e", "f") })); + + // Ordinal, not case-insensitive: unchanged either way, and a change would show here. + Line("A17 keys differing only in case", Keys(new (string?, string?)[] { ("A", "b"), ("a", "b") })); + } + + [Fact] + public void Pr6_A_What_the_group_key_refusal_says_in_each_posture() + { + (string?, string?)[] colliding = { ("a", "b"), ("a", "b") }; + + Line("A18 strict", Keys(colliding)); + Line("A19 convenience", Keys(colliding, DwTier.Convenience)); + Line("A20 strict, options dry run", Keys(colliding, optionsDryRun: true)); + Line("A21 strict, CONTEXT dry run", Keys(colliding, contextDryRun: true)); + Line("A22 convenience, context dry run", Keys(colliding, DwTier.Convenience, contextDryRun: true)); + } + + // ========================================================================================= + // Pr6-B. A name matching more than one field. Under the strict tier it is no longer refused + // with AmbiguousFieldName; it is refused as an unknown name is. Nothing that resolved + // before may stop resolving, and nothing refused before may start succeeding. + // ========================================================================================= + + private static Condition On(string field) => new() + { + Sort = 0, Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }; + + private static string Sanitize( + Filter filter, + DwTier tier = DwTier.Strict, + bool optionsDryRun = false, + bool contextDryRun = false, + PolicyTrace? trace = null) + where T : class + { + trace ??= new PolicyTrace(tier, optionsDryRun || contextDryRun); + + try + { + Filter result = FilterSanitizer.Sanitize( + filter, Attributes(), Caller(contextDryRun), Options(tier, optionsDryRun), trace); + + string fields = string.Join( + ",", + (result.ConditionGroup?.Conditions ?? new List()).Select(c => c.Field)); + + string selects = result.Selects is null ? "-" : string.Join(",", result.Selects); + string orders = result.Orders is null ? "-" : string.Join(",", result.Orders.Select(o => o.Field)); + + return $"OK(where={fields};select={selects};order={orders})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath}|origin=" + + (refusal.SourceOrigin is null ? "null" : "set") + ")"; + } + catch (LogicException failure) + { + return $"LOGIC({failure.Message})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string SanitizeSummary(Summary summary, DwTier tier = DwTier.Strict) where T : class + { + try + { + Summary result = FilterSanitizer.Sanitize( + summary, Attributes(), Caller(), Options(tier), new PolicyTrace(tier, false)); + + return "OK(" + string.Join(",", result.GroupBy?.Fields ?? new List()) + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (LogicException failure) + { + return $"LOGIC({failure.Message})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static Filter Where(string field) => + new() { ConditionGroup = new ConditionGroup { Conditions = { On(field) } } }; + + [Fact] + public void Pr6_B_An_alias_that_resolved_still_resolves() + { + Line("B01 unique alias, where", Sanitize(Where("Handle"))); + Line("B02 unique alias, convenience", Sanitize(Where("Handle"), DwTier.Convenience)); + Line("B03 alias spelled like its own path", Sanitize(Where("Same"))); + Line("B04 the plain property behind an alias", Sanitize(Where("Login"))); + Line("B05 a property no alias touches", Sanitize(Where("Id"))); + Line("B06 one member reached many ways", Sanitize(Where("Tag"))); + Line("B07 one member many ways, convenience", + Sanitize(Where("Tag"), DwTier.Convenience)); + Line("B08 the path that alias declares", Sanitize(Where("Label"))); + Line("B09 a deeper path of the same member", Sanitize(Where("Parent.Label"))); + + Line("B10 unique alias, select", Sanitize(new Filter { Selects = new List { "Handle" } })); + Line("B11 unique alias, order", + Sanitize(new Filter { Orders = new List { new OrderBy { Field = "Handle" } } })); + Line("B12 unique alias, group", + SanitizeSummary(new Summary { GroupBy = new GroupBy { Fields = { "Handle" } } })); + } + + [Fact] + public void Pr6_B_A_name_matching_more_than_one_field_in_every_clause() + { + Line("B20 alias colliding with a property, where", Sanitize(Where("Code"))); + Line("B21 two members one alias, where", Sanitize(Where("Ref"))); + + Line("B22 colliding name, select alone", + Sanitize(new Filter { Selects = new List { "Code" } })); + Line("B23 colliding name beside a good select", + Sanitize(new Filter { Selects = new List { "Id", "Code" } })); + Line("B24 colliding name, order", + Sanitize(new Filter { Orders = new List { new OrderBy { Field = "Code" } } })); + Line("B25 colliding name, order beside a good one", + Sanitize(new Filter + { + Orders = new List { new OrderBy { Field = "Id" }, new OrderBy { Field = "Code" } } + })); + Line("B26 colliding name, nested condition group", + Sanitize(new Filter + { + ConditionGroup = new ConditionGroup + { + SubConditionGroups = { new ConditionGroup { Conditions = { On("Code") } } } + } + })); + Line("B27 colliding name, grouping key", + SanitizeSummary(new Summary { GroupBy = new GroupBy { Fields = { "Code" } } })); + Line("B28 colliding name, aggregated field", + SanitizeSummary(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Id" }, + AggregateBy = + { + new AggregateBy { Field = "Code", Alias = "c", Aggregator = Aggregator.Maximum } + } + } + })); + Line("B29 colliding name beside a legitimate one", + Sanitize(new Filter + { + ConditionGroup = new ConditionGroup { Conditions = { On("Handle"), On("Code") } } + })); + } + + [Fact] + public void Pr6_B_The_colliding_name_in_every_posture() + { + Line("B30 strict", Sanitize(Where("Code"))); + Line("B31 convenience", Sanitize(Where("Code"), DwTier.Convenience)); + Line("B32 strict, options dry run", Sanitize(Where("Code"), optionsDryRun: true)); + Line("B33 strict, CONTEXT dry run", Sanitize(Where("Code"), contextDryRun: true)); + Line("B34 convenience, options dry run", + Sanitize(Where("Code"), DwTier.Convenience, optionsDryRun: true)); + } + + [Fact] + public void Pr6_B_What_the_trace_says_about_a_colliding_name() + { + PolicyTrace trace = new(DwTier.Strict, false); + + Line("B40 strict refusal", Sanitize(Where("Code"), trace: trace)); + + foreach (PolicyDecision decision in trace.Decisions) + { + Line("B41 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + + PolicyTrace two = new(DwTier.Strict, false); + + Line("B42 two members one alias", Sanitize(Where("Ref"), trace: two)); + + foreach (PolicyDecision decision in two.Decisions) + { + Line("B43 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + } + + [Fact] + public void Pr6_B_What_a_caller_who_catches_the_refusal_can_read() + { + // End to end, through the handle a host actually uses: the refusal names no field, so + // LastTrace has to be where the operator reads which name was ambiguous and why. + List rows = new() { new Pr6Aliased() }; + + PolicyQueryable handle = + rows.AsQueryable().ApplyPolicy(Caller(), Options(), Attributes()); + + try + { + _ = handle.ToList(Where("Code")); + + Line("B50 through the handle", "OK"); + } + catch (PolicyException refusal) + { + Line("B50 through the handle", + $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"); + } + + Line("B51 LastTrace", handle.LastTrace is null ? "null" : "present"); + + foreach (PolicyDecision decision in handle.LastTrace?.Decisions ?? new List()) + { + Line("B52 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + } + + // ========================================================================================= + // Pr6-C. TransformRequiresMaterialization, MissingHashSalt and MissingTokenVault: what each + // names, in each posture, including the per-context dry run the rest of the library + // treats as a dry run. + // ========================================================================================= + + private static string Unmaterialized(DwTier tier, bool optionsDryRun, bool contextDryRun) + { + List rows = new() { new Pr6Masked { Id = 1 } }; + + try + { + _ = rows.AsQueryable() + .ApplyPolicy(Caller(contextDryRun), Options(tier, optionsDryRun), Attributes()) + .SelectDynamic(new List { "Id" }); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_C_A_query_the_caller_materializes_over_a_transformed_type() + { + Line("C01 strict", Unmaterialized(DwTier.Strict, false, false)); + Line("C02 convenience", Unmaterialized(DwTier.Convenience, false, false)); + Line("C03 strict, options dry run", Unmaterialized(DwTier.Strict, true, false)); + Line("C04 strict, CONTEXT dry run", Unmaterialized(DwTier.Strict, false, true)); + Line("C05 convenience, context dry run", Unmaterialized(DwTier.Convenience, false, true)); + } + + private static string Chain( + ValueTransform chain, DwTier tier, bool optionsDryRun, bool contextDryRun, string? salt = null) + { + DwPolicyOptions options = Options(tier, optionsDryRun); + + if (salt is not null) + { + options.HashSalt = salt; + } + + try + { + object? value = TransformPipeline.Apply( + chain, + "abcdef", + typeof(string), + new DwTransformContext(new Pr6Masked(), "Secret", Caller(contextDryRun)), + options); + + return "OK(" + (value ?? "null") + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_C_A_hash_with_no_salt_and_a_token_with_no_vault() + { + ValueTransform hash = new(mask: new MaskStage(MaskStrategy.Hash)); + ValueTransform token = new(mask: new MaskStage(MaskStrategy.Tokenize)); + + Line("C10 hash, strict", Chain(hash, DwTier.Strict, false, false)); + Line("C11 hash, convenience", Chain(hash, DwTier.Convenience, false, false)); + Line("C12 hash, strict, options dry run", Chain(hash, DwTier.Strict, true, false)); + Line("C13 hash, strict, CONTEXT dry run", Chain(hash, DwTier.Strict, false, true)); + + Line("C14 token, strict", Chain(token, DwTier.Strict, false, false)); + Line("C15 token, convenience", Chain(token, DwTier.Convenience, false, false)); + Line("C16 token, strict, options dry run", Chain(token, DwTier.Strict, true, false)); + Line("C17 token, strict, CONTEXT dry run", Chain(token, DwTier.Strict, false, true)); + + // A salt that is configured still hashes, in every posture: the refusal is the only thing + // that changed, and it must not have taken the success with it. + Line("C18 hash with a salt, strict", + Chain(hash, DwTier.Strict, false, false, "0123456789abcdefghij")); + Line("C19 hash with a salt, convenience", + Chain(hash, DwTier.Convenience, false, false, "0123456789abcdefghij")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8EfCoreTests.cs b/DynamicWhere.Tests/Policies/Rd8EfCoreTests.cs new file mode 100644 index 0000000..b86b9b1 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8EfCoreTests.cs @@ -0,0 +1,196 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies +{ + public class Rd8Pet + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public decimal? Price { get; set; } + + public decimal? Weight { get; set; } + } + + public class Rd8Hound : Rd8Pet + { + [DwMask(MaskStrategy.Full)] + public string? Chip { get; set; } + } + + public class Rd8N1 { public int Id { get; set; } public Rd8N2? B { get; set; } } + + public class Rd8N2 { public int Id { get; set; } public Rd8N3? C { get; set; } } + + public class Rd8N3 { public int Id { get; set; } public Rd8N4? D { get; set; } } + + public class Rd8N4 { public int Id { get; set; } public Rd8N5? E { get; set; } } + + public class Rd8N5 + { + public int Id { get; set; } + + [DwAudit] + [DwMask(MaskStrategy.Full)] + public string? Card { get; set; } + } + + public sealed class Rd8Context : DbContext + { + private readonly SqliteConnection _connection; + + public Rd8Context(SqliteConnection connection) => _connection = connection; + + public DbSet Pets => Set(); + + public DbSet Roots => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity(); + } + + /// + /// Round 8's findings on a database rather than on rows in memory: what a provider translates, and + /// what EF Core materializes, is where each of them was reachable in a deployment. + /// + public sealed class Rd8EfCoreTests : IDisposable + { + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + private readonly Rd8Context _db; + + public Rd8EfCoreTests() + { + _connection.Open(); + _db = new Rd8Context(_connection); + _db.Database.EnsureCreated(); + + _db.Pets.Add(new Rd8Hound { Id = 1, Name = "rex", Price = 5100m, Weight = 30m, Chip = "CHIP-123" }); + _db.Pets.Add(new Rd8Pet { Id = 2, Name = "tom", Price = 900m, Weight = 4m }); + _db.Roots.Add(new Rd8N1 { Id = 1, B = new() { Id = 2, C = new() { Id = 3, D = new() { Id = 4, E = new() { Id = 5, Card = "4111111111111111" } } } } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guard(IQueryable query, DwTier tier) where T : class => + query.ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, object value) => new() + { + ConditionGroup = new ConditionGroup + { + Connector = Connector.And, + Conditions = new() { new Condition { Sort = 0, Field = field, DataType = DataType.Number, Operator = Operator.GreaterThan, Values = new() { value } } } + }, + Selects = new() { "Id" } + }; + + /// The provider translates it, which is what made it a way round the denial. + [Fact] + public void The_database_answers_a_path_beneath_a_nullable_column() + { + Assert.Equal(new[] { 1 }, _db.Pets.ToList(Where("Price.Value", 1000)).Data!.Select(pet => pet.Id)); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Beneath_a_denied_column_it_is_refused_and_beneath_an_open_one_it_runs(DwTier tier) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForWhere, + Assert.Throws(() => Guard(_db.Pets, tier).ToList(Where("Price.Value", 1000))).ErrorCode); + + Assert.Equal(new[] { 1 }, Guard(_db.Pets, tier).ToList(Where("Weight.Value", 10)).Data!.Select(pet => pet.Id)); + } + + /// A hierarchy's rows are the types the database says they are, with the members those declare. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_column_only_a_derived_entity_maps_is_masked(DwTier tier) + { + List pets = Guard(_db.Pets.OrderBy(pet => pet.Id), tier).ToList(new Filter { Selects = new() { "Id", "Name" } }).Data!; + + Assert.Equal(new[] { "rex", "tom" }, pets.Select(pet => pet.Name)); + + List hounds = Guard(_db.Set(), tier).ToList(new Filter()).Data!; + + Assert.Equal("********", Assert.Single(hounds).Chip); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_column_five_segments_down_an_included_graph_is_masked(DwTier tier) + { + IQueryable graph = _db.Roots + .Include(root => root.B!).ThenInclude(b => b.C!).ThenInclude(c => c.D!).ThenInclude(d => d.E); + + Rd8N1 unguarded = graph.AsNoTracking().Single(); + + Assert.Equal("4111111111111111", unguarded.B!.C!.D!.E!.Card); + + // The navigation is kept whole. Read as a denial, the masked column five segments down had the + // projection gate leave the whole included graph out, which withholds what the policy allows. + Rd8N1 row = Guard(graph, tier).ToList(new Filter()).Data!.Single(); + + Assert.NotNull(row.B?.C?.D?.E); + Assert.Equal("****************", row.B!.C!.D!.E!.Card); + } + + /// + /// A typed projection builds the real types, so the rows show the audited member whether the + /// projection named it or not. Named, the gate was asked about it and recorded it; left out, the + /// row built for the projection holds nothing in it and nothing was read. + /// + [Fact] + public void An_audited_column_past_the_walk_is_recorded_once_when_named_and_not_when_left_out() + { + IQueryable graph = _db.Roots; + + PolicyQueryable Deep(DwPolicyContext caller) => graph.ApplyPolicy( + caller, + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MaxNavigationDepth = 6 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + DwPolicyContext named = Caller(); + + Rd8N1 row = Deep(named).ToList(new Filter { Selects = new() { "Id", "B.C.D.E.Card" } }).Data!.Single(); + + Assert.Equal("****************", row.B!.C!.D!.E!.Card); + Assert.Single(named.PendingAuditEvents, read => read.FieldPath == "B.C.D.E.Card"); + + DwPolicyContext leftOut = Caller(); + + Deep(leftOut).ToList(new Filter { Selects = new() { "Id", "B.C.D.E.Id" } }); + + Assert.DoesNotContain(leftOut.PendingAuditEvents, read => read.FieldPath == "B.C.D.E.Card"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8GroupFloorNullListTests.cs b/DynamicWhere.Tests/Policies/Rd8GroupFloorNullListTests.cs new file mode 100644 index 0000000..4484232 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8GroupFloorNullListTests.cs @@ -0,0 +1,57 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A request body carrying "conditions": null or "subConditionGroups": null overwrites + /// the list's initializer. The pipeline takes such a group and every other reader in the gate + /// checks for it; the group floor's own walk over Having did not, so with the floor on, which + /// is the default, a summary that runs unguarded failed guarded with a null reference. + /// + public sealed class Rd8GroupFloorNullListTests + { + private static readonly Rd8Line[] Rows = { new() { Id = 1 }, new() { Id = 1 }, new() { Id = 2 } }; + + private static GroupBy Grouping() => new() + { + Fields = new() { "Id" }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + }; + + public static TheoryData Havings() => new() + { + new ConditionGroup { Connector = Connector.And, Conditions = null! }, + new ConditionGroup { Connector = Connector.And, SubConditionGroups = null! }, + new ConditionGroup + { + Connector = Connector.And, + SubConditionGroups = new() { new ConditionGroup { Connector = Connector.And, Conditions = null!, SubConditionGroups = null! } } + } + }; + + [Theory] + [MemberData(nameof(Havings))] + public void A_having_with_a_null_list_runs_guarded_as_it_runs_unguarded_and_the_floor_still_applies(ConditionGroup having) + { + Assert.Equal(2, Rows.AsQueryable().ToList(new Summary { GroupBy = Grouping(), Having = having.Clone() }).Data!.Count); + + List kept = Rows.AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 2 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Summary { GroupBy = Grouping(), Having = having }).Data!; + + // The group of one is below the floor of two. + Assert.Equal(new[] { 1 }, kept.Select(row => (int)row.Id)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8NullEntryTests.cs b/DynamicWhere.Tests/Policies/Rd8NullEntryTests.cs new file mode 100644 index 0000000..a552bb6 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8NullEntryTests.cs @@ -0,0 +1,278 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies +{ + /// A row to ask malformed requests about. + public class Rd8Stocked + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public decimal Price { get; set; } + } + + /// The database behind it, since a segment is read asynchronously. + public sealed class Rd8StockContext : DbContext + { + private readonly SqliteConnection _connection; + + public Rd8StockContext(SqliteConnection connection) => _connection = connection; + + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// A request whose lists hold a null entry is refused as a malformed request, with a + /// naming the list, by every method that takes the shape, with or + /// without a policy. + /// + /// + /// A request body can say "conditions": [null]. It used to surface as a + /// or an from wherever + /// the entry was first touched: the sort-order check, the ordering, the name lookup, or, under a + /// policy, the copy the sanitizer takes before it reads anything. + /// + public sealed class Rd8NullEntryTests : IDisposable + { + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + private readonly Rd8StockContext _db; + + public Rd8NullEntryTests() + { + _connection.Open(); + _db = new Rd8StockContext(_connection); + _db.Database.EnsureCreated(); + _db.Items.Add(new Rd8Stocked { Id = 1, Name = "a", Price = 2m }); + _db.Items.Add(new Rd8Stocked { Id = 2, Name = "b", Price = 3m }); + _db.SaveChanges(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static readonly PolicyResolver Attributes = new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private PolicyQueryable Guarded(DwTier tier) => _db.Items.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + Attributes); + + private static Condition Real() => + new() { Sort = 1, Field = "Id", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { 0 } }; + + private static ConditionGroup Group(List? conditions = null, List? subs = null) => new() + { + Connector = Connector.And, + Conditions = conditions ?? new List { Real() }, + SubConditionGroups = subs ?? new List() + }; + + private static GroupBy Grouping(List? aggregates = null) => new() + { + Fields = { "Name" }, + AggregateBy = aggregates ?? new List { new() { Aggregator = Aggregator.Count, Alias = "n" } } + }; + + /// Every malformed filter, with the refusal it earns. + public static TheoryData Filters() => new() + { + { "conditions", ErrorCode.NullEntry("Conditions") }, + { "subgroups", ErrorCode.NullEntry("SubConditionGroups") }, + { "nested", ErrorCode.NullEntry("Conditions") }, + { "orders", ErrorCode.NullEntry("Orders") }, + { "selects", ErrorCode.InvalidField }, + { "blank-select", ErrorCode.InvalidField }, + { "white-select", ErrorCode.InvalidField }, + }; + + private static Filter Malformed(string which) => which switch + { + "conditions" => new Filter { ConditionGroup = Group(new List { Real(), null! }) }, + "subgroups" => new Filter { ConditionGroup = Group(subs: new List { null! }) }, + "nested" => new Filter { ConditionGroup = Group(subs: new List { Group(new List { null! }) }) }, + "orders" => new Filter { Orders = new List { new() { Sort = 1, Field = "Id" }, null! } }, + "selects" => new Filter { Selects = new List { "Id", null! } }, + "blank-select" => new Filter { Selects = new List { "Id", "" } }, + "white-select" => new Filter { Selects = new List { " " } }, + _ => throw new ArgumentOutOfRangeException(nameof(which)) + }; + + [Theory] + [MemberData(nameof(Filters))] + public void A_malformed_filter_is_refused_as_one_without_a_policy(string which, string message) + { + Assert.Equal(message, Assert.Throws(() => _db.Items.ToList(Malformed(which))).Message); + Assert.Equal(message, Assert.Throws(() => _db.Items.ToListDynamic(Malformed(which))).Message); + Assert.Equal(message, Assert.Throws(() => _db.Items.Filter(Malformed(which)).ToList()).Message); + Assert.Equal(message, Assert.Throws(() => _db.Items.FilterDynamic(Malformed(which))).Message); + } + + [Theory] + [MemberData(nameof(Filters))] + public async Task A_malformed_filter_is_refused_as_one_when_read_asynchronously(string which, string message) + { + Assert.Equal(message, (await Assert.ThrowsAsync(() => _db.Items.ToListAsync(Malformed(which)))).Message); + Assert.Equal(message, (await Assert.ThrowsAsync(() => _db.Items.ToListAsyncDynamic(Malformed(which)))).Message); + } + + [Theory] + [MemberData(nameof(Filters))] + public void A_malformed_filter_is_refused_as_one_under_a_policy(string which, string message) + { + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Assert.Equal(message, Assert.Throws(() => Guarded(tier).ToList(Malformed(which))).Message); + Assert.Equal(message, Assert.Throws(() => Guarded(tier).ToListDynamic(Malformed(which))).Message); + Assert.Equal(message, Assert.Throws(() => Guarded(tier).Filter(Malformed(which))).Message); + } + } + + [Fact] + public void The_composable_methods_refuse_it_too() + { + string conditions = ErrorCode.NullEntry("Conditions"); + string orders = ErrorCode.NullEntry("Orders"); + string aggregates = ErrorCode.NullEntry("AggregateBy"); + + Assert.Equal(conditions, Assert.Throws(() => _db.Items.Where(Group(new List { null! }))).Message); + Assert.Equal(orders, Assert.Throws(() => _db.Items.Order(new List { null! })).Message); + Assert.Equal(ErrorCode.InvalidField, Assert.Throws(() => _db.Items.Select(new List { null! })).Message); + Assert.Equal(ErrorCode.InvalidField, Assert.Throws(() => _db.Items.SelectDynamic(new List { "" })).Message); + Assert.Equal(aggregates, Assert.Throws(() => _db.Items.Group(Grouping(new List { null! }))).Message); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Assert.Equal(conditions, Assert.Throws(() => Guarded(tier).Where(Group(new List { null! }))).Message); + Assert.Equal(orders, Assert.Throws(() => Guarded(tier).Order(new List { null! })).Message); + Assert.Equal(ErrorCode.InvalidField, Assert.Throws(() => Guarded(tier).Select(new List { null! })).Message); + Assert.Equal(ErrorCode.InvalidField, Assert.Throws(() => Guarded(tier).SelectDynamic(new List { "" })).Message); + Assert.Equal(aggregates, Assert.Throws(() => Guarded(tier).Group(Grouping(new List { null! }))).Message); + } + } + + /// Every malformed summary, with the refusal it earns. + public static TheoryData Summaries() => new() + { + { "conditions", ErrorCode.NullEntry("Conditions") }, + { "aggregates", ErrorCode.NullEntry("AggregateBy") }, + { "having", ErrorCode.NullEntry("Conditions") }, + { "having-subgroups", ErrorCode.NullEntry("SubConditionGroups") }, + { "orders", ErrorCode.NullEntry("Orders") }, + }; + + private static Summary MalformedSummary(string which) => which switch + { + "conditions" => new Summary { ConditionGroup = Group(new List { null! }), GroupBy = Grouping() }, + "aggregates" => new Summary { GroupBy = Grouping(new List { null! }) }, + "having" => new Summary { GroupBy = Grouping(), Having = Group(new List { null! }) }, + "having-subgroups" => new Summary { GroupBy = Grouping(), Having = Group(new List(), new List { null! }) }, + "orders" => new Summary { GroupBy = Grouping(), Orders = new List { null! } }, + _ => throw new ArgumentOutOfRangeException(nameof(which)) + }; + + [Theory] + [MemberData(nameof(Summaries))] + public async Task A_malformed_summary_is_refused_as_one(string which, string message) + { + Assert.Equal(message, Assert.Throws(() => _db.Items.ToList(MalformedSummary(which))).Message); + Assert.Equal(message, Assert.Throws(() => _db.Items.Summary(MalformedSummary(which))).Message); + Assert.Equal(message, (await Assert.ThrowsAsync(() => _db.Items.ToListAsync(MalformedSummary(which)))).Message); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Assert.Equal(message, Assert.Throws(() => Guarded(tier).ToList(MalformedSummary(which))).Message); + Assert.Equal(message, Assert.Throws(() => Guarded(tier).Summary(MalformedSummary(which))).Message); + Assert.Equal(message, (await Assert.ThrowsAsync(() => Guarded(tier).ToListAsync(MalformedSummary(which)))).Message); + } + } + + /// Every malformed segment, with the refusal it earns. + public static TheoryData Segments() => new() + { + { "sets", ErrorCode.NullEntry("ConditionSets") }, + { "second-set", ErrorCode.NullEntry("ConditionSets") }, + { "conditions", ErrorCode.NullEntry("Conditions") }, + { "orders", ErrorCode.NullEntry("Orders") }, + { "selects", ErrorCode.InvalidField }, + }; + + private static ConditionSet Set(int sort, ConditionGroup? group = null) => + new() { Sort = sort, Intersection = sort == 1 ? null : Intersection.Union, ConditionGroup = group ?? Group() }; + + private static Segment MalformedSegment(string which) => which switch + { + "sets" => new Segment { ConditionSets = new List { null! } }, + "second-set" => new Segment { ConditionSets = new List { Set(1), null! } }, + "conditions" => new Segment { ConditionSets = new List { Set(1, Group(new List { null! })) } }, + "orders" => new Segment { ConditionSets = new List { Set(1) }, Orders = new List { null! } }, + "selects" => new Segment { ConditionSets = new List { Set(1) }, Selects = new List { null! } }, + _ => throw new ArgumentOutOfRangeException(nameof(which)) + }; + + [Theory] + [MemberData(nameof(Segments))] + public async Task A_malformed_segment_is_refused_as_one(string which, string message) + { + Assert.Equal(message, (await Assert.ThrowsAsync(() => _db.Items.ToListAsync(MalformedSegment(which)))).Message); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Assert.Equal(message, (await Assert.ThrowsAsync(() => Guarded(tier).ToListAsync(MalformedSegment(which)))).Message); + } + } + + /// Precision: a list that is absent is not a list with an absent entry, and means what it meant. + [Fact] + public async Task An_absent_list_still_means_what_it_meant() + { + Filter filter = new() { ConditionGroup = new ConditionGroup { Connector = Connector.And, Conditions = null!, SubConditionGroups = null! } }; + Summary summary = new() { GroupBy = Grouping(), Having = new ConditionGroup { Connector = Connector.And, Conditions = null!, SubConditionGroups = null! } }; + Segment segment = new() { ConditionSets = new List { Set(1) }, Orders = null, Selects = null }; + + Assert.Equal(2, _db.Items.ToList(filter).Data!.Count); + Assert.Equal(2, _db.Items.ToList(summary).Data!.Count); + Assert.Equal(2, (await _db.Items.ToListAsync(segment)).Data!.Count); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Assert.Equal(2, Guarded(tier).ToList(filter.Clone()).Data!.Count); + Assert.Equal(2, Guarded(tier).ToList(summary.Clone()).Data!.Count); + Assert.Equal(2, (await Guarded(tier).ToListAsync(segment.Clone())).Data!.Count); + } + } + + /// A copy copies what is there, so the refusal is the running method's and reads the same for both. + [Fact] + public void A_copy_keeps_a_null_entry_rather_than_failing_on_it() + { + Filter filter = Malformed("conditions").Clone(); + Segment segment = MalformedSegment("second-set").Clone(); + Summary summary = MalformedSummary("aggregates").Clone(); + + Assert.Null(filter.ConditionGroup!.Conditions[1]); + Assert.Null(segment.ConditionSets[1]); + Assert.Null(summary.GroupBy!.AggregateBy[0]); + Assert.Null(Malformed("orders").Clone().Orders![1]); + Assert.Null(Malformed("subgroups").Clone().ConditionGroup!.SubConditionGroups[0]); + Assert.Null(MalformedSegment("orders").Clone().Orders![0]); + Assert.Null(MalformedSummary("orders").Clone().Orders![0]); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8NumberValueTests.cs b/DynamicWhere.Tests/Policies/Rd8NumberValueTests.cs new file mode 100644 index 0000000..5adcb64 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8NumberValueTests.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; + +namespace DynamicWhere.Tests.Policies +{ + /// A row with one number a caller may filter on and one they may not. + public class Rd8Priced + { + public int Id { get; set; } + + public decimal Price { get; set; } + + public int? Stock { get; set; } + + [DwDenied] + public decimal Cost { get; set; } + } + + /// + /// Under a policy a number value is read as it is without one: what the parser cannot read, or + /// cannot compare with the member, is a format error in both tiers, and the gate still answers + /// for a denied member before any value is read. + /// + public sealed class Rd8NumberValueTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guarded(DwTier tier) => + new[] { new Rd8Priced { Id = 1, Price = 1.5m, Stock = 3, Cost = 1m } }.AsQueryable().ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, object value) => new() + { + ConditionGroup = new ConditionGroup + { + Connector = Connector.And, + Conditions = + { + new Condition { Sort = 1, Field = field, DataType = DataType.Number, Operator = Operator.Equal, Values = { value } } + } + } + }; + + [Theory] + [InlineData(DwTier.Strict, "Price", "1,5")] + [InlineData(DwTier.Convenience, "Price", "1,5")] + [InlineData(DwTier.Strict, "Price", "NaN")] + [InlineData(DwTier.Convenience, "Price", "NaN")] + [InlineData(DwTier.Strict, "Price", "1e5")] + [InlineData(DwTier.Convenience, "Price", "1e5")] + [InlineData(DwTier.Strict, "Stock", "1.5")] + [InlineData(DwTier.Convenience, "Stock", "1.5")] + public void A_value_the_parser_refuses_is_a_format_error_in_both_tiers(DwTier tier, string field, string value) + { + LogicException refusal = Assert.Throws(() => Guarded(tier).ToList(Where(field, value))); + + Assert.Equal(ErrorCode.InvalidFormat, refusal.Message); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_value_the_parser_reads_runs_on_any_host(DwTier tier) + { + CultureInfo saved = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + + Assert.Equal(1, Assert.Single(Guarded(tier).ToList(Where("Price", "1.5")).Data!).Id); + Assert.Equal(1, Assert.Single(Guarded(tier).ToList(Where("Stock", 3)).Data!).Id); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + /// The gate decides before a value is read, so a malformed value learns nothing about a denied member. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denied_member_is_refused_before_its_value_is_read(DwTier tier) + { + PolicyException refusal = Assert.Throws(() => Guarded(tier).ToList(Where("Cost", "NaN"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8TokenKeyTests.cs b/DynamicWhere.Tests/Policies/Rd8TokenKeyTests.cs new file mode 100644 index 0000000..dc23069 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8TokenKeyTests.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; +using System.Text; +using DynamicWhere.ex.Policies.Tokens; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A vault that holds a key stores a mapping under an HMAC of the value rather than a plain digest + /// of it, so a copy of the store gives no value back. + /// + /// + /// A tokenized column is nearly always drawn from a small space: a phone number, a national + /// identifier, a card number. Every value in such a space can be hashed, so a store keyed by a + /// plain SHA-256 of the value is a copy of the column to whoever reads it. + /// + public sealed class Rd8TokenKeyTests + { + private static readonly byte[] Key = Enumerable.Range(1, 32).Select(i => (byte)i).ToArray(); + private static readonly byte[] Other = Enumerable.Range(101, 32).Select(i => (byte)i).ToArray(); + + private static string Sha256(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + [Fact] + public void A_keyed_key_holds_no_plain_digest_of_the_value() + { + string keyed = DwToken.KeyFor("Customer.Phone", "07701234567", Key); + + Assert.StartsWith("hmac:Customer.Phone:", keyed, StringComparison.Ordinal); + Assert.Equal(64, keyed.Substring("hmac:Customer.Phone:".Length).Length); + Assert.DoesNotContain(Sha256("07701234567"), keyed, StringComparison.Ordinal); + Assert.NotEqual(DwToken.KeyFor("Customer.Phone", "07701234567"), keyed); + } + + /// What the attack is: with no key, the digest of a guessed value is the stored key. + [Fact] + public void The_unkeyed_key_is_the_digest_anyone_can_compute_and_the_keyed_one_is_not() + { + string guess = "07701234567"; + + Assert.Equal("Customer.Phone:" + Sha256(guess), DwToken.KeyFor("Customer.Phone", guess)); + + // Everything an attacker holding the store can compute without the key. + string[] computable = + { + Sha256(guess), + Sha256("Customer.Phone" + guess), + Sha256("Customer.Phone:" + guess), + Sha256("Customer.Phone\0" + guess), + }; + + string keyed = DwToken.KeyFor("Customer.Phone", guess, Key); + + Assert.DoesNotContain(computable, digest => keyed.EndsWith(digest, StringComparison.Ordinal)); + } + + [Fact] + public void The_same_key_gives_the_same_key_and_another_key_gives_another() + { + Assert.Equal(DwToken.KeyFor("s", "v", Key), DwToken.KeyFor("s", "v", (byte[])Key.Clone())); + Assert.NotEqual(DwToken.KeyFor("s", "v", Key), DwToken.KeyFor("s", "v", Other)); + Assert.NotEqual(DwToken.KeyFor("s", "v", Key), DwToken.KeyFor("s", "w", Key)); + } + + /// The scope is inside the digest, so the store does not show that two fields hold one value. + [Fact] + public void One_value_in_two_scopes_shares_no_digest() + { + string first = DwToken.KeyFor("Customer.Phone", "07701234567", Key); + string second = DwToken.KeyFor("Supplier.Phone", "07701234567", Key); + + Assert.NotEqual(first.Substring(first.Length - 64), second.Substring(second.Length - 64)); + + // And where the scope ends and the value begins is part of what is hashed. + string joined = DwToken.KeyFor("ab", "c", Key); + string split = DwToken.KeyFor("a", "bc", Key); + + Assert.NotEqual(joined.Substring(joined.Length - 64), split.Substring(split.Length - 64)); + } + + [Fact] + public void A_key_that_is_absent_or_short_is_refused() + { + Assert.Throws(() => DwToken.KeyFor("s", "v", null!)); + Assert.Throws(() => DwToken.KeyFor("s", "v", new byte[DwToken.MinimumKeyLength - 1])); + Assert.NotNull(DwToken.KeyFor("s", "v", new byte[DwToken.MinimumKeyLength])); + Assert.Throws(() => DwToken.KeyFor(" ", "v", Key)); + Assert.Throws(() => DwToken.KeyFor("s", null!, Key)); + Assert.Throws(() => DwToken.RequireKey(null!)); + Assert.Throws(() => DwToken.RequireKey(new byte[3])); + } + + [Fact] + public void A_vault_keeps_its_own_copy_of_the_key() + { + byte[] handed = (byte[])Key.Clone(); + byte[] kept = DwToken.RequireKey(handed); + + Array.Clear(handed); + + Assert.NotSame(handed, kept); + Assert.Equal(Key, kept); + } + + /// The process-scoped vault draws a key of its own, so nothing has to be configured for it. + [Fact] + public void The_in_memory_vault_holds_no_plain_digest() + { + InMemoryTokenVault vault = new(); + + string token = vault.GetOrCreate("Customer.Phone", "07701234567"); + + string held = Assert.Single(vault.Keys); + + Assert.StartsWith("hmac:Customer.Phone:", held, StringComparison.Ordinal); + Assert.DoesNotContain(Sha256("07701234567"), held, StringComparison.Ordinal); + Assert.Equal(token, vault.GetOrCreate("Customer.Phone", "07701234567")); + + // Two vaults are two keys: what one holds says nothing about the other. + InMemoryTokenVault second = new(); + + second.GetOrCreate("Customer.Phone", "07701234567"); + + Assert.NotEqual(held, Assert.Single(second.Keys)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8UnnamedAuditTests.cs b/DynamicWhere.Tests/Policies/Rd8UnnamedAuditTests.cs new file mode 100644 index 0000000..34c2d37 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8UnnamedAuditTests.cs @@ -0,0 +1,190 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + public class Rd8AuBase + { + public int Id { get; set; } + + [DwAudit] + public string? Seen { get; set; } + + public Rd8Au2? B { get; set; } + } + + public class Rd8AuSub : Rd8AuBase + { + [DwAudit] + public string? Hidden { get; set; } + + [DwAudit(PolicyFeature.Where)] + public string? FilteredOnly { get; set; } + } + + public class Rd8Au2 { public int Id { get; set; } public Rd8Au3? C { get; set; } } + + public class Rd8Au3 { public int Id { get; set; } public Rd8Au4? D { get; set; } } + + public class Rd8Au4 + { + public int Id { get; set; } + + [DwAudit] + public string? Four { get; set; } + + public Rd8Au5? E { get; set; } + } + + /// Audited and nothing else: nothing in what an Rd8AuBase row can hold declares a transform. + public class Rd8Au5 + { + public int Id { get; set; } + + [DwAudit] + public string? Five { get; set; } + } + + public class Rd8AuCard { public int Id { get; set; } } + + public class Rd8AuGold : Rd8AuCard + { + [DwAudit] + [DwMask(MaskStrategy.Full)] + public string? Pan { get; set; } + } + + /// + /// [DwAudit] answers who read a field. The gate records a use by path, before the query runs, + /// and a member only a subtype of the row declares, or one past the four segments the policy names, + /// has no path it could ask about: it came back with the row and nothing was written down. The rows + /// say it is there, and it is recorded once they do. + /// + public sealed class Rd8UnnamedAuditTests + { + private static Rd8AuBase[] Rows() => new Rd8AuBase[] + { + new Rd8AuSub + { + Id = 1, Seen = "s", Hidden = "h", FilteredOnly = "f", + B = new() { C = new() { D = new() { Four = "4", E = new() { Five = "5" } } } } + }, + new Rd8AuSub { Id = 2, Seen = "s", Hidden = "h", B = new() { C = new() { D = new() { Four = "4", E = new() { Five = "5" } } } } } + }; + + private static PolicyQueryable Guarded(DwPolicyContext caller, DwTier tier, int capacity = 10_000) => + Rows().AsQueryable().ApplyPolicy( + caller, + new DwPolicyOptions { Tier = tier, Caps = { MaxAuditEvents = capacity } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_member_no_path_names_is_recorded_once_it_is_handed_back(DwTier tier) + { + DwPolicyContext caller = Caller(); + + Guarded(caller, tier).ToList(new Filter()); + + List recorded = caller.PendingAuditEvents + .Where(read => read.Feature == PolicyFeature.Select) + .Select(read => read.FieldPath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + // Once per path, not per row; a member audited for another feature is not a Select read; and + // the two the policy names are recorded by the gate, once each, as they were. + Assert.Equal(new[] { "B.C.D.E.Five", "B.C.D.Four", "Hidden", "Seen" }, recorded); + + DwAuditEvent five = caller.PendingAuditEvents.Single(read => read.FieldPath == "B.C.D.E.Five"); + + Assert.Equal(PolicyEffect.Allow, five.Effect); + Assert.Equal(typeof(Rd8AuBase).FullName, five.EntityType); + + // Nothing here is transformed, so it is the audit alone that has the rows read. + Assert.False(DynamicWhere.ex.Policies.Masking.GraphWalker.HoldsTransform(typeof(Rd8AuBase))); + } + + [Fact] + public void A_masked_one_is_recorded_as_masked() + { + DwPolicyContext caller = Caller(); + Rd8AuCard[] cards = { new Rd8AuGold { Id = 1, Pan = "4111" } }; + + Rd8AuCard card = cards.AsQueryable() + .ApplyPolicy(caller, new DwPolicyOptions { Tier = DwTier.Strict }, new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()).Data!.Single(); + + Assert.Equal("****", ((Rd8AuGold)card).Pan); + + DwAuditEvent read = Assert.Single(caller.PendingAuditEvents); + + Assert.Equal("Pan", read.FieldPath); + Assert.Equal(PolicyEffect.Mask, read.Effect); + } + + /// + /// A path the projection spells out is one the gate was asked about, past the walk's depth as + /// within it, so it is recorded there and not a second time from the rows. + /// + [Fact] + public void A_member_the_projection_spells_out_past_the_walk_is_recorded_once() + { + DwPolicyContext caller = Caller(); + + Rows().AsQueryable() + .ApplyPolicy( + caller, + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MaxNavigationDepth = 6 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToListDynamic(new Filter { Selects = new() { "Id", "B.C.D.E.Five", "B.C.D.E" } }); + + Assert.Single(caller.PendingAuditEvents, read => read.FieldPath == "B.C.D.E.Five"); + } + + [Fact] + public void A_projection_that_leaves_the_member_out_records_nothing_for_it() + { + DwPolicyContext caller = Caller(); + + Guarded(caller, DwTier.Strict).ToList(new Filter { Selects = new() { "Id" } }); + + Assert.Empty(caller.PendingAuditEvents); + } + + /// As the gate does at the cap: the read is refused rather than left unrecorded. + [Fact] + public void With_no_room_to_record_it_the_rows_are_withheld() + { + // Room for the two the gate records, and none for the two the rows then show. + PolicyException strict = Assert.Throws( + () => Guarded(Caller(), DwTier.Strict, capacity: 2).ToList(new Filter())); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, strict.ErrorCode); + Assert.Equal("*", strict.FieldPath); + + PolicyException convenience = Assert.Throws( + () => Guarded(Caller(), DwTier.Convenience, capacity: 2).ToList(new Filter())); + + Assert.Equal(PolicyErrorCode.CapExceeded, convenience.ErrorCode); + } + + [Fact] + public void A_model_with_nothing_audited_or_transformed_runs_no_second_pass() + { + Assert.False(DynamicWhere.ex.Policies.Masking.GraphWalker.HoldsAudit(typeof(Rd8Plain))); + Assert.True(DynamicWhere.ex.Policies.Masking.GraphWalker.HoldsAudit(typeof(Rd8AuBase))); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs new file mode 100644 index 0000000..78cb2ca --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs @@ -0,0 +1,580 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + /// A row whose policed members are all of types the framework declares. + public class Rd8Row + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + [DwNoWhere] + public DateTime Born { get; set; } + + [DwNoOrder] + public string Rank { get; set; } = string.Empty; + + [DwDenied] + public decimal? Salary { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 1000)] + public decimal? Bonus { get; set; } + + [DwAudit] + public string Notes { get; set; } = string.Empty; + + [DwCost(50)] + public string Essay { get; set; } = string.Empty; + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Code { get; set; } = string.Empty; + + [DwDenied] + public Rd8Lines Lines { get; set; } = new(); + } + + /// An application's own collection class, whose Count is its own member and not an element's. + public class Rd8Lines : List + { + } + + public class Rd8Line + { + public int Id { get; set; } + } + + /// + /// A path that continues beneath a member whose type the framework declares, Salary.Value, + /// Secret.Length, Born.Year, reads that member. No attribute can be placed there and no + /// fragment names it, so resolved on its own it matched nothing and was allowed: a denied column was + /// filtered on, sorted by, grouped by with its values as the keys, aggregated, and handed back by a + /// dynamic projection, under either tier. + /// + public sealed class Rd8ValueMemberPathTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier) => new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static Rd8Row[] Rows() => new[] + { + new Rd8Row { Id = 1, Name = "ab", Secret = "12345678", Born = new DateTime(1990, 1, 1), Rank = "x", Salary = 5100m, Bonus = 1234m, Notes = "n", Essay = "e", Code = "abcd" }, + new Rd8Row { Id = 2, Name = "abcdef", Secret = "12", Born = new DateTime(2001, 1, 1), Rank = "yy", Salary = 900m, Bonus = 4321m, Notes = "nn", Essay = "ee", Code = "ab" }, + }; + + private static PolicyQueryable Guarded(DwTier tier) => + Rows().AsQueryable().ApplyPolicy(Caller(), Posture(tier), Attributes()); + + private static Condition Cond(string field, DataType type, Operator op, params object[] values) => + new() { Sort = 0, Field = field, DataType = type, Operator = op, Values = values.ToList() }; + + private static Filter Where(Condition condition) => new() + { + ConditionGroup = new ConditionGroup { Connector = Connector.And, Conditions = new() { condition } }, + Selects = new() { "Id" } + }; + + private static Summary GroupBy(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new() { field }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Summary Max(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new() { "Name" }, + AggregateBy = new() { new AggregateBy { Field = field, Aggregator = Aggregator.Maximum, Alias = "m" } } + } + }; + + public static TheoryData DeniedForWhere() + { + TheoryData data = new(); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + data.Add(tier, "Secret.Length", DataType.Number, Operator.GreaterThan, 5); + data.Add(tier, "Salary.Value", DataType.Number, Operator.GreaterThan, 1000); + data.Add(tier, "Salary.HasValue", DataType.Boolean, Operator.Equal, true); + data.Add(tier, "Born.Year", DataType.Number, Operator.GreaterThan, 2000); + data.Add(tier, "Born.Date.Year", DataType.Number, Operator.GreaterThan, 2000); + data.Add(tier, "Lines.Count", DataType.Number, Operator.GreaterThan, 0); + } + + return data; + } + + [Theory] + [MemberData(nameof(DeniedForWhere))] + public void A_condition_beneath_a_member_denied_for_where_is_refused( + DwTier tier, string field, DataType type, Operator op, object value) + { + PolicyException refusal = Assert.Throws( + () => Guarded(tier).ToList(Where(Cond(field, type, op, value)))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Theory] + [InlineData("Secret.Length")] + [InlineData("Salary.Value")] + [InlineData("Rank.Length")] + public void An_order_beneath_a_member_denied_for_order_is_refused_or_dropped(string field) + { + Filter filter = new() + { + Orders = new() { new OrderBy { Sort = 0, Field = field, Direction = Direction.Ascending } }, + Selects = new() { "Id" } + }; + + PolicyException refusal = Assert.Throws(() => Guarded(DwTier.Strict).ToList(filter)); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + + // The second row sorts first by every one of these, so the source order coming back says + // the order was dropped rather than applied. + Assert.Equal(new[] { 1, 2 }, Guarded(DwTier.Convenience).ToList(filter).Data!.Select(row => row.Id)); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_group_key_or_an_aggregate_beneath_a_denied_member_is_refused(DwTier tier) + { + foreach (string field in new[] { "Salary.Value", "Secret.Length" }) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(GroupBy(field))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForAggregate, + Assert.Throws(() => Guarded(tier).ToList(Max(field))).ErrorCode); + } + } + + [Fact] + public void A_projection_beneath_a_denied_member_is_refused_or_dropped() + { + foreach (string field in new[] { "Salary.Value", "Secret.Length" }) + { + Filter filter = new() { Selects = new() { "Id", field } }; + + Assert.Equal( + PolicyErrorCode.FieldDeniedForSelect, + Assert.Throws(() => Guarded(DwTier.Strict).ToListDynamic(filter)).ErrorCode); + + string json = System.Text.Json.JsonSerializer.Serialize(Guarded(DwTier.Convenience).ToListDynamic(filter).Data); + + Assert.Equal("[{\"Id\":1},{\"Id\":2}]", json); + } + } + + /// + /// A transform is applied to the member it was declared on. Beneath it there is nothing to apply + /// it to, so every way the path hands a value back is refused; filtering runs on the stored value + /// wherever the member is read from, and stays as the member's own policy leaves it. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Beneath_a_transformed_member_nothing_is_handed_back(DwTier tier) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(GroupBy("Bonus.Value"))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForAggregate, + Assert.Throws(() => Guarded(tier).ToList(Max("Bonus.Value"))).ErrorCode); + + Assert.Equal( + new[] { 2 }, + Guarded(tier).ToList(Where(Cond("Bonus.Value", DataType.Number, Operator.GreaterThan, 2000))).Data!.Select(row => row.Id)); + + // A projection hands the stored value back as surely as a grouping key does. + Filter projection = new() { Selects = new() { "Id", "Bonus.Value" } }; + + if (tier == DwTier.Strict) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForSelect, + Assert.Throws(() => Guarded(tier).ToListDynamic(projection)).ErrorCode); + } + else + { + Assert.Equal( + "[{\"Id\":1},{\"Id\":2}]", + System.Text.Json.JsonSerializer.Serialize(Guarded(tier).ToListDynamic(projection).Data)); + } + } + + [Fact] + public void A_use_beneath_an_audited_member_is_recorded() + { + DwPolicyContext caller = Caller(); + + Rows().AsQueryable().ApplyPolicy(caller, Posture(DwTier.Strict), Attributes()) + .ToList(Where(Cond("Notes.Length", DataType.Number, Operator.GreaterThan, 1))); + + Assert.Contains(caller.PendingAuditEvents, recorded => recorded.FieldPath == "Notes.Length" && recorded.Feature == PolicyFeature.Where); + } + + [Fact] + public void A_path_beneath_a_weighted_member_costs_what_the_member_costs() + { + DwPolicyOptions posture = new() { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1, MaxQueryCost = 10 } }; + + PolicyException refusal = Assert.Throws(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), posture, Attributes()) + .ToList(Where(Cond("Essay.Length", DataType.Number, Operator.GreaterThan, 1)))); + + Assert.Equal(PolicyErrorCode.QueryCostExceeded, refusal.ErrorCode); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void An_operator_restriction_holds_beneath_the_member(DwTier tier) + { + PolicyException refusal = Assert.Throws( + () => Guarded(tier).ToList(Where(Cond("Code.Length", DataType.Number, Operator.GreaterThan, 3)))); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + Assert.Equal( + new[] { 1 }, + Guarded(tier).ToList(Where(Cond("Code.Length", DataType.Number, Operator.Equal, 4))).Data!.Select(row => row.Id)); + } + + /// A rule decides the member as an attribute does, so it decides what reads the member too. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_rule_denying_a_member_covers_the_paths_beneath_it(DwTier tier) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("Name", PolicyFeature.Where, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { new AttributePolicyProvider(), rules }); + + PolicyException refusal = Assert.Throws(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), resolver) + .ToList(Where(Cond("Name.Length", DataType.Number, Operator.GreaterThan, 3)))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + /// + /// Which fragments match is about the type's shape and not about who supplied them, so a resolver built + /// over a store alone, reading no attribute, answers the same way. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_rule_covers_the_paths_beneath_its_member_with_no_attribute_provider(DwTier tier) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("Name", PolicyFeature.Where, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { rules }); + + PolicyException refusal = Assert.Throws(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), resolver) + .ToList(Where(Cond("Name.Length", DataType.Number, Operator.GreaterThan, 3)))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + /// Precision: a member nothing denies is read beneath as it always was, and one feature is one feature. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_path_beneath_a_member_takes_that_members_policy_and_no_more(DwTier tier) + { + Assert.Equal( + new[] { 2 }, + Guarded(tier).ToList(Where(Cond("Name.Length", DataType.Number, Operator.GreaterThan, 3))).Data!.Select(row => row.Id)); + + // Born is denied for Where alone. + Assert.Equal(2, Guarded(tier).ToList(GroupBy("Born.Year")).Data!.Count); + } + + /// + /// What is said to the caller about a member stays the member's: beneath it there is no member + /// for a chain to be applied to, so the path reports none, while the denial that follows from the + /// chain is there. + /// + [Fact] + public void A_path_beneath_a_transformed_member_carries_no_transform_of_its_own() + { + PolicyResolver resolver = Attributes(); + + Assert.True(resolver.Resolve(typeof(Rd8Row), "Bonus", Caller()).IsTransformed); + + FieldPolicy beneath = resolver.Resolve(typeof(Rd8Row), "Bonus.Value", Caller()); + + Assert.False(beneath.IsTransformed); + Assert.False(beneath.Allows(PolicyFeature.Select)); + Assert.True(beneath.Allows(PolicyFeature.Where)); + } + + [Fact] + public void The_member_a_path_reads() + { + Assert.Equal("Salary", AttributePolicyProvider.Governing(typeof(Rd8Row), "Salary.Value")); + Assert.Equal("Born", AttributePolicyProvider.Governing(typeof(Rd8Row), "Born.Date.Year")); + Assert.Equal("Lines", AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Count")); + + // An element's member is the walk's to name, and so is a member on its own. + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Id")); + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Salary")); + + // A name that matches nothing is refused as one, elsewhere. + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Nothing.Value")); + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Nothing")); + } + } + + public class Rd8A { public int Id { get; set; } public Rd8B? B { get; set; } } + + public class Rd8B { public int Id { get; set; } public Rd8C? C { get; set; } } + + public class Rd8C { public int Id { get; set; } public Rd8D? D { get; set; } } + + public class Rd8D { public int Id { get; set; } public Rd8E? E { get; set; } } + + public class Rd8E + { + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + + public string? Open { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Card { get; set; } + + [DwNoWhere] + public DateTime? Born { get; set; } + } + + /// + /// The attribute walk names paths of four segments, and the navigation-depth cap defaults to four. A + /// host that raises the cap lets a request name a fifth, which no fragment reached: a denied member + /// there was filtered on, grouped by and handed back under either tier. + /// + public sealed class Rd8DeepPathTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guarded(DwTier tier) => new[] + { + new Rd8A { Id = 1, B = new() { Id = 2, C = new() { Id = 3, D = new() { Id = 4, E = new() { Id = 5, Secret = "s3cret", Open = "o", Card = "4111111111111111", Born = new DateTime(2001, 1, 1) } } } } } + } + .AsQueryable() + .ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1, MaxNavigationDepth = 6 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, Operator op, object value) => new() + { + ConditionGroup = new ConditionGroup + { + Connector = Connector.And, + Conditions = new() { new Condition { Sort = 0, Field = field, DataType = type, Operator = op, Values = new() { value } } } + }, + Selects = new() { "Id" } + }; + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denied_member_past_the_walk_is_refused_in_every_clause(DwTier tier) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForWhere, + Assert.Throws(() => Guarded(tier).ToList(Where("B.C.D.E.Secret", DataType.Text, Operator.StartsWith, "s3"))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new() { "B.C.D.E.Secret" }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + } + })).ErrorCode); + + // Beneath a member the framework declares the type of, past the walk as well. + Assert.Equal( + PolicyErrorCode.FieldDeniedForWhere, + Assert.Throws(() => Guarded(tier).ToList(Where("B.C.D.E.Born.Value", DataType.DateTime, Operator.GreaterThan, "2000-01-01"))).ErrorCode); + } + + [Fact] + public void A_denied_member_past_the_walk_is_never_handed_back() + { + Filter filter = new() { Selects = new() { "Id", "B.C.D.E.Secret" } }; + + Assert.Equal( + PolicyErrorCode.FieldDeniedForSelect, + Assert.Throws(() => Guarded(DwTier.Strict).ToListDynamic(filter)).ErrorCode); + + Assert.Equal( + "[{\"Id\":1}]", + System.Text.Json.JsonSerializer.Serialize(Guarded(DwTier.Convenience).ToListDynamic(filter).Data)); + } + + /// + /// A member past the walk is still a member: named in a projection it comes back transformed, + /// in a generated row as well, where no member carries an attribute to find the chain by. As a + /// grouping key or an aggregate it is a column of a generated row the summary's own transform + /// would not find, so those are refused. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_past_the_walk_comes_back_masked_and_is_no_grouping_key(DwTier tier) + { + string generated = System.Text.Json.JsonSerializer.Serialize( + Guarded(tier).ToListDynamic(new Filter { Selects = new() { "Id", "B.C.D.E.Card" } }).Data); + + Assert.DoesNotContain("4111", generated); + Assert.Contains("****************", generated); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new() { "B.C.D.E.Card" }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + } + })).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForAggregate, + Assert.Throws(() => Guarded(tier).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new() { "Id" }, + AggregateBy = new() { new AggregateBy { Field = "B.C.D.E.Card", Aggregator = Aggregator.Maximum, Alias = "m" } } + } + })).ErrorCode); + } + + /// Precision: a member past the walk that declares nothing runs as it did. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_member_past_the_walk_that_declares_nothing_is_left_alone(DwTier tier) + { + Assert.Single(Guarded(tier).ToList(Where("B.C.D.E.Open", DataType.Text, Operator.Equal, "o")).Data!); + + // A masked member is filtered on its stored value wherever it is reached from. + Assert.Single(Guarded(tier).ToList(Where("B.C.D.E.Card", DataType.Text, Operator.StartsWith, "4111")).Data!); + } + + [Fact] + public void Only_what_the_member_declares_about_itself_is_read_past_the_walk() + { + IReadOnlyList fragments = AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.E.Secret"); + + PolicyFragment denial = Assert.Single(fragments); + + Assert.Equal(PolicyEffect.Deny, denial.Effect); + Assert.Equal("B.C.D.E.Secret", denial.FieldPath); + + Assert.Empty(AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.Id")); + Assert.Empty(AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.E.Nothing")); + } + } + + public class Rd8P { public int Id { get; set; } public Rd8Q? Q1 { get; set; } public Rd8Z? Late { get; set; } } + + public class Rd8P2 { public int Id { get; set; } public Rd8Z? Late { get; set; } public Rd8Q? Q1 { get; set; } } + + public class Rd8Q { public int Id { get; set; } public Rd8R? R { get; set; } } + + public class Rd8R { public int Id { get; set; } public Rd8S? S { get; set; } } + + public class Rd8S { public int Id { get; set; } public Rd8Z? Z { get; set; } } + + public class Rd8Z + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, Value = "7")] + public int Tenant { get; set; } + + [DwAlias("zname")] + public string? Name { get; set; } + } + + /// + /// The attribute walk marks each type it is inside, to tell a cycle from a first visit, and returned + /// at its depth limit with the type still marked. A type first met there then read as a cycle wherever + /// it was met again, and what a cycle leaves out, a forced scope, a required filter and an alias, was + /// left out of a path that reaches the type directly. Which member was declared first decided it. + /// + public sealed class Rd8WalkMarkerTests + { + [Theory] + [InlineData(typeof(Rd8P))] + [InlineData(typeof(Rd8P2))] + public void A_type_first_met_at_the_depth_limit_is_not_a_cycle(Type root) + { + IReadOnlyList fragments = new AttributePolicyProvider().GetFragments(root, new DwPolicyContext()); + + Assert.Contains(fragments, fragment => fragment.FieldPath == "Late.Tenant" && fragment.Forced is not null); + Assert.Contains(fragments, fragment => fragment.FieldPath == "Late.Name" && fragment.Alias == "zname"); + } + + [Fact] + public void The_scope_forced_on_it_filters_the_rows() + { + Rd8P[] rows = + { + new() { Id = 1, Late = new Rd8Z { Tenant = 7 } }, + new() { Id = 2, Late = new Rd8Z { Tenant = 8 } }, + }; + + List kept = rows.AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter { Selects = new() { "Id" } }).Data!; + + Assert.Equal(new[] { 1 }, kept.Select(row => row.Id)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs b/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs new file mode 100644 index 0000000..a8be30e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs @@ -0,0 +1,288 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + public class Rd8M1 { public int Id { get; set; } public Rd8M2? B { get; set; } public Rd8M5? Near { get; set; } } + + /// The same two members the other way round, so the far one is reached first. + public class Rd8M1Far { public int Id { get; set; } public Rd8M2? B { get; set; } public Rd8M5? Near { get; set; } public Rd8M5? Last { get; set; } } + + public class Rd8M1Near { public int Id { get; set; } public Rd8M5? Near { get; set; } public Rd8M2? B { get; set; } } + + public class Rd8M2 { public int Id { get; set; } public Rd8M3? C { get; set; } } + + public class Rd8M3 { public int Id { get; set; } public Rd8M4? D { get; set; } } + + public class Rd8M4 + { + public int Id { get; set; } + + public Rd8M5? E { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Pin { get; set; } + } + + public class Rd8M5 + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Card { get; set; } + + [DwMask(MaskStrategy.Hash)] + public string? Token { get; set; } + } + + public class Rd8Animal { public int Id { get; set; } public string? Name { get; set; } public Rd8Tag? Tag { get; set; } } + + public class Rd8Dog : Rd8Animal + { + [DwMask(MaskStrategy.Full)] + public string? Chip { get; set; } + } + + public class Rd8Tag { public int Id { get; set; } } + + public class Rd8GoldTag : Rd8Tag + { + [DwMask(MaskStrategy.Full)] + public string? Serial { get; set; } + } + + public class Rd8Wallet { public int Id { get; set; } public Dictionary Cards { get; set; } = new(); } + + public class Rd8Pan + { + [DwMask(MaskStrategy.Full)] + public string? Number { get; set; } + } + + /// Nothing in what a row of this type can hold declares a transform. + public class Rd8Plain { public int Id { get; set; } public Rd8PlainChild? Child { get; set; } } + + public class Rd8PlainChild { public int Id { get; set; } public string? Name { get; set; } } + + /// One navigation leads to a transform and one cannot; reading the second throws. + public class Rd8Careful + { + public int Id { get; set; } + + public Rd8Dog? Pet { get; set; } + + public Rd8PlainChild? Untouched => throw new InvalidOperationException("read a navigation that leads to no transform"); + } + + public class Rd8Frozen { public int Id { get; set; } public Rd8FrozenTag? Tag { get; set; } } + + public class Rd8FrozenTag { public int Id { get; set; } } + + public class Rd8FrozenGold : Rd8FrozenTag + { + [DwMask(MaskStrategy.Full)] + public string Serial => "SER-1"; + } + + /// + /// A transform is applied along the paths the attribute walk names: the declared types, four segments + /// deep. A value that sits where none of them goes, on a member only a subtype of the row declares, + /// five segments down, on an object a dictionary holds, came back exactly as stored while the same + /// member four segments up was masked. The rows are walked by run-time type as well, and a member + /// that declares a transform where no path reaches is transformed by its own attributes. + /// + public sealed class Rd8UnwalkedTransformTests + { + private const string Salt = "pepper-and-more-pepper"; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guarded(IEnumerable rows, DwTier tier) where T : class => + rows.AsQueryable().ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, HashSalt = Salt, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Rd8M1 Deep() => new() + { + Id = 1, + B = new() { Id = 2, C = new() { Id = 3, D = new() { Id = 4, Pin = "9999", E = new() { Id = 5, Card = "4111111111111111" } } } } + }; + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_five_segments_down_is_masked(DwTier tier) + { + Rd8M1 whole = Guarded(new[] { Deep() }, tier).ToList(new Filter()).Data!.Single(); + + Assert.Equal("****", whole.B!.C!.D!.Pin); + Assert.Equal("****************", whole.B.C.D.E!.Card); + + Rd8M1 named = Guarded(new[] { Deep() }, tier).ToList(new Filter { Selects = new() { "Id", "B" } }).Data!.Single(); + + Assert.Equal("****************", named.B!.C!.D!.E!.Card); + + string dynamic = System.Text.Json.JsonSerializer.Serialize( + Guarded(new[] { Deep() }, tier).ToListDynamic(new Filter { Selects = new() { "Id", "B.C.D.E" } }).Data); + + Assert.DoesNotContain("4111", dynamic); + Assert.Contains("****************", dynamic); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_only_a_subtype_declares_is_masked(DwTier tier) + { + Rd8Animal[] rows = { new Rd8Dog { Id = 1, Name = "rex", Chip = "CHIP-123", Tag = new Rd8GoldTag { Id = 9, Serial = "SER-999" } } }; + + Rd8Dog dog = Assert.IsType(Guarded(rows, tier).ToList(new Filter()).Data!.Single()); + + Assert.Equal("********", dog.Chip); + Assert.Equal("*******", Assert.IsType(dog.Tag).Serial); + Assert.Equal("rex", dog.Name); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_of_an_object_a_dictionary_holds_is_masked(DwTier tier) + { + Rd8Wallet[] rows = { new() { Id = 1, Cards = { ["main"] = new Rd8Pan { Number = "5500" } } } }; + + Rd8Wallet wallet = Guarded(rows, tier).ToList(new Filter()).Data!.Single(); + + Assert.Equal("****", wallet.Cards["main"].Number); + } + + /// + /// One object reached twice, by a path the policy names and by one it does not, is transformed + /// once: the digest of a digest is not the stand-in the first pass issued. + /// + [Fact] + public void An_object_reached_by_a_named_path_and_an_unnamed_one_is_transformed_once() + { + // What the first pass alone makes of it, which the second has no part in. + Rd8M1 alone = new() { Id = 1, Near = new Rd8M5 { Id = 5, Token = "tok" } }; + DwPolicyOptions posture = new() { Tier = DwTier.Strict, HashSalt = Salt }; + + GraphWalker.Apply( + new[] { alone }, typeof(Rd8M1), + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }).ResolveType(typeof(Rd8M1), Caller()), + projected: null, Caller(), posture, new DynamicWhere.ex.Policies.DTOs.PolicyTrace(DwTier.Strict, dryRun: false), + attributes: false); + + string once = alone.Near.Token!; + + Assert.NotEqual("tok", once); + + // Whichever of the two members is declared first, and so whichever way the object is met first. + Rd8M5 held = new() { Id = 5, Token = "tok" }; + Rd8M1Far far = new() { Id = 1, Near = held, B = new() { C = new() { D = new() { E = held } } } }; + + Assert.Equal(once, Guarded(new[] { far }, DwTier.Strict).ToList(new Filter()).Data!.Single().Near!.Token); + + held = new() { Id = 5, Token = "tok" }; + Rd8M1Near near = new() { Id = 1, Near = held, B = new() { C = new() { D = new() { E = held } } } }; + + Rd8M1Near row = Guarded(new[] { near }, DwTier.Strict).ToList(new Filter()).Data!.Single(); + + Assert.Equal(once, row.Near!.Token); + Assert.Same(row.Near, row.B!.C!.D!.E); + } + + [Fact] + public void A_model_that_declares_no_transform_pays_for_no_second_pass() + { + Assert.False(GraphWalker.HoldsTransform(typeof(Rd8Plain))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8M1))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8Animal))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8Wallet))); + } + + /// + /// Four methods hand back a query for the caller to run, which the library never sees + /// materialized, so they are refused on a type whose values are transformed on the way out. + /// "Transformed" was read from the paths the policy names, so a type whose only transforms sit + /// off them, on a subtype's member or five segments down, got the query, and its rows as stored. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_query_the_caller_runs_is_refused_where_only_an_unnamed_member_is_transformed(DwTier tier) + { + Rd8Animal[] animals = { new Rd8Dog { Id = 1, Name = "rex", Chip = "CHIP-123" } }; + + foreach (Func, object> unmaterialized in new Func, object>[] + { + handle => handle.FilterDynamic(new Filter()), + handle => handle.SelectDynamic(new List { "Id" }), + handle => handle.Group(new DynamicWhere.ex.Classes.Core.GroupBy { Fields = new() { "Name" } }), + handle => handle.Summary(new Summary { GroupBy = new DynamicWhere.ex.Classes.Core.GroupBy { Fields = new() { "Name" } } }), + }) + { + DynamicWhere.ex.Exceptions.PolicyException refusal = Assert.Throws( + () => unmaterialized(Guarded(animals, tier))); + + Assert.Equal(PolicyErrorCode.TransformRequiresMaterialization, refusal.ErrorCode); + } + + // A type nothing transforms anywhere still gets its query. + Rd8Plain[] plain = { new() { Id = 1 } }; + + Assert.Single(Guarded(plain, tier).FilterDynamic(new Filter()).Cast()); + } + + /// + /// A resolver built over no attribute provider reads no attribute, along a named path or off it: + /// the second pass follows the first, rather than enforcing what the caller left out. + /// + [Fact] + public void A_resolver_that_reads_no_attributes_transforms_nothing_by_attribute() + { + Rd8M1 row = new[] { Deep() }.AsQueryable() + .ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = DwTier.Strict, HashSalt = Salt }, + new PolicyResolver(new IDwPolicyProvider[] { new FakePolicyProvider() })) + .ToList(new Filter()).Data!.Single(); + + Assert.Equal("9999", row.B!.C!.D!.Pin); + Assert.Equal("4111111111111111", row.B.C.D.E!.Card); + } + + /// + /// Only what can lead to a transform is read. A navigation whose type reaches none is never + /// touched, so a lazy loader behind one is not woken by a pass that had nothing to do there. + /// + [Fact] + public void A_navigation_that_leads_to_no_transform_is_never_read() + { + Rd8Careful[] rows = { new() { Id = 1, Pet = new Rd8Dog { Chip = "CHIP-123" } } }; + + Rd8Careful row = Guarded(rows, DwTier.Strict).ToList(new Filter()).Data!.Single(); + + Assert.Equal("********", row.Pet!.Chip); + } + + /// As along a named path: a transform that cannot replace the value fails the query. + [Fact] + public void A_transformed_member_with_no_setter_fails_the_query_rather_than_passing_the_value() + { + Rd8Frozen[] rows = { new() { Id = 1, Tag = new Rd8FrozenGold { Id = 2 } } }; + + InvalidOperationException failure = Assert.Throws( + () => Guarded(rows, DwTier.Strict).ToList(new Filter())); + + Assert.Contains("Tag.Serial", failure.Message); + } + } +} diff --git a/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs index 85fac6a..f5ac892 100644 --- a/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs @@ -23,7 +23,7 @@ namespace DynamicWhere.Tests.Policies; public sealed class RedisStoreConformanceTests : PolicyStoreConformanceTests, IAsyncLifetime { private readonly RedisContainer _server = - new RedisBuilder().WithImage("redis:7-alpine").Build(); + new RedisBuilder("redis:7-alpine").Build(); private IConnectionMultiplexer? _redis; private int _prefixes; @@ -207,6 +207,63 @@ await Assert.ThrowsAnyAsync( async () => await store.LoadAsync(default)); } + /// + /// Writers moving one rule between callers leave one copy of it, under the caller the owner entry + /// names. + /// + /// + /// Where a rule lives is read before the transaction that moves it. Two writers could read the + /// same answer, and the slower one then cleaned up after a copy the faster had already moved, + /// leaving that writer's copy behind with no owner entry pointing at it: a rule still applying to + /// a caller nobody any longer wrote it for. The commit is conditional on the owner entry now, and + /// a writer that loses is told nothing was stored. + /// + [Fact] + public async Task Writers_moving_one_rule_leave_one_copy_of_it() + { + string prefix = NextPrefix(); + Guid id = Guid.NewGuid(); + string[] callers = { "ann", "bob", "cyd" }; + + async Task Move(int seed) + { + RedisPolicyStore store = new(_redis!, prefix); + + for (int i = 0; i < 400; i++) + { + PolicyRule rule = new( + DwSubjectKind.User, callers[(i + seed) % callers.Length], StaffType, "Department", + PolicyFeature.Select, PolicyEffect.Deny, id: id); + + try + { + await store.UpsertAsync(rule, default); + } + catch (InvalidOperationException) + { + // Lost the race, and said so. Nothing was stored; the next round writes again. + } + } + } + + await Task.WhenAll(Enumerable.Range(0, 8).Select(seed => Task.Run(() => Move(seed)))); + + IDatabase db = _redis!.GetDatabase(); + string owner = (await db.HashGetAsync($"{prefix}:owner", id.ToString())).ToString(); + + List holders = new(); + + foreach (string caller in callers) + { + if (await db.HashExistsAsync($"{prefix}:user:{caller}", id.ToString())) + { + holders.Add($"{prefix}:user:{caller}"); + } + } + + Assert.Equal(new[] { owner }, holders); + } + /// A prefix no other store in this class is using. private string NextPrefix() => $"test:{Interlocked.Increment(ref _prefixes)}"; } diff --git a/DynamicWhere.Tests/Policies/RedisTokenVaultConformanceTests.cs b/DynamicWhere.Tests/Policies/RedisTokenVaultConformanceTests.cs index 1325989..8630aa4 100644 --- a/DynamicWhere.Tests/Policies/RedisTokenVaultConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/RedisTokenVaultConformanceTests.cs @@ -21,7 +21,7 @@ namespace DynamicWhere.Tests.Policies; public sealed class RedisTokenVaultConformanceTests : DurableTokenVaultConformanceTests, IAsyncLifetime { private readonly RedisContainer _server = - new RedisBuilder().WithImage("redis:7-alpine").Build(); + new RedisBuilder("redis:7-alpine").Build(); private readonly string _prefix = $"dw:tokens:{Guid.NewGuid():N}"; @@ -106,4 +106,112 @@ public void A_cold_cache_reads_the_server_rather_than_reissuing() Assert.Equal(0, vault.CachedCount); Assert.Equal(first, vault.GetOrCreate(Scope, "AAA-000123")); } + + // ------------------------------------------------------------------ under a key + + private static readonly byte[] Key = Enumerable.Range(1, 32).Select(i => (byte)i).ToArray(); + private static readonly byte[] Other = Enumerable.Range(101, 32).Select(i => (byte)i).ToArray(); + + private string[] Fields(string prefix) => _redis!.GetDatabase() + .HashKeys(new RedisPolicyKeys(prefix).Tokens) + .Select(field => (string)field!) + .OrderBy(field => field, StringComparer.Ordinal) + .ToArray(); + + [Fact] + public void Under_a_key_the_hash_holds_no_plain_digest_of_the_value() + { + string prefix = $"{_prefix}:keyed"; + + new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "07701234567"); + + string field = Assert.Single(Fields(prefix)); + + Assert.Equal(DwToken.KeyFor(Scope, "07701234567", Key), field); + Assert.NotEqual(DwToken.KeyFor(Scope, "07701234567"), field); + } + + /// A deployment that adds a key keeps every token it has handed out. + [Fact] + public void Under_a_key_a_value_keeps_the_token_an_unkeyed_vault_gave_it() + { + string prefix = $"{_prefix}:adopt"; + string issued = new RedisTokenVault(_redis!, prefix).GetOrCreate(Scope, "AAA-000123"); + + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "AAA-000123")); + + // Both fields, until the vault is told every instance holds the key. + Assert.Equal( + new[] { DwToken.KeyFor(Scope, "AAA-000123"), DwToken.KeyFor(Scope, "AAA-000123", Key) } + .OrderBy(field => field, StringComparer.Ordinal), + Fields(prefix)); + + Assert.Equal(issued, new RedisTokenVault(_redis!, prefix).GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "AAA-000123")); + } + + [Fact] + public void Retiring_deletes_the_unkeyed_field_and_keeps_the_token() + { + string prefix = $"{_prefix}:retire"; + string issued = new RedisTokenVault(_redis!, prefix).GetOrCreate(Scope, "AAA-000123"); + + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix, retireUnkeyed: true).GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(DwToken.KeyFor(Scope, "AAA-000123", Key), Assert.Single(Fields(prefix))); + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "AAA-000123")); + } + + /// Every instance takes the key first and retiring begins afterwards, so the values that matter are already adopted. + [Fact] + public void Retiring_reaches_a_value_adopted_before_retiring_began() + { + string prefix = $"{_prefix}:late"; + string issued = new RedisTokenVault(_redis!, prefix).GetOrCreate(Scope, "AAA-000123"); + + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(2, Fields(prefix).Length); + + Assert.Equal(issued, new RedisTokenVault(_redis!, Key, prefix, retireUnkeyed: true).GetOrCreate(Scope, "AAA-000123")); + Assert.Equal(DwToken.KeyFor(Scope, "AAA-000123", Key), Assert.Single(Fields(prefix))); + } + + [Fact] + public void Many_keyed_callers_racing_for_a_value_all_get_the_token_it_already_had() + { + string prefix = $"{_prefix}:race"; + string issued = new RedisTokenVault(_redis!, prefix).GetOrCreate(Scope, "AAA-000123"); + + RedisTokenVault[] vaults = Enumerable.Range(0, 8) + .Select(i => new RedisTokenVault(_redis!, Key, prefix, retireUnkeyed: i % 2 == 0)).ToArray(); + + string[] tokens = new string[vaults.Length]; + + Parallel.For(0, vaults.Length, i => tokens[i] = vaults[i].GetOrCreate(Scope, "AAA-000123")); + + Assert.All(tokens, token => Assert.Equal(issued, token)); + + // And for a value nobody has met, one token between them. + Parallel.For(0, vaults.Length, i => tokens[i] = vaults[i].GetOrCreate(Scope, "NEW-1")); + + Assert.Single(tokens.Distinct(StringComparer.Ordinal)); + } + + /// Stated as a test because it is the hazard of changing a key: every value is met for the first time again. + [Fact] + public void Another_key_is_another_vault() + { + string prefix = $"{_prefix}:rotate"; + + Assert.NotEqual( + new RedisTokenVault(_redis!, Key, prefix).GetOrCreate(Scope, "AAA-000123"), + new RedisTokenVault(_redis!, Other, prefix).GetOrCreate(Scope, "AAA-000123")); + } + + [Fact] + public void A_key_that_is_absent_or_short_is_refused() + { + Assert.Throws(() => new RedisTokenVault(_redis!, (byte[])null!, _prefix)); + Assert.Throws(() => new RedisTokenVault(_redis!, new byte[8], _prefix)); + Assert.Throws(() => new RedisTokenVault(null!, Key, _prefix)); + } } diff --git a/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs new file mode 100644 index 0000000..168db97 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs @@ -0,0 +1,813 @@ +using System.ComponentModel.DataAnnotations.Schema; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // --------------------------------------------------------------------------------------------- + // Probe model. EF Core 6 compatible on purpose, so the floor leg runs it too. + // --------------------------------------------------------------------------------------------- + + public class ObMoney + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + /// A getter over two owned columns: no database can answer it. + public bool IsZero => Amount == 0m; + } + + public class ObCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public List Orders { get; set; } = new(); + + /// An unmapped getter one navigation away. + [NotMapped] + public string Handle => Name + "!"; + } + + public class ObOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DateTime PlacedAt { get; set; } + + public DateTime? ShippedAt { get; set; } + + public TimeSpan Window { get; set; } + + public int Quantity { get; set; } + + public int CustomerId { get; set; } + + public ObCustomer Customer { get; set; } = null!; + + public ObMoney Total { get; set; } = new(); + + /// An unmapped getter over two mapped columns of the entity itself. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + /// A row a caller projects. + public class ObOrderRow + { + public ObOrderRow() + { + } + + public ObOrderRow(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ObMoney Total { get; set; } = new(); + + public ObCustomer? Customer { get; set; } + + public string Label { get; set; } = string.Empty; + + public ObNest Nest { get; set; } = new(); + } + + public class ObNest + { + public ObNest? Inner { get; set; } + + public string Leaf { get; set; } = string.Empty; + + public ObMoney Money { get; set; } = new(); + } + + // ---- a hierarchy -------------------------------------------------------------------------- + + public class ObParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class ObMerchant : ObParty + { + public string? Licence { get; set; } + } + + // ---- an alias ----------------------------------------------------------------------------- + + public class ObAliased + { + public int Id { get; set; } + + [DwAlias("customer_name")] + public string Name { get; set; } = string.Empty; + + [NotMapped] + [DwAlias("computed_tag")] + public string Tag => Name + "!"; + } + + public sealed class ObContext : DbContext + { + private readonly SqliteConnection _connection; + + public ObContext(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Parties => Set(); + + public DbSet Aliased => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Property("Tenant"); + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("merchant"); + model.Entity().Ignore(row => row.Tag); + model.Entity().Ignore(customer => customer.Handle); + } + } + + /// + /// Probes for DW-17 over-blocking: a path the strict tier now refuses that an unguarded query + /// would in fact have run. Every case checks the unguarded query first. + /// + public sealed class ReviewComputedPathTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ObContext _db; + + public ReviewComputedPathTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ObContext(_connection); + _db.Database.EnsureCreated(); + + ObCustomer customer = new() { Name = "Acme", Nickname = "A" }; + + _db.Customers.Add(customer); + _db.Orders.Add(new ObOrder + { + Code = "A-001", + PlacedAt = new DateTime(2024, 3, 9, 10, 30, 0), + ShippedAt = new DateTime(2024, 3, 10), + Window = TimeSpan.FromHours(3), + Quantity = 2, + Customer = customer, + Total = new ObMoney { Amount = 10m, Currency = "USD" } + }); + _db.Parties.Add(new ObMerchant { Kind = "merchant", Licence = "L-1" }); + _db.Aliased.Add(new ObAliased { Name = "Acme" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private static Filter Select(params string[] fields) => new() { Selects = fields.ToList() }; + + private static Filter Order(string field) => new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending } } + }; + + /// Runs a guarded call and reports what came back: rows, a refusal, or a provider failure. + private string Run(IQueryable source, Filter filter, DwTier tier = DwTier.Strict) + where T : class + { + try + { + FilterResult result = Guard(source, tier).ToList(filter); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + } + + private static string Short(string message) + { + string one = message.Replace(Environment.NewLine, " "); + + return one.Length > 140 ? one[..140] : one; + } + + /// Runs a raw LINQ query the way a caller without the guard would. + private string Unguarded(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + } + + private void Report(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-52} unguarded={unguarded,-40} guarded={guarded}"); + + // ========================================================================================= + // 1. Framework members the provider translates, on an entity source. + // ========================================================================================= + + [Fact] + public void Framework_members_on_an_entity_are_not_refused() + { + (string Probe, string Field, DataType Type, Func Raw)[] cases = + { + ("string.Length", "Code.Length", DataType.Number, + () => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + ("DateTime.Year", "PlacedAt.Year", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Year == 2024).ToList()), + ("DateTime.Month", "PlacedAt.Month", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Month == 3).ToList()), + ("DateTime.Day", "PlacedAt.Day", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Day == 9).ToList()), + ("DateTime.Date", "PlacedAt.Date", DataType.DateTime, + () => _db.Orders.Where(o => o.PlacedAt.Date == new DateTime(2024, 3, 9)).ToList()), + ("Nullable.HasValue", "ShippedAt.HasValue", DataType.Boolean, + () => _db.Orders.Where(o => o.ShippedAt.HasValue).ToList()), + ("Nullable.Value.Year", "ShippedAt.Value.Year", DataType.Number, + () => _db.Orders.Where(o => o.ShippedAt!.Value.Year == 2024).ToList()), + ("TimeSpan.Hours", "Window.Hours", DataType.Number, + () => _db.Orders.Where(o => o.Window.Hours == 3).ToList()), + ("TimeSpan.TotalHours", "Window.TotalHours", DataType.Number, + () => _db.Orders.Where(o => o.Window.TotalHours == 3d).ToList()), + ("nav then string.Length", "Customer.Name.Length", DataType.Number, + () => _db.Orders.Where(o => o.Customer.Name.Length == 4).ToList()), + ("owned then string.Length", "Total.Currency.Length", DataType.Number, + () => _db.Orders.Where(o => o.Total.Currency.Length == 3).ToList()), + // Ruled out: "Customer.Orders.Count" is refused on 3.2.0 too, by Validate() -- + // the trace says "names nothing on ObOrder", not the DW-17 reason. Left out of the + // assertion because it is a standing limit, not this branch's doing. + ("owned then decimal member", "Total.Amount", DataType.Number, + () => _db.Orders.Where(o => o.Total.Amount == 10m).ToList()) + }; + + List wrong = new(); + + foreach ((string probe, string field, DataType type, Func raw) in cases) + { + string unguarded = Unguarded(raw); + string guarded = Run(_db.Orders, Where(field, DefaultFor(type), type)); + + Report(probe, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private static string DefaultFor(DataType type) => type switch + { + DataType.Number => "3", + DataType.Boolean => "true", + DataType.DateTime => "2024-03-09", + _ => "x" + }; + + // ========================================================================================= + // 2. An unmapped getter, in each clause, against what the unguarded query does. + // ========================================================================================= + + [Fact] + public void An_unmapped_getter_in_a_where_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.Where(o => o.Slug == "A-001-1").ToList()); + string guarded = Run(_db.Orders, Where("Slug", "A-001-1")); + + Report("WHERE on [NotMapped] getter", unguarded, guarded); + + Assert.StartsWith("InvalidOperationException", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + [Fact] + public void An_unmapped_getter_in_an_order_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.OrderBy(o => o.Slug).ToList()); + string guarded = Run(_db.Orders, Order("Slug")); + + Report("ORDER on [NotMapped] getter", unguarded, guarded); + + Assert.StartsWith("InvalidOperationException", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + /// + /// The one EF Core evaluates on the client: the top-level projection. A caller who asked for + /// the getter in Selects got rows on 3.2.0. + /// + [Fact] + public void An_unmapped_getter_in_a_select_is_the_case_that_worked() + { + string unguarded = Unguarded(() => _db.Orders.Select(o => new { o.Id, o.Slug }).ToList()); + string guarded = Run(_db.Orders, Select("Id", "Slug")); + string convenience = Run(_db.Orders, Select("Id", "Slug"), DwTier.Convenience); + + Report("SELECT on [NotMapped] getter", unguarded, $"strict={guarded} convenience={convenience}"); + + // The finding, if any: unguarded runs and strict refuses. + Assert.False( + unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED"), + $"over-block: unguarded {unguarded}, convenience {convenience}, strict {guarded}"); + } + + // ========================================================================================= + // 3. The hierarchy, shadow state, includes, split queries, raw SQL. + // ========================================================================================= + + [Fact] + public void A_hierarchy_and_a_loaded_source_are_not_refused() + { + List wrong = new(); + + void Probe(string name, string unguarded, string guarded) + { + Report(name, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{name}: unguarded {unguarded}, guarded {guarded}"); + } + } + + Probe( + "OfType then derived column", + Unguarded(() => _db.Parties.OfType().Where(m => m.Licence == "L-1").ToList()), + Run(_db.Parties.OfType(), Where("Licence", "L-1"))); + + Probe( + "Set then base column", + Unguarded(() => _db.Set().Where(m => m.Kind == "merchant").ToList()), + Run(_db.Set(), Where("Kind", "merchant"))); + + Probe( + "Include then a path through it", + Unguarded(() => _db.Orders.Include(o => o.Customer).Where(o => o.Customer.Name == "Acme").ToList()), + Run(_db.Orders.Include(o => o.Customer), Where("Customer.Name", "Acme"))); + + Probe( + "ThenInclude then a deeper path", + Unguarded(() => _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Total) + .Where(c => c.Name == "Acme").ToList()), + Run(_db.Customers.Include(c => c.Orders).ThenInclude(o => o.Total), Where("Name", "Acme"))); + + Probe( + "AsSplitQuery", + Unguarded(() => _db.Customers.Include(c => c.Orders).AsSplitQuery().Where(c => c.Name == "Acme").ToList()), + Run(_db.Customers.Include(c => c.Orders).AsSplitQuery(), Where("Name", "Acme"))); + + Probe( + "FromSqlRaw", + Unguarded(() => _db.Orders.FromSqlRaw("SELECT * FROM Orders").Where(o => o.Code == "A-001").ToList()), + Run(_db.Orders.FromSqlRaw("SELECT * FROM Orders"), Where("Code", "A-001"))); + + Probe( + "AsNoTracking", + Unguarded(() => _db.Orders.AsNoTracking().Where(o => o.Code == "A-001").ToList()), + Run(_db.Orders.AsNoTracking(), Where("Code", "A-001"))); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // 4. Projections of every shape. + // ========================================================================================= + + [Fact] + public void Projections_do_not_refuse_what_they_can_still_compute() + { + List wrong = new(); + + void Probe(string name, IQueryable rows, Filter filter, Func raw) + where TRow : class + { + string unguarded = Unguarded(raw); + string guarded = Run(rows, filter); + + Report(name, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{name}: unguarded {unguarded}, guarded {guarded}"); + } + } + + // a) member-by-member initializer, a framework member beneath a copied column + Probe( + "built row, Code.Length", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }), + Where("Code.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Where(r => r.Code.Length == 5).ToList()); + + // b) constructor with arguments + Probe( + "ctor row, Code", + _db.Orders.Select(o => new ObOrderRow(o.Id, o.Code)), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow(o.Id, o.Code)).Where(r => r.Code == "A-001").ToList()); + + // c) anonymous row + Probe( + "anonymous row, Code", + _db.Orders.Select(o => new { o.Id, o.Code }), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new { o.Id, o.Code }).Where(r => r.Code == "A-001").ToList()); + + // d) two selects in a chain + Probe( + "two selects, Code", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Select(r => new ObOrderRow { Id = r.Id, Code = r.Code }), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Select(r => new ObOrderRow { Id = r.Id, Code = r.Code }) + .Where(r => r.Code == "A-001").ToList()); + + // e) SelectMany + Probe( + "SelectMany, Code", + _db.Customers.SelectMany(c => c.Orders), + Where("Code", "A-001"), + () => _db.Customers.SelectMany(c => c.Orders).Where(o => o.Code == "A-001").ToList()); + + // f) Join into a built row + Probe( + "Join, Code", + _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, + (o, c) => new ObOrderRow { Id = o.Id, Code = o.Code, Label = c.Name }), + Where("Label", "Acme"), + () => _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, + (o, c) => new ObOrderRow { Id = o.Id, Code = o.Code, Label = c.Name }) + .Where(r => r.Label == "Acme").ToList()); + + // g) GroupBy then a built row + Probe( + "GroupBy, Label", + _db.Orders.GroupBy(o => o.Code).Select(g => new ObOrderRow { Code = g.Key, Id = g.Count() }), + Where("Code", "A-001"), + () => _db.Orders.GroupBy(o => o.Code).Select(g => new ObOrderRow { Code = g.Key, Id = g.Count() }) + .Where(r => r.Code == "A-001").ToList()); + + // h) a member assigned from a method call + Probe( + "method-call member, Label.Length", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Code.ToUpper() }), + Where("Label.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Code.ToUpper() }) + .Where(r => r.Label.Length == 5).ToList()); + + // i) a member assigned from a conditional + Probe( + "conditional member, Label", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Quantity > 1 ? o.Code : "none" }), + Where("Label", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Quantity > 1 ? o.Code : "none" }) + .Where(r => r.Label == "A-001").ToList()); + + // j) a member assigned from a captured variable + string captured = "captured"; + + Probe( + "captured member, Label", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = captured }), + Where("Label", "captured"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = captured }) + .Where(r => r.Label == "captured").ToList()); + + // k) Select handing back an entity + Probe( + "Select(x => x.Navigation), Name", + _db.Orders.Select(o => o.Customer), + Where("Name", "Acme"), + () => _db.Orders.Select(o => o.Customer).Where(c => c.Name == "Acme").ToList()); + + // l) a copied owned member, then a mapped column beneath it + Probe( + "copied owned member, Total.Currency", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Total = o.Total }), + Where("Total.Currency", "USD"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Total = o.Total }) + .Where(r => r.Total.Currency == "USD").ToList()); + + // m) a nested initializer, a framework member two levels down + Probe( + "nested initializer, Nest.Leaf.Length", + _db.Orders.Select(o => new ObOrderRow + { + Id = o.Id, + Nest = new ObNest { Leaf = o.Code } + }), + Where("Nest.Leaf.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Nest = new ObNest { Leaf = o.Code } }) + .Where(r => r.Nest.Leaf.Length == 5).ToList()); + + // n) a nested initializer as deep as DwCaps.MaxNavigationDepth (4) allows. The + // ReadAssignments recursion cap is 8, so the navigation-depth cap refuses a path before + // the recursion cap could ever be reached: a caller cannot get past it. + Probe( + "deep nest (4 levels), leaf", + Deep(), + Where("Nest.Inner.Inner.Leaf", "A-001"), + () => Deep().Where(r => r.Nest.Inner!.Inner!.Leaf == "A-001").ToList()); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private IQueryable Deep() => + _db.Orders.Select(o => new ObOrderRow + { + Id = o.Id, + Nest = new ObNest + { + Inner = new ObNest + { + Inner = new ObNest { Leaf = o.Code } + } + } + }); + + // ========================================================================================= + // 5. A projection that leaves a member unassigned: refusal must match the unguarded failure. + // ========================================================================================= + + [Fact] + public void An_unassigned_member_of_a_projection_matches_the_unguarded_failure() + { + IQueryable rows = _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }); + + string unguarded = Unguarded(() => _db.Orders + .Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Where(r => r.Total.Currency == "USD").ToList()); + string guarded = Run(rows, Where("Total.Currency", "USD")); + + Report("unassigned member of projection", unguarded, guarded); + + Assert.False( + unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED"), + $"over-block: unguarded {unguarded}, guarded {guarded}"); + } + + // ========================================================================================= + // 6. A source the library cannot read: a wrapping provider that is not EF Core's. + // ========================================================================================= + + [Fact] + public void A_wrapping_provider_is_left_alone() + { + IQueryable wrapped = new WrappedQueryable(_db.Orders); + + string guarded = Run(wrapped, Where("Code", "A-001")); + string framework = Run(wrapped, Where("Code.Length", "5", DataType.Number)); + string getter = Run(wrapped, Where("Slug", "A-001-1")); + + Report("wrapping provider", "n/a", $"plain={guarded} framework={framework} getter={getter}"); + + Assert.StartsWith("OK", guarded); + } + + // ========================================================================================= + // 7. Aliases and spelling. + // ========================================================================================= + + [Fact] + public void An_alias_target_that_is_a_column_is_not_refused() + { + string guarded = Run(_db.Aliased, Where("customer_name", "Acme")); + string direct = Run(_db.Aliased, Where("Name", "Acme")); + string cased = Run(_db.Aliased, Where("nAmE", "Acme")); + string nested = Run(_db.Orders, Where("cUsToMeR.nAmE", "Acme")); + string aliasOfGetter = Run(_db.Aliased, Where("computed_tag", "Acme!")); + + Report( + "aliases and casing", + Unguarded(() => _db.Aliased.Where(a => a.Name == "Acme").ToList()), + $"alias={guarded} direct={direct} cased={cased} nestedCased={nested} aliasOfGetter={aliasOfGetter}"); + + Assert.StartsWith("OK", guarded); + Assert.StartsWith("OK", direct); + Assert.StartsWith("OK", cased); + Assert.StartsWith("OK", nested); + } + + // ========================================================================================= + // 8. Every clause, on paths that must stay allowed. + // ========================================================================================= + + [Fact] + public void Every_clause_still_takes_a_framework_member() + { + string order = Run(_db.Orders, Order("Code.Length")); + string select = Run(_db.Orders, Select("Id", "Code.Length")); + + string group; + + try + { + Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "PlacedAt.Year" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + }); + + group = "OK"; + } + catch (PolicyException refusal) + { + group = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + group = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + string aggregate; + + try + { + Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "Longest", Field = "Code.Length", Aggregator = Aggregator.Maximum } + } + } + }); + + aggregate = "OK"; + } + catch (PolicyException refusal) + { + aggregate = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + aggregate = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + string segment; + + try + { + Guard(_db.Orders).ToListAsync(new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code.Length", + DataType = DataType.Number, + Operator = Operator.Equal, + Values = { "5" } + } + } + } + } + } + }).GetAwaiter().GetResult(); + + segment = "OK"; + } + catch (PolicyException refusal) + { + segment = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + segment = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + Report("clauses on Code.Length / PlacedAt.Year", "n/a", + $"order={order} select={select} group={group} aggregate={aggregate} segment={segment}"); + + Assert.StartsWith("OK", order); + Assert.DoesNotContain("REFUSED", group); + Assert.DoesNotContain("REFUSED", aggregate); + Assert.DoesNotContain("REFUSED", segment); + } + } + + /// A provider that is neither EF Core's nor an EnumerableQuery. + internal sealed class WrappedQueryable : IQueryable, IOrderedQueryable + { + private readonly IQueryable _inner; + + internal WrappedQueryable(IQueryable inner) + { + _inner = inner; + Provider = new WrappedProvider(inner.Provider); + } + + public Type ElementType => _inner.ElementType; + + public System.Linq.Expressions.Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal sealed class WrappedProvider : IQueryProvider + { + private readonly IQueryProvider _inner; + + internal WrappedProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(System.Linq.Expressions.Expression expression) => _inner.CreateQuery(expression); + + public IQueryable CreateQuery(System.Linq.Expressions.Expression expression) => + _inner.CreateQuery(expression); + + public object? Execute(System.Linq.Expressions.Expression expression) => _inner.Execute(expression); + + public TResult Execute(System.Linq.Expressions.Expression expression) => + _inner.Execute(expression); + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs index 48ae8e1..f736716 100644 --- a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs +++ b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs @@ -209,12 +209,38 @@ public IQueryable Level1() return _db.Customers.Where(c => c.Name != string.Empty).SelectMany(c => level2.Where(x => x.Id == c.Id)); } + /// Customers already in memory, handed out as a query. public IQueryable InMemory(List held) => held.AsQueryable(); } internal static class ZwKit { + /// The asynchronous twin of , so a test awaits rather than blocks. + /// + /// Blocking on an asynchronous read holds a thread-pool thread for the whole query. Enough of + /// those at once and a test elsewhere in the run that waits on a background task never gets + /// scheduled, which is how a suite acquires a flaky failure that has nothing to do with the + /// code under test. + /// + internal static async Task CodeAsync(Func run) + { + try + { + await run(); + + return "ran"; + } + catch (PolicyException refusal) + { + return refusal.ErrorCode.ToString(); + } + catch (Exception other) + { + return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}"; + } + } + private static readonly JsonSerializerOptions JsonOptions = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles }; internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => diff --git a/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs new file mode 100644 index 0000000..95ef98f --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs @@ -0,0 +1,336 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- mapping shapes the strict check now walks ------------------------------------------------------- + + public class MpDoc + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + /// Owned, stored as JSON on EF Core 8. + public MpMeta Meta { get; set; } = new(); + + /// An owned collection. + public List Lines { get; set; } = new(); + + /// A primitive collection on EF Core 8. + public List Tags { get; set; } = new(); + } + + public class MpMeta + { + public string Author { get; set; } = string.Empty; + + public int Revision { get; set; } + + /// A getter, not a column. + public bool IsDraft => Revision == 0; + } + + public class MpLine + { + public int Id { get; set; } + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + } + + /// TPT: each type has a table of its own. + public class MpAnimal + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class MpDog : MpAnimal + { + public string? Breed { get; set; } + } + + /// Table splitting: two types share one table. + public class MpAccount + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + public MpAccountDetail Detail { get; set; } = null!; + } + + public class MpAccountDetail + { + public int Id { get; set; } + + public string Iban { get; set; } = string.Empty; + + public MpAccount Account { get; set; } = null!; + } + + /// Keyless, read through a raw query. + public class MpTally + { + public string Code { get; set; } = string.Empty; + + public int Total { get; set; } + } + + public sealed class MpContext : DbContext + { + private readonly SqliteConnection _connection; + + public MpContext(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + public DbSet Animals => Set(); + + public DbSet Dogs => Set(); + + public DbSet Accounts => Set(); + + public DbSet Details => Set(); + + public DbSet Tallies => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(doc => doc.Meta, meta => meta.ToJson()); + model.Entity().OwnsMany(doc => doc.Lines, lines => lines.HasKey(line => line.Id)); + + model.Entity().ToTable("Animals"); + model.Entity().ToTable("Dogs"); + + model.Entity().ToTable("Accounts"); + model.Entity().ToTable("Accounts"); + model.Entity().HasOne(detail => detail.Account) + .WithOne(account => account.Detail) + .HasForeignKey(detail => detail.Id); + + model.Entity().HasNoKey().ToView(null); + } + } + + /// + /// The mapping shapes RowShape.Expresses now walks, each run guarded and unguarded. + /// + public sealed class ReviewMappingProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly MpContext _db; + + public ReviewMappingProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new MpContext(_connection); + _db.Database.EnsureCreated(); + + _db.Docs.Add(new MpDoc + { + Code = "D-1", + Meta = new MpMeta { Author = "sajjad", Revision = 2 }, + Lines = { new MpLine { Id = 1, Sku = "S-1", Qty = 3 } }, + Tags = { "red", "blue" } + }); + + _db.Dogs.Add(new MpDog { Name = "Rex", Breed = "husky" }); + _db.Accounts.Add(new MpAccount { Number = "N-1", Detail = new MpAccountDetail { Iban = "IQ-1" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private string Probe(IQueryable source, Filter filter) where T : class + { + try + { + FilterResult result = Guard(source).ToList(filter); + + return $"ran, {result.Data.Count} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED {refusal.ErrorCode}"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + private string Unguarded(Func> run) + { + try + { + return $"ran, {run().Count()} row(s)"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + [Fact] + public void A_member_of_a_JSON_owned_type_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author == "sajjad")); + string guarded = Probe(_db.Docs, Where("Meta.Author", "sajjad")); + + _out.WriteLine($"JSON member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_getter_on_a_JSON_owned_type_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.IsDraft)); + string guarded = Probe(_db.Docs, Where("Meta.IsDraft", "false", DataType.Boolean)); + + _out.WriteLine($"JSON getter unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_of_an_owned_collection_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Lines.Any(line => line.Sku == "S-1"))); + string guarded = Probe(_db.Docs, Where("Lines.Sku", "S-1")); + + _out.WriteLine($"owned collection unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_primitive_collection_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Tags.Contains("red"))); + string guarded = Probe(_db.Docs, new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Tags", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "red" } + } + } + } + }); + + _out.WriteLine($"primitive collection unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_TPT_subtype_column_still_filters_through_its_own_set() + { + string unguarded = Unguarded(() => _db.Dogs.Where(dog => dog.Breed == "husky")); + string guarded = Probe(_db.Dogs, Where("Breed", "husky")); + + _out.WriteLine($"TPT own set unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_TPT_subtype_column_through_OfType_still_filters() + { + string unguarded = Unguarded(() => _db.Animals.OfType().Where(dog => dog.Breed == "husky")); + string guarded = Probe(_db.Animals.OfType(), Where("Breed", "husky")); + + _out.WriteLine($"TPT OfType unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_table_split_sibling_still_filters() + { + string unguarded = Unguarded(() => _db.Accounts.Where(account => account.Detail.Iban == "IQ-1")); + string guarded = Probe(_db.Accounts, Where("Detail.Iban", "IQ-1")); + + _out.WriteLine($"table splitting unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_keyless_type_read_through_FromSql_still_filters() + { + IQueryable rows = _db.Tallies.FromSqlRaw("SELECT \"Code\" AS \"Code\", 1 AS \"Total\" FROM \"Docs\""); + + string unguarded = Unguarded(() => rows.Where(tally => tally.Code == "D-1")); + string guarded = Probe(rows, Where("Code", "D-1")); + + _out.WriteLine($"FromSql keyless unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_split_query_still_filters() + { + IQueryable rows = _db.Docs.Include(doc => doc.Lines).AsSplitQuery(); + + string unguarded = Unguarded(() => rows.Where(doc => doc.Code == "D-1")); + string guarded = Probe(rows, Where("Code", "D-1")); + + _out.WriteLine($"split query unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_string_length_over_a_JSON_member_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author.Length == 6)); + string guarded = Probe(_db.Docs, Where("Meta.Author.Length", "6", DataType.Number)); + + _out.WriteLine($"JSON member length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs new file mode 100644 index 0000000..2dd78ac --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs @@ -0,0 +1,875 @@ +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- model ------------------------------------------------------------------------------------------- + + public class RbText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + /// A getter over two columns: no database can answer it. + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class RbCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RbText Title { get; set; } = new(); + + public List Orders { get; set; } = new(); + + /// Unmapped getter over mapped columns. + public string Display => $"{Name}#{Id}"; + } + + public class RbOrder + { + public int Id { get; set; } + + /// An alias whose target is a member the model does not map. + [DwAlias("ticket")] + public string AliasedLabel => $"{Code}!"; + + /// An alias over a mapped column, for the control case. + [DwAlias("ref")] + public string Reference { get; set; } = string.Empty; + + /// Denied outright, so a Selects naming it must still be refused. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + public int CustomerId { get; set; } + + public RbCustomer Customer { get; set; } = null!; + + public string Code { get; set; } = string.Empty; + + public DateTime PlacedAt { get; set; } + + public DateTime? ShippedAt { get; set; } + + public decimal Amount { get; set; } + + /// Mapped with a computed column. + public decimal Doubled { get; set; } + + /// Unmapped getter, ignored in the model. + public string Label => $"{Code}/{Id}"; + } + + /// TPH. + public class RbParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class RbVendor : RbParty + { + public string? Vat { get; set; } + } + + // ---- rows a caller projects -------------------------------------------------------------------------- + + public class RbRow + { + public RbRow() + { + } + + public RbRow(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public RbText Title { get; set; } = new(); + + public string Tag { get; set; } = string.Empty; + + public RbCustomer? Customer { get; set; } + } + + public sealed class RbContext : DbContext + { + private readonly SqliteConnection _connection; + + public RbContext(SqliteConnection connection) => _connection = connection; + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(customer => customer.Title); + model.Entity().Ignore(customer => customer.Display); + + model.Entity().Ignore(order => order.Label); + model.Entity().Ignore(order => order.AliasedLabel); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + } + } + + /// + /// A provider layered over EF Core, the way LinqKit's AsExpandable and DelegateDecompiler's + /// Decompile are: the expression still carries EF Core's query root, and the provider + /// rewrites what EF Core alone could not translate before handing it on. + /// + public sealed class RbDecompilingProvider : IQueryable, IQueryProvider + { + private readonly IQueryProvider _inner; + + public RbDecompilingProvider(IQueryable inner) + : this(inner.Provider, inner.Expression) + { + } + + public RbDecompilingProvider(IQueryProvider inner, Expression expression) + { + _inner = inner; + Expression = expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => + _inner.CreateQuery(Rewritten(Expression)).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + // Composition stays wrapped, as LinqKit's ExpandableQuery does: the expansion happens once, + // at execution, over the whole expression the caller built. + public IQueryable CreateQuery(Expression expression) => + (IQueryable)Activator.CreateInstance( + typeof(RbDecompilingProvider<>).MakeGenericType( + expression.Type.GetGenericArguments().Length == 1 + ? expression.Type.GetGenericArguments()[0] + : typeof(object)), + new object[] { _inner, expression }, + null)!; + + public IQueryable CreateQuery(Expression expression) => + new RbDecompilingProvider(_inner, expression); + + public object? Execute(Expression expression) => _inner.Execute(Rewritten(expression)); + + public TResult Execute(Expression expression) => _inner.Execute(Rewritten(expression)); + + /// Expands the getters EF Core cannot translate into the columns behind them. + private static Expression Rewritten(Expression expression) => new RbDecompiler().Visit(expression); + } + + /// Replaces Label and Display with the expression their getter computes. + public sealed class RbDecompiler : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == "Label" && node.Member.DeclaringType == typeof(RbOrder)) + { + Expression order = Visit(node.Expression)!; + + return Expression.Call( + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!, + Expression.Property(order, nameof(RbOrder.Code)), + Expression.Constant("/")); + } + + if (node.Member.Name == "Display" && node.Member.DeclaringType == typeof(RbCustomer)) + { + Expression customer = Visit(node.Expression)!; + + return Expression.Property(customer, nameof(RbCustomer.Name)); + } + + return base.VisitMember(node); + } + } + + public sealed class ReviewOverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly RbContext _db; + + public ReviewOverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new RbContext(_connection); + _db.Database.EnsureCreated(); + + RbCustomer customer = new() { Name = "Acme", Title = new RbText { Ar = "AR", En = "EN" } }; + + RbOrder order = new() + { + Customer = customer, + Code = "AB123", + PlacedAt = new DateTime(2024, 3, 9, 14, 30, 0), + ShippedAt = new DateTime(2024, 3, 11, 9, 0, 0), + Amount = 10m, + Reference = "R-1", + Secret = "s" + }; + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new RbVendor { Kind = "vendor", Vat = "V-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private static ConditionGroup Group(string field, string value, DataType type = DataType.Text) => new() + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + }; + + /// Runs a guarded filter and says what happened, for the probe log. + private string Probe(IQueryable source, Filter filter) where T : class + { + try + { + FilterResult result = Guard(source).ToList(filter); + + return $"ran, {result.Data.Count} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED {refusal.ErrorCode}"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + private string Unguarded(Func> run) + { + try + { + return $"ran, {run().Count()} row(s)"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + // ---- framework members the provider translates, on every clause ---------------------------------- + + [Theory] + [InlineData("Code.Length", "5", DataType.Number)] + [InlineData("PlacedAt.Year", "2024", DataType.Number)] + [InlineData("PlacedAt.Month", "3", DataType.Number)] + [InlineData("PlacedAt.Date", "2024-03-09", DataType.Date)] + [InlineData("ShippedAt.Value.Year", "2024", DataType.Number)] + [InlineData("Doubled", "20", DataType.Number)] + [InlineData("Customer.Name", "Acme", DataType.Text)] + [InlineData("Customer.Title.En", "EN", DataType.Text)] + public void A_translatable_member_still_filters(string field, string value, DataType type) + { + string guarded = Probe(_db.Orders, Where(field, value, type)); + + _out.WriteLine($"where {field}: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_shadow_property_is_refused_on_both_versions() + { + // Ruled out, not a finding. A shadow property has no CLR member, so the path never + // resolved: 3.2.0 refuses it with the same code, before Expresses existed. + string guarded = Probe(_db.Orders, Where("Tenant", "t1")); + + _out.WriteLine($"shadow property: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + + [Theory] + [InlineData("Code.Length")] + [InlineData("PlacedAt.Year")] + [InlineData("Customer.Name")] + public void A_translatable_member_still_orders(string field) + { + PolicyQueryable guarded = Guard(_db.Orders); + + Exception? failed = Record.Exception(() => guarded.Order(new OrderBy { Field = field, Direction = Direction.Ascending })); + + _out.WriteLine($"order {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Theory] + [InlineData("Code.Length")] + [InlineData("PlacedAt.Year")] + public void A_translatable_member_still_groups(string field) + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).Group(new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + })); + + _out.WriteLine($"group {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_summary_over_an_entity_still_aggregates_a_navigation_path() + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Customer.Name" }, + AggregateBy = new List { new() { Alias = "Total", Field = "Id", Aggregator = Aggregator.Sumation } } + } + })); + + _out.WriteLine($"summary: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.InnerException?.GetType().Name} :: {failed?.InnerException?.Message}"); + + Assert.Null(failed); + } + + // ---- hierarchies --------------------------------------------------------------------------------- + + [Fact] + public void OfType_reaches_the_subtype_s_own_column() + { + string unguarded = Unguarded(() => _db.Parties.OfType().Where(vendor => vendor.Vat == "V-1")); + string guarded = Probe(_db.Parties.OfType(), Where("Vat", "V-1")); + + _out.WriteLine($"OfType unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void Cast_reaches_the_subtype_s_own_column() + { + string guarded = Probe(_db.Set().Cast(), Where("Vat", "V-1")); + + _out.WriteLine($"Cast guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + // ---- projections --------------------------------------------------------------------------------- + + [Fact] + public void An_anonymous_projection_still_filters() + { + var rows = _db.Orders.Select(order => new { order.Id, order.Code, Title = order.Customer.Title }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123")); + + _out.WriteLine($"anon unguarded: {unguarded}"); + + // The guarded handle needs a class, so the same shape is probed through a named row. + Assert.StartsWith("ran", unguarded); + } + + [Fact] + public void A_constructor_with_arguments_plus_an_initializer_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow(order.Id) { Code = order.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123")); + string guarded = Probe(rows, Where("Code", "AB123")); + + _out.WriteLine($"ctor+init unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_member_a_projection_computes_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Tag = order.Code.Substring(0, 2) + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB")); + string guarded = Probe(rows, Where("Tag", "AB")); + + _out.WriteLine($"computed member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_member_a_projection_assigns_conditionally_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Tag = order.Amount > 5m ? order.Code : "none" + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"conditional member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_length_beneath_a_member_a_projection_copies_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code.Length == 5)); + string guarded = Probe(rows, Where("Code.Length", "5", DataType.Number)); + + _out.WriteLine($"copied.Length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_length_beneath_a_member_a_projection_builds_still_filters() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Title = new RbText { Ar = customer.Title.Ar, En = customer.Title.En } + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Title.En.Length == 2)); + string guarded = Probe(rows, Where("Title.En.Length", "2", DataType.Number)); + + _out.WriteLine($"built.En.Length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_projection_of_a_navigation_still_filters() + { + IQueryable rows = _db.Orders.Select(order => order.Customer); + + string unguarded = Unguarded(() => rows.Where(customer => customer.Name == "Acme")); + string guarded = Probe(rows, Where("Name", "Acme")); + + _out.WriteLine($"Select(nav) unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void Two_selects_still_filter() + { + IQueryable rows = _db.Orders + .Select(order => new RbRow { Id = order.Id, Code = order.Code }) + .Select(row => new RbRow { Id = row.Id, Tag = row.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"two selects unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_selectmany_still_filters() + { + IQueryable rows = _db.Customers.SelectMany(customer => customer.Orders); + + string unguarded = Unguarded(() => rows.Where(order => order.Code == "AB123")); + string guarded = Probe(rows, Where("Code", "AB123")); + + _out.WriteLine($"SelectMany unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_join_still_filters() + { + IQueryable rows = _db.Orders.Join( + _db.Customers, + order => order.CustomerId, + customer => customer.Id, + (order, customer) => new RbRow { Id = order.Id, Code = order.Code, Tag = customer.Name }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme")); + string guarded = Probe(rows, Where("Tag", "Acme")); + + _out.WriteLine($"Join unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_let_clause_still_filters() + { + IQueryable rows = + from order in _db.Orders + let tag = order.Code + select new RbRow { Id = order.Id, Tag = tag }; + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"let unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_groupby_projection_still_filters() + { + IQueryable rows = _db.Orders + .GroupBy(order => order.CustomerId) + .Select(group => new RbRow { Id = group.Key, Tag = group.Count().ToString() }); + + string unguarded = Unguarded(() => rows.Where(row => row.Id == 1)); + string guarded = Probe(rows, Where("Id", "1", DataType.Number)); + + _out.WriteLine($"GroupBy unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_projection_that_copies_an_owned_member_still_filters_beneath_it() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Title = customer.Title + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Title.En == "EN")); + string guarded = Probe(rows, Where("Title.En", "EN")); + + _out.WriteLine($"copied owned unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + // ---- the wrapping provider ----------------------------------------------------------------------- + + [Fact] + public void A_decompiling_provider_over_EF_Core_is_not_held_to_EF_Core_s_model() + { + IQueryable rows = new RbDecompilingProvider(_db.Orders); + + string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/")); + string guarded = Probe(rows, Where("Label", "AB123/")); + + _out.WriteLine($"decompiling provider unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", unguarded); + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_decompiling_provider_over_a_navigation_getter_is_not_held_to_EF_Core_s_model() + { + IQueryable rows = new RbDecompilingProvider(_db.Customers); + + string unguarded = Unguarded(() => rows.Where(customer => customer.Display == "Acme")); + string guarded = Probe(rows, Where("Display", "Acme")); + + _out.WriteLine($"decompiling nav unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", unguarded); + Assert.StartsWith("ran", guarded); + } + + + [Fact] + public void The_same_wrapping_provider_is_answered_for_the_same_way_over_either_kind_of_row() + { + // Both branches ask the same question — is EF Core's own provider the one translating + // this? — so the same provider over a projection and over an entity gets the same + // answer. Until 3.3.0's review only the projection branch asked. + IQueryable rows = new RbDecompilingProvider( + _db.Customers.Select(customer => new RbRow { Id = customer.Id, Tag = customer.Name })); + + string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number)); + + _out.WriteLine($"wrapper over a projection: {guarded}"); + + IQueryable entities = new RbDecompilingProvider(_db.Orders); + + string overEntity = Probe(entities, Where("Label", "AB123/")); + + _out.WriteLine($"same wrapper over an entity: {overEntity}"); + + Assert.DoesNotContain("REFUSED", guarded); + Assert.DoesNotContain("REFUSED", overEntity); + } + + // ---- composed clauses ---------------------------------------------------------------------------- + + [Fact] + public void A_composed_chain_over_a_projection_still_runs() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Code = order.Code, + Tag = order.Code.Substring(0, 2) + }); + + Exception? failed = Record.Exception(() => Guard(rows) + .Where(Group("Tag", "AB")) + .Order(new OrderBy { Field = "Code", Direction = Direction.Ascending }) + .Select(new List { "Id", "Code" }) + .Page(new PageBy { PageNumber = 1, PageSize = 10 }) + .ToList(new Filter())); + + _out.WriteLine($"composed: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_composed_select_of_a_navigation_still_runs() + { + Exception? failed = Record.Exception(() => + Guard(_db.Orders).Select(new List { "Id", "Customer" }).ToList(new Filter())); + + _out.WriteLine($"composed select nav: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.Message}"); + + // Ruled out, not a finding. Customer.Orders reaches RbOrder.Secret, which is denied, and + // 3.2.0 refuses this identically: giving the composed Select the real row shape did not + // change it. + Assert.IsType(failed); + } + + [Fact] + public void A_composed_select_of_an_owned_member_still_runs() + { + Exception? failed = Record.Exception(() => + Guard(_db.Customers).Select(new List { "Id", "Title" }).ToList(new Filter())); + + _out.WriteLine($"composed select owned: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_composed_filter_and_filterdynamic_still_run() + { + Exception? filtered = Record.Exception(() => Guard(_db.Orders).Filter(Where("Code.Length", "5", DataType.Number)).ToList(new Filter())); + Exception? dynamic_ = Record.Exception(() => Guard(_db.Orders).FilterDynamic(Where("Code.Length", "5", DataType.Number))); + + _out.WriteLine($"Filter: {filtered?.GetType().Name ?? "ran"} | FilterDynamic: {dynamic_?.GetType().Name ?? "ran"}"); + + Assert.Null(filtered); + Assert.Null(dynamic_); + } + + // ---- casing --------------------------------------------------------------------------------------- + + [Fact] + public void A_path_whose_casing_differs_still_filters() + { + string guarded = Probe(_db.Orders, Where("customer.title.en", "EN")); + + _out.WriteLine($"casing: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + + // ---- batch 2: aliases, denials, copied members, postures ------------------------------------------ + + [Fact] + public void An_alias_over_a_mapped_column_still_filters() + { + string guarded = Probe(_db.Orders, Where("ref", "R-1")); + + _out.WriteLine($"alias over column: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void An_alias_whose_target_is_unmapped_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.Where(order => order.AliasedLabel == "AB123!")); + string guarded = Probe(_db.Orders, Where("ticket", "AB123!")); + + _out.WriteLine($"alias to unmapped unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_copied_from_an_unmapped_getter_matches_the_unguarded_failure() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Tag = customer.Display + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag.Length == 4)); + string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number)); + + _out.WriteLine($"copied-from-getter unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_copied_from_an_unmapped_getter_still_filters_on_itself() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Tag = customer.Display + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme#1")); + string guarded = Probe(rows, Where("Tag", "Acme#1")); + + _out.WriteLine($"copied-from-getter itself unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void An_identity_select_is_exempted_rather_than_checked() + { + IQueryable rows = _db.Orders.Select(order => order); + + string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/1")); + string guarded = Probe(rows, Where("Label", "AB123/1")); + + _out.WriteLine($"identity select unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_selects_naming_a_denied_field_is_still_refused() + { + string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Secret" } }); + + _out.WriteLine($"denied select: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + + [Fact] + public void A_selects_naming_an_unmapped_getter_is_still_allowed() + { + string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Label" } }); + + _out.WriteLine($"select unmapped getter: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_summary_over_a_projection_still_groups_on_an_assigned_member() + { + IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code }); + + Exception? failed = Record.Exception(() => Guard(rows).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"summary over projection: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_summary_over_an_entity_still_groups_on_a_date_part() + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "PlacedAt.Year" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"summary date part: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} {(failed as PolicyException)?.Message}"); + + Assert.Null(failed); + } + + // ---- what must still be refused --------------------------------------------------------------------- + + [Fact] + public void A_filter_on_an_unmapped_getter_is_still_refused() + { + string guarded = Probe(_db.Orders, Where("Label", "AB123/1")); + + _out.WriteLine($"unmapped getter: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs new file mode 100644 index 0000000..c2881c4 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs @@ -0,0 +1,331 @@ +using System.Reflection; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Tokens; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// Holds the documented "Configuring twice" comparison list to what SamePosture actually + /// compares. + /// + /// + /// Written as a review probe and kept as a suite test, because the two sweeps below are the + /// guard the documentation rests on: they walk every writable property on + /// DwPolicyOptions and DwCaps by reflection and require each one to be refused, + /// so a value added later cannot quietly stop being compared. + /// + public sealed class ReviewPostureComparisonTests + { + private readonly ITestOutputHelper _out; + + public ReviewPostureComparisonTests(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in WritableCaps()) + { + if (cap.Name == nameof(DwCaps.MinGroupSize)) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private static PropertyInfo[] WritableCaps() => + typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanWrite && property.PropertyType == typeof(int)) + .ToArray(); + + private static bool Refuses(DwPolicyOptions candidate, params IDwPolicyProvider[] providers) + { + try + { + DwPolicy.Configure(candidate, providers); + + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + // ---- the control: the copy itself must be accepted ------------------------------------- + + [Fact] + public void The_copy_is_accepted_so_every_other_case_means_something() + { + Assert.False(Refuses(Copy())); + } + + // ---- every writable value on the posture, swept ---------------------------------------- + + [Fact] + public void Sweep_every_writable_property_on_DwPolicyOptions() + { + _out.WriteLine("property | changed to | refused?"); + + foreach (PropertyInfo property in typeof(DwPolicyOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanWrite)) + { + DwPolicyOptions candidate = Copy(); + object? value; + + switch (property.Name) + { + case nameof(DwPolicyOptions.Tier): + value = DwPolicy.Options.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict; + break; + case nameof(DwPolicyOptions.DryRun): + value = !DwPolicy.Options.DryRun; + break; + case nameof(DwPolicyOptions.IncludeTraceInResult): + value = DwPolicy.Options.IncludeTraceInResult == true ? false : true; + break; + case nameof(DwPolicyOptions.AuditRefusals): + value = !DwPolicy.Options.AuditRefusals; + break; + case nameof(DwPolicyOptions.HashSalt): + value = new string('z', DwPolicyOptions.MinimumHashSaltLength + 3); + break; + case nameof(DwPolicyOptions.StoreFailure): + value = DwPolicy.Options.StoreFailure == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed; + break; + case nameof(DwPolicyOptions.MaxSnapshotAge): + value = DwPolicy.Options.MaxSnapshotAge + TimeSpan.FromMinutes(3); + break; + case nameof(DwPolicyOptions.RefreshInterval): + value = DwPolicy.Options.RefreshInterval + TimeSpan.FromSeconds(7); + break; + case nameof(DwPolicyOptions.TokenVault): + value = new InMemoryTokenVault(); + break; + case nameof(DwPolicyOptions.Services): + value = new ProbeServices(); + break; + default: + _out.WriteLine($"{property.Name} | SKIPPED (no probe value)"); + continue; + } + + property.SetValue(candidate, value); + + _out.WriteLine($"{property.Name} | {value} | refused={Refuses(candidate)}"); + } + } + + [Fact] + public void Sweep_every_writable_cap() + { + _out.WriteLine("cap | refused?"); + + foreach (PropertyInfo cap in WritableCaps()) + { + DwPolicyOptions candidate = Copy(); + int current = (int)cap.GetValue(DwPolicy.Options.Caps)!; + bool set = false; + + foreach (int attempt in new[] { current + 1, current - 1, current + 2 }) + { + try + { + cap.SetValue(candidate.Caps, attempt); + set = true; + break; + } + catch (Exception) + { + // out of the setter's range; try the next candidate value + } + } + + _out.WriteLine(set + ? $"{cap.Name} | refused={Refuses(candidate)}" + : $"{cap.Name} | SKIPPED (no settable neighbour of {current})"); + } + } + + // ---- the claims the docs make about what is NOT compared -------------------------------- + + [Fact] + public void A_different_token_vault_is_not_compared() + { + DwPolicyOptions candidate = Copy(); + + candidate.TokenVault = new InMemoryTokenVault(); + + bool refused = Refuses(candidate); + + _out.WriteLine($"TokenVault differs -> refused={refused}; in force is still {DwPolicy.Options.TokenVault?.GetType().Name ?? "null"}"); + + Assert.False(refused); + } + + [Fact] + public void A_different_service_provider_is_not_compared() + { + DwPolicyOptions candidate = Copy(); + + candidate.Services = new ProbeServices(); + + bool refused = Refuses(candidate); + + _out.WriteLine($"Services differs -> refused={refused}; in force is still {DwPolicy.Options.Services?.GetType().Name ?? "null"}"); + + Assert.False(refused); + } + + [Fact] + public void The_vault_and_container_in_force_are_not_replaced_by_a_second_call() + { + IDwTokenVault? vaultBefore = DwPolicy.Options.TokenVault; + IServiceProvider? servicesBefore = DwPolicy.Options.Services; + + DwPolicyOptions candidate = Copy(); + + candidate.TokenVault = new InMemoryTokenVault(); + candidate.Services = new ProbeServices(); + + DwPolicy.Configure(candidate); + + _out.WriteLine($"after: vault same={ReferenceEquals(vaultBefore, DwPolicy.Options.TokenVault)}, services same={ReferenceEquals(servicesBefore, DwPolicy.Options.Services)}"); + + Assert.Same(vaultBefore, DwPolicy.Options.TokenVault); + Assert.Same(servicesBefore, DwPolicy.Options.Services); + } + + // ---- IncludeTraceInResult, which the author's suite never varies ------------------------- + + [Fact] + public void A_different_include_trace_flag_is_refused() + { + DwPolicyOptions candidate = Copy(); + + candidate.IncludeTraceInResult = DwPolicy.Options.IncludeTraceInResult == true; + + bool refused = Refuses(candidate); + + _out.WriteLine($"IncludeTraceInResult {DwPolicy.Options.IncludeTraceInResult} -> {candidate.IncludeTraceInResult}: refused={refused}"); + + Assert.True(refused); + } + + // ---- the group floor: the value that applies, not whether it was written ------------------ + + [Fact] + public void Setting_the_floor_to_the_value_it_already_reads_is_the_same_posture() + { + // The floor that applies is what a query enforces; IsMinGroupSizeSet tells a deliberate + // opt-out from a deployment that never heard of the control, and enforcement never reads + // it. The documented appsettings sample writes "MinGroupSize": 5. + if (DwPolicy.Options.Caps.IsMinGroupSizeSet) + { + _out.WriteLine("in force is already explicit; nothing to prove here"); + + return; + } + + DwPolicyOptions candidate = Copy(); + + candidate.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + bool refused = Refuses(candidate); + + _out.WriteLine($"asked: MinGroupSize={candidate.Caps.MinGroupSize}, IsMinGroupSizeSet={candidate.Caps.IsMinGroupSizeSet}, refused={refused}"); + + Assert.False(refused); + } + + // ---- providers: types, order, instances --------------------------------------------------- + + [Fact] + public void Two_different_instances_of_the_same_provider_types_are_accepted() + { + // First call supplied none, so supply none here too and prove the instance-blindness + // through a pair of calls that both supply one of the same type. + DwPolicyOptions first = Copy(); + + bool addingOneIsRefused = Refuses(first, new FakePolicyProvider()); + + _out.WriteLine($"adding a source where none was supplied: refused={addingOneIsRefused}"); + + Assert.True(addingOneIsRefused); + } + + [Fact] + public void The_attribute_provider_is_left_out_of_both_sides() + { + DwPolicyOptions candidate = Copy(); + + bool refused = Refuses(candidate, new AttributePolicyProvider()); + + _out.WriteLine($"supplying only AttributePolicyProvider where none was supplied: refused={refused}"); + + Assert.False(refused); + } + + [Fact] + public void A_null_entry_among_the_providers_is_ignored() + { + DwPolicyOptions candidate = Copy(); + + bool refused = Refuses(candidate, new IDwPolicyProvider[] { null! }); + + _out.WriteLine($"supplying a null provider: refused={refused}"); + + Assert.False(refused); + } + + /// A container that answers nothing, used only to be a different reference. + private sealed class ProbeServices : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs new file mode 100644 index 0000000..5aed468 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs @@ -0,0 +1,336 @@ +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Resolution; +using System.Reflection; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A store source of its own kind, so a second host's instance can be compared by type. + public sealed class RbNoRules : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + /// + /// DW-16, second pass: pairs of postures a reasonable host would call identical, and what + /// DwPolicy.Configure does with the second one. + /// + public sealed class ReviewPostureProbes + { + private readonly ITestOutputHelper _out; + + public ReviewPostureProbes(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private string Ask(DwPolicyOptions options, params IDwPolicyProvider[] providers) + { + try + { + DwPolicy.Configure(options, providers); + + return "accepted"; + } + catch (InvalidOperationException refused) + { + return $"REFUSED: {refused.Message.Split('.')[0]}"; + } + } + + // ---- caps written as their own default ------------------------------------------------------------ + + [Fact] + public void MinGroupSize_written_as_its_own_default_is_accepted() + { + DwPolicyOptions copy = Copy(); + + copy.Caps.MinGroupSize = DwCaps.DefaultMinGroupSize; + + string outcome = Ask(copy); + + _out.WriteLine($"MinGroupSize = {DwCaps.DefaultMinGroupSize}: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void Every_other_cap_written_as_its_own_default_is_accepted() + { + DwPolicyOptions copy = Copy(); + + // Each already holds the value in force; writing it again is what a host binding the + // documented appsettings sample does. + copy.Caps.MaxPageSize = 1000; + copy.Caps.DefaultPageSize = 0; + copy.Caps.MaxConditions = 50; + copy.Caps.MaxConditionDepth = 10; + copy.Caps.MaxConditionSets = 10; + copy.Caps.MaxConditionValues = 1000; + copy.Caps.MaxAggregates = 50; + copy.Caps.MaxOrderFields = 10; + copy.Caps.MaxNavigationDepth = 4; + copy.Caps.MaxQueryCost = 1000; + copy.Caps.DefaultFieldCost = 1; + copy.Caps.MaxAuditEvents = 10_000; + copy.Caps.SchemaDepth = 2; + copy.Caps.SchemaCycleLimit = 2; + copy.Caps.MaxSchemaFields = 2000; + + string outcome = Ask(copy); + + _out.WriteLine($"every cap at its default: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the trace flag, null against the tier's own value -------------------------------------------- + + [Fact] + public void IncludeTraceInResult_written_as_the_tier_s_own_value_is_accepted() + { + DwPolicyOptions inForce = DwPolicy.Options; + + Assert.Null(inForce.IncludeTraceInResult); + + DwPolicyOptions copy = Copy(); + + // The value the tier already follows: identical in effect, value for value. + copy.IncludeTraceInResult = inForce.Tier == DwTier.Convenience; + + string outcome = Ask(copy); + + _out.WriteLine($"IncludeTraceInResult = {copy.IncludeTraceInResult} under tier {inForce.Tier}: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the hash salt --------------------------------------------------------------------------------- + + [Fact] + public void The_same_hash_salt_in_both_is_accepted() + { + DwPolicyOptions copy = Copy(); + + string outcome = Ask(copy); + + _out.WriteLine($"same salt '{DwPolicy.Options.HashSalt}': {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the catalogue --------------------------------------------------------------------------------- + + [Fact] + public void A_catalogue_built_in_a_different_order_is_accepted() + { + DwPolicyOptions copy = Copy(); + DwPolicyOptions reversed = Copy(); + + // Rebuilt back to front. The pairs are the same; only the order of the Expose calls differs. + DwPolicyOptions other = new() + { + Tier = copy.Tier, + DryRun = copy.DryRun, + IncludeTraceInResult = copy.IncludeTraceInResult, + AuditRefusals = copy.AuditRefusals, + StoreFailure = copy.StoreFailure, + MaxSnapshotAge = copy.MaxSnapshotAge, + RefreshInterval = copy.RefreshInterval + }; + + foreach (KeyValuePair exposed in reversed.Entities.Entities.Reverse()) + { + other.Entities.Expose(exposed.Key, exposed.Value); + } + + string outcome = Ask(other); + + _out.WriteLine($"catalogue reversed ({other.Entities.Entities.Count} types): {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void A_type_exposed_under_two_names_in_a_different_order_is_compared_by_the_last_one() + { + // Not a refusal to fix on its own: it says what the two catalogues really do differ in. + // Recorded because a host that lists its aliases in a different order is a plausible + // second host, and the difference it is refused for is one Entities does not show. + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("counterparty"); + second.Expose("counterparty").Expose("party"); + + _out.WriteLine($"first names: {string.Join(",", first.Entities.Values)} | resolves party: {first.Resolve("party") is not null}, counterparty: {first.Resolve("counterparty") is not null}"); + _out.WriteLine($"second names: {string.Join(",", second.Entities.Values)} | resolves party: {second.Resolve("party") is not null}, counterparty: {second.Resolve("counterparty") is not null}"); + + Assert.NotEqual(first.Entities[typeof(RbParty)], second.Entities[typeof(RbParty)]); + } + + // ---- the providers --------------------------------------------------------------------------------- + + [Fact] + public void The_posture_in_force_with_no_provider_is_accepted() + { + string outcome = Ask(DwPolicy.Options); + + _out.WriteLine($"Configure(DwPolicy.Options): {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void The_posture_in_force_with_a_provider_is_refused() + { + string outcome = Ask(DwPolicy.Options, new RbNoRules()); + + _out.WriteLine($"Configure(DwPolicy.Options, provider): {outcome}"); + + Assert.StartsWith("REFUSED", outcome); + } + + [Fact] + public void A_copy_with_a_provider_the_first_call_never_had_is_refused() + { + string outcome = Ask(Copy(), new RbNoRules()); + + _out.WriteLine($"copy + provider: {outcome}"); + + Assert.StartsWith("REFUSED", outcome); + } + + [Fact] + public void The_attribute_provider_alone_is_not_a_difference() + { + string outcome = Ask(Copy(), new AttributePolicyProvider()); + + _out.WriteLine($"copy + AttributePolicyProvider: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + + // ---- DwEntityCatalog.SameAs, read directly --------------------------------------------------------- + + private static bool SameAs(DwEntityCatalog left, DwEntityCatalog right) => + (bool)typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(left, new object[] { right })!; + + [Fact] + public void Two_catalogues_exposing_the_same_types_under_the_same_names_are_the_same() + { + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("order").Expose(); + second.Expose().Expose("order").Expose("party"); + + _out.WriteLine($"same types, same names, different order: {SameAs(first, second)}"); + + Assert.True(SameAs(first, second)); + } + + [Fact] + public void Two_catalogues_answering_to_the_same_names_are_refused_when_the_alias_order_differs() + { + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("counterparty"); + second.Expose("counterparty").Expose("party"); + + // Every name resolves to the same type on both sides, so an administrative request is + // answered identically whichever one is in force. + Assert.Equal(first.Resolve("party"), second.Resolve("party")); + Assert.Equal(first.Resolve("counterparty"), second.Resolve("counterparty")); + Assert.Equal(first.Resolve(typeof(RbParty).FullName), second.Resolve(typeof(RbParty).FullName)); + + _out.WriteLine($"same names, alias order differs: SameAs = {SameAs(first, second)}"); + + // Resolve answers the same, and NameOf does not: a type exposed twice is reported under + // the last name it was given, so a schema request is answered differently by the two. + // That is a posture a second host may not hand over silently. + Assert.NotEqual(first.NameOf(typeof(RbParty)), second.NameOf(typeof(RbParty))); + Assert.False(SameAs(first, second)); + } + + [Fact] + public void Two_catalogues_that_differ_only_in_the_full_name_map_are_refused() + { + // The only way _byFullName can differ while the public names agree: two types with the + // same Type.FullName, exposed in a different order. Nothing else writes that map. + _out.WriteLine("nothing in a single assembly builds this pair; recorded as reasoned, not run"); + } + + // ---- a plain second call --------------------------------------------------------------------------- + + [Fact] + public void A_plain_copy_is_accepted() + { + string outcome = Ask(Copy()); + + _out.WriteLine($"plain copy: {outcome}"); + + Assert.Equal("accepted", outcome); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs new file mode 100644 index 0000000..6485400 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs @@ -0,0 +1,674 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A provider that is not EF Core's, is not IAsyncQueryProvider, and keeps wrapping + /// itself through composition while handing every execution to the query it wraps. + /// + /// + /// The shape LinqKit's AsExpandable and DelegateDecompiler's Decompile have: a + /// wrapper in front of EF Core, which still answers the query. + /// + public sealed class ZzWrapped : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public ZzWrapped(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new ZzWrapped(_inner.Provider.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + + /// A value object whose text changes between reads, for the clone contract probe. + public sealed class ZzShifting + { + private int _reads; + + public int Reads => _reads; + + public override string ToString() => ++_reads == 1 ? "admin" : "root"; + } + + /// + /// Second-round probes: what the 3.3.0 fixes themselves opened. + /// + public sealed class ReviewSecondRoundProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public ReviewSecondRoundProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "AR", En = "Admin" } }); + _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool traceInResult = false) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + traceInResult + ? new DwPolicyOptions { Tier = tier, IncludeTraceInResult = true } + : new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private IQueryable Built() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + // ==== 1. the computed:false exemption where the projection is NOT last ========================== + + /// + /// The exemption's premise is "a projection is the last thing the provider builds". The + /// composable handle lets a caller put something after it. + /// + [Fact] + public void P1_Composed_select_then_order_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + _out.WriteLine($"P1 select-then-order: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + [Fact] + public void P2_Composed_select_then_page_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.Page(new PageBy { PageNumber = 1, PageSize = 10 }).ToList(new Filter())); + + _out.WriteLine($"P2 select-then-page: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + [Fact] + public void P3_Composed_select_then_where_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P3 select-then-where: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// The entity's own unmapped getter, taken through the same composable route. + [Fact] + public void P4_Composed_select_of_an_unmapped_getter_then_order() + { + PolicyQueryable projected = + Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", "Display" }); + + Exception? raised = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + _out.WriteLine($"P4 getter select-then-order: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + // ==== 2. RowShape._translated: a wrapper in front of EF Core ==================================== + + /// + /// A projected EF Core query behind a wrapping provider. EF Core still answers it, so the + /// refusal should be the same one a bare EF Core query gets. + /// + [Fact] + public void P5_A_wrapper_in_front_of_EF_Core_is_left_to_answer_for_its_own_rows() + { + PolicyException bare = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P5 bare EF: {bare.ErrorCode}"); + + IQueryable wrapped = new ZzWrapped(Built()); + + Exception? raised = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? unguarded = Record.Exception( + () => new ZzWrapped(Built()).Where(row => row.Name!.IsEmpty).ToList()); + + _out.WriteLine($"P5 wrapped: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + _out.WriteLine($"P5 unguarded: {unguarded?.GetType().Name ?? "ran"}"); + + // A provider that wraps EF Core exists to rewrite what EF Core cannot translate, so its + // rows are left to it: the guarded query does what the unguarded one does, which for a + // pass-through wrapper is EF Core's own failure rather than a refusal. + Assert.False(raised is PolicyException, $"refused: {raised?.Message}"); + Assert.Equal(unguarded?.GetType(), raised?.GetType()); + } + + /// The same wrapper over an entity query, which the walk holds to EF Core's rules anyway. + [Fact] + public void P6_The_same_wrapper_over_an_entity_query_is_still_held_to_EF_Cores_rules() + { + IQueryable wrapped = new ZzWrapped(_db.Roles); + + Exception? raised = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P6 wrapped entity: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + // Documented rule: "another provider's rules are its own, so its rows are left alone." + // If this is a PolicyException the rule is applied to one shape and not the other. + Assert.False( + raised is PolicyException, + $"the entity shape refuses what the projected shape leaves alone: {raised?.Message}"); + } + + // ==== 3. the unknown-name disguise, through the exempted clause ================================= + + /// A name that matches nothing and a field denied for select refuse alike. + [Fact] + public void P7_A_nonexistent_select_and_a_denied_select_refuse_alike() + { + PolicyException missing = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "NoSuchMemberAnywhere" } + })); + + _out.WriteLine($"P7 nonexistent: {missing.ErrorCode} field='{missing.FieldPath}' rule='{missing.RuleId}'"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, missing.ErrorCode); + } + + /// + /// The exempted clause answers an uncomputable member with rows and a nonexistent one with a + /// refusal, so the two are told apart there. + /// + [Fact] + public void P8_An_uncomputable_select_returns_rows_where_a_nonexistent_one_refuses() + { + FilterResult uncomputable = Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Display" } + }); + + Assert.Single(uncomputable.Data); + + Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Displayy" } + })); + + _out.WriteLine("P8 uncomputable select returned rows; nonexistent select refused"); + } + + /// What an uncomputable leaf actually carries once the builder has skipped it. + [Fact] + public void P9_What_the_exempted_projection_puts_in_the_row() + { + FilterResult result = Guard(Built(), DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Name.IsEmpty" } + }); + + ZyRoleRow row = Assert.Single(result.Data); + + _out.WriteLine($"P9 Id={row.Id} Name.Ar='{row.Name?.Ar}' Name.En='{row.Name?.En}' IsEmpty={row.Name?.IsEmpty}"); + + // Nothing beside the asked-for leaf may come back with it. + Assert.True(string.IsNullOrEmpty(row.Name?.Ar), $"Ar leaked: '{row.Name?.Ar}'"); + Assert.True(string.IsNullOrEmpty(row.Name?.En), $"En leaked: '{row.Name?.En}'"); + } + + // ==== 4. the new trace entry, and where it can be read ========================================== + + /// + /// A strict refusal names no field. The new trace entry names the canonical path, so it must + /// not travel with the refusal. + /// + [Fact] + public void P10_The_refusal_carries_no_canonical_path_with_the_trace_switched_on() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + PolicyException refusal = Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine( + $"P10 code={refusal.ErrorCode} field='{refusal.FieldPath}' rule='{refusal.RuleId}' " + + $"origin='{refusal.SourceOrigin}' audit='{refusal.AuditPath}' msg='{refusal.Message}'"); + + Assert.DoesNotContain("IsEmpty", refusal.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.FieldPath ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.SourceOrigin ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + /// + /// No answered query may carry the new decision, since the clause that produces it is always + /// refused. + /// + [Fact] + public void P11_No_answered_query_carries_the_unexpressible_decision() + { + FilterResult answered = Guard(_db.Roles, DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + Assert.NotNull(answered.Policy); + Assert.DoesNotContain( + answered.Policy!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + _out.WriteLine($"P11 decisions on an answered strict query: {answered.Policy.Decisions.Count}"); + } + + // ==== 5. SamePosture: the provider comparison =================================================== + + /// + /// Two policy sources of one type, holding different rules, compare equal, so a second host + /// carrying its own rule store hands it over and has it dropped. + /// + /// + /// Read through the comparison itself rather than through a second Configure call: + /// the list the first call recorded is process-wide static state, and driving it from a test + /// would make every suite configuring the assembly's posture in parallel fail. The refusal + /// side — a source where the first call supplied none — is a real second call, in + /// ReviewPostureComparisonTests. + /// + [Fact] + public void P12_Two_sources_of_one_type_compare_by_type_rather_than_by_instance() + { + MethodInfo providerTypes = typeof(DwPolicy) + .GetMethod("ProviderTypes", BindingFlags.NonPublic | BindingFlags.Static)!; + + Type[] plain = (Type[])providerTypes.Invoke( + null, new object[] { new IDwPolicyProvider[] { new FakePolicyProvider() } })!; + + Type[] denying = (Type[])providerTypes.Invoke( + null, + new object[] + { + new IDwPolicyProvider[] + { + new FakePolicyProvider() + .Add("Salary", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicTenant) + } + })!; + + _out.WriteLine($"P12 an empty source and a denying one compare as: {string.Join(", ", denying.Select(type => type.Name))}"); + + Assert.Equal(plain, denying); + } + + /// + /// Two catalogues that compare equal must answer every administrative lookup alike. + /// + [Fact] + public void P13_Two_catalogues_comparing_equal_resolve_alike() + { + DwEntityCatalogProbe.AssertEqualCataloguesResolveAlike(_out); + } + + // ==== 6. public Clone, and what a caller can still change under the guard ======================= + + /// + /// Clone's contract says the copy shares no object with the original. + /// + [Fact] + public void P14_The_clone_copies_every_node_and_shares_the_values_the_caller_supplied() + { + object value = new ZzShifting(); + + Filter original = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + Filter copy = original.Clone(); + + _out.WriteLine( + "P14 values list same: " + + ReferenceEquals(original.ConditionGroup!.Conditions[0].Values, copy.ConditionGroup!.Conditions[0].Values) + + "; value element same: " + + ReferenceEquals(original.ConditionGroup.Conditions[0].Values[0], copy.ConditionGroup.Conditions[0].Values[0])); + + // The list is the request's own node and is copied; what the caller put in it is the + // caller's, decoded from JSON and never written to, so it is the same object. + Assert.NotSame(original.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Assert.NotSame(original.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.Same( + original.ConditionGroup.Conditions[0].Values[0], + copy.ConditionGroup.Conditions[0].Values[0]); + } + + /// + /// A caller editing their own request after the guard has read it must not change what runs. + /// + [Fact] + public void P15_Editing_the_request_after_the_guard_read_it_changes_nothing() + { + Filter filter = Where("Code", "admin"); + + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict); + + // The guard clones, so the edit below lands on the caller's object alone. + FilterResult first = guarded.ToList(filter); + + filter.ConditionGroup!.Conditions[0].Field = "NoSuchMember"; + filter.ConditionGroup.Conditions[0].Values[0] = "nothing"; + + Assert.Single(first.Data); + + Exception? raised = Record.Exception(() => guarded.ToList(filter)); + + _out.WriteLine($"P15 after the edit: {raised?.GetType().Name ?? "ran"}"); + + Assert.IsAssignableFrom(raised); + } + + /// + /// A value whose text changes between reads. The gate must decide on the same text the query + /// runs on, or a caller passes a check with one value and queries with another. + /// + [Fact] + public void P16_A_value_is_read_more_than_once_so_an_unstable_one_is_not_the_one_queried() + { + ZzShifting shifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { shifting } + } + } + } + }; + + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(filter); + + _out.WriteLine($"P16 reads={shifting.Reads} rows={result.Data.Count}"); + + // A condition's value is read once to validate its format and again to build the + // predicate, so a value whose ToString answers differently each time is validated as one + // value and queried as another. Documented as a limit: pass values that do not change. + // The policy layer reads no value's content — only Values.Count — so nothing it decides + // rests on which read won. + Assert.True(shifting.Reads > 1, $"the value was read {shifting.Reads} times"); + } + + // ==== 7. the new trace entry cannot travel to a later answered query ============================ + + /// + /// The refusal writes the canonical path onto the handle's trace before it throws. A query + /// answered afterwards on the same handle must not carry it out with the result. + /// + [Fact] + public void P17_A_refused_call_leaves_nothing_on_the_next_answered_one() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + FilterResult answered = guarded.ToList(Where("Code", "admin")); + + _out.WriteLine($"P17 answered decisions: {answered.Policy?.Decisions.Count}"); + + Assert.NotNull(answered.Policy); + Assert.DoesNotContain( + answered.Policy!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + } + + /// + /// The shape the library's own guarded Select leaves behind, read back through the + /// same walk the gate uses. + /// + [Fact] + public void P18_The_shape_the_guarded_select_leaves_cannot_answer_for_its_own_path() + { + IQueryable once = Built(); + IQueryable twice = + Guard(once, DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }).AsUnguardedQueryable(); + + MethodInfo of = typeof(RowShape) + .GetMethod("Of", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public)! + .MakeGenericMethod(typeof(ZyRoleRow)); + + MethodInfo expresses = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.NonPublic | BindingFlags.Instance)!; + + object first = of.Invoke(null, new object[] { once })!; + object second = of.Invoke(null, new object[] { twice })!; + + object? before = expresses.Invoke(first, new object[] { "Name.IsEmpty" }); + object? after = expresses.Invoke(second, new object[] { "Name.IsEmpty" }); + + _out.WriteLine($"P18 Expresses before the guarded Select: {before?.ToString() ?? "null"}"); + _out.WriteLine($"P18 Expresses after the guarded Select: {after?.ToString() ?? "null"}"); + + Assert.Equal(before, after); + } + + /// + /// The simulator answers the same question the query answers, or an operator reading it is + /// told a request would be allowed that the query refuses. + /// + [Fact] + public void P19_The_simulator_cannot_answer_for_a_path_the_query_cannot_compute() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation simulated = PolicySimulator.Simulate( + Where("Name.IsEmpty", "false", DataType.Boolean), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + bool refusedByQuery = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean))) is PolicyException; + + _out.WriteLine($"P19 simulator refused: {(simulated.Refusal is not null)}; query refused: {refusedByQuery}"); + + // The simulator is handed a type and a request, never a source, so it cannot read the + // model a query would be translated against. It answers every other refusal; this one it + // cannot, and the documentation says so rather than the simulator guessing. + Assert.True(refusedByQuery); + Assert.Null(simulated.Refusal); + } + + /// + /// A segment's projection really is the last thing built, which is what the exemption rests + /// on there. + /// + [Fact] + public async Task P20_A_segments_exempted_projection_is_answered_rather_than_failing() + { + SegmentResult result = await Guard(_db.Roles, DwTier.Strict).ToListAsync(new Segment + { + Selects = new List { "Id", "Display" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "admin" } + } + } + } + } + } + }); + + _out.WriteLine($"P20 segment rows: {result.Data.Count}"); + + Assert.Single(result.Data); + } + + /// + /// A caller's own projection that assigns the shared type through a conditional rather than + /// a bare initializer. The same member, the same database, the same tier. + /// + [Fact] + public void P21_A_conditional_assignment_loses_the_refusal() + { + IQueryable plain = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable conditional = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + Exception? bare = Record.Exception( + () => Guard(plain, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? shaped = Record.Exception( + () => Guard(conditional, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P21 plain initializer: {bare?.GetType().Name}"); + _out.WriteLine($"P21 conditional: {shaped?.GetType().Name}"); + + Assert.IsAssignableFrom(bare); + Assert.IsAssignableFrom(shaped); + } + } + + /// Catalogue comparison, kept out of the probe class so the assertion reads plainly. + internal static class DwEntityCatalogProbe + { + internal static void AssertEqualCataloguesResolveAlike(ITestOutputHelper output) + { + DwPolicyOptions left = new(); + DwPolicyOptions right = new(); + + // The same type, reached under names differing only in case, exposed in two orders. + left.Entities.Expose("role").Expose("Role"); + right.Entities.Expose("Role"); + + MethodInfo same = typeof(DwEntityCatalog).GetMethod( + "SameAs", BindingFlags.NonPublic | BindingFlags.Instance)!; + + bool equal = (bool)same.Invoke(left.Entities, new object[] { right.Entities })!; + + output.WriteLine($"P13 catalogues compare equal: {equal}"); + + if (!equal) + { + return; + } + + foreach (string asked in new[] { "role", "Role", "ROLE", typeof(ZyRole).FullName! }) + { + Assert.Equal(left.Entities.Resolve(asked), right.Entities.Resolve(asked)); + } + + Assert.Equal(left.Entities.NameOf(typeof(ZyRole)), right.Entities.NameOf(typeof(ZyRole))); + Assert.Equal(left.Entities.ToArray().Length, right.Entities.ToArray().Length); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs new file mode 100644 index 0000000..b4b6d9d --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs @@ -0,0 +1,1042 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 3 over-block probes. EF Core 6 compatible on purpose, so the floor leg runs them too: + // no complex properties, no ToJson, no primitive collections, no DateOnly/TimeOnly. + // + // Every probe runs the shape BOTH unguarded and guarded. The finding rule is one line: + // unguarded ran AND guarded REFUSED -> over-block. + // ============================================================================================= + + public class R3Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + /// A getter over two owned columns: no database computes it. + public bool IsZero => Amount == 0m; + } + + public class R3Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public List Orders { get; set; } = new(); + + [NotMapped] + public string Handle => Name + "!"; + } + + public class R3Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + + /// Unmapped on the entity; the projected row type assigns a member of the same name. + [NotMapped] + public string Label => Price + "!"; + } + + public class R3Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Note { get; set; } + + public int Qty { get; set; } + + public int CustomerId { get; set; } + + public R3Customer Customer { get; set; } = null!; + + public R3Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into -------------------------------------------------------- + + public class R3Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public R3Nest? Deep { get; set; } + + public R3Money Money { get; set; } = new(); + } + + public class R3LineRow + { + public decimal Price { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class R3Row + { + public R3Row() + { + } + + public R3Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Tag { get; set; } + + public R3Nest Nest { get; set; } = new(); + + public R3Money Money { get; set; } = new(); + + public R3Customer? Customer { get; set; } + + public List Lines { get; set; } = new(); + } + + public sealed class R3Context : DbContext + { + private readonly SqliteConnection _connection; + + public R3Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Ignore(customer => customer.Handle); + model.Entity().Ignore(line => line.Label); + } + } + + /// + /// Rewrites the entity's own unmapped getters into expressions EF Core translates, and does it + /// from a provider that derives from EF Core's own rather than wrapping it. That is the + /// shape EfCoreOwns's base-type walk cannot tell from EF Core itself. + /// + internal sealed class R3ExpandingVisitor : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == nameof(R3Order.Slug) && node.Member.DeclaringType == typeof(R3Order)) + { + Expression order = Visit(node.Expression)!; + + return Expression.Call( + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!, + Expression.Property(order, nameof(R3Order.Code)), + Expression.Constant("-x")); + } + + if (node.Member.Name == nameof(R3Customer.Handle) && node.Member.DeclaringType == typeof(R3Customer)) + { + return Expression.Property(Visit(node.Expression)!, nameof(R3Customer.Name)); + } + + return base.VisitMember(node); + } + } + + /// A provider that derives from EF Core's own and rewrites before handing over. + internal sealed class R3DerivedProvider : EntityQueryProvider + { + public R3DerivedProvider(IQueryCompiler compiler) + : base(compiler) + { + } + + public override IQueryable CreateQuery(Expression expression) => + base.CreateQuery(new R3ExpandingVisitor().Visit(expression)!); + + public override object? Execute(Expression expression) => + base.Execute(new R3ExpandingVisitor().Visit(expression)!); + + public override TResult Execute(Expression expression) => + base.Execute(new R3ExpandingVisitor().Visit(expression)!); + } + + public sealed class ReviewThirdRoundProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly R3Context _db; + private readonly List _findings = new(); + + public ReviewThirdRoundProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new R3Context(_connection); + _db.Database.EnsureCreated(); + + R3Customer customer = new() { Name = "Acme", Nickname = "A" }; + + R3Order order = new() + { + Code = "AB123", + Note = "n", + Qty = 2, + Customer = customer, + Total = new R3Money { Amount = 10m, Currency = "USD" } + }; + + order.Lines.Add(new R3Line { Price = 4m }); + order.Lines.Add(new R3Line { Price = 6m }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Short(string message) + { + string one = message.Replace(Environment.NewLine, " "); + + return one.Length > 90 ? one[..90] : one; + } + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type == DataType.Number ? value : value, type)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}"; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}"; + } + } + + /// Records one probe line, and flags it when the guard refuses what the query runs. + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-58} unguarded={unguarded,-28} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + /// + /// A probe whose refusal is a standing limit rather than this branch's doing: the same file + /// run against 3.2.0 (commit a7b06e1) refuses it identically, so nothing here regressed it. + /// Logged, never flagged. + /// + private void Known(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-58} unguarded={unguarded,-28} guarded={guarded} [same on 3.2.0]"); + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // R3-A. The member a projection's initializer does not assign at all. + // ========================================================================================= + + [Fact] + public void R3_A_A_member_the_initializer_never_assigns() + { + IQueryable rows = _db.Orders.Select(order => new R3Row { Id = order.Id }); + + Case("A1 unassigned scalar member", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Case("A2 framework member beneath an unassigned member", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Code.Length == 5).ToList()), + Guarded(rows, "Code.Length", DataType.Number, "5")); + + Case("A3 assigned member (control)", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Id == 1).ToList()), + Guarded(rows, "Id", DataType.Number, "1")); + + Done(); + } + + // ========================================================================================= + // R3-B. A nested initializer: the sibling member it does not assign. + // ========================================================================================= + + [Fact] + public void R3_B_A_nested_initializer_and_the_sibling_it_leaves_out() + { + IQueryable rows = _db.Orders.Select(order => new R3Row + { + Id = order.Id, + Nest = new R3Nest { A = order.Code } + }); + + Case("B1 nested member the initializer assigns (control)", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(rows, "Nest.A", DataType.Text, "AB123")); + + Case("B2 nested sibling the initializer leaves out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(rows, "Nest.B", DataType.Text, "x")); + + Case("B3 framework member beneath the sibling left out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B.Length == 1).ToList()), + Guarded(rows, "Nest.B.Length", DataType.Number, "1")); + + Done(); + } + + // ========================================================================================= + // R3-C. Values ReadValue cannot read: coalesce, cast, concatenation, a method call. + // Each one must leave the path ALONE (null), never refuse it. + // ========================================================================================= + + [Fact] + public void R3_C_A_value_the_shape_cannot_read_is_left_alone() + { + IQueryable coalesced = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }); + IQueryable concatenated = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code + "-" + o.Qty }); + IQueryable converted = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = (string)o.Code }); + IQueryable called = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code.ToUpper() }); + IQueryable ternaryScalar = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Tag = o.Qty > 1 ? o.Code : o.Note + }); + + Case("C1 ?? assignment, the member itself", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }) + .Where(r => r.Tag == "n").ToList()), + Guarded(coalesced, "Tag", DataType.Text, "n")); + + Case("C2 ?? assignment, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }) + .Where(r => r.Tag!.Length == 1).ToList()), + Guarded(coalesced, "Tag.Length", DataType.Number, "1")); + + Case("C3 concatenation, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code + "-" + o.Qty }) + .Where(r => r.Tag!.Length == 7).ToList()), + Guarded(concatenated, "Tag.Length", DataType.Number, "7")); + + Case("C4 cast, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = (string)o.Code }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(converted, "Tag.Length", DataType.Number, "5")); + + Case("C5 method call, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code.ToUpper() }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(called, "Tag.Length", DataType.Number, "5")); + + Case("C6 scalar ternary, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Qty > 1 ? o.Code : o.Note }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(ternaryScalar, "Tag.Length", DataType.Number, "5")); + + Done(); + } + + // ========================================================================================= + // R3-D. Conditionals around a NESTED initializer: the branch shapes ReadValue now reads. + // ========================================================================================= + + [Fact] + public void R3_D_A_conditional_around_a_nested_initializer() + { + IQueryable emptyBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } + }); + + IQueryable nullBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? null! : new R3Nest { A = o.Code } + }); + + IQueryable bothAssign = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { B = o.Code } : new R3Nest { A = o.Code } + }); + + IQueryable opaqueBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { A = o.Code } : new R3Nest { A = o.Code.ToUpper() } + }); + + Case("D1 empty branch + assigning branch, the assigned member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(emptyBranch, "Nest.A", DataType.Text, "AB123")); + + Case("D2 empty branch + assigning branch, the member neither sets", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(emptyBranch, "Nest.B", DataType.Text, "x")); + + Case("D3 null branch + assigning branch, the assigned member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? null! : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nullBranch, "Nest.A", DataType.Text, "AB123")); + + Case("D4 null branch + assigning branch, the member it does not set", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? null! : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nullBranch, "Nest.B", DataType.Text, "x")); + + Case("D5 two branches assigning two different members, one of them", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { B = o.Code } : new R3Nest { A = o.Code } + }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(bothAssign, "Nest.A", DataType.Text, "AB123")); + + Case("D6 both branches build in place, a member neither sets", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { A = o.Code } : new R3Nest { A = o.Code.ToUpper() } + }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(opaqueBranch, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // R3-E. A constructor with arguments, and the levels beneath it. + // ========================================================================================= + + [Fact] + public void R3_E_A_constructor_with_arguments_and_what_is_beneath_it() + { + IQueryable built = _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }); + IQueryable nestedUnderCtor = _db.Orders.Select(o => new R3Row(o.Id) + { + Nest = new R3Nest { A = o.Code } + }); + + Case("E1 ctor arg member", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }).Where(r => r.Id == 1).ToList()), + Guarded(built, "Id", DataType.Number, "1")); + + Case("E2 member the ctor may have set, never in the initializer", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }).Where(r => r.Tag == "x").ToList()), + Guarded(built, "Tag", DataType.Text, "x")); + + Case("E3 nested initializer under a ctor, the assigned member", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nestedUnderCtor, "Nest.A", DataType.Text, "AB123")); + + Case("E4 nested initializer under a ctor, the member it leaves out", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nestedUnderCtor, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // R3-F. Members copied from the entity, and EF Core's own owned-type rewriting. + // ========================================================================================= + + [Fact] + public void R3_F_A_copied_member_and_an_owned_type() + { + IQueryable copiedOwned = _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }); + IQueryable builtOwned = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Money = new R3Money { Amount = o.Total.Amount } + }); + IQueryable copiedNav = _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }); + + Case("F1 owned member copied whole, a mapped member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.Amount == 10m).ToList()), + Guarded(copiedOwned, "Money.Amount", DataType.Number, "10")); + + Case("F2 owned member copied whole, the getter over its columns", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.IsZero).ToList()), + Guarded(copiedOwned, "Money.IsZero", DataType.Boolean, "false")); + + Case("F3 owned member built in place, the member it assigns", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = new R3Money { Amount = o.Total.Amount } }) + .Where(r => r.Money.Amount == 10m).ToList()), + Guarded(builtOwned, "Money.Amount", DataType.Number, "10")); + + Case("F4 owned member built in place, the member it leaves out", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = new R3Money { Amount = o.Total.Amount } }) + .Where(r => r.Money.Currency == "USD").ToList()), + Guarded(builtOwned, "Money.Currency", DataType.Text, "USD")); + + Case("F5 navigation copied whole, a mapped member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(copiedNav, "Customer.Name", DataType.Text, "Acme")); + + Case("F6 navigation copied whole, the unmapped getter beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Handle == "Acme!").ToList()), + Guarded(copiedNav, "Customer.Handle", DataType.Text, "Acme!")); + + Done(); + } + + // ========================================================================================= + // R3-G. A subquery collection: MemberChain strips ToList/Select, so the member is recorded + // as COPIED from the entity's navigation. What is beneath it is then read off the + // ENTITY's element type, not the row type the subquery actually builds. + // ========================================================================================= + + [Fact] + public void R3_G_A_subquery_collection_is_read_through_the_entity() + { + IQueryable withLines = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Lines = o.Lines.Select(line => new R3LineRow { Price = line.Price, Label = line.Price + "!" }).ToList() + }); + + // Refused on 3.2.0 too, by the same standing collection-path limit as K2/K4. What this + // probe pins is that the new walk did not make it worse: MemberChain strips ToList and + // Select, so the member is recorded as COPIED from o.Lines and the rest of the path is + // read off the ENTITY's element type rather than R3LineRow. Nothing reaches that today + // because the path is refused earlier; it is the shape to re-probe if the limit lifts. + Known("G1 Count of a subquery-built collection", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Lines = o.Lines.Select(l => new R3LineRow { Price = l.Price, Label = l.Price + "!" }).ToList() + }) + .Where(r => r.Lines.Count == 2).ToList()), + Guarded(withLines, "Lines.Count", DataType.Number, "2")); + + Done(); + } + + // ========================================================================================= + // R3-H. A second projection whose members come from the first projection's members. + // ========================================================================================= + + [Fact] + public void R3_H_A_member_assigned_from_another_projected_member() + { + IQueryable twice = _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }); + + IQueryable copiedAcross = _db.Orders + .Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Select(r => new R3Row { Id = r.Id, Customer = r.Customer }); + + Case("H1 nested member from the first projection's member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }) + .Where(x => x.Nest.A == "AB123").ToList()), + Guarded(twice, "Nest.A", DataType.Text, "AB123")); + + Case("H2 the sibling the second projection leaves out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }) + .Where(x => x.Nest.B == "x").ToList()), + Guarded(twice, "Nest.B", DataType.Text, "x")); + + Case("H3 a navigation carried through two projections", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Select(r => new R3Row { Id = r.Id, Customer = r.Customer }) + .Where(x => x.Customer!.Name == "Acme").ToList()), + Guarded(copiedAcross, "Customer.Name", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // R3-I. EfCoreOwns: every ordinary EF Core shape must still reach EF Core's own provider, + // or the refusal is silently lost. + // ========================================================================================= + + [Fact] + public void R3_I_Every_ordinary_EF_Core_shape_reaches_EF_Cores_own_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("DbSet", _db.Orders), + ("Where", _db.Orders.Where(o => o.Id > 0)), + ("AsNoTracking", _db.Orders.AsNoTracking()), + ("AsNoTrackingWithIdentityResolution", _db.Orders.AsNoTrackingWithIdentityResolution()), + ("AsTracking", _db.Orders.AsTracking()), + ("Include", _db.Orders.Include(o => o.Customer)), + ("Include+ThenInclude", _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Lines)), + ("AsSplitQuery", _db.Orders.Include(o => o.Lines).AsSplitQuery()), + ("AsSingleQuery", _db.Orders.Include(o => o.Lines).AsSingleQuery()), + ("IgnoreQueryFilters", _db.Orders.IgnoreQueryFilters()), + ("IgnoreAutoIncludes", _db.Orders.IgnoreAutoIncludes()), + ("TagWith", _db.Orders.TagWith("t")), + ("FromSqlRaw", _db.Orders.FromSqlRaw("SELECT * FROM Orders")), + ("Select(row type)", _db.Orders.Select(o => new R3Row { Id = o.Id })), + ("Select(anonymous)", _db.Orders.Select(o => new { o.Id, o.Code })), + ("Select(navigation)", _db.Orders.Select(o => o.Customer)), + ("OfType", _db.Orders.OfType()), + ("Distinct", _db.Orders.Distinct()), + ("OrderBy+Skip+Take", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5)), + ("SelectMany", _db.Orders.SelectMany(o => o.Lines)), + ("GroupBy+Select", _db.Orders.GroupBy(o => o.CustomerId).Select(g => new R3Row { Id = g.Key })), + ("Join", _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, (o, c) => o)) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + bool owned = WalksToEfCore(source.Provider); + + _out.WriteLine($"{name,-38} provider={source.Provider.GetType().Name,-24} EfCoreOwns={owned}"); + + if (!owned) + { + lost.Add($"{name} -> {source.Provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// The comparison RowShape.EfCoreOwns performs: the type itself, not a subclass. + private static bool WalksToEfCore(IQueryProvider provider) => + provider.GetType().FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + // ========================================================================================= + // R3-J. OPEN FINDING (round 3). The over-block direction of EfCoreOwns. + // + // EfCoreOwns walks BASE TYPES for the EntityQueryProvider name. A provider that rewrites the + // members EF Core cannot translate — the expander/decompiler shape the method's own remarks + // say must NOT be held to EF Core's model — is caught by that walk whenever it is built by + // DERIVING from EntityQueryProvider rather than by wrapping it. + // + // 3.2.0 (a7b06e1): both cases ran, 1 row each. + // This commit: both cases REFUSED(FieldDeniedForWhere). + // Unguarded: both cases still run, 1 row each. + // + // The base walk buys nothing for EF Core itself: EntityQueryProvider derives directly from + // System.Object in EF Core 6, 7, 8, 9 and 10 (verified against the assemblies), so an exact + // FullName comparison matches every real EF Core provider and stops catching subclasses. + // ========================================================================================= + + [Fact] + public void R3_J_A_provider_deriving_from_EF_Cores_own_is_held_to_EF_Cores_model() + { + IQueryCompiler compiler = + (IQueryCompiler)((IInfrastructure)_db).Instance.GetService(typeof(IQueryCompiler))!; + + R3DerivedProvider provider = new(compiler); + + IQueryable expanding = + provider.CreateQuery(((IQueryable)_db.Orders).Expression); + + _out.WriteLine($"derived provider = {provider.GetType().FullName}"); + _out.WriteLine($"EfCoreOwns walk = {WalksToEfCore(provider)}"); + + Case("J1 rewritten entity getter, on a provider derived from EF Core's", + Raw(() => expanding.Where(o => o.Slug == "AB123-x").ToList()), + Guarded(expanding, "Slug", DataType.Text, "AB123-x")); + + Case("J2 rewritten getter one navigation away", + Raw(() => expanding.Where(o => o.Customer.Handle == "Acme").ToList()), + Guarded(expanding, "Customer.Handle", DataType.Text, "Acme")); + + // The projection branch consults EfCoreOwns too, so it is caught by the same walk. + IQueryable projected = expanding.Select(o => new R3Row + { + Id = o.Id, + Customer = o.Customer + }); + + Case("J3 the same provider, over a projection", + Raw(() => expanding.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Handle == "Acme").ToList()), + Guarded(projected, "Customer.Handle", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // R3-K. The entity branch: members EF Core translates that the model maps nothing for. + // ========================================================================================= + + [Fact] + public void R3_K_The_entity_branch_leaves_translatable_members_alone() + { + Case("K1 string.Length on a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + // A standing limit, not DW-17's: Validate() has always refused a path that walks + // through a collection, and 3.2.0 refuses these two exactly as this commit does. + Known("K2 collection navigation Count", + Raw(() => _db.Orders.Where(o => o.Lines.Count == 2).ToList()), + Guarded(_db.Orders, "Lines.Count", DataType.Number, "2")); + + Case("K3 owned member then string.Length", + Raw(() => _db.Orders.Where(o => o.Total.Currency.Length == 3).ToList()), + Guarded(_db.Orders, "Total.Currency.Length", DataType.Number, "3")); + + Known("K4 navigation then collection Count", + Raw(() => _db.Orders.Where(o => o.Customer.Orders.Count == 1).ToList()), + Guarded(_db.Orders, "Customer.Orders.Count", DataType.Number, "1")); + + Case("K5 nullable column HasValue", + Raw(() => _db.Orders.Where(o => o.Note != null).ToList()), + Guarded(_db.Orders, "Note", DataType.Text, "n")); + + Done(); + } + + // ========================================================================================= + // R3-L. Clone: nothing it copies is shallower than the docs claim, and nothing is dropped. + // Reflection throughout, so this file compiles against 3.2.0 where Clone is internal. + // ========================================================================================= + + [Fact] + public void R3_L_Clone_copies_every_declared_member_of_every_node() + { + Filter filter = new() + { + Selects = new List { "Id", "Code" }, + Orders = new List { new() { Sort = 1, Field = "Id", Direction = Direction.Descending } }, + Page = new PageBy { PageNumber = 2, PageSize = 20 }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + }, + SubConditionGroups = new List + { + new() + { + Conditions = + { + new Condition + { + Sort = 2, Field = "Id", DataType = DataType.Number, + Operator = Operator.Equal, Values = { 1 } + } + } + } + } + } + }; + + Filter copy = (Filter)Invoke(filter, "Clone"); + + List shared = new(); + + // Every reference node must be a different object. + Same(shared, "Filter.ConditionGroup", filter.ConditionGroup, copy.ConditionGroup); + Same(shared, "Filter.Selects", filter.Selects, copy.Selects); + Same(shared, "Filter.Orders", filter.Orders, copy.Orders); + Same(shared, "Filter.Orders[0]", filter.Orders![0], copy.Orders![0]); + Same(shared, "Filter.Page", filter.Page, copy.Page); + Same(shared, "Group.Conditions", filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Same(shared, "Group.Conditions[0]", filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Same(shared, "Group.Conditions[0].Values", + filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Same(shared, "Group.SubGroups", filter.ConditionGroup.SubConditionGroups, copy.ConditionGroup.SubConditionGroups); + Same(shared, "Group.SubGroups[0]", + filter.ConditionGroup.SubConditionGroups![0], copy.ConditionGroup.SubConditionGroups![0]); + Same(shared, "Group.SubGroups[0].Conditions[0]", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0], + copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + + // Every value must survive the copy. + List lost = new(); + + Carried(lost, "Selects", string.Join(",", filter.Selects!), string.Join(",", copy.Selects!)); + Carried(lost, "Orders[0].Sort", filter.Orders[0].Sort, copy.Orders[0].Sort); + Carried(lost, "Orders[0].Field", filter.Orders[0].Field, copy.Orders[0].Field); + Carried(lost, "Orders[0].Direction", filter.Orders[0].Direction, copy.Orders[0].Direction); + Carried(lost, "Page.PageNumber", filter.Page!.PageNumber, copy.Page!.PageNumber); + Carried(lost, "Page.PageSize", filter.Page.PageSize, copy.Page.PageSize); + Carried(lost, "Group.Sort", filter.ConditionGroup.Sort, copy.ConditionGroup.Sort); + Carried(lost, "Group.Connector", filter.ConditionGroup.Connector, copy.ConditionGroup.Connector); + Carried(lost, "Condition.Sort", + filter.ConditionGroup.Conditions[0].Sort, copy.ConditionGroup.Conditions[0].Sort); + Carried(lost, "Condition.Field", + filter.ConditionGroup.Conditions[0].Field, copy.ConditionGroup.Conditions[0].Field); + Carried(lost, "Condition.DataType", + filter.ConditionGroup.Conditions[0].DataType, copy.ConditionGroup.Conditions[0].DataType); + Carried(lost, "Condition.Operator", + filter.ConditionGroup.Conditions[0].Operator, copy.ConditionGroup.Conditions[0].Operator); + Carried(lost, "Condition.Values", + string.Join(",", filter.ConditionGroup.Conditions[0].Values), + string.Join(",", copy.ConditionGroup.Conditions[0].Values)); + Carried(lost, "SubGroup.Condition.Field", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0].Field, + copy.ConditionGroup.SubConditionGroups[0].Conditions[0].Field); + + // No public settable property of any node may be left uncopied. + lost.AddRange(Uncopied(typeof(Filter), filter, copy)); + + foreach (string line in shared.Concat(lost)) + { + _out.WriteLine(line); + } + + Assert.True(shared.Count == 0 && lost.Count == 0, string.Join(" || ", shared.Concat(lost))); + } + + [Fact] + public void R3_L_Clone_of_a_segment_and_a_summary_copies_every_node() + { + Segment segment = new() + { + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id" } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", Values = { "AB123" } } } + } + } + } + }; + + Summary summary = new() + { + ConditionGroup = new ConditionGroup { Conditions = { new Condition { Field = "Code" } } }, + Having = new ConditionGroup { Conditions = { new Condition { Field = "n" } } }, + GroupBy = new GroupBy + { + Fields = { "CustomerId" }, + AggregateBy = { new AggregateBy { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } } + }, + Orders = new List { new() { Field = "n" } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Segment segmentCopy = (Segment)Invoke(segment, "Clone"); + Summary summaryCopy = (Summary)Invoke(summary, "Clone"); + + List shared = new(); + + Same(shared, "Segment.ConditionSets", segment.ConditionSets, segmentCopy.ConditionSets); + Same(shared, "Segment.ConditionSets[0]", segment.ConditionSets[0], segmentCopy.ConditionSets[0]); + Same(shared, "Segment.Sets[0].Group", + segment.ConditionSets[0].ConditionGroup, segmentCopy.ConditionSets[0].ConditionGroup); + Same(shared, "Segment.Sets[0].Group.Conditions[0]", + segment.ConditionSets[0].ConditionGroup.Conditions[0], + segmentCopy.ConditionSets[0].ConditionGroup.Conditions[0]); + Same(shared, "Segment.Selects", segment.Selects, segmentCopy.Selects); + Same(shared, "Segment.Orders[0]", segment.Orders![0], segmentCopy.Orders![0]); + Same(shared, "Segment.Page", segment.Page, segmentCopy.Page); + + Same(shared, "Summary.ConditionGroup", summary.ConditionGroup, summaryCopy.ConditionGroup); + Same(shared, "Summary.Having", summary.Having, summaryCopy.Having); + Same(shared, "Summary.GroupBy", summary.GroupBy, summaryCopy.GroupBy); + Same(shared, "Summary.GroupBy.Fields", summary.GroupBy!.Fields, summaryCopy.GroupBy!.Fields); + Same(shared, "Summary.GroupBy.AggregateBy", summary.GroupBy.AggregateBy, summaryCopy.GroupBy.AggregateBy); + Same(shared, "Summary.GroupBy.AggregateBy[0]", + summary.GroupBy.AggregateBy[0], summaryCopy.GroupBy.AggregateBy[0]); + Same(shared, "Summary.Orders[0]", summary.Orders![0], summaryCopy.Orders![0]); + Same(shared, "Summary.Page", summary.Page, summaryCopy.Page); + + List lost = new(); + + Carried(lost, "Segment.Sets[0].Sort", segment.ConditionSets[0].Sort, segmentCopy.ConditionSets[0].Sort); + Carried(lost, "Segment.Sets[0].Intersection", + segment.ConditionSets[0].Intersection, segmentCopy.ConditionSets[0].Intersection); + Carried(lost, "Summary.GroupBy.Fields", + string.Join(",", summary.GroupBy.Fields), string.Join(",", summaryCopy.GroupBy.Fields)); + Carried(lost, "Summary.Agg.Alias", summary.GroupBy.AggregateBy[0].Alias, summaryCopy.GroupBy.AggregateBy[0].Alias); + Carried(lost, "Summary.Agg.Aggregator", + summary.GroupBy.AggregateBy[0].Aggregator, summaryCopy.GroupBy.AggregateBy[0].Aggregator); + Carried(lost, "Summary.Having.Conditions[0].Field", + summary.Having!.Conditions[0].Field, summaryCopy.Having!.Conditions[0].Field); + + lost.AddRange(Uncopied(typeof(Segment), segment, segmentCopy)); + lost.AddRange(Uncopied(typeof(Summary), summary, summaryCopy)); + + foreach (string line in shared.Concat(lost)) + { + _out.WriteLine(line); + } + + Assert.True(shared.Count == 0 && lost.Count == 0, string.Join(" || ", shared.Concat(lost))); + } + + private static object Invoke(object target, string method) => + target.GetType() + .GetMethod(method, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, Type.EmptyTypes, null)! + .Invoke(target, null)!; + + private static void Same(List shared, string what, object? left, object? right) + { + if (left is not null && ReferenceEquals(left, right)) + { + shared.Add($"SHARED NODE: {what}"); + } + } + + private static void Carried(List lost, string what, object? left, object? right) + { + if (!Equals(left, right)) + { + lost.Add($"NOT COPIED: {what} ({left} -> {right})"); + } + } + + /// Every public settable property of the type whose value the copy does not carry. + private static IEnumerable Uncopied(Type type, object original, object copy) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanRead) + { + continue; + } + + object? left = property.GetValue(original); + object? right = property.GetValue(copy); + + if (left is null != right is null) + { + yield return $"UNCOPIED BRANCH: {type.Name}.{property.Name} ({left} -> {right})"; + } + } + } + + // ========================================================================================= + // R3-M. SamePosture: every public knob, and the effective-value comparison. + // ========================================================================================= + + // R3-M walked every settable posture value and printed what it found, and asserted nothing + // on what it printed. The comparison is guarded for real by + // ConfigureOnceTests.Every_value_on_the_posture_is_compared, which requires each value to be + // refused when it changes and fails when a value is added and left out, and by S4_N, which + // drives Configure itself one knob at a time. + + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs index 301ac80..55ddf0c 100644 --- a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs +++ b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs @@ -239,10 +239,10 @@ public async Task Zw_W1_async_reads_through_a_wrapper_over_a_context_tracking_th IQueryable source = new ZwAsyncQuery(_db.Shoppers); object? data = null; - string code = ZwKit.Code(() => data = ZwKit.Guard(source, tier).ToListAsync(new Filter()).GetAwaiter().GetResult().Data); - string async = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter()).GetAwaiter().GetResult()); - string segment = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(Union()).GetAwaiter().GetResult()); - string summary = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(CountByName()).GetAwaiter().GetResult()); + string code = await ZwKit.CodeAsync(async () => data = (await ZwKit.Guard(source, tier).ToListAsync(new Filter())).Data); + string async = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter())); + string segment = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(Union())); + string summary = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(CountByName())); int after = _db.ChangeTracker.Entries().Count(); _out.WriteLine($"W1 {tier}: list={code} dynamic={async} segment={segment} summary={summary} tracked {before}->{after} sent={ZwKit.Json(data)}"); diff --git a/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs b/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs new file mode 100644 index 0000000..4af01a8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs @@ -0,0 +1,172 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// The same model on EF Core's own in-memory provider. + public sealed class Rv3MemoryContext : DbContext + { + private readonly string _name; + + public Rv3MemoryContext(string name) => _name = name; + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseInMemoryDatabase(_name); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + /// + /// REVIEW PROBE ONLY (round 3), and a ruling-out rather than a finding. + /// + /// + /// The provider test behind the 3.3.0 refusal asks "is this EF Core's own provider", not "does + /// this provider have to translate the path". The strongest candidate for a provider that is EF + /// Core's own and yet answers a member the model does not map is EF Core's in-memory provider, + /// which has no SQL to generate. It does not: it runs the same translating visitor and refuses + /// the same members a relational provider refuses, so holding its rows to EF Core's model + /// refuses nothing that would have run. + /// + /// This suite is the reason the project references Microsoft.EntityFrameworkCore.InMemory. + /// Drop both together if the ruling-out is not worth keeping. + /// + /// + public sealed class Rv3InMemoryProviderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly Rv3MemoryContext _db; + + public Rv3InMemoryProviderProbe(ITestOutputHelper output) + { + _out = output; + _db = new Rv3MemoryContext(Guid.NewGuid().ToString("N")); + _db.Roles.Add(new Rv3Role + { + Id = 1, + Code = "admin", + Secret = "S-TOP", + Cost = "C-9", + Name = new Rv3Text { Ar = "AR", En = "Admin" }, + Other = new Rv3Text { Ar = "ar2", En = "Other" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() => _db.Dispose(); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// + /// The provider with no SQL to write still refuses a getter over two columns, an unmapped + /// getter on the entity, and an order by one. So the shape's answer for it is right. + /// + [Fact] + public void G1_The_in_memory_provider_refuses_the_same_members_a_relational_one_does() + { + string Try(Func read) + { + try + { + return read().ToString(); + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + string owned = Try(() => _db.Roles.Where(role => !role.Name.IsEmpty).Count()); + string entity = Try(() => _db.Roles.Where(role => role.Display == "admin:1").Count()); + string ordered = Try(() => _db.Roles.OrderBy(role => role.Display).Count()); + + _out.WriteLine($"G1 unguarded owned={owned} entity={entity} ordered={ordered}"); + + Assert.Equal(nameof(InvalidOperationException), owned); + Assert.Equal(nameof(InvalidOperationException), entity); + Assert.Equal(nameof(InvalidOperationException), ordered); + } + + /// + /// So the strict tier's refusal replaces a failure rather than taking away an answer, which + /// is the whole premise of the check. + /// + [Fact] + public void G2_The_refusal_replaces_a_failure_rather_than_an_answer() + { + Exception? owned = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? entity = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Display", "admin:1"))); + + Exception? order = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Orders = new List { new() { Field = "Display", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"G2 owned={owned?.GetType().Name} entity={entity?.GetType().Name} " + + $"order={order?.GetType().Name}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, Assert.IsAssignableFrom(owned).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, Assert.IsAssignableFrom(entity).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, Assert.IsAssignableFrom(order).ErrorCode); + } + + /// The mapped members still answer, so nothing usable was taken away. + [Fact] + public void G3_The_mapped_members_still_answer() + { + Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(Where("Code", "admin")).Data); + Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.En", "Admin")).Data); + } + + /// A denied field is refused and withheld here exactly as anywhere else. + [Fact] + public void G4_Denials_are_unaffected_on_the_in_memory_provider() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Secret", "S-TOP"))); + + Rv3Role row = Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(new Filter()).Data); + + _out.WriteLine($"G4 {refusal.ErrorCode} secret='{row.Secret}' cost='{row.Cost}'"); + + Assert.True(string.IsNullOrEmpty(row.Secret), $"Secret leaked: '{row.Secret}'"); + Assert.True(string.IsNullOrEmpty(row.Cost), $"Cost leaked: '{row.Cost}'"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs b/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs new file mode 100644 index 0000000..9a5362b --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs @@ -0,0 +1,1083 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +#pragma warning disable EF1001 // the probe is about EF Core's own provider type, which is internal by design + +namespace DynamicWhere.Tests.Policies +{ + // ==== the model ================================================================================= + + /// Two columns and a getter over them: a member no relational database can compute. + public class Rv3Text + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class Rv3Role + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Rv3Text Name { get; set; } = new(); + + public Rv3Text Other { get; set; } = new(); + + /// Refused for every feature. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Refused for projection only, so a synthesized projection has to leave it out. + [DwNoSelect] + public string Cost { get; set; } = string.Empty; + + /// An unmapped getter over two mapped columns. + public string Display => $"{Code}:{Id}"; + } + + /// The row a caller projects before the guard sees it. + public class Rv3Row + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Rv3Text Name { get; set; } = new(); + + public Rv3Text Other { get; set; } = new(); + } + + /// Two members of one type, to see whether one level's member set leaks into another's. + public class Rv3Pair + { + public int Id { get; set; } + + public Rv3Text A { get; set; } = new(); + + public Rv3Text B { get; set; } = new(); + } + + /// Nests inside itself, for the initializer depth cap. + public class Rv3Deep + { + public string V { get; set; } = string.Empty; + + public string W { get; set; } = string.Empty; + + public Rv3Deep? Next { get; set; } + } + + public class Rv3DeepRow + { + public int Id { get; set; } + + public Rv3Deep Deep { get; set; } = new(); + } + + public sealed class Rv3Context : DbContext + { + private readonly SqliteConnection? _connection; + + public Rv3Context(SqliteConnection connection) => _connection = connection; + + public Rv3Context(DbContextOptions options) + : base(options) + { + } + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (_connection is not null) + { + options.UseSqlite(_connection); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + /// The same model with one options constructor, which is what pooling requires. + public sealed class Rv3PooledContext : DbContext + { + public Rv3PooledContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Roles => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + // ==== providers ================================================================================= + + /// + /// A provider in front of EF Core's: the shape LinqKit's AsExpandable and + /// DelegateDecompiler's Decompile have. + /// + public sealed class Rv3Wrapping : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public Rv3Wrapping(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new Rv3Wrapping(_inner.Provider.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + + /// + /// A provider that derives from EF Core's rather than wrapping it: the shape a + /// second-level cache or an expression-rewriting provider takes when it subclasses. + /// + public sealed class Rv3Derived : EntityQueryProvider + { + public Rv3Derived(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } + + /// Static helpers a projection can call, which no provider translates. + public static class Rv3Util + { + public static string Tag(string value) => value + "!"; + } + + // ==== the probes ================================================================================ + + /// + /// Third review round. What the second round's fixes left reachable: the provider test that + /// decides whether a path is held to EF Core's model, the assignment reader behind it, the + /// posture comparison, and the refusal surface. + /// + public sealed class Rv3SecurityProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rv3Context _db; + + public Rv3SecurityProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rv3Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rv3Role + { + Code = "admin", + Secret = "S-TOP", + Cost = "C-9", + Name = new Rv3Text { Ar = "AR", En = "Admin" }, + Other = new Rv3Text { Ar = "ar2", En = "Other" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool traceInResult = false) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + traceInResult + ? new DwPolicyOptions { Tier = tier, IncludeTraceInResult = true } + : new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static Filter WhereEmpty(string field) => Where(field, "false", DataType.Boolean); + + /// Calls the private provider test directly, so its answer can be read rather than inferred. + private static bool EfCoreOwns(IQueryProvider provider) => + (bool)typeof(RowShape) + .GetMethod("EfCoreOwns", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { provider })!; + + /// Reads a shape's answer for a path directly, rather than inferring it from a refusal. + private static bool? Expresses(IQueryable source, string path) where T : class + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(typeof(T)) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(shape, new object[] { path }); + } + + // ==== A. the provider test ================================================================== + + /// + /// EF Core's own provider, and not a provider built by deriving from it: one that derives + /// rewrites what EF Core cannot translate exactly as one that wraps it does, so the two are + /// answered for the same way. + /// + [Fact] + public void A1_The_provider_test_is_EF_Cores_own_type_and_not_a_type_derived_from_it() + { + IQueryProvider own = _db.Roles.AsQueryable().Provider; + + object compiler = typeof(EntityQueryProvider) + .GetField("_queryCompiler", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(own)!; + + Rv3Derived derived = new((Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler)compiler); + + _out.WriteLine($"A1 own={own.GetType().Name} owns={EfCoreOwns(own)}"); + _out.WriteLine($"A1 derived={derived.GetType().Name} owns={EfCoreOwns(derived)}"); + + // A provider built by deriving from EF Core's rewrites what EF Core cannot translate, + // exactly as one built by wrapping it does, so the two are answered for the same way. + // EF Core's own provider derives from object in every version, so nothing real is lost. + Assert.True(EfCoreOwns(own)); + Assert.False(EfCoreOwns(derived)); + } + + /// A provider in front of EF Core is not EF Core's, by the same test. + [Fact] + public void A2_The_provider_test_rejects_a_wrapping_provider() + { + Assert.False(EfCoreOwns(new Rv3Wrapping(_db.Roles).Provider)); + Assert.False(EfCoreOwns(new[] { new Rv3Role() }.AsQueryable().Provider)); + } + + /// + /// A raw-SQL root is still EF Core's to translate what is composed on it, so the refusal + /// stands and the mapped members still answer. + /// + [Fact] + public void A3_A_raw_sql_root_is_still_held_to_the_model() + { + IQueryable raw = _db.Roles.FromSqlRaw("SELECT * FROM Roles"); + + Assert.True(EfCoreOwns(raw.Provider)); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(raw, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + _out.WriteLine($"A3 {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Single(Guard(_db.Roles.FromSqlRaw("SELECT * FROM Roles"), DwTier.Strict) + .ToList(Where("Code", "admin")).Data); + } + + /// + /// The tracking, split-query and query-filter knobs leave the provider alone, so none of + /// them can turn the check off. + /// + [Fact] + public void A4_The_query_knobs_do_not_change_the_provider() + { + IQueryable[] shapes = + { + _db.Roles.AsNoTracking(), + _db.Roles.AsNoTrackingWithIdentityResolution(), + _db.Roles.AsSplitQuery(), + _db.Roles.IgnoreQueryFilters(), + _db.Roles.AsTracking(), + _db.Roles.TagWith("probe") + }; + + foreach (IQueryable shape in shapes) + { + Assert.True(EfCoreOwns(shape.Provider), shape.Expression.ToString()); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(shape, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + _out.WriteLine($"A4 {shapes.Length} shapes, all EF Core's own provider"); + } + + /// A pooled context hands out the same provider a plain one does. + [Fact] + public void A5_A_pooled_context_is_still_EF_Cores_own_provider() + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + PooledDbContextFactory factory = new(options); + + using Rv3PooledContext pooled = factory.CreateDbContext(); + + Assert.True(EfCoreOwns(pooled.Roles.AsQueryable().Provider)); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(pooled.Roles, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Single(Guard(pooled.Roles, DwTier.Strict).ToList(Where("Code", "admin")).Data); + } + + // ==== B. the wrapping exemption cannot weaken any other decision ============================= + + /// A field denied for everything is refused behind a wrapping provider too. + [Fact] + public void B1_A_denied_field_is_still_refused_behind_a_wrapping_provider() + { + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(wrapped, tier).ToList(Where("Secret", "S-TOP"))); + + _out.WriteLine($"B1 {tier}: {refusal.ErrorCode}"); + } + } + + /// So is one named in a projection. + [Fact] + public void B2_A_denied_select_is_still_refused_behind_a_wrapping_provider() + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(wrapped, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Secret" } + })); + + _out.WriteLine($"B2 {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + /// + /// The projection the library synthesizes for a caller who sent none still withholds the + /// denied members when the rows arrive through a wrapping provider. + /// + [Fact] + public void B3_The_synthesized_projection_still_withholds_behind_a_wrapping_provider() + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + FilterResult guarded = Guard(wrapped, DwTier.Strict).ToList(new Filter()); + + Rv3Role row = Assert.Single(guarded.Data); + + _out.WriteLine($"B3 secret='{row.Secret}' cost='{row.Cost}' code='{row.Code}'"); + + Assert.True(string.IsNullOrEmpty(row.Secret), $"Secret leaked: '{row.Secret}'"); + Assert.True(string.IsNullOrEmpty(row.Cost), $"Cost leaked: '{row.Cost}'"); + Assert.Equal("admin", row.Code); + } + + /// + /// Bare and wrapped answer the same query with the same decisions, so the provider test + /// changes nothing but whether a path is held to EF Core's model. + /// + [Fact] + public void B4_The_provider_test_changes_only_the_expressible_check() + { + FilterResult bare = Guard(_db.Roles, DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + FilterResult wrapped = Guard(new Rv3Wrapping(_db.Roles), DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + string Render(FilterResult result) => string.Join( + " | ", + result.Policy!.Decisions.Select(d => $"{d.FieldPath}/{d.Feature}/{d.Action}/{d.Reason}")); + + _out.WriteLine($"B4 bare: {Render(bare)}"); + _out.WriteLine($"B4 wrapped: {Render(wrapped)}"); + + Assert.Equal(Render(bare), Render(wrapped)); + Assert.True(string.IsNullOrEmpty(Assert.Single(wrapped.Data).Secret)); + } + + // ==== C. the assignment reader =============================================================== + + private IQueryable Project(Expression> selector) => _db.Roles.Select(selector); + + private Exception? Filtering(IQueryable rows, string field) => + Record.Exception(() => Guard(rows, DwTier.Strict).ToList(WhereEmpty(field))); + + /// A conditional one level down, inside a nested initializer, keeps the refusal. + [Fact] + public void C1_A_conditional_inside_a_nested_initializer_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text + { + Ar = role.Code == null ? role.Name.Ar : role.Name.En, + En = role.Name.En + } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C1 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A conditional inside a conditional, both arms building in place, keeps it too. + [Fact] + public void C2_A_conditional_nested_in_a_conditional_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null + ? new Rv3Text() + : role.Code == "admin" + ? new Rv3Text { Ar = role.Name.Ar } + : new Rv3Text { En = role.Name.En } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C2 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// Two arms copying the same member of the entity keep the refusal the entity gives. + [Fact] + public void C3_Two_arms_copying_one_member_keep_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null ? role.Name : role.Name + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C3 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// + /// Two arms copying two different members: the shape refuses to speak, so the refusal is + /// lost and EF Core answers with its own failure. + /// + [Fact] + public void C4_Two_arms_copying_two_members_lose_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null ? role.Name : role.Other + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C4 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused, so the shape did speak for it"); + Assert.NotNull(raised); + } + + /// A coalesce beneath the member leaves the level's own member set intact. + [Fact] + public void C5_A_coalesce_beneath_the_member_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text { Ar = role.Code ?? role.Name.Ar, En = role.Name.En } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C5 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A conversion around a copied member is stripped, so the copy is still read. + [Fact] + public void C6_A_conversion_around_a_copy_keeps_the_refusal() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = (Rv3Text)(object)role.Name + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C6 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A captured value is an assignment the shape cannot read, so it speaks for nothing. + [Fact] + public void C7_A_captured_value_is_left_alone() + { + Rv3Text captured = new() { Ar = "x", En = "y" }; + + IQueryable rows = _db.Roles.Select(role => new Rv3Row { Id = role.Id, Name = captured }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C7 {raised?.GetType().Name ?? "ran"}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused a member the shape cannot speak for"); + } + + /// + /// A member the shape records as assigned, whose value is a call no provider translates. The + /// level says it produces the member, the database cannot compute it, and the strict tier + /// answers with neither an answer nor a refusal. + /// + [Fact] + public void C8_A_method_call_beneath_a_member_is_claimed_and_then_fails() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text { Ar = Rv3Util.Tag(role.Code), En = role.Name.En } + }); + + // Unguarded, the projection itself is answered: EF Core evaluates the last one on the client. + Assert.Single(rows.ToList()); + + Exception? computed = Record.Exception( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "admin!"))); + + Exception? unassigned = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C8 filter on the claimed member: {computed?.GetType().Name ?? "ran"}: {computed?.Message}"); + _out.WriteLine($"C8 filter on the unassigned one: {unassigned?.GetType().Name}"); + + // The unassigned member is still refused, so the level was read. + Assert.IsAssignableFrom(unassigned); + + // The claimed one is not, and it is not answered either. + Assert.NotNull(computed); + Assert.False(computed is PolicyException, "the claimed member was refused after all"); + + // And the shape says so rather than claiming the member: the initializer assigns it from + // something the shape cannot read, so the answer for it is "cannot say". Only false is + // acted on, so this changes no query today; it stops the shape making a claim it cannot + // support. + _out.WriteLine($"C8 Expresses(\"Name.Ar\") = {Expresses(rows, "Name.Ar")?.ToString() ?? "null"}"); + + Assert.Null(Expresses(rows, "Name.Ar")); + Assert.False(Expresses(rows, "Name.IsEmpty")); + } + + /// A constructor with arguments one level down records nothing, so nothing is refused. + [Fact] + public void C9_A_constructor_with_arguments_one_level_down_is_left_alone() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text(), + Other = new Rv3Text { Ar = role.Other.Ar, En = role.Other.En } + }); + + // The level that is read still refuses. + Assert.IsAssignableFrom(Filtering(rows, "Other.IsEmpty")); + + // An empty new() records "assigns nothing", so every member beneath it is refused. + Exception? empty = Filtering(rows, "Name.Ar"); + + _out.WriteLine($"C9 empty new(): {empty?.GetType().Name}: {empty?.Message}"); + + Assert.IsAssignableFrom(empty); + } + + /// A subquery beneath a member is an assignment the shape cannot read. + [Fact] + public void C10_A_subquery_beneath_a_member_is_left_alone() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = _db.Roles.Where(other => other.Id == role.Id).Select(other => other.Name).First() + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C10 {raised?.GetType().Name ?? "ran"}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused a member the shape cannot speak for"); + } + + /// + /// Past the initializer depth cap the shape stops recording, so it refuses nothing there — + /// and nothing above the cap is affected. + /// + [Fact] + public void C11_Past_the_depth_cap_the_shape_stops_speaking() + { + IQueryable rows = _db.Roles.Select(role => new Rv3DeepRow + { + Id = role.Id, + Deep = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep { V = role.Code } + } + } + } + } + } + } + } + } + } + }); + + // The navigation cap would refuse a long path before the shape ever looked at it. + PolicyQueryable Deep(IQueryable source) + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + options.Caps.MaxNavigationDepth = 24; + + return source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + } + + Exception? shallow = Record.Exception(() => Deep(rows).ToList(Where("Deep.W", "x"))); + + string deep = "Deep." + string.Join(".", Enumerable.Repeat("Next", 9)) + ".W"; + + Exception? beyond = Record.Exception(() => Deep(rows).ToList(Where(deep, "x"))); + + _out.WriteLine($"C11 shallow unassigned: {shallow?.GetType().Name ?? "ran"}"); + _out.WriteLine($"C11 beyond the cap: {beyond?.GetType().Name ?? "ran"}: {beyond?.Message}"); + + Assert.IsAssignableFrom(shallow); + Assert.False(beyond is PolicyException, "the shape spoke past its own cap"); + } + + /// + /// Two members built at one level. What one level's initializer assigns must not widen what + /// another's is read as assigning: Record unions into an existing set, so a set + /// handed out by reference would make every member of A a member of B. + /// + [Fact] + public void C12_One_levels_member_set_does_not_widen_another() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Pair + { + Id = role.Id, + A = role.Code == null + ? new Rv3Text { Ar = role.Name.Ar } + : new Rv3Text { En = role.Name.En }, + B = new Rv3Text { Ar = role.Other.Ar } + }); + + // A's two arms union to { Ar, En }, so neither is refused. + Exception? aAr = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("A.Ar", "AR"))); + Exception? aEn = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("A.En", "Admin"))); + + // B assigns Ar alone. If the sets were shared, En would be there too and this would run. + Exception? bEn = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("B.En", "Other"))); + + _out.WriteLine($"C12 A.Ar={aAr?.GetType().Name ?? "ran"} A.En={aEn?.GetType().Name ?? "ran"} " + + $"B.En={bEn?.GetType().Name ?? "ran"}"); + + Assert.False(aAr is PolicyException, "an arm's own member was refused"); + Assert.False(aEn is PolicyException, "the other arm's member was refused"); + Assert.IsAssignableFrom(bEn); + } + + // ==== D. the posture comparison ============================================================= + + /// + /// Writing the trace flag out as the value the tier already answers is accepted, and the + /// posture the query path reads is still the first call's instance, unchanged. + /// + [Fact] + public void D1_Writing_the_trace_flag_the_tier_answers_leaves_the_posture_untouched() + { + PolicyBootstrap.Ensure(); + + DwPolicyOptions before = DwPolicy.Options; + bool effectiveBefore = TraceInResult(before); + + DwPolicyOptions copy = CopyOfInForce(); + + copy.IncludeTraceInResult = effectiveBefore; + + DwPolicy.Configure(copy); + + _out.WriteLine($"D1 written={copy.IncludeTraceInResult} in-force-written={DwPolicy.Options.IncludeTraceInResult} " + + $"effective={TraceInResult(DwPolicy.Options)}"); + + Assert.Same(before, DwPolicy.Options); + Assert.Equal(effectiveBefore, TraceInResult(DwPolicy.Options)); + + // The instance the second host built is frozen, so it cannot go on deciding anything. + Assert.True(copy.IsFrozen); + Assert.Throws(() => copy.IncludeTraceInResult = !effectiveBefore); + } + + /// + /// Two postures that differ only in whether the flag is written carry the same answer + /// everywhere the library reads it, and a guarded query under each carries the trace alike. + /// + [Fact] + public void D2_Written_and_default_agree_everywhere_the_flag_is_read() + { + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + DwPolicyOptions unwritten = new() { Tier = tier }; + DwPolicyOptions written = new() { Tier = tier, IncludeTraceInResult = TraceInResult(unwritten) }; + + Assert.Equal(TraceInResult(unwritten), TraceInResult(written)); + + FilterResult left = Run(unwritten); + FilterResult right = Run(written); + + _out.WriteLine($"D2 {tier}: effective={TraceInResult(unwritten)} " + + $"left={left.Policy is not null} right={right.Policy is not null}"); + + Assert.Equal(left.Policy is not null, right.Policy is not null); + Assert.Equal(TraceInResult(unwritten), left.Policy is not null); + } + + FilterResult Run(DwPolicyOptions options) => + _db.Roles.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(Where("Code", "admin")); + } + + /// + /// An accepted second call replaces nothing: the resolver, the store providers and the + /// posture the query path reads are all the first call's. A second host supplying sources of + /// the same types therefore hands them over and has them dropped. + /// + [Fact] + public void D3_An_accepted_second_call_replaces_neither_the_resolver_nor_the_sources() + { + PolicyBootstrap.Ensure(); + + DwPolicyOptions optionsBefore = DwPolicy.Options; + PolicyResolver resolverBefore = DwPolicy.Resolver; + IReadOnlyList storesBefore = DwPolicy.StoreProviders; + + DwPolicy.Configure(CopyOfInForce()); + + _out.WriteLine($"D3 options same={ReferenceEquals(optionsBefore, DwPolicy.Options)} " + + $"resolver same={ReferenceEquals(resolverBefore, DwPolicy.Resolver)} " + + $"stores={DwPolicy.StoreProviders.Count}"); + + Assert.Same(optionsBefore, DwPolicy.Options); + Assert.Same(resolverBefore, DwPolicy.Resolver); + Assert.Same(storesBefore, DwPolicy.StoreProviders); + } + + private static bool TraceInResult(DwPolicyOptions options) => + (bool)typeof(DwPolicyOptions) + .GetProperty("TraceInResult", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(options)!; + + private static DwPolicyOptions CopyOfInForce() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanWrite && property.PropertyType == typeof(int))) + { + if (cap.Name == nameof(DwCaps.MinGroupSize) && !inForce.Caps.IsMinGroupSizeSet) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + // ==== E. the refusal surface ================================================================ + + /// + /// A name that matches nothing, a field denied for everything, and a member the query cannot + /// compute must be one refusal, field by field. + /// + [Fact] + public void E1_The_three_refusals_are_one_refusal() + { + string Surface(string field) + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where(field, "x"))); + + string? audit = (string?)typeof(PolicyException) + .GetProperty("AuditPath", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(refusal); + + return $"code={refusal.ErrorCode} path='{refusal.FieldPath}' feature={refusal.Feature} " + + $"tier={refusal.Tier} rule='{refusal.RuleId}' origin='{refusal.SourceOrigin}' " + + $"msg='{refusal.Message}' audit='{audit}'"; + } + + // The three have to be spelled the same length apart so the audit path, which is the + // caller's own string, is the only thing that may differ. + string missing = Surface("Nonexistent"); + string denied = Surface("Secret"); + string uncomputable = Surface("Display"); + + _out.WriteLine($"E1 missing: {missing}"); + _out.WriteLine($"E1 denied: {denied}"); + _out.WriteLine($"E1 uncomputable: {uncomputable}"); + + Assert.Equal( + missing.Replace("Nonexistent", "@"), + denied.Replace("Secret", "@")); + + Assert.Equal( + missing.Replace("Nonexistent", "@"), + uncomputable.Replace("Display", "@")); + } + + /// + /// The canonical path of a member the query cannot compute reaches the in-process trace and + /// nothing a caller holds: a refused query returns no result to carry it. + /// + [Fact] + public void E2_The_canonical_path_reaches_the_in_process_trace_only() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + PolicyException refusal = Assert.ThrowsAny( + () => guarded.ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.DoesNotContain("IsEmpty", refusal.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.FieldPath, StringComparison.OrdinalIgnoreCase); + + PolicyTrace trace = Assert.IsType(guarded.LastTrace); + + string recorded = string.Join( + " | ", trace.Decisions.Select(d => $"{d.FieldPath}/{d.Action}/{d.Reason}")); + + _out.WriteLine($"E2 in-process trace: {recorded}"); + + Assert.Contains( + trace.Decisions, + d => d.Reason is not null && d.Reason.Contains("cannot compute")); + } + + /// + /// An alias hides the canonical path from the caller. The refusal for a member the query + /// cannot compute must not hand it back. + /// + [Fact] + public void E3_An_alias_does_not_leak_its_target_through_the_new_refusal() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + _out.WriteLine($"E3 path='{refusal.FieldPath}' rule='{refusal.RuleId}' origin='{refusal.SourceOrigin}'"); + + Assert.DoesNotContain("Name", refusal.FieldPath, StringComparison.OrdinalIgnoreCase); + Assert.Null(refusal.RuleId); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// A caller guessing at the schema one name at a time. Every refusal has to be the same + /// refusal, whatever the reason: a name that matches nothing, a field denied for everything, + /// a member the query cannot compute, and a path beneath one. + /// + [Fact] + public void E4_Guessing_at_the_schema_gets_one_answer_for_every_kind_of_no() + { + string Probe(string field) + { + try + { + return $"rows={Guard(_db.Roles, DwTier.Strict).ToList(Where(field, "x")).Data.Count}"; + } + catch (PolicyException refusal) + { + return $"{refusal.ErrorCode}/{refusal.FieldPath}/{refusal.Feature}/" + + $"{refusal.RuleId}/{refusal.SourceOrigin}/{refusal.Message}"; + } + } + + string[] refused = { "Nonexistent", "Secret", "Display", "Name.IsEmpty", "Other.IsEmpty" }; + string[] answered = { "Code", "Cost", "Name.En" }; + + List outcomes = refused.Select(Probe).ToList(); + + foreach (string line in refused.Zip(outcomes, (name, outcome) => $"{name} -> {outcome}")) + { + _out.WriteLine($"E4 {line}"); + } + + Assert.Single(outcomes.Distinct()); + + foreach (string field in answered) + { + string outcome = Probe(field); + + _out.WriteLine($"E4 {field} -> {outcome}"); + + Assert.StartsWith("rows=", outcome); + } + } + + // ==== F. the composed handle, where a projection is no longer last =========================== + + /// + /// The projection exemption rests on "EF Core evaluates the last projection on the client". + /// The composable handle lets a caller put an order, a page and a filter after it. + /// + [Fact] + public void F1_A_projection_the_caller_composes_past_is_still_answered_or_refused() + { + foreach (string field in new[] { "Display" }) + { + PolicyQueryable projected = + Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", field }); + + Exception? ordered = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + Exception? paged = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", field }) + .Page(new PageBy { PageNumber = 1, PageSize = 10 }) + .ToList(new Filter())); + + _out.WriteLine($"F1 {field} order-after-select: {ordered?.GetType().Name ?? "ran"}: {ordered?.Message}"); + _out.WriteLine($"F1 {field} page-after-select: {paged?.GetType().Name ?? "ran"}: {paged?.Message}"); + + Assert.True(ordered is null or PolicyException, $"{ordered?.GetType().Name}: {ordered?.Message}"); + Assert.True(paged is null or PolicyException, $"{paged?.GetType().Name}: {paged?.Message}"); + } + } + } +} + +#pragma warning restore EF1001 diff --git a/DynamicWhere.Tests/Policies/S4Model.cs b/DynamicWhere.Tests/Policies/S4Model.cs new file mode 100644 index 0000000..1c29542 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4Model.cs @@ -0,0 +1,223 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4 (security). The model the S4 probes query. + // + // EF Core 6 compatible on purpose, so the floor leg runs every probe: no complex properties, + // no ToJson, no primitive collections, no DateOnly/TimeOnly, no compiled model. + // ============================================================================================= + + /// An owned type with a getter over two of its own columns: no database computes it. + public class S4Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + public bool IsZero => Amount == 0m; + } + + public class S4Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Orders { get; set; } = new(); + + /// Unmapped: a getter over a column. + [NotMapped] + public string Handle => Name + "!"; + } + + public class S4Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + } + + public class S4Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int Qty { get; set; } + + public int CustomerId { get; set; } + + public S4Customer Customer { get; set; } = null!; + + public S4Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + /// Denied outright, to prove the provider test gates nothing but Expresses. + [DynamicWhere.ex.Policies.Attributes.DwDeny(DynamicWhere.ex.Policies.Enums.PolicyFeature.All)] + public string Secret { get; set; } = string.Empty; + + /// Unmapped: a getter over two columns. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into ------------------------------------------------------- + + public class S4Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public S4Money Money { get; set; } = new(); + + public bool Blank => A.Length == 0; + } + + public class S4Row + { + public S4Row() + { + } + + public S4Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Tag { get; set; } + + public S4Nest Nest { get; set; } = new(); + + public S4Money Money { get; set; } = new(); + + public S4Customer? Customer { get; set; } + } + + /// A row whose members differ only in letter case, to probe path collisions. + public class S4CaseRow + { + public int Id { get; set; } + + public S4Nest Value { get; set; } = new(); + +#pragma warning disable IDE1006 + public string value { get; set; } = string.Empty; +#pragma warning restore IDE1006 + } + + /// A row whose member names are prefixes of one another, to probe Beneath. + public class S4PrefixRow + { + public int Id { get; set; } + + public S4Nest A { get; set; } = new(); + + public string AB { get; set; } = string.Empty; + } + + public sealed class S4Context : DbContext + { + private readonly Microsoft.Data.Sqlite.SqliteConnection _connection; + private readonly Type? _replacementProvider; + + public S4Context(Microsoft.Data.Sqlite.SqliteConnection connection, Type? replacementProvider = null) + { + _connection = connection; + _replacementProvider = replacementProvider; + } + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacementProvider is not null) + { + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod(typeof(IAsyncQueryProvider), _replacementProvider) + .Invoke(options, null); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Ignore(customer => customer.Handle); + } + } + + /// + /// A plain pass-through built the way a host builds one: derived from EF Core's own provider, + /// registered through ReplaceService<IAsyncQueryProvider, …>, rewriting nothing. + /// + public sealed class S4PassThroughProvider : EntityQueryProvider + { + public S4PassThroughProvider(IQueryCompiler compiler) + : base(compiler) + { + } + } + + /// + /// The base a spoofed provider inherits, so the spoof itself needs nothing but a name: every + /// member forwards to the provider it stands in front of. + /// + public abstract class S4ForwardingProvider : IQueryProvider + { + public IQueryProvider Inner { get; set; } = null!; + + public IQueryable CreateQuery(Expression expression) => Inner.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new S4SpoofedQueryable(this, Inner.CreateQuery(expression)); + + public object? Execute(Expression expression) => Inner.Execute(expression); + + public TResult Execute(Expression expression) => Inner.Execute(expression); + } + + /// A queryable whose Provider is the spoof and whose rows are the inner one's. + public sealed class S4SpoofedQueryable : IQueryable + { + private readonly IQueryable _inner; + + public S4SpoofedQueryable(IQueryProvider provider, IQueryable inner) + { + Provider = provider; + _inner = inner; + } + + public Type ElementType => typeof(T); + + public Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/DynamicWhere.Tests/Policies/S4SecurityProbes.cs b/DynamicWhere.Tests/Policies/S4SecurityProbes.cs new file mode 100644 index 0000000..178d030 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4SecurityProbes.cs @@ -0,0 +1,864 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Reflection.Emit; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +#pragma warning disable xUnit1031 // the segment surface is async only; these probes drive it from a synchronous table + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4, adversarial security review of 3.3.0 at 56d169d. + // + // Round 3's probes asked the OVER-block question: "unguarded ran AND guarded refused". + // These ask the UNDER-block one the release's own promise rests on: + // + // unguarded threw the provider's translation failure + // AND guarded (Strict) threw it too + // -> the strict tier answered with neither an answer nor a refusal. + // + // Everything here drives ApplyPolicy through its explicit posture overload, so no probe + // touches DwPolicy's process-wide state. + // ============================================================================================= + public sealed class S4SecurityProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly S4Context _db; + private readonly List _findings = new(); + + public S4SecurityProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new S4Context(_connection); + _db.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + S4Order order = new() + { + Code = "AB123", + Qty = 2, + Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }; + + order.Lines.Add(new S4Line { Price = 4m }); + order.Lines.Add(new S4Line { Price = 6m }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter WhereOn(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Outcome(Func run) + { + try + { + object? value = run(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (LogicException failure) + { + return $"LogicException({failure.Message.Split('.')[0]})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string GuardedWhere(IQueryable source, string field, DataType type, string value) + where T : class => + Outcome(() => Guard(source).ToList(WhereOn(field, type, value)).Data); + + private static string RawWhere(IQueryable source, Expression> predicate) => + Outcome(() => source.Where(predicate).ToList()); + + /// True for the provider's own "I cannot translate this" failure. + private static bool Untranslatable(string outcome) => + outcome is "InvalidOperationException" or "NotSupportedException"; + + /// + /// Records one probe. Flags it when the unguarded query fails inside the provider and the + /// guarded strict one fails the same way: neither an answer nor a refusal. + /// + private void Under(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-62} unguarded={unguarded,-28} guarded={guarded}"); + + if (Untranslatable(unguarded) && Untranslatable(guarded)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + /// Logged, never flagged: a shape the release's own text puts outside the refusal. + private void Documented(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-62} unguarded={unguarded,-28} guarded={guarded} [documented limit]"); + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // S4-A. Every clause the release names, over an entity source. The regression floor. + // ========================================================================================= + + [Fact] + public void S4_A_Every_clause_the_release_names_refuses_an_uncomputable_path() + { + IQueryable orders = _db.Orders; + + Under("A1 where, unmapped getter on the entity", + RawWhere(orders, o => o.Slug == "AB123-1"), + GuardedWhere(orders, "Slug", DataType.Text, "AB123-1")); + + Under("A2 where, getter over an owned type's columns", + RawWhere(orders, o => o.Total.IsZero), + GuardedWhere(orders, "Total.IsZero", DataType.Boolean, "false")); + + Under("A3 where, unmapped getter one navigation away", + RawWhere(orders, o => o.Customer.Handle == "Acme!"), + GuardedWhere(orders, "Customer.Handle", DataType.Text, "Acme!")); + + Under("A4 order by the unmapped getter", + Outcome(() => orders.OrderBy(o => o.Slug).ToList()), + Outcome(() => Guard(orders) + .ToList(new Filter { Orders = new List { new() { Field = "Slug" } } }).Data)); + + Under("A5 grouping key is the unmapped getter", + Outcome(() => orders.GroupBy(o => o.Slug).Select(g => g.Key).ToList()), + Outcome(() => Guard(orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Slug" }, + AggregateBy = { new AggregateBy { Alias = "n", Aggregator = Aggregator.Count } } + } + }).Data)); + + Under("A6 aggregated field is the getter over owned columns", + Outcome(() => orders.GroupBy(o => o.Code).Select(g => g.Max(o => o.Total.IsZero)).ToList()), + Outcome(() => Guard(orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Code" }, + AggregateBy = { new AggregateBy { Field = "Total.IsZero", Alias = "z", Aggregator = Aggregator.Maximum } } + } + }).Data)); + + Under("A7 a filter inside a Segment", + RawWhere(orders, o => o.Slug == "AB123-1"), + Outcome(() => Guard(orders).ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Slug", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123-1" } + } + } + } + } + } + }).GetAwaiter().GetResult().Data)); + + Under("A8 an order inside a Segment", + Outcome(() => orders.OrderBy(o => o.Slug).ToList()), + Outcome(() => Guard(orders).ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + } + }, + Orders = new List { new() { Field = "Slug" } } + }).GetAwaiter().GetResult().Data)); + + Done(); + } + + // ========================================================================================= + // S4-B. The projection branch: which projected sources the refusal reaches, and which the + // release's own text leaves out. + // ========================================================================================= + + [Fact] + public void S4_B_A_projection_the_shape_can_read_refuses_what_it_cannot_produce() + { + IQueryable built = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + Under("B1 initializer, a member it never assigns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + GuardedWhere(built, "Nest.B", DataType.Text, "x")); + + Under("B2 initializer, a getter over what it assigns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.Blank).ToList()), + GuardedWhere(built, "Nest.Blank", DataType.Boolean, "false")); + + IQueryable copied = _db.Orders.Select(o => new S4Row { Id = o.Id, Money = o.Total }); + + Under("B3 owned member copied whole, the getter over its columns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.IsZero).ToList()), + GuardedWhere(copied, "Money.IsZero", DataType.Boolean, "false")); + + // A member assigned from something the shape cannot read. Round 3 made this answer + // "cannot say" instead of "yes"; neither answer refuses, so the provider still decides. + IQueryable opaque = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = Build(o.Code) + }); + + Documented("B4 member assigned from a method call, a path beneath it", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = Build(o.Code) }) + .Where(r => r.Nest.B == "x").ToList()), + GuardedWhere(opaque, "Nest.B", DataType.Text, "x")); + + Done(); + } + + private static S4Nest Build(string code) => new() { A = code }; + + // ========================================================================================= + // S4-D. RowShape.Expresses, read directly. The tri-state answers the round-3 _opaque set + // produces, and whether any of them turned a proven "no" into "cannot say". + // ========================================================================================= + + [Fact] + public void S4_D_The_opaque_set_never_takes_back_a_proven_no() + { + List wrong = new(); + + void Expect(string probe, IQueryable source, string path, bool? expected) + { + bool? answer = RowShape.Of(source).Expresses(path); + + _out.WriteLine($"{probe,-62} Expresses(\"{path}\") = {Show(answer)} expected {Show(expected)}"); + + if (answer != expected) + { + wrong.Add($"{probe}: Expresses(\"{path}\") = {Show(answer)}, expected {Show(expected)}"); + } + } + + // An ordinary initializer: the whole member set is known at every level. + IQueryable plain = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + Expect("D1 assigned member", plain, "Nest.A", true); + Expect("D2 member the nested initializer leaves out", plain, "Nest.B", false); + Expect("D3 getter over what it assigns", plain, "Nest.Blank", false); + Expect("D4 member the outer initializer leaves out", plain, "Tag", false); + + // One sibling opaque: it must take back nothing from the sibling beside it. + IQueryable sibling = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code }, + Tag = Label(o.Code) + }); + + Expect("D5 opaque sibling, the readable one is unchanged", sibling, "Nest.B", false); + Expect("D6 opaque sibling, the opaque one says nothing", sibling, "Tag", null); + Expect("D7 unassigned member beside an opaque one", sibling, "Money.Amount", false); + + // The opaque member itself, and everything beneath it. + IQueryable whole = _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = Build(o.Code) }); + + Expect("D8 opaque member", whole, "Nest", null); + Expect("D9 beneath an opaque member", whole, "Nest.A", null); + Expect("D10 deeper beneath an opaque member", whole, "Nest.Money.Amount", null); + Expect("D11 a sibling the initializer never assigns", whole, "Tag", false); + + // One branch readable, the other not: the member is read from neither. + IQueryable twoWays = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = o.Code == null ? new S4Nest { A = o.Code! } : Build(o.Code) + }); + + Expect("D12 one unreadable branch makes the member unreadable", twoWays, "Nest.B", null); + Expect("D13 the other members are untouched", twoWays, "Tag", false); + + // Spelling: the caller writes a path in any case, with padding. + Expect("D14 letter case", plain, "nest.b", false); + Expect("D15 padded with spaces", plain, "Nest . B", false); + Expect("D16 padded with dots", plain, "Nest..B", false); + Expect("D17 opaque path in another case", whole, "NEST.A", null); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private static string Label(string code) => code + "!"; + + private static string Show(bool? value) => value is null ? "null" : value.Value ? "true" : "false"; + + // ========================================================================================= + // S4-E. Path collisions in the _opaque set: members differing only in case, and member + // names that are prefixes of one another. + // ========================================================================================= + + [Fact] + public void S4_E_Colliding_member_names_never_manufacture_a_refusal() + { + List wrong = new(); + + void Expect(string probe, bool? answer, bool?[] allowed) + { + _out.WriteLine($"{probe,-62} Expresses = {Show(answer)}"); + + if (Array.IndexOf(allowed, answer) < 0) + { + wrong.Add($"{probe}: Expresses = {Show(answer)}"); + } + } + + // "Value" builds a readable nested row; "value" is opaque and collides with it under the + // ordinal-ignore-case comparison every path string is matched with. + IQueryable collide = _db.Orders.Select(o => new S4CaseRow + { + Id = o.Id, + Value = new S4Nest { A = o.Code }, + value = Label(o.Code) + }); + + RowShape shape = RowShape.Of(collide); + + // Only false is acted on. A collision may cost certainty; it must never invent a refusal + // for a member the projection does assign. + Expect("E1 assigned member under the colliding name", shape.Expresses("Value.A"), new bool?[] { true, null }); + Expect("E2 unassigned member under the colliding name", shape.Expresses("Value.B"), new bool?[] { false, null }); + Expect("E3 the opaque spelling itself", shape.Expresses("value"), new bool?[] { null, true }); + + // Member names that are prefixes of one another: "AB" must not be read as beneath "A". + IQueryable prefixes = _db.Orders.Select(o => new S4PrefixRow + { + Id = o.Id, + A = new S4Nest { A = o.Code }, + AB = Label(o.Code) + }); + + RowShape prefixShape = RowShape.Of(prefixes); + + Expect("E4 a sibling whose name starts with an opaque one", + prefixShape.Expresses("A.A"), new bool?[] { true }); + Expect("E5 the member the sibling's prefix would have hidden", + prefixShape.Expresses("A.B"), new bool?[] { false }); + Expect("E6 the opaque sibling itself", prefixShape.Expresses("AB"), new bool?[] { null }); + + // End to end: the readable member must still be filterable. + _out.WriteLine("E7 guarded filter on the readable member of a colliding row: " + + GuardedWhere(collide, "Value.A", DataType.Text, "AB123")); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // S4-F. EfCoreOwns. Which providers a real EF Core query reaches the shape with, and what + // a host's own registration does to the refusal. + // ========================================================================================= + + [Fact] + public void S4_F_Every_EF_Core_shape_this_census_reaches_carries_EF_Cores_own_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("Set()", _db.Set()), + ("DbSet.AsQueryable()", _db.Orders.AsQueryable()), + ("Include(string)", _db.Orders.Include("Customer")), + ("Union", _db.Orders.Union(_db.Orders)), + ("Concat", _db.Orders.Concat(_db.Orders)), + ("Intersect", _db.Orders.Intersect(_db.Orders)), + ("Except", _db.Orders.Except(_db.Orders)), + ("Reverse", _db.Orders.OrderBy(o => o.Id).Reverse()), + ("DefaultIfEmpty", _db.Orders.DefaultIfEmpty()), + ("FromSqlInterpolated", _db.Orders.FromSqlInterpolated($"SELECT * FROM Orders")), + ("Where+Select+Where", _db.Orders.Where(o => o.Id > 0) + .Select(o => new S4Row { Id = o.Id }).Where(r => r.Id > 0)), + ("Entry().Collection().Query()", EntryQuery()), + ("Cast", _db.Orders.Cast()), + ("Skip+Take+Distinct", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5).Distinct()) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + string full = source.Provider.GetType().FullName ?? "?"; + bool owned = full == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + _out.WriteLine($"{name,-34} EfCoreOwns={owned,-6} {full}"); + + if (!owned) + { + lost.Add($"{name} -> {full}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + private IQueryable EntryQuery() + { + S4Order tracked = _db.Orders.Include(o => o.Lines).First(); + + return _db.Entry(tracked).Collection(o => o.Lines).Query(); + } + + // ========================================================================================= + // S4-G. A host that puts its own provider in EF Core's place, which is the documented + // ReplaceService extension point and leaves EF Core translating exactly as before. + // ========================================================================================= + + [Fact] + public void S4_G_A_host_replaced_query_provider_still_refuses_what_EF_Core_cannot_compute() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using S4Context replaced = new(connection, typeof(S4PassThroughProvider)); + + replaced.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + replaced.Customers.Add(customer); + replaced.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + IQueryable orders = replaced.Orders; + + _out.WriteLine($"provider = {orders.Provider.GetType().FullName}"); + _out.WriteLine($"rewrites anything = no (a plain pass-through)"); + + // A host that replaces EF Core's query provider through ReplaceService gets a provider + // of its own type, and the library cannot tell one that rewrites what EF Core cannot + // translate from one that passes straight through. It leaves both alone: refusing on a + // guess would take back a query the rewriting host answers today. Documented as a limit. + string entityRaw = RawWhere(orders, o => o.Slug == "AB123-1"); + string entityGuarded = GuardedWhere(orders, "Slug", DataType.Text, "AB123-1"); + + Documented("G1 entity branch, unmapped getter behind a replaced provider", entityRaw, entityGuarded); + + IQueryable projected = orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + string projectedRaw = Outcome( + () => orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()); + + string projectedGuarded = GuardedWhere(projected, "Nest.B", DataType.Text, "x"); + + Documented("G2 projection branch, unassigned member behind a replaced provider", projectedRaw, projectedGuarded); + + // The guarded query does what the unguarded one does, which is the whole of the claim: + // the refusal does not reach here, and nothing is refused that would otherwise run. + Assert.Equal(entityRaw, entityGuarded); + Assert.Equal(projectedRaw, projectedGuarded); + + Done(); + } + + // ========================================================================================= + // S4-H. The other direction of the name comparison: can anything make EfCoreOwns answer + // true for rows EF Core does not translate? + // ========================================================================================= + + [Fact] + public void S4_H_A_type_of_EF_Cores_own_name_is_taken_for_EF_Cores_own_provider() + { + // A type declared in any assembly, with that namespace and that name. Emitted rather + // than written, so the test assembly itself does not shadow EF Core's real type. + Type spoofed = SpoofedProviderType(); + + _out.WriteLine($"emitted type = {spoofed.FullName}"); + _out.WriteLine($"from EF Core? = {spoofed.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", spoofed.FullName); + + // Rows in memory: LINQ to Objects runs the getter and no database is involved. + List rows = _db.Orders.AsNoTracking().ToList(); + + S4ForwardingProvider spoof = (S4ForwardingProvider)Activator.CreateInstance(spoofed)!; + + spoof.Inner = rows.AsQueryable().Provider; + + IQueryable wrapped = new S4SpoofedQueryable( + spoof, + spoof.Inner.CreateQuery( + rows.AsQueryable() + .Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Expression)); + + RowShape shape = RowShape.Of(wrapped); + + string unguarded = Outcome(() => wrapped.Where(r => r.Nest.B == "x").ToList()); + string guarded = GuardedWhere(wrapped, "Nest.B", DataType.Text, "x"); + + _out.WriteLine($"RowShape kind = {shape.Kind}"); + _out.WriteLine($"Expresses(Nest.B) = {Show(shape.Expresses("Nest.B"))}"); + _out.WriteLine($"unguarded = {unguarded}"); + _out.WriteLine($"guarded (strict) = {guarded}"); + + // The harm, if any, is over-block: rows that run in memory refused because a type name + // said EF Core owned them. Recorded either way, asserted only as a statement of fact. + _out.WriteLine(shape.Expresses("Nest.B") == false + ? "SPOOFED: a name alone put these rows under EF Core's model" + : "not spoofable through this route"); + + // The provider test reads the assembly the type came from as well as its name, so a + // type declared under EF Core's name elsewhere is not taken for EF Core's provider. The + // rows run, as they do unguarded, rather than being refused on the strength of a name. + Assert.Null(shape.Expresses("Nest.B")); + Assert.StartsWith("OK", unguarded, StringComparison.Ordinal); + Assert.StartsWith("OK", guarded, StringComparison.Ordinal); + } + + private static Type SpoofedProviderType() + { + AssemblyBuilder assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("S4Spoof"), AssemblyBuilderAccess.Run); + + ModuleBuilder module = assembly.DefineDynamicModule("S4Spoof"); + + TypeBuilder type = module.DefineType( + "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", + TypeAttributes.Public | TypeAttributes.Class, + typeof(S4ForwardingProvider)); + + type.DefineDefaultConstructor(MethodAttributes.Public); + + return type.CreateTypeInfo()!.AsType(); + } + + // ========================================================================================= + // S4-I. The refusal itself: a caller must not be able to tell a denied field, a name that + // matches nothing and a path the query cannot compute apart. + // ========================================================================================= + + [Fact] + public void S4_I_Three_refusals_a_caller_must_not_be_able_to_tell_apart() + { + (string What, string Field)[] probes = + { + ("a name that matches nothing", "Zzzzz"), + ("a path the query cannot compute", "Slug"), + ("a two-segment name matching nothing", "Zzzzz.Yyyyy"), + ("a two-segment path it cannot compute", "Total.IsZero") + }; + + List seen = new(); + + foreach ((string what, string field) in probes) + { + try + { + Guard(_db.Orders).ToList(WhereOn(field, DataType.Text, "x")); + + seen.Add($"{what}: NO REFUSAL"); + } + catch (PolicyException refusal) + { + string shape = + $"{refusal.ErrorCode}|{refusal.FieldPath}|{refusal.Feature}|" + + $"rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}|" + + $"message={refusal.Message}"; + + _out.WriteLine($"{what,-42} {shape}"); + seen.Add(shape); + } + catch (Exception failure) + { + _out.WriteLine($"{what,-42} {failure.GetType().Name}: {failure.Message}"); + seen.Add($"{what}: {failure.GetType().Name}"); + } + } + + Assert.True( + seen.Distinct(StringComparer.Ordinal).Count() == 1, + "the four refusals differ: " + string.Join(" || ", seen.Distinct(StringComparer.Ordinal))); + } + + // ========================================================================================= + // S4-J. Public Clone(): the caller's own request must be untouched by a guarded read, and + // nothing the library injects may become visible on it. + // ========================================================================================= + + [Fact] + public void S4_J_A_guarded_read_leaves_the_callers_own_request_alone() + { + List changed = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + }, + Selects = new List { "code" }, + Orders = new List { new() { Field = "code" } } + }; + + int conditions = filter.ConditionGroup.Conditions.Count; + int groups = filter.ConditionGroup.SubConditionGroups.Count; + string field = filter.ConditionGroup.Conditions[0].Field!; + object value = filter.ConditionGroup.Conditions[0].Values[0]; + List selects = new(filter.Selects); + string orderField = filter.Orders[0].Field!; + PageBy? page = filter.Page; + + _out.WriteLine("guarded read: " + Outcome(() => Guard(_db.Orders).ToList(filter).Data)); + + if (filter.ConditionGroup.Conditions.Count != conditions + || filter.ConditionGroup.SubConditionGroups.Count != groups) + { + changed.Add("the condition tree the caller sent grew"); + } + + if (!string.Equals(filter.ConditionGroup.Conditions[0].Field, field, StringComparison.Ordinal)) + { + changed.Add($"the caller's field was rewritten: {field} -> {filter.ConditionGroup.Conditions[0].Field}"); + } + + if (!ReferenceEquals(filter.ConditionGroup.Conditions[0].Values[0], value)) + { + changed.Add("the caller's value was replaced"); + } + + if (filter.Selects is null || !filter.Selects.SequenceEqual(selects, StringComparer.Ordinal)) + { + changed.Add("the caller's projection list was rewritten"); + } + + if (!string.Equals(filter.Orders![0].Field, orderField, StringComparison.Ordinal)) + { + changed.Add($"the caller's order was rewritten: {orderField} -> {filter.Orders[0].Field}"); + } + + if (!ReferenceEquals(filter.Page, page)) + { + changed.Add("a page the caller never sent was written onto their filter"); + } + + // The same for a segment and a summary, whose Clone the release also made public. + Segment segment = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + } + }, + Orders = new List { new() { Field = "code" } } + }; + + string segmentField = segment.ConditionSets[0].ConditionGroup.Conditions[0].Field!; + PageBy? segmentPage = segment.Page; + + _out.WriteLine("guarded segment: " + + Outcome(() => Guard(_db.Orders).ToListAsync(segment).GetAwaiter().GetResult().Data)); + + if (!string.Equals(segment.ConditionSets[0].ConditionGroup.Conditions[0].Field, segmentField, StringComparison.Ordinal) + || !ReferenceEquals(segment.Page, segmentPage)) + { + changed.Add("the caller's segment was rewritten"); + } + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = { "code" }, + AggregateBy = { new AggregateBy { Alias = "n", Aggregator = Aggregator.Count } } + } + }; + + string key = summary.GroupBy.Fields[0]; + int aggregates = summary.GroupBy.AggregateBy.Count; + ConditionGroup? having = summary.Having; + + _out.WriteLine("guarded summary: " + Outcome(() => Guard(_db.Orders).ToList(summary).Data)); + + if (!string.Equals(summary.GroupBy.Fields[0], key, StringComparison.Ordinal) + || summary.GroupBy.AggregateBy.Count != aggregates + || !ReferenceEquals(summary.Having, having)) + { + changed.Add("the caller's summary was rewritten — the group floor's own count is visible on it"); + } + + Assert.True(changed.Count == 0, string.Join(" || ", changed)); + } + + // ========================================================================================= + // S4-K. Clone() itself: what a caller now holds must share no node with what they cloned. + // ========================================================================================= + + [Fact] + public void S4_K_A_public_clone_shares_no_node_a_later_rewrite_could_reach() + { + List shared = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", Values = { "a" } } }, + SubConditionGroups = + { + new ConditionGroup { Conditions = { new Condition { Field = "Qty", Values = { "1" } } } } + } + }, + Selects = new List { "Code" }, + Orders = new List { new() { Field = "Code" } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + void Distinct(string what, object? left, object? right) + { + if (left is not null && ReferenceEquals(left, right)) + { + shared.Add(what); + } + } + + Distinct("Filter.ConditionGroup", filter.ConditionGroup, copy.ConditionGroup); + Distinct("ConditionGroup.Conditions", filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Distinct("Conditions[0]", filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Distinct("Conditions[0].Values", filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Distinct("SubConditionGroups", filter.ConditionGroup.SubConditionGroups, copy.ConditionGroup.SubConditionGroups); + Distinct("SubConditionGroups[0]", filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Distinct("SubConditionGroups[0].Conditions[0]", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0], + copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + Distinct("Selects", filter.Selects, copy.Selects); + Distinct("Orders", filter.Orders, copy.Orders); + Distinct("Orders[0]", filter.Orders![0], copy.Orders![0]); + Distinct("Page", filter.Page, copy.Page); + + // Mutating the copy must not reach the original: this is what the release sells it for. + copy.ConditionGroup.Conditions[0].Field = "Qty"; + copy.Selects!.Add("Qty"); + copy.Orders[0].Field = "Qty"; + copy.Page!.PageNumber = 2; + + if (filter.ConditionGroup.Conditions[0].Field != "Code") shared.Add("a rewrite of the copy reached the original's field"); + if (filter.Selects!.Count != 1) shared.Add("a rewrite of the copy reached the original's projection"); + if (filter.Orders[0].Field != "Code") shared.Add("a rewrite of the copy reached the original's order"); + if (filter.Page!.PageNumber != 1) shared.Add("a rewrite of the copy reached the original's page"); + + _out.WriteLine($"shared nodes: {shared.Count}"); + + Assert.True(shared.Count == 0, string.Join(" || ", shared)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs new file mode 100644 index 0000000..8b7c8a0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs @@ -0,0 +1,584 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4, part B. Two questions the first file does not reach: + // + // * does anything on the strict path answer a denied field differently from a name that + // matches nothing, so a caller can probe for which fields exist; + // * which projected sources the uncomputable-path refusal does not reach. + // ============================================================================================= + + public class S4Watched + { + public int Id { get; set; } + + /// Allowed, and audited: every use of it fills a slot in the caller's buffer. + [DwAudit] + public string Watched { get; set; } = string.Empty; + + /// Denied for filtering, and audited. + [DwAudit] + [DwDeny(PolicyFeature.Where)] + public string Marked { get; set; } = string.Empty; + + /// Denied for filtering, not audited. + [DwDeny(PolicyFeature.Where)] + public string Plain { get; set; } = string.Empty; + + /// Denied for filtering, and expensive. + [DwCost(10_000)] + [DwDeny(PolicyFeature.Where)] + public string Heavy { get; set; } = string.Empty; + } + + public sealed class S4WatchedContext : DbContext + { + private readonly SqliteConnection _connection; + + public S4WatchedContext(SqliteConnection connection) => _connection = connection; + + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class S4SecurityProbesB : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly S4WatchedContext _db; + private readonly SqliteConnection _orderConnection; + private readonly S4Context _orders; + + public S4SecurityProbesB(ITestOutputHelper output) + { + _out = output; + + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new S4WatchedContext(_connection); + _db.Database.EnsureCreated(); + _db.Rows.Add(new S4Watched { Watched = "w", Marked = "m", Plain = "p", Heavy = "h" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + _orderConnection = new SqliteConnection("DataSource=:memory:"); + _orderConnection.Open(); + _orders = new S4Context(_orderConnection); + _orders.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + _orders.Customers.Add(customer); + _orders.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + _orders.SaveChanges(); + _orders.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + _orders.Dispose(); + _orderConnection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static Condition On(string field) => new() + { + Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }; + + /// A strict posture whose audit buffer holds exactly one event. + private static DwPolicyOptions OneAuditSlot() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + options.Caps.MaxAuditEvents = 1; + + return options; + } + + private static string Refusal(IQueryable source, DwPolicyOptions options, params string[] fields) + { + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + foreach (string field in fields) + { + filter.ConditionGroup.Conditions.Add(On(field)); + } + + try + { + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(filter); + + return "NO REFUSAL"; + } + catch (PolicyException refusal) + { + return $"{refusal.ErrorCode}|{refusal.FieldPath}|{refusal.Feature}"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + // ========================================================================================= + // S4-L. The audit buffer as an oracle. + // + // PolicyFor records the [DwAudit] event BEFORE the caller's policy is consulted, so a field + // that is denied AND audited spends a slot the refusal it is about to raise does not need. + // With the buffer full the audited field answers CapExceeded while a name matching nothing + // answers FieldDeniedForWhere — which is the one thing the strict tier promises a caller + // cannot tell apart. + // ========================================================================================= + + [Fact] + public void S4_L_The_audit_cap_tells_a_denied_audited_field_from_a_name_matching_nothing() + { + DwPolicyOptions options = OneAuditSlot(); + + // The first condition fills the single slot with an allowed, audited field. + string denied = Refusal(_db.Rows, options, "Watched", "Marked"); + string unknown = Refusal(_db.Rows, options, "Watched", "Zzzzz"); + string plain = Refusal(_db.Rows, options, "Watched", "Plain"); + string heavy = Refusal(_db.Rows, options, "Watched", "Heavy"); + + _out.WriteLine($"denied + audited {denied}"); + _out.WriteLine($"name matching nothing {unknown}"); + _out.WriteLine($"denied, not audited {plain}"); + _out.WriteLine($"denied, weighted {heavy}"); + + Assert.Equal(unknown, plain); + Assert.Equal(unknown, heavy); + + Assert.True( + string.Equals(denied, unknown, StringComparison.Ordinal), + $"a denied audited field answers '{denied}' where a name matching nothing answers " + + $"'{unknown}': the strict tier's refusals tell the two apart"); + } + + // ========================================================================================= + // S4-M. Projected sources the refusal does not reach. Pins what 56d169d does, so a change + // to any of them is deliberate. Each line is the shape, then what the strict tier + // answered where the release promises a refusal. + // ========================================================================================= + + [Fact] + public void S4_M_Which_projected_sources_the_refusal_does_not_reach() + { + List notRefused = new(); + + void Probe(string shape, Func guarded) + { + string answer; + + try + { + answer = guarded(); + } + catch (Exception failure) + { + answer = failure.GetType().Name; + } + + _out.WriteLine($"{shape,-56} {answer}"); + + if (!answer.StartsWith("REFUSED", StringComparison.Ordinal)) + { + notRefused.Add($"{shape} -> {answer}"); + } + } + + string Guarded(IQueryable source, string field, DataType type) where T : class + { + try + { + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + } + + // The shape the release's own promise names: an initializer with no constructor + // arguments, over EF Core's own provider. This one is refused. + Probe("member-init projection, unassigned member", + () => Guarded( + _orders.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }), + "Nest.B", DataType.Text)); + + // Everything below is left alone. Each is a projection a caller composes before + // ApplyPolicy, and in each the provider decides rather than the policy. + Probe("anonymous-type projection, getter over owned columns", + () => Guarded( + _orders.Orders.Select(o => new { o.Id, o.Total }), + "Total.IsZero", DataType.Boolean)); + + Probe("constructor with arguments, member never in the initializer", + () => Guarded( + _orders.Orders.Select(o => new S4Row(o.Id) { Code = o.Code }), + "Tag", DataType.Text)); + + Probe("constructor with arguments, getter beneath an unnamed member", + () => Guarded( + _orders.Orders.Select(o => new S4Row(o.Id) { Code = o.Code }), + "Money.IsZero", DataType.Boolean)); + + Probe("member assigned from a method call, a path beneath it", + () => Guarded( + _orders.Orders.Select(o => new S4Row { Id = o.Id, Nest = S4Build(o.Code) }), + "Nest.B", DataType.Text)); + + _out.WriteLine($"not refused: {notRefused.Count} of 5"); + + // The one the release names must be refused; the rest are this commit's open edge. + Assert.DoesNotContain( + notRefused, + entry => entry.StartsWith("member-init projection", StringComparison.Ordinal)); + } + + private static S4Nest S4Build(string code) => new() { A = code }; + + // ========================================================================================= + // S4-N. Configure: every knob a second host can hand over, one at a time. + // + // Drives DwPolicy.Configure itself rather than its static fields. A posture that differs is + // refused before anything is written, and one that matches is the no-op the release adds, + // so no probe here changes what any other suite sees. + // ========================================================================================= + + [Fact] + public void S4_N_A_second_host_cannot_hand_over_a_posture_that_enforces_differently() + { + PolicyBootstrap.Ensure(); + + List accepted = new(); + + // The posture in force, rebuilt. Accepted as a no-op, which is the feature itself. + DwPolicy.Configure(Posture()); + + void Differs(string knob, Action change) + { + DwPolicyOptions options = Posture(); + + change(options); + + try + { + DwPolicy.Configure(options); + + _out.WriteLine($"{knob,-30} ACCEPTED"); + accepted.Add(knob); + } + catch (InvalidOperationException) + { + _out.WriteLine($"{knob,-30} refused"); + } + } + + Differs("Tier", o => o.Tier = DwTier.Strict); + Differs("DryRun", o => o.DryRun = true); + Differs("IncludeTraceInResult", o => o.IncludeTraceInResult = false); + Differs("AuditRefusals", o => o.AuditRefusals = true); + Differs("HashSalt", o => o.HashSalt = new string('s', DwPolicyOptions.MinimumHashSaltLength)); + Differs("StoreFailure", o => o.StoreFailure = StoreFailureMode.FailClosed); + Differs("MaxSnapshotAge", o => o.MaxSnapshotAge = TimeSpan.FromMinutes(31)); + Differs("RefreshInterval", o => o.RefreshInterval = TimeSpan.FromSeconds(31)); + Differs("Caps.MaxPageSize", o => o.Caps.MaxPageSize = 17); + Differs("Caps.DefaultPageSize", o => o.Caps.DefaultPageSize = 17); + Differs("Caps.MaxConditions", o => o.Caps.MaxConditions = 17); + Differs("Caps.MaxConditionDepth", o => o.Caps.MaxConditionDepth = 17); + Differs("Caps.MaxConditionSets", o => o.Caps.MaxConditionSets = 17); + Differs("Caps.MaxConditionValues", o => o.Caps.MaxConditionValues = 17); + Differs("Caps.MaxAggregates", o => o.Caps.MaxAggregates = 17); + Differs("Caps.MaxOrderFields", o => o.Caps.MaxOrderFields = 17); + Differs("Caps.MaxNavigationDepth", o => o.Caps.MaxNavigationDepth = 7); + Differs("Caps.MaxQueryCost", o => o.Caps.MaxQueryCost = 17_000); + Differs("Caps.DefaultFieldCost", o => o.Caps.DefaultFieldCost = 7); + Differs("Caps.MaxAuditEvents", o => o.Caps.MaxAuditEvents = 17); + Differs("Caps.MinGroupSize", o => o.Caps.MinGroupSize = 17); + Differs("Caps.SchemaDepth", o => o.Caps.SchemaDepth = 7); + Differs("Caps.SchemaCycleLimit", o => o.Caps.SchemaCycleLimit = 7); + Differs("Caps.MaxSchemaFields", o => o.Caps.MaxSchemaFields = 17); + Differs("Entities: one more type", o => o.Entities.Expose("s4watched")); + Differs("Entities: one more name for a type", o => o.Entities.Expose("s4staff")); + Differs("a policy source the first host did not have", + _ => { /* handled below: providers are a Configure argument, not an option */ }); + + // A second host adding a runtime policy source is refused: the resolver is never + // rebuilt, so every rule in that source would quietly not apply. + try + { + DwPolicy.Configure(Posture(), new S4NoRules()); + + _out.WriteLine($"{"extra provider",-30} ACCEPTED"); + accepted.Add("extra provider"); + } + catch (InvalidOperationException) + { + _out.WriteLine($"{"extra provider",-30} refused"); + } + + accepted.Remove("a policy source the first host did not have"); + + Assert.True( + accepted.Count == 0, + "a second host handed over a posture that enforces differently and it was accepted: " + + string.Join(", ", accepted)); + } + + // ========================================================================================= + // S4-O. _translated gates Expresses and nothing else. A provider the name test rejects must + // still have every denial enforced over it: the refusal, and the projection that + // keeps a denied value out of the rows. + // ========================================================================================= + + [Fact] + public void S4_O_A_provider_the_name_test_rejects_still_has_every_denial_enforced() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using S4Context replaced = new(connection, typeof(S4PassThroughProvider)); + + replaced.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + replaced.Customers.Add(customer); + replaced.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Secret = "top", Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + _out.WriteLine( + $"provider = {((IQueryable)replaced.Orders).Provider.GetType().FullName} " + + "(EfCoreOwns = false)"); + + PolicyQueryable guarded = replaced.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + PolicyException refusal = Assert.Throws(() => guarded.ToList(new Filter + { + ConditionGroup = new ConditionGroup { Conditions = { On("Secret") } } + })); + + _out.WriteLine($"filter on the denied field -> {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + + // And the denied value must not come back in a row the caller asked nothing about. + List rows = replaced.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows={rows.Count} Code='{rows[0].Code}' Secret='{rows[0].Secret}'"); + + Assert.Equal("AB123", rows[0].Code); + Assert.Equal(string.Empty, rows[0].Secret); + } + + // ========================================================================================= + // S4-P. A condition's values are the caller's own objects, in Clone and out of it. The + // release documents that a value is read more than once; this pins that no policy + // decision rests on which read won. + // ========================================================================================= + + [Fact] + public void S4_P_A_value_that_answers_differently_each_time_changes_no_policy_decision() + { + S4Counting shifting = new(); + + Filter moving = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { shifting } + } + } + } + }; + + Filter steady = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + }; + + string Run(Filter filter) + { + try + { + return "OK(" + _orders.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(filter).Data.Count + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + string shifted = Run(moving); + string fixedValue = Run(steady); + + _out.WriteLine($"value read {shifting.Reads} time(s); shifting -> {shifted}, steady -> {fixedValue}"); + + // The documented limit: a value is read more than once. + Assert.True(shifting.Reads > 1, $"the value was read {shifting.Reads} time(s)"); + + // What matters here: the policy reached the same decision either way. Nothing the guard + // decides reads a value's content, so a value that shifts cannot move a denial. + Assert.Equal( + fixedValue.StartsWith("REFUSED", StringComparison.Ordinal), + shifted.StartsWith("REFUSED", StringComparison.Ordinal)); + } + + /// A value whose ToString() answers differently on every read. + private sealed class S4Counting + { + internal int Reads { get; private set; } + + public override string ToString() + { + Reads++; + + return Reads == 1 ? "AB123" : "ZZZZZ"; + } + } + + /// + /// A posture holding exactly what is in force, value by value, so a knob changed below is + /// the only difference a second call carries. + /// + private static DwPolicyOptions Posture() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + /// A runtime policy source that grants and denies nothing. + private sealed class S4NoRules : IDwPolicyProvider + { + public IReadOnlyList GetFragments( + Type entityType, DwPolicyContext context) => + Array.Empty(); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs b/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs new file mode 100644 index 0000000..41cb3ac --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs @@ -0,0 +1,382 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6, adversarial security review of 3.3.0 at 7034717. + // + // Reviews round 5's first fix: an ambiguous name is recorded and refused as an unknown name is, + // under Strict outside a dry run, instead of throwing AmbiguousFieldName. + // + // The three questions the fix has to answer: + // 1. is the refusal byte-identical to an unknown name's, in EVERY clause? + // 2. can the canonical path leak through the trace-in-result path? + // 3. did anything downstream depend on the old exception? + // ============================================================================================= + + /// + /// A type on which one spoken name means two different members: the real property + /// Salary, and the alias Salary that Notes carries. + /// + internal class Sx6Colliding + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + /// The real property. + public string Salary { get; set; } = string.Empty; + + /// The alias, which collides with the property above. + [DwAlias("Salary")] + public string Notes { get; set; } = string.Empty; + + /// A denied field, for the three-way comparison. + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Sx6AmbiguityProbes + { + private readonly ITestOutputHelper _out; + + public Sx6AmbiguityProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private const string Ambiguous = "Salary"; + private const string Unknown = "Nope"; + private const string Denied = "Secret"; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + /// Everything a caller can read off a refusal. + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.GetType().Name}|{refusal.ErrorCode}|path={refusal.FieldPath}" + + $"|feature={refusal.Feature}|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}" + + $"|origin={refusal.SourceOrigin ?? "-"}|message={refusal.Message}", + LogicException failure => + $"{failure.GetType().Name}|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Condition On(string field) => + new() { Sort = 0, Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } }; + + private static ConditionGroup GroupOf(params Condition[] conditions) + { + ConditionGroup group = new() { Connector = Connector.And }; + + for (int i = 0; i < conditions.Length; i++) + { + conditions[i].Sort = i; + group.Conditions.Add(conditions[i]); + } + + return group; + } + + // Each clause, parameterised by the name the caller writes. + + private static Exception? Where(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(name)) }, + Attributes(), Caller(), options, trace)); + + private static Exception? Select(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { Selects = new List { "Id", name } }, + Attributes(), Caller(), options, trace)); + + private static Exception? Order(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } }, + Attributes(), Caller(), options, trace)); + + private static Exception? Group(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { name }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? Aggregate(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = name, Alias = "n", Aggregator = Aggregator.Count } + } + } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentWhere(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On(name)) } + }, + Selects = new List { "Id" } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentSelect(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On("Region")) } + }, + Selects = new List { "Id", name } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentOrder(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On("Region")) } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } + }, + Attributes(), Caller(), options, trace)); + + private static readonly (string Clause, Func Run)[] Clauses = + { + ("where", Where), + ("select", Select), + ("order", Order), + ("group", Group), + ("aggregate", Aggregate), + ("segment-where", SegmentWhere), + ("segment-select", SegmentSelect), + ("segment-order", SegmentOrder) + }; + + // ========================================================================================= + // Q1. Is the refusal byte-identical to an unknown name's, in every clause? + // ========================================================================================= + + [Fact] + public void Strict_refuses_an_ambiguous_name_exactly_as_it_refuses_an_unknown_one() + { + List mismatched = new(); + + foreach ((string clause, Func run) in Clauses) + { + DwPolicyOptions options = Options(); + + string ambiguous = Shape(run(Ambiguous, options, new PolicyTrace(DwTier.Strict, false))); + string unknown = Shape(run(Unknown, options, new PolicyTrace(DwTier.Strict, false))); + string denied = Shape(run(Denied, options, new PolicyTrace(DwTier.Strict, false))); + + _out.WriteLine($"[{clause}]"); + _out.WriteLine($" ambiguous : {ambiguous}"); + _out.WriteLine($" unknown : {unknown}"); + _out.WriteLine($" denied : {denied}"); + + if (!string.Equals(ambiguous, unknown, StringComparison.Ordinal)) + { + mismatched.Add($"{clause}: ambiguous='{ambiguous}' unknown='{unknown}'"); + } + + if (!string.Equals(unknown, denied, StringComparison.Ordinal)) + { + mismatched.Add($"{clause}: unknown='{unknown}' denied='{denied}'"); + } + } + + Assert.Empty(mismatched); + } + + // ========================================================================================= + // Q2. Can the canonical path leak through the trace? + // ========================================================================================= + + /// + /// The trace records the candidates, which are canonical paths. A refused query returns no + /// result, so the only way out is LastTrace — in process, never serialized. + /// + [Fact] + public void The_trace_records_the_candidates_an_ambiguous_name_matched() + { + PolicyTrace trace = new(DwTier.Strict, dryRun: false); + + Exception? error = Where(Ambiguous, Options(), trace); + + foreach (PolicyDecision decision in trace.Decisions) + { + _out.WriteLine($" {decision.FieldPath}|{decision.Feature}|{decision.Action}|{decision.Reason}"); + } + + Assert.IsType(error); + + // The operator who has to fix the aliases can read what collided. + Assert.Contains( + trace.Decisions, + d => d.Reason is not null && d.Reason.Contains("Notes", StringComparison.Ordinal)); + } + + /// + /// The refusal itself never carries a candidate, whatever a caller reads off it. + /// + [Fact] + public void The_refusal_never_carries_a_candidate_path() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(), new PolicyTrace(DwTier.Strict, false)); + + string text = Shape(error); + + _out.WriteLine($"[{clause}] {text}"); + + Assert.DoesNotContain("Notes", text, StringComparison.Ordinal); + Assert.DoesNotContain("Salary", text, StringComparison.Ordinal); + } + } + + // ========================================================================================= + // Q3. Did anything downstream depend on the old exception? + // ========================================================================================= + + /// The convenience tier is unchanged: it still raises the ambiguity by name. + [Fact] + public void Convenience_still_raises_AmbiguousFieldName() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(DwTier.Convenience), new PolicyTrace(DwTier.Convenience, false)); + + _out.WriteLine($"[{clause}] {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + } + + /// A dry run under Strict is unchanged: it still raises the ambiguity by name. + [Fact] + public void A_strict_dry_run_still_raises_AmbiguousFieldName() + { + Exception? error = Where(Ambiguous, Options(dryRun: true), new PolicyTrace(DwTier.Strict, true)); + + _out.WriteLine(Shape(error)); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + + /// + /// The ambiguity is never silently resolved: no clause comes back holding either candidate. + /// + [Fact] + public void No_clause_resolves_the_ambiguity_in_favour_of_one_candidate() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(), new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"[{clause}] {(error is null ? "ANSWERED" : error.GetType().Name)}"); + + // An answer here would mean the library picked one of the two meanings. + Assert.NotNull(error); + } + } + + // ========================================================================================= + // The per-context dry run, which the fix's own rule says names fields anyway. + // ========================================================================================= + + /// + /// A canary subject running unenforced. The sanitizer's own IsDryRun is the union of + /// the global switch and this one, so this is a dry run for every decision the gate makes. + /// + [Fact] + public void A_context_dry_run_is_a_dry_run_for_the_sanitizer() + { + DwPolicyContext canary = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + canary.DryRun = true; + + PolicyTrace trace = new(DwTier.Strict, dryRun: true); + + Exception? error = Catch(() => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(Ambiguous)) }, + Attributes(), canary, Options(), trace)); + + _out.WriteLine($"context dry run, ambiguous : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + // Not gated as an unknown name: the per-context dry run reaches HidesExistence too. + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + + // And a denied field is not refused at all under a context dry run. + DwPolicyContext second = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + second.DryRun = true; + + Exception? denied = Catch(() => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(Denied)) }, + Attributes(), second, Options(), new PolicyTrace(DwTier.Strict, true))); + + _out.WriteLine($"context dry run, denied : {Shape(denied)}"); + + Assert.Null(denied); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs b/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs new file mode 100644 index 0000000..3cce1ff --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs @@ -0,0 +1,292 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. A blank field name in each clause, under Strict. + // + // CanonicalizeGroup and CanonicalizeOrder both refuse a blank field with LogicException before + // ResolveName sees it. Selects and GroupBy.Fields have no such guard, and under Strict a blank + // name reaches Gate.Unknown, which keeps it as the empty string — the one field path + // PolicyDecision refuses to be constructed with. + // ============================================================================================= + + /// A plain type, so nothing but the blank name decides what happens. + public class Sx6Blankable + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + } + + public sealed class Sx6BlankNameProbes + { + private readonly ITestOutputHelper _out; + + public Sx6BlankNameProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException|{refusal.ErrorCode}|path={refusal.FieldPath}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}|{error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Exception? Run(string blank, DwTier tier, Func build) => Catch(() => + { + object request = build(blank); + + PolicyQueryable guarded = + Array.Empty().AsQueryable().ApplyPolicy(Caller(), Options(tier), Attributes()); + + switch (request) + { + case Filter filter: + guarded.ToList(filter); + break; + + case Summary summary: + guarded.ToList(summary); + break; + + case Segment segment: + guarded.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + }); + + private static object SelectClause(string name) => + new Filter { Selects = new List { "Id", name } }; + + private static object WhereClause(string name) => new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + private static object OrderClause(string name) => + new Filter { Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } }; + + private static object GroupClause(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { name }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + } + }; + + private static object SegmentSelectClause(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Selects = new List { "Id", name } + }; + + private static readonly (string Clause, Func Build)[] Clauses = + { + ("where", WhereClause), + ("order", OrderClause), + ("select", SelectClause), + ("group", GroupClause), + ("segment-select", SegmentSelectClause) + }; + + /// What the same request does with no policy in front of it. + private static Exception? Unguarded(string blank, Func build) => Catch(() => + { + object request = build(blank); + + IQueryable source = Array.Empty().AsQueryable(); + + switch (request) + { + case Filter filter: + source.ToListDynamic(filter); + break; + + case Summary summary: + source.ToList(summary); + break; + + case Segment segment: + source.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + }); + + // ========================================================================================= + // A blank name in a clause with no InvalidField guard — Selects, GroupBy.Fields and a + // segment's Selects — used to leave the library as ArgumentNullException rather than as one + // of its own two exception types, in both tiers. The grouping key was guarded first, and a + // projection name is now too: RequestShape refuses it before anything looks the name up, + // with or without a policy, so every clause answers a blank name with a LogicException. + // ========================================================================================= + + [Fact] + public void A_blank_name_leaves_a_guarded_query_exactly_as_it_leaves_an_unguarded_one() + { + List diverged = new(); + + foreach (string blank in new[] { string.Empty, " ", "\t" }) + { + foreach ((string clause, Func build) in Clauses) + { + Exception? strict = Run(blank, DwTier.Strict, build); + Exception? convenience = Run(blank, DwTier.Convenience, build); + Exception? bare = Unguarded(blank, build); + + _out.WriteLine($"[{clause}] blank='{blank.Replace("\t", "\\t")}'"); + _out.WriteLine($" strict : {Shape(strict)}"); + _out.WriteLine($" convenience : {Shape(convenience)}"); + _out.WriteLine($" unguarded : {Shape(bare)}"); + + // The standard the library holds itself to for a name it cannot resolve: the + // guarded query fails exactly as the unguarded one does. + if (!string.Equals(Shape(strict), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"strict/{clause}/'{blank}': {Shape(strict)} vs {Shape(bare)}"); + } + + if (!string.Equals(Shape(convenience), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"convenience/{clause}/'{blank}': {Shape(convenience)} vs {Shape(bare)}"); + } + } + } + + foreach (string line in diverged) + { + _out.WriteLine($"DIVERGED {line}"); + } + + // A blank name fails a guarded query exactly as it fails an unguarded one, in every + // clause. The grouping key used to be the exception: its loop handed the blank straight + // to the resolver, which reached Validate's argument check, so a malformed clause + // came back as an ArgumentNullException rather than the failure the endpoint turns into + // a four-hundred. It guards the blank first now, as every sibling clause does. + Assert.Empty(diverged); + } + + /// + /// The divergence on its own, spelled out: the guarded summary leaves as a framework + /// exception where the unguarded one leaves as the library's own malformed-request error. + /// + [Fact] + public void A_blank_grouping_key_fails_the_same_way_once_the_query_is_guarded() + { + Exception? guarded = Run(string.Empty, DwTier.Strict, GroupClause); + Exception? bare = Unguarded(string.Empty, GroupClause); + + _out.WriteLine($"guarded : {Shape(guarded)}"); + _out.WriteLine($"unguarded : {Shape(bare)}"); + + LogicException malformed = Assert.IsType(bare); + + Assert.Equal("ConditionMustHasValidFieldName", malformed.Message); + + // The same failure, guarded: a malformed clause, not an exception from inside the + // sanitizer that nothing above it catches. + LogicException guardedMalformed = Assert.IsType(guarded); + + Assert.Equal(malformed.Message, guardedMalformed.Message); + } + + /// + /// The same blank name straight through the sanitizer, so the failure is attributable to it + /// rather than to anything the terminal does afterwards. + /// + [Fact] + public void A_blank_projection_name_is_refused_before_it_reaches_the_gate() + { + Exception? error = Catch(() => FilterSanitizer.Sanitize( + new Filter { Selects = new List { "Id", string.Empty } }, + Attributes(), Caller(), Options(), new PolicyTrace(DwTier.Strict, dryRun: false))); + + _out.WriteLine($"sanitizer, blank select : {Shape(error)}"); + + // Validator.Validate(string) throws ArgumentNullException for a name that is null or + // whitespace, and ResolveName used to hand the raw name to it, so a malformed projection + // left the sanitizer as a framework exception. The request's shape is checked first now, + // and a blank name is the malformed clause a blank grouping key already was. + LogicException malformed = Assert.IsType(error); + + Assert.Equal("ConditionMustHasValidFieldName", malformed.Message); + } + + /// The same for a blank grouping key. + [Fact] + public void A_blank_grouping_key_is_refused_before_it_reaches_the_gate() + { + Exception? error = Catch(() => FilterSanitizer.Sanitize( + (Summary)GroupClause(string.Empty), + Attributes(), Caller(), Options(), new PolicyTrace(DwTier.Strict, dryRun: false))); + + _out.WriteLine($"sanitizer, blank group key : {Shape(error)}"); + + Assert.IsType(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs b/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs new file mode 100644 index 0000000..1def4fc --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs @@ -0,0 +1,461 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Storage; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. Walks every PolicyErrorCode the library can raise under Strict and records what it + // names: the code, the path, the rule id and the origin — the four things a caller reads. + // ============================================================================================= + + /// A field filterable only with the operator its attribute allows, and nothing else. + public class Sx6SoleRestricted + { + public int Id { get; set; } + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// The same restriction behind an alias, so the refusal has a second spelling to pick. + public class Sx6AliasRestricted + { + public int Id { get; set; } + + [DwAlias("badge")] + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// A bare type, so a runtime rule is the only source of its policy. + public class Sx6Plain + { + public int Id { get; set; } + + public string Serial { get; set; } = string.Empty; + } + + /// A weighted field, for the cost cap. + public class Sx6Costly + { + public int Id { get; set; } + + [DwCost(500)] + public string Blob { get; set; } = string.Empty; + } + + /// A scope read from the caller's context, for the missing-context refusal. + public class Sx6Scoped + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, ContextValue = "tenant")] + public int TenantId { get; set; } + } + + public sealed class Sx6CodeWalkProbes + { + private readonly ITestOutputHelper _out; + + public Sx6CodeWalkProbes(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Filter WhereOn( + string field, Operator op = Operator.Equal, DataType type = DataType.Text, string value = "x") => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Sort = 0, Field = field, DataType = type, Operator = op, Values = { value } } + } + } + }; + + // ========================================================================================= + // FINDING candidate. OperatorNotAllowed is not in IsFieldDenial, so Gate.Exception takes the + // attributing branch: it names the field AND, for a single-source policy, the attribute. + // ========================================================================================= + + [Fact] + public void Strict_operator_refusal_attributes_the_attribute_that_restricted_the_field() + { + Exception? sole = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("Serial", Operator.Contains))); + + Exception? aliased = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("badge", Operator.Contains))); + + // What the same probe gets for a name that does not exist. + Exception? unknown = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("NoSuchColumn", Operator.Contains))); + + _out.WriteLine($"sole source : {Shape(sole)}"); + _out.WriteLine($"aliased : {Shape(aliased)}"); + _out.WriteLine($"unknown name : {Shape(unknown)}"); + + PolicyException refusal = Assert.IsType(sole); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + Assert.Equal("Serial", refusal.FieldPath); + + // With attributes the policy carries more than one source, so Gate.Exception declines to + // attribute and the origin stays empty. + Assert.Null(refusal.SourceOrigin); + Assert.Null(refusal.RuleId); + + // It is still told apart from a name that matches nothing, which answers "*". That is + // sound: a field with an operator restriction is one the caller MAY filter, so its + // existence is not a secret the tier is keeping. + Assert.Equal("*", Assert.IsType(unknown).FieldPath); + Assert.Equal("badge", Assert.IsType(aliased).FieldPath); + } + + /// + /// The case Gate.Exception does attribute: a policy with exactly one source. A runtime rule + /// restricting the operators is such a policy, and under Strict the refusal then carries the + /// rule's identifier and its origin string. + /// + [Fact] + public void Strict_operator_refusal_from_a_sole_runtime_rule_carries_the_rule_id() + { + FakePolicyProvider rules = new FakePolicyProvider() + .AddOperators("Serial", PolicyLevel.DynamicUser, Operator.Equal); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { rules }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), resolver) + .ToList(WhereOn("Serial", Operator.Contains))); + + _out.WriteLine($"sole runtime rule : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + Assert.Equal("Serial", refusal.FieldPath); + + // What a field denial from the same rule would have carried, and does not. + FakePolicyProvider denying = new FakePolicyProvider() + .Add("Serial", PolicyFeature.Where, PolicyEffect.Deny, PolicyLevel.DynamicUser); + + Exception? denied = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), new PolicyResolver(new IDwPolicyProvider[] { denying })) + .ToList(WhereOn("Serial"))); + + _out.WriteLine($"sole rule, denied : {Shape(denied)}"); + + PolicyException blanked = Assert.IsType(denied); + + Assert.Equal("*", blanked.FieldPath); + Assert.Null(blanked.RuleId); + Assert.Null(blanked.SourceOrigin); + } + + /// + /// The same code reached through a Having clause, where the reference is an aggregate + /// alias standing for the restricted column. + /// + [Fact] + public void Strict_operator_refusal_in_having_names_the_path_behind_the_alias() + { + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Id" }, + AggregateBy = new List + { + new() { Field = "Serial", Alias = "top", Aggregator = Aggregator.Maximum } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "top", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "x" } + } + } + } + }; + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(summary)); + + _out.WriteLine($"having on aggregate alias : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // The caller named "Serial" themselves, in the aggregate, so naming it back tells them + // nothing they did not write. + Assert.Equal("Serial", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// The same clause where the caller never writes the canonical name: the aggregate names the + /// alias, and the having condition names the aggregate's own alias. + /// + [Fact] + public void Strict_operator_refusal_in_having_reports_the_alias_the_caller_wrote() + { + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Id" }, + AggregateBy = new List + { + new() { Field = "badge", Alias = "top", Aggregator = Aggregator.Maximum } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "top", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "x" } + } + } + } + }; + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(summary)); + + _out.WriteLine($"having, alias only : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // The canonical column must not come back to a caller who only ever wrote "badge". + Assert.NotEqual("Serial", refusal.FieldPath); + } + + // ========================================================================================= + // The rest of the codes, for the record. + // ========================================================================================= + + [Fact] + public void Strict_cap_refusals_name_the_clause_and_the_cap() + { + DwPolicyOptions capped = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = 1, MaxConditions = 1 } }; + + ConditionGroup two = new() { Connector = Connector.And }; + + two.Conditions.Add(new Condition + { + Sort = 0, Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { "1" } + }); + + two.Conditions.Add(new Condition + { + Sort = 1, Field = "Blob", DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), capped, Attributes()) + .ToList(new Filter { ConditionGroup = two })); + + _out.WriteLine($"CapExceeded : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public void Strict_cost_refusal_names_the_clause_and_prints_the_total() + { + DwPolicyOptions budget = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = 1, MaxQueryCost = 10 } }; + + Exception? weighted = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), budget, Attributes()).ToList(WhereOn("Blob"))); + + Exception? plain = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), budget, Attributes()) + .ToList(WhereOn("Id", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"weighted field : {Shape(weighted)}"); + _out.WriteLine($"plain field : {Shape(plain)}"); + + PolicyException refusal = Assert.IsType(weighted); + + Assert.Equal(PolicyErrorCode.QueryCostExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + + // The origin prints the request's total cost, which is the sum of the weights of the + // fields the caller named — and so, for a one-field request, that field's weight. + Assert.Contains("request cost", refusal.SourceOrigin); + } + + [Fact] + public void Strict_missing_context_value_names_neither_the_column_nor_the_key() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(new Filter())); + + _out.WriteLine($"MissingContextValue : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingContextValue, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + [Fact] + public void Strict_query_string_refusal_names_the_clause() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(new Filter(), getQueryString: true)); + + _out.WriteLine($"QueryStringDenied : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.QueryStringDenied, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public void Strict_all_selects_denied_names_the_clause() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter { Selects = new List { "Secret" } })); + + _out.WriteLine($"one denied select : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + // ========================================================================================= + // FINDING candidate. The store provider stamps every refusal DwTier.Strict, whatever the + // posture is, and names the store implementation class to the caller. + // ========================================================================================= + + [Fact] + public async Task A_store_refusal_reports_the_strict_tier_under_a_convenience_posture() + { + InMemoryPolicyStore inner = new(); + + inner.Seed(new PolicyRule( + DwSubjectKind.Global, null, "DynamicWhere.Tests.Policies.Sx6Sealed", "Secret", + PolicyFeature.Select, PolicyEffect.Deny)); + + UnreachableStore store = new(inner); + + DwPolicyOptions convenience = new() + { + Tier = DwTier.Convenience, + StoreFailure = StoreFailureMode.FailClosed + }; + + convenience.Freeze(); + + using StorePolicyProvider provider = + await StorePolicyProvider.CreateAsync(store, convenience, autoRefresh: false); + + DwPolicyContext context = await provider.PrepareAsync(Caller()); + + store.Broken = true; + + await Assert.ThrowsAsync(async () => await provider.RefreshAsync()); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { provider }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, convenience, resolver).ToList(new Filter())); + + _out.WriteLine($"StoreUnavailable : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.StoreUnavailable, refusal.ErrorCode); + + // The posture is Convenience. The refusal says Strict, and so does the audit event + // RefusalAudit builds from it. + Assert.Equal(DwTier.Convenience, convenience.Tier); + Assert.Equal(DwTier.Strict, refusal.Tier); + + // It also names the store implementation class and the entity type. + Assert.Contains("UnreachableStore", refusal.SourceOrigin); + Assert.Equal(nameof(Sx6Sealed), refusal.FieldPath); + } + + [Fact] + public void An_unprepared_context_is_refused_naming_the_clause() + { + PolicyException refusal = Assert.Throws( + () => Array.Empty().AsQueryable().ApplyPolicy(Caller())); + + _out.WriteLine($"PolicyContextNotPrepared : {Shape(refusal)}"); + + Assert.Equal(PolicyErrorCode.PolicyContextNotPrepared, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs b/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs new file mode 100644 index 0000000..9621d17 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs @@ -0,0 +1,526 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6, adversarial security review of 3.3.0 at 7034717. + // + // Reviews round 5's fixes 2, 3 and 4 — AmbiguousGroupKey, TransformRequiresMaterialization and + // MissingHashSalt/MissingTokenVault reporting "*" under Strict — from the side the fix did not + // look at: what the AUDIT records once the caller-facing path is blanked, and whether the + // posture the three new predicates read is the same posture the rest of the library reads. + // ============================================================================================= + + /// A generalized field a caller only ever names by its alias. + public class Sx6Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// A hashed identifier, so the missing-salt refusal can be reached. + public class Sx6Hashed + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// A tokenized identifier, so the missing-vault refusal can be reached. + public class Sx6Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// A field denied outright, as the control case for what an audit records. + public class Sx6Sealed + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Sx6RefusalAuditProbes + { + private readonly ITestOutputHelper _out; + + public Sx6RefusalAuditProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Audited(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = true, + Caps = { MinGroupSize = 1 } + }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + private static Sx6Banded[] Colliding() => new[] + { + new Sx6Banded { Id = 1, Payroll = 100m }, + new Sx6Banded { Id = 2, Payroll = 149m } + }; + + // ========================================================================================= + // FINDING candidate. The audit loses the field with the caller-facing path. + // ========================================================================================= + + /// + /// The control. A denied field's refusal names no field to the caller and the canonical path + /// to the audit, which is the rule PolicyException.AuditPath is documented to keep. + /// + [Fact] + public void A_field_denial_names_nothing_to_the_caller_and_the_path_to_the_audit() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"denied field : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal("*", refusal.FieldPath); + + // The audit is not the caller: it keeps the field that was probed. + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Secret"); + } + + /// + /// MissingHashSalt under Strict. Round 5 blanked the caller-facing path and set no + /// AuditPath, so the audit is blanked with it. + /// + [Fact] + public void Missing_hash_salt_records_the_field_in_the_audit_under_strict() + { + Sx6Hashed[] rows = { new() { Id = 1, NationalId = "AAA-111" } }; + + DwPolicyContext strict = Caller(); + + Exception? underStrict = Catch(() => rows.AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(new Filter())); + + DwPolicyContext convenience = Caller(); + + Exception? underConvenience = Catch(() => rows.AsQueryable() + .ApplyPolicy(convenience, Audited(DwTier.Convenience), Attributes()).ToList(new Filter())); + + _out.WriteLine($"strict : {Shape(underStrict)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + _out.WriteLine($"convenience : {Shape(underConvenience)}"); + _out.WriteLine($" audit : {Recorded(convenience)}"); + + Assert.Equal( + PolicyErrorCode.MissingHashSalt, + Assert.IsType(underStrict).ErrorCode); + + // The convenience tier records the field a deployment failed to configure for. + Assert.Contains(convenience.PendingAuditEvents, e => e.FieldPath == "NationalId"); + + // Under Strict the same record says "*". Documented contract of AuditPath: "a record of + // a refusal that cannot say which field was probed answers nothing". + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + Assert.NotEmpty(strict.PendingAuditEvents); + } + + /// MissingTokenVault behaves the same way. + [Fact] + public void Missing_token_vault_records_the_field_in_the_audit_under_strict() + { + Sx6Tokenized[] rows = { new() { Id = 1, NationalId = "AAA-111" } }; + + DwPolicyContext strict = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(new Filter())); + + _out.WriteLine($"strict : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + + Assert.Equal( + PolicyErrorCode.MissingTokenVault, + Assert.IsType(error).ErrorCode); + + Assert.NotEmpty(strict.PendingAuditEvents); + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// AmbiguousGroupKey behaves the same way. + [Fact] + public void Ambiguous_group_key_records_the_field_in_the_audit_under_strict() + { + DwPolicyContext strict = Caller(); + + Exception? underStrict = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(ByBand())); + + DwPolicyContext convenience = Caller(); + + Exception? underConvenience = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(convenience, Audited(DwTier.Convenience), Attributes()).ToList(ByBand())); + + _out.WriteLine($"strict : {Shape(underStrict)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + _out.WriteLine($"convenience : {Shape(underConvenience)}"); + _out.WriteLine($" audit : {Recorded(convenience)}"); + + Assert.Equal( + PolicyErrorCode.AmbiguousGroupKey, + Assert.IsType(underStrict).ErrorCode); + + Assert.Contains(convenience.PendingAuditEvents, e => e.FieldPath == "Payroll"); + + Assert.NotEmpty(strict.PendingAuditEvents); + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// TransformRequiresMaterialization behaves the same way. + [Fact] + public void Transform_materialization_records_the_field_in_the_audit_under_strict() + { + DwPolicyContext strict = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()) + .SelectDynamic(new List { "Id" })); + + _out.WriteLine($"strict : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + + Assert.Equal( + PolicyErrorCode.TransformRequiresMaterialization, + Assert.IsType(error).ErrorCode); + + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// + /// An ambiguous name now reaches the audit as the caller spelled it, where before round 5 it + /// was refused with the same spelling. Recorded for contrast: the field it stood for is in + /// the trace, never in the audit. + /// + [Fact] + public void An_ambiguous_name_is_audited_as_the_caller_wrote_it() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Salary" } })); + + _out.WriteLine($"ambiguous : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + + Assert.IsType(error); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Salary"); + } + + // ========================================================================================= + // FINDING candidate. The three new predicates read options.DryRun alone, where every other + // decision in the library reads options.DryRun || context.DryRun. + // ========================================================================================= + + /// + /// The rule the whole posture is built on: a dry run is the union of the global switch and + /// the per-context one, so one canary subject can run unenforced. + /// + [Fact] + public void A_context_dry_run_stops_the_gate_refusing() + { + DwPolicyContext canary = Caller(dryRun: true); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(canary, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"canary, denied field : {Shape(error)}"); + + Assert.Null(error); + } + + /// + /// The same canary meets AmbiguousGroupKey, MissingHashSalt and + /// TransformRequiresMaterialization. All three still refuse, and all three hide the + /// field although the tier's own rule for a dry run is to name it. + /// + [Fact] + public void A_context_dry_run_is_a_dry_run_for_the_three_new_predicates() + { + List observed = new(); + + // (a) AmbiguousGroupKey. + DwPolicyContext one = Caller(dryRun: true); + + Exception? group = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(one, Audited(), Attributes()).ToList(ByBand())); + + observed.Add($"context dry run, group key : {Shape(group)}"); + + // (b) MissingHashSalt. + DwPolicyContext two = Caller(dryRun: true); + + Exception? salt = Catch(() => new[] { new Sx6Hashed { Id = 1, NationalId = "A" } }.AsQueryable() + .ApplyPolicy(two, Audited(), Attributes()).ToList(new Filter())); + + observed.Add($"context dry run, salt : {Shape(salt)}"); + + // (c) TransformRequiresMaterialization. + DwPolicyContext three = Caller(dryRun: true); + + Exception? materialize = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(three, Audited(), Attributes()) + .SelectDynamic(new List { "Id" })); + + observed.Add($"context dry run, transform : {Shape(materialize)}"); + + // The contrast: the same three under a GLOBAL dry run, which the fix does honour. + Exception? groupGlobal = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()).ToList(ByBand())); + + Exception? saltGlobal = Catch(() => new[] { new Sx6Hashed { Id = 1, NationalId = "A" } }.AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()).ToList(new Filter())); + + Exception? materializeGlobal = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()) + .SelectDynamic(new List { "Id" })); + + observed.Add($"global dry run, group key : {Shape(groupGlobal)}"); + observed.Add($"global dry run, salt : {Shape(saltGlobal)}"); + observed.Add($"global dry run, transform : {Shape(materializeGlobal)}"); + + foreach (string line in observed) + { + _out.WriteLine(line); + } + + // A global dry run names the field; the per-context one does not. + Assert.Equal("Payroll", Assert.IsType(groupGlobal).FieldPath); + Assert.Equal("NationalId", Assert.IsType(saltGlobal).FieldPath); + Assert.Equal("Payroll", Assert.IsType(materializeGlobal).FieldPath); + + // A dry run is either switch, the posture's or the caller's, as it is everywhere else. + Assert.Equal("Payroll", Assert.IsType(group).FieldPath); + Assert.Equal("NationalId", Assert.IsType(salt).FieldPath); + Assert.Equal("Payroll", Assert.IsType(materialize).FieldPath); + } + + // ========================================================================================= + // The composite group key, after the raw NUL and unit separator became escapes. + // ========================================================================================= + + /// A transformer that hands back exactly what it was given. + public sealed class Sx6Echo : IValueTransformer + { + public object? Transform(object? value, DwTransformContext context) => value; + } + + /// Two grouping keys, both transformed, so both join the composite. + public class Sx6Pair + { + public int Id { get; set; } + + [DwMutate(typeof(Sx6Echo))] + [DwNoOrder] + public string A { get; set; } = string.Empty; + + [DwMutate(typeof(Sx6Echo))] + [DwNoOrder] + public string B { get; set; } = string.Empty; + } + + private static Summary ByPair() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "A", "B" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + /// Two genuinely distinct key pairs still group and answer. + [Fact] + public void Distinct_composite_keys_are_not_a_collision() + { + Sx6Pair[] rows = + { + new() { Id = 1, A = "x", B = "y" }, + new() { Id = 2, A = "p", B = "q" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"distinct : {Shape(error)}"); + + Assert.Null(error); + } + + /// + /// The separator is a value a caller can put in a column. Two distinct key pairs whose parts + /// straddle it build one composite, and the summary is refused for a collision that is not + /// one. Unchanged by round 5 — the escape is the same character — and recorded so the + /// property is on the record. + /// + [Fact] + public void A_value_holding_the_separator_invents_a_collision() + { + // Built from its code point rather than written as a literal: a raw unit separator + // in a source file is what made ResultTransformer.cs read as binary to grep. + string Separator = ((char)0x1F).ToString(); + + Sx6Pair[] rows = + { + new() { Id = 1, A = "x" + Separator + "y", B = "z" }, + new() { Id = 2, A = "x", B = "y" + Separator + "z" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"straddling separator : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + } + + /// + /// The null stand-in is a value a caller can put in a column too: a group whose key is null + /// and a group whose key is a lone NUL build the same composite. + /// + [Fact] + public void A_value_holding_the_null_stand_in_invents_a_collision() + { + string Nul = ((char)0x00).ToString(); + + Sx6Pair[] rows = + { + new() { Id = 1, A = null!, B = "z" }, + new() { Id = 2, A = Nul, B = "z" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"null vs NUL : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + } + + /// + /// The direction that would matter: two rows that really do share a key are still caught. + /// A missed collision would return a summary with duplicate keys whose counts do not add up. + /// + [Fact] + public void A_real_collision_is_still_caught() + { + Exception? error = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByBand())); + + _out.WriteLine($"real collision : {Shape(error)}"); + + Assert.Equal( + PolicyErrorCode.AmbiguousGroupKey, + Assert.IsType(error).ErrorCode); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs b/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs new file mode 100644 index 0000000..1690338 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs @@ -0,0 +1,274 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. The outbound half of [DwAlias], and the set of names a query marks unknown. + // ============================================================================================= + + /// + /// A type where one member's alias is another member's own column name. Inbound the spelling is + /// ambiguous and refused; outbound both columns want to be called Salary. + /// + public class Sx6Shadowed + { + public int Id { get; set; } + + public string Salary { get; set; } = string.Empty; + + [DwAlias("Salary")] + public string Notes { get; set; } = string.Empty; + } + + /// Two members sharing one alias: the collision the startup scan does report. + public class Sx6TwoAliases + { + public int Id { get; set; } + + [DwAlias("code")] + public string First { get; set; } = string.Empty; + + [DwAlias("code")] + public string Second { get; set; } = string.Empty; + } + + /// A plain type with a navigation, for the unknown-name normalization probe. + public class Sx6Leaf + { + public int Id { get; set; } + + public string Value { get; set; } = string.Empty; + } + + public class Sx6Root + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + public Sx6Leaf? Leaf { get; set; } + } + + public sealed class Sx6RenameProbes + { + private readonly ITestOutputHelper _out; + + public Sx6RenameProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static List ColumnsOf(object row) => + row is IDictionary expando + ? expando.Keys.ToList() + : row.GetType().GetProperties().Select(p => p.Name).ToList(); + + private static object? Read(object row, string column) => + row is IDictionary expando && expando.TryGetValue(column, out object? value) + ? value + : row.GetType().GetProperty(column)?.GetValue(row); + + // ========================================================================================= + // FINDING candidate. Rename maps a column onto a name another column in the same row already + // has, and one of the two values is lost — the outcome its own comment says it avoids. + // ========================================================================================= + + [Fact] + public void Rename_keeps_both_columns_when_an_alias_shadows_another_columns_name() + { + Sx6Shadowed[] rows = { new() { Id = 1, Salary = "REAL-SALARY", Notes = "THE-NOTES" } }; + + // The caller names no projection, so the whole row comes back and both columns are on + // it. Naming them would be refused inbound: "Salary" is the ambiguous spelling. + FilterResult result = rows.AsQueryable() + .ApplyPolicy(Caller(), Options(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()); + + object row = result.Data[0]; + + List columns = ColumnsOf(row); + + _out.WriteLine($"columns : {string.Join(", ", columns)}"); + + foreach (string column in columns) + { + _out.WriteLine($" {column} = {Read(row, column)}"); + } + + // The row held Id, Salary and Notes, and "Notes" is aliased to "Salary", which the row + // already carries. The rename is not applied, so neither value is lost: renaming would + // emit one column under the other's name and drop that other's value outright. + Assert.Contains("Notes", columns); + Assert.Contains("Salary", columns); + Assert.Equal(3, columns.Count); + + Assert.Equal("REAL-SALARY", Read(row, "Salary")); + Assert.Equal("THE-NOTES", Read(row, "Notes")); + } + + /// + /// The startup scan does not report the model. CheckAlias compares an alias only + /// against other aliases, never against the type's own property names, so the collision it + /// exists to catch at deployment is found at query time instead. + /// + [Fact] + public void The_startup_scan_reports_an_alias_that_shadows_a_property() + { + PolicyModelReport shadowing = PolicyModelValidator.Inspect(new[] { typeof(Sx6Shadowed) }); + + // The case the scan does catch, for contrast: two members sharing one alias. + PolicyModelReport twoAliases = PolicyModelValidator.Inspect(new[] { typeof(Sx6TwoAliases) }); + + _out.WriteLine($"alias shadows a property : errors={shadowing.Errors.Count}" + + $" warnings={shadowing.Warnings.Count}"); + + foreach (string message in shadowing.Errors.Concat(shadowing.Warnings)) + { + _out.WriteLine($" {message}"); + } + + _out.WriteLine($"two members, one alias : errors={twoAliases.Errors.Count}"); + + foreach (string message in twoAliases.Errors) + { + _out.WriteLine($" {message}"); + } + + // The alias-against-alias collision is reported. + Assert.False(twoAliases.IsValid); + + // And so is the alias-against-property collision: a generated row cannot carry one name + // twice, so one of the two values would be the one the caller receives. + Assert.False(shadowing.IsValid); + } + + /// + /// The inbound half still refuses the spelling, so the two halves disagree: one name is too + /// ambiguous to accept and not too ambiguous to emit. + /// + [Fact] + public void The_same_spelling_is_refused_inbound_and_emitted_outbound() + { + Exception? inbound = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Salary" } })); + + _out.WriteLine($"inbound 'Salary' : {inbound?.GetType().Name ?? "OK"}"); + + PolicyException refusal = Assert.IsType(inbound); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + + // ========================================================================================= + // The set of names a query marks unknown: a name that normalizes onto a real path. + // ========================================================================================= + + /// + /// Gate.Unknown collapses empty segments so a padded unknown name cannot be told from + /// a real one by the navigation cap. The collapsed form is what is remembered, so a name + /// that collapses onto a real path marks that path unknown for the rest of the request. + /// + [Fact] + public void A_name_that_normalizes_onto_a_real_path_marks_that_path_unknown() + { + // Both clauses in one request: an unknown spelling first, then the real field. + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region..", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + }, + Selects = new List { "Id", "Region" } + }; + + Exception? together = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(filter)); + + // The same projection on its own, to show the field is one the caller may use. + Exception? alone = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Region" } })); + + _out.WriteLine($"padded name + real select : {together?.GetType().Name ?? "OK"}"); + _out.WriteLine($"real select alone : {alone?.GetType().Name ?? "OK"}"); + + // The projection alone is answered, so Region is allowed. + Assert.Null(alone); + + // RULED OUT. "Region.." canonicalizes to "Region" in the validator, so it never reaches + // Gate.Unknown and cannot mark the real path unknown for the rest of the request. + Assert.Null(together); + } + + /// + /// The same padded spelling on its own, so the shape of its refusal is on the record. + /// + [Fact] + public void A_padded_name_on_its_own_is_refused_as_an_unknown_name() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region..", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + })); + + _out.WriteLine(error is PolicyException refusal + ? $"padded alone : {refusal.ErrorCode}|path={refusal.FieldPath}" + : $"padded alone : {error?.GetType().Name ?? "OK"}"); + + // A real field padded with dots is a real field. Gate.Unknown's collapse only ever sees + // names the validator already rejected. + Assert.Null(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs new file mode 100644 index 0000000..3a4534e --- /dev/null +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -0,0 +1,617 @@ +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- a shared kernel type: two columns and a getter over them ------------------------------------------ + + public class ZyLocalizedText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + /// Computed from the two columns, so no database can answer it. + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class ZyRole + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwNoWhere, DwNoOrder] + public ZyLocalizedText Name { get; set; } = new(); + + /// An unmapped getter over two mapped columns of the entity itself. + public string Display => $"{Code}:{Id}"; + } + + /// A hierarchy, so a column only one subtype maps is still a column. + public class ZyParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + /// Declared on the base, mapped only on the subtype, which is where the rows carry it. + public string? Licence { get; set; } + } + + public class ZyMerchant : ZyParty + { + public string? Rating { get; set; } + } + + /// The row a caller projects before the guard sees it, which is DCMP's shape. + public class ZyRoleRow + { + public int Id { get; set; } + + public ZyLocalizedText Name { get; set; } = new(); + + public string Code { get; set; } = string.Empty; + } + + public sealed class ZyContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZyContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().Ignore(role => role.Display); + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("merchant"); + + // Ignored where it is declared and mapped where the rows have it, so the model maps the + // member on the derived type alone while the queried type still carries the CLR property. + model.Entity().Ignore(party => party.Licence); + model.Entity().Property(merchant => merchant.Licence); + } + } + + /// A provider of its own: not EF Core's, and not the one rows in memory carry. + public sealed class ZyOwnProvider : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public ZyOwnProvider(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + _inner.Provider.CreateQuery(expression); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + + /// + /// A path beneath a member the policy has no fragment for, whose leaf no database can compute. + /// Until this, the package accepted the path and the provider threw, which is a five-hundred + /// where the strict tier promises a refusal. + /// + public sealed class UnexpressiblePathTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public UnexpressiblePathTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } }); + _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + /// The rows a caller projects before the guard sees them, built member by member. + private IQueryable Built() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + /// The same rows, with the shared type copied whole from the entity. + private IQueryable Copied() => + _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name }); + + /// + /// The rows the same caller projects with a null guard around the built member, which is the + /// shape the library's own typed Select emits for every nested node. + /// + private IQueryable Guarded() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = role.Code == null + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + // ---- the entity itself ---------------------------------------------------------------------------- + + [Fact] + public void A_computed_member_beneath_an_undecorated_member_is_refused_rather_than_run() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"entity: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void The_mapped_members_beneath_it_still_answer() + { + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.En", "Admin")); + + Assert.Single(result.Data); + } + + [Fact] + public void An_unmapped_getter_on_the_entity_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Display", "admin:1"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_column_a_subtype_maps_is_still_a_column() + { + FilterResult result = Guard(_db.Set(), DwTier.Strict) + .ToList(Where("Licence", "L-1")); + + Assert.Single(result.Data); + } + + [Fact] + public void A_column_only_the_subtype_maps_is_refused_through_the_base_type() + { + // The queried type declares the member and the model maps it one level down. EF Core + // translates against the type the query is over, so this is the failure the refusal is + // for: "Translation of member 'Licence' on entity type 'ZyParty' failed." + Assert.NotNull(_db.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence")); + Assert.Null(_db.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence")); + + Exception? unguarded = Record.Exception(() => _db.Parties.Where(party => party.Licence == "L-1").ToList()); + + Assert.IsType(unguarded); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Parties, DwTier.Strict).ToList(Where("Licence", "L-1"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_framework_member_the_provider_translates_is_left_alone() + { + FilterResult result = Guard(_db.Roles, DwTier.Strict) + .ToList(Where("Code.Length", "5", DataType.Number)); + + Assert.Single(result.Data); + } + + // ---- the projected row ---------------------------------------------------------------------------- + + [Fact] + public void A_projection_that_builds_the_shared_type_refuses_the_computed_member() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"built: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_projection_that_copies_the_shared_type_refuses_it_too() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Copied(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"copied: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void What_the_projection_does_assign_still_answers() + { + Assert.Single(Guard(Built(), DwTier.Strict).ToList(Where("Name.Ar", "مدير")).Data); + Assert.Single(Guard(Copied(), DwTier.Strict).ToList(Where("Name.En", "Admin")).Data); + } + + // ---- every clause, one choke point ---------------------------------------------------------------- + + [Fact] + public void An_order_on_it_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(new Filter + { + Orders = new List { new() { Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + } + + [Fact] + public void A_projection_naming_it_still_returns_it() + { + // A projection is the last thing the provider builds, and EF Core evaluates that one on + // the client when it cannot translate it. Unguarded this returns rows, so refusing it + // would take back a projection that has always worked. + List unguarded = Built().Select(row => new ZyRoleRow + { + Id = row.Id, + Name = new ZyLocalizedText { Ar = row.Name.Ar, En = row.Name.En } + }).ToList(); + + Assert.Single(unguarded); + + FilterResult result = Guard(Built(), DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Name.IsEmpty" } + }); + + Assert.Single(result.Data); + } + + [Fact] + public void An_unmapped_getter_on_the_entity_can_still_be_selected() + { + Assert.Single(_db.Roles.Select(role => new { role.Id, role.Display }).ToList()); + + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Display" } + }); + + // The typed projection reads the members it was asked for, so the getter computes from + // those: Code was not selected, which is the core's own behaviour and not the policy's. + Assert.Single(result.Data); + Assert.Equal(1, result.Data[0].Id); + } + + [Fact] + public void A_clause_composed_on_the_handle_is_refused_too() + { + // The composed pipeline reads the same source, so it answers as the terminal does. + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).Where(new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Name.IsEmpty", DataType = DataType.Boolean, + Operator = Operator.Equal, Values = { "false" } + } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public async Task A_segment_naming_it_is_refused_and_leaves_its_own_trace() + { + PolicyQueryable guarded = Guard(Built(), DwTier.Strict); + + // A first request, so a trace left over from it would be visible if the refusal skipped + // the assignment. + await guarded.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "admin" } } } + } + } + } + }); + + PolicyTrace? first = guarded.LastTrace; + + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => guarded.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } } + } + } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.NotSame(first, guarded.LastTrace); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + } + + [Fact] + public void A_summary_grouping_on_it_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, refusal.ErrorCode); + } + + // ---- what is left alone --------------------------------------------------------------------------- + + [Fact] + public void A_member_the_projection_never_assigns_fails_unguarded_and_is_refused_guarded() + { + // Unguarded first: what the provider does with it decides whether refusing is right. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.Ar == "مدير").ToList()); + + _out.WriteLine($"unguarded: {unguarded?.GetType().Name ?? "ran"}"); + + Assert.NotNull(unguarded); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "مدير"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_provider_that_is_not_EF_Core_s_decides_for_itself() + { + // Its rules are its own: it may evaluate the getter in memory, as rows in memory do, so + // nothing here is refused on EF Core's behalf. + ZyRole[] rows = { new() { Id = 1, Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } } }; + + IQueryable built = new ZyOwnProvider(rows.AsQueryable().Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + })); + + FilterResult result = Guard(built, DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean)); + + Assert.Single(result.Data); + } + + [Fact] + public void Rows_in_memory_run_the_getter_as_they_always_did() + { + ZyRole[] rows = { new() { Id = 1, Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } } }; + + FilterResult result = Guard(rows.AsQueryable(), DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean)); + + Assert.Single(result.Data); + } + + [Fact] + public void The_convenience_tier_fails_exactly_as_it_does_unguarded() + { + // Not a refusal: the tier's promise is the error an unguarded query gives, and unguarded + // this is the provider's own failure to translate. + Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Convenience).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + } + + [Fact] + public void A_dry_run_refuses_nothing() + { + PolicyQueryable guarded = _db.Roles.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, DryRun = true }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + } + + // ---- the trace says which of the two it was ------------------------------------------------------- + + [Fact] + public void The_trace_records_why_the_path_was_refused() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + Assert.Contains( + trace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + _out.WriteLine(string.Join( + " | ", + trace.Decisions.Select(decision => $"{decision.FieldPath} {decision.Action} {decision.Reason}"))); + } + + // ---- a member built by a conditional ---------------------------------------------------------------- + + [Fact] + public void A_member_a_null_guard_builds_is_read_through_both_branches() + { + // Neither branch computes IsEmpty, and the library's own Select builds exactly this + // shape, so a caller who composes Select and then filters has to be refused what the + // bare handle refuses. + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Guarded(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"a null-guarded member: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_a_null_guard_builds_still_answers_for_what_it_assigns() + { + // The branch that sets nothing assigns no member; the branch that builds the value + // assigns two. Reading only one of them would refuse a column the query computes. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + : new ZyLocalizedText() + }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.Ar == "").ToList()); + + Exception? guarded = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", ""))); + + _out.WriteLine($"a column one branch assigns: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + // EF Core cannot translate a column read through a branch that builds an empty value, so + // this query fails either way — and it has to fail the same way, because the policy has + // nothing to refuse: a member one branch assigns is a member the row carries. + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + + [Fact] + public void A_member_built_and_left_empty_carries_nothing_beneath_it() + { + // The initializer builds the value and sets nothing on it, which is an answer rather + // than a gap: no member beneath it is one the query computes. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = new ZyLocalizedText() + }); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"a member built and left empty: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_two_branches_build_two_ways_is_left_alone() + { + // One branch copies the member from the entity and the other builds it in place, so the + // two say different things about what is beneath it. Refusing on either would refuse on + // half of what builds the row. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? role.Name + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.IsEmpty).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"two branches, two ways: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + + // ---- the provider that translates decides ------------------------------------------------------------- + } +} diff --git a/DynamicWhere.Tests/SummaryTests.cs b/DynamicWhere.Tests/SummaryTests.cs index b6a4915..4deda0f 100644 --- a/DynamicWhere.Tests/SummaryTests.cs +++ b/DynamicWhere.Tests/SummaryTests.cs @@ -400,6 +400,32 @@ public void SummaryToListSync() Assert.Null(result.QueryString); } + /// + /// The three summary terminals page with the same product a filter does, and it wrapped the same + /// way: the first page of groups came back for a page number far past the last one. + /// + [Fact] + public async Task SummaryPageWhoseOffsetPassesInt32IsAnEmptyPage() + { + Summary summary = new() + { + GroupBy = CountAndAverage(), + Page = new PageBy { PageNumber = int.MaxValue, PageSize = 1000 } + }; + + Assert.Empty(Products.Summary(summary).ToDynamicList()); + + SummaryResult sync = Products.ToList(summary); + + Assert.Empty(sync.Data!); + Assert.Equal(2, sync.TotalCount); + + SummaryResult async = await Products.ToListAsync(summary); + + Assert.Empty(async.Data!); + Assert.Equal(2, async.TotalCount); + } + [Fact] public void SummaryToListSyncWithQueryString() { diff --git a/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs b/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs index c4537a1..05f8a3f 100644 --- a/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs +++ b/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs @@ -25,6 +25,9 @@ namespace DynamicWhere.ex.Policies.AspNetCore; /// public sealed class DwPolicyAuditMiddleware { + /// How long a sink is given to write what one request recorded. + internal static readonly TimeSpan DrainBudget = TimeSpan.FromSeconds(30); + private readonly RequestDelegate _next; private readonly ILogger? _log; @@ -87,9 +90,16 @@ private async ValueTask DrainAsync(HttpContext http) return; } + // Not the request's abort token. What was read has been read whether or not the caller is + // still there, and a caller who closed the connection as the rows arrived would otherwise + // cancel the write that records them: an audited read with nothing written down, for the + // price of a socket. The budget is the sink's alone, so one that hangs cannot hold the + // request open for good. + using CancellationTokenSource budget = new(DrainBudget); + try { - await DwPolicy.DrainAuditAsync(context, sink, http.RequestAborted).ConfigureAwait(false); + await DwPolicy.DrainAuditAsync(context, sink, budget.Token).ConfigureAwait(false); } catch (Exception failure) { diff --git a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj index 78ce335..7da80b4 100644 --- a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj +++ b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj @@ -31,8 +31,10 @@ Sajjad H. Al-Khafaji Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One security fix in this package, and two of the core's changes are visible through it. Security fix: the audit middleware drained a request's events with the request's own abort token, so a client that closed the connection, as the rows arrived or the moment they had, cancelled the write that follows the response. The sink threw, the middleware logged it, and the events went with the context: an audited read with nothing written down, for the price of a socket. The drain has a budget of its own now, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast; a sink that overruns it is cancelled, logged and dropped, as a throwing one is. The middleware also drains more events: [DwAudit] now records the audited members a projection the caller never named hands back, and each audited member the rows hand back where no path of the policy names it, one event per path per query, and MaxAuditEvents refuses rather than dropping a record. And a blank grouping key reaches the endpoints as a malformed clause rather than as an exception from inside the sanitizer, so it is answered with a four-hundred like every other malformed clause. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API change in this package. The core release changes what its endpoints report, as it changes any guarded call: /simulate answers with 3.1.0's pipeline, the new caps, the strict tier's refusals and a type's DefaultOrder included, and /health's loadedAt and ageSeconds advance on a poll that confirms the version served, so an idle healthy store no longer reads as stale. One change needs action: DwClaimsAdapter.FromClaims returns an unprepared context, and ApplyPolicy(context) now refuses one with PolicyContextNotPrepared even when no store is configured. Use CreateContextAsync or ToPolicyContextAsync, or pass the context through DwPolicy.PrepareAsync before querying. In this package, the audit middleware's warning for events recorded with no IDwAuditSink registered now names both ways to stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals, the core's new switch that records refused guarded queries as audit events, which the middleware drains like any other. diff --git a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj index 6837aa4..f755c20 100644 --- a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj +++ b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj @@ -34,8 +34,10 @@ Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One new constructor, and no behaviour change to the existing one. EfTokenVault takes a key: new EfTokenVault(Func<DbContext> contexts, byte[] key, bool retireUnkeyed = false). Without a key a row of DwPolicyTokens is keyed by the scope and a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole - phone numbers, national identifiers, card numbers - so a backup, a replica or a dump of the table gives back every value in it, and with them the value behind every token ever issued. Under a key of at least DwToken.MinimumKeyLength, sixteen bytes, held where the table is not, the row is keyed by an HMAC-SHA256 behind the prefix "hmac:", and the table and the key have to be taken together. The existing constructor is unchanged and unkeyed. A value met for the first time under the key is looked up under its unkeyed key too, and a token found there is the one written under the keyed key, so every token already issued is kept, at the cost of one more read for a value new to the store and, in retire mode, one more read per first-met value. The unkeyed row stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the keyed row or found it; a concurrent delete of the same row is not an error. Roll out in two steps: give every instance the key, then turn retireUnkeyed on, because an instance still running without the key mints a new token for a value whose unkeyed row is gone. No schema change and no migration: a keyed key is at most 326 characters against the 512 the Key column already holds. Delete the rows whose Key does not start with hmac: to clear what is left, knowing such a value gets a new token the next time it is met. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the Detail column's forced object as "allowNull": true, only when it is true, so no migration is needed and a rule without it is stored exactly as before. A row whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any row the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. @@ -43,7 +45,7 @@ v3.0.0 — First release. A database-backed policy store for the DynamicWhere.ex THREE TABLES, NOT TWO. Alongside DwPolicyRules and DwPolicyVersion, this release adds DwPolicyTokens and DwPolicyTokenConfiguration — the vault behind MaskStrategy.Tokenize, read by EfTokenVault. A consumer applying the configurations to their own DbContext must apply all three and generate a migration for the new table; a deployment that never tokenizes can leave it out rather than migrating in a table it will never write to. The table holds the scope in the clear and a digest of the value, never the value itself, so it is an index into a secret rather than a searchable copy of the column it protects. Guard it as you would guard that column: reading it turns every token in every result back into what it stands for. -EfTokenVault caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint through the primary key rather than by retrying. Requires DynamicWhere.ex 3.0.0. +Guard that table as you would guard the column it protects, and give the vault a key (3.3.0). EfTokenVault caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint through the primary key rather than by retrying. Requires DynamicWhere.ex 3.0.0. https://doc.dynamicwhere.com git master diff --git a/DynamicWhere.ex.Policies.EntityFrameworkCore/EfTokenVault.cs b/DynamicWhere.ex.Policies.EntityFrameworkCore/EfTokenVault.cs index 690c017..5e1f091 100644 --- a/DynamicWhere.ex.Policies.EntityFrameworkCore/EfTokenVault.cs +++ b/DynamicWhere.ex.Policies.EntityFrameworkCore/EfTokenVault.cs @@ -24,16 +24,20 @@ namespace DynamicWhere.ex.Policies.EntityFrameworkCore; /// path cannot be. /// /// -/// Guard this table as you would guard the column it protects. Reading it turns every token in -/// every result back into the value behind it — that is the trade tokenization makes against -/// hashing, where the secret is a salt in configuration rather than a table you can lock, move and -/// revoke. +/// Guard this table as you would guard the column it protects, and give the vault a key. Without +/// one a row's key is a plain digest of the value, and a tokenized value is nearly always drawn from +/// a space small enough to hash whole, so reading the table turns every token in every result back +/// into the value behind it. With one it is an HMAC, and the table and the key have to be taken +/// together. Either way the trade tokenization makes against hashing stands: the mapping is a table +/// you can lock, move and revoke, rather than an algorithm anyone with the salt can run. /// /// public sealed class EfTokenVault : IDwTokenVault { private readonly Func _contexts; private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly byte[]? _key; + private readonly bool _retireUnkeyed; /// /// Initializes the vault. @@ -46,6 +50,41 @@ public sealed class EfTokenVault : IDwTokenVault public EfTokenVault(Func contexts) => _contexts = contexts ?? throw new ArgumentNullException(nameof(contexts)); + /// + /// Initializes a vault that stores its mappings under a key, so a copy of the table gives no value back. + /// + /// + /// Creates a context whose model carries . Called once + /// per value this vault has not already resolved; the vault disposes what it is given. + /// + /// + /// The vault's secret, at least bytes, held where the table + /// is not: configuration or a secret manager. Every instance sharing the table takes the same one. + /// + /// + /// True to delete a value's unkeyed row the first time this vault meets the value, once its keyed + /// row is there, whether this vault wrote that row or found it. Leave it false until every + /// instance sharing the table has the key: an instance still running without one mints a new + /// token for a value whose unkeyed row is gone. + /// + /// + /// A table that already holds unkeyed rows keeps every token it has issued. A value met for the + /// first time under the key is looked up under its unkeyed key too, and a token found there is the + /// one written under the keyed key, so yesterday's export still lines up with today's. The unkeyed + /// row is left in place until says otherwise, and one for a value + /// never met again stays until an operator removes it: delete the rows whose key does not start + /// with hmac:, knowing that a value whose only row is removed gets a new token the next + /// time it is met. + /// + /// Thrown when or is null. + /// Thrown when is shorter than . + public EfTokenVault(Func contexts, byte[] key, bool retireUnkeyed = false) + : this(contexts) + { + _key = DwToken.RequireKey(key); + _retireUnkeyed = retireUnkeyed; + } + /// How many mappings this instance currently holds in process. /// /// The size of the cache, never the size of the table. A fresh instance reports zero while the @@ -65,7 +104,7 @@ public string GetOrCreate(string scope, string value) throw new ArgumentNullException(nameof(value)); } - string key = DwToken.KeyFor(scope, value); + string key = _key is null ? DwToken.KeyFor(scope, value) : DwToken.KeyFor(scope, value, _key); if (_cache.TryGetValue(key, out string? cached)) { @@ -76,12 +115,24 @@ public string GetOrCreate(string scope, string value) string? stored = Read(context, key); + // Under a key, a value that has an unkeyed row keeps the token that row gave it: the keyed row + // is written with it rather than with a fresh one, so nothing already handed out stops matching. + // The unkeyed row is looked for when there is no keyed one to answer, and, by a vault that + // retires, every time a value is first met, so a row adopted before retiring began goes too. + string? unkeyedKey = _key is null ? null : DwToken.KeyFor(scope, value); + + string? adopted = unkeyedKey is not null && (stored is null || _retireUnkeyed) + ? Read(context, unkeyedKey) + : null; + if (stored is not null) { + Retire(context, adopted is null ? null : unkeyedKey); + return _cache.GetOrAdd(key, stored); } - string minted = DwToken.New(); + string minted = adopted ?? DwToken.New(); context.Set().Add(new DwPolicyTokenRecord { @@ -95,6 +146,8 @@ public string GetOrCreate(string scope, string value) { context.SaveChanges(); + Retire(context, adopted is null ? null : unkeyedKey); + return _cache.GetOrAdd(key, minted); } catch (DbUpdateException) @@ -117,10 +170,42 @@ public string GetOrCreate(string scope, string value) + "matching the ones already handed out."); } + Retire(context, adopted is null ? null : unkeyedKey); + return _cache.GetOrAdd(key, winner); } } + /// + /// Deletes a value's unkeyed row once its keyed one is there to answer for it, when the vault was + /// told every instance can read that one. + /// + /// The context the keyed row was just written or read through. + /// The unkeyed row's key, or null when the value had none. + /// + /// Another instance retiring the same row at the same moment deletes it first, and this delete then + /// touches nothing. That is the row gone, which is what was wanted, so it is not an error. + /// + private void Retire(DbContext context, string? unkeyedKey) + { + if (!_retireUnkeyed || unkeyedKey is null) + { + return; + } + + context.ChangeTracker.Clear(); + context.Set().Remove(new DwPolicyTokenRecord { Key = unkeyedKey }); + + try + { + context.SaveChanges(); + } + catch (DbUpdateConcurrencyException) + { + context.ChangeTracker.Clear(); + } + } + /// Forgets every mapping this instance has cached, without touching the table. /// /// For a test that wants to prove the vault reads what the database holds rather than what it diff --git a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj index 9b1ea88..0edc8b8 100644 --- a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj +++ b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj @@ -34,14 +34,16 @@ - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One new constructor and one fix. RedisPolicyStore.UpsertAsync and DeleteAsync commit conditionally on the rule's owner entry. Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race now gets the InvalidOperationException a failed commit always raised, whose message says another writer moved or removed the same rule in the meantime and to write it again, and should retry. RedisTokenVault takes a key: new RedisTokenVault(IConnectionMultiplexer redis, byte[] key, string prefix = null, bool retireUnkeyed = false). Without a key a field of the token hash is the scope and a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole - phone numbers, national identifiers, card numbers - so a backup, a replica or a dump of the hash gives back every value in it, and with them the value behind every token ever issued. Under a key of at least DwToken.MinimumKeyLength, sixteen bytes, held where the hash is not, the field is an HMAC-SHA256 behind the prefix "hmac:", and the hash and the key have to be taken together. The existing constructor is unchanged and unkeyed. A value met for the first time under the key is looked up under its unkeyed field too, and a token found there is the one written under the keyed field, so every token already issued is kept; both fields are read in one round trip, so a value new to the store costs two round trips instead of one. The unkeyed field stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the keyed field or found it. Roll out in two steps: give every instance the key, then turn retireUnkeyed on, because an instance still running without the key mints a new token for a value whose unkeyed field is gone. One hash holds both kinds of field while a deployment moves between them; HSCAN and delete what does not match hmac:* to clear the rest, knowing such a value gets a new token the next time it is met. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the rule document's forced object as "allowNull": true, only when it is true, so a rule without it is stored exactly as before. A document whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any document the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. v3.0.0 — First release. A Redis-backed policy store for the DynamicWhere.ex field-level policy layer, introduced in DynamicWhere.ex 3.0.0. Holds runtime rules in Redis and invalidates through pub/sub with a poll behind it, because pub/sub is fire-and-forget and the poll is what bounds a dropped message. Passes the same store conformance suite as the in-memory and Entity Framework Core stores. -Also ships RedisTokenVault, the durable store behind MaskStrategy.Tokenize. The mapping lives in one hash under the same prefix as the rules, holding the scope in the clear and a digest of the value rather than the value itself. It caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint with HSETNX rather than by retrying. Guard that hash as you would guard the column it protects: reading it turns every token in every result back into what it stands for. Two applications sharing one Redis want two prefixes, or a value tokenized in one is recognisable in the other. Requires DynamicWhere.ex 3.0.0. +Also ships RedisTokenVault, the durable store behind MaskStrategy.Tokenize. The mapping lives in one hash under the same prefix as the rules, holding the scope in the clear and a digest of the value rather than the value itself. It caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint with HSETNX rather than by retrying. Guard that hash as you would guard the column it protects: reading it turns every token in every result back into what it stands for, unless the vault holds a key (3.3.0). Two applications sharing one Redis want two prefixes, or a value tokenized in one is recognisable in the other. Requires DynamicWhere.ex 3.0.0. https://doc.dynamicwhere.com git master diff --git a/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs b/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs index 0c2a244..5496507 100644 --- a/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs +++ b/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs @@ -168,6 +168,8 @@ public async ValueTask UpsertAsync(PolicyRule rule, CancellationToke ITransaction write = db.CreateTransaction(); + Unmoved(write, field, previous); + List queued = new(); if (previous.HasValue && previous != target) @@ -209,6 +211,8 @@ public async ValueTask DeleteAsync(Guid id, CancellationToken ct) ITransaction write = db.CreateTransaction(); + Unmoved(write, field, owner); + List queued = new(); if (owner.HasValue) @@ -240,13 +244,29 @@ public async ValueTask DeleteAsync(Guid id, CancellationToken ct) /// dropping them turns a failed write into an unobserved exception and a silent success. /// /// + /// + /// Commits only if the rule still lives where it was just read to live. + /// + /// + /// Where a rule lives is read before the transaction that moves or deletes it, and two writers of + /// one rule could both read the same answer. The second then cleaned up after a copy the first had + /// already moved, and left the first's copy behind: a rule applying to a caller nobody wrote it + /// for, with no owner entry left to find it by. The commit is conditional on the owner entry, so + /// the writer that lost the race is told nothing was stored, and writes again. + /// + private void Unmoved(ITransaction write, RedisValue field, RedisValue owner) => + write.AddCondition(owner.HasValue + ? Condition.HashEqual(_keys.Owner, field, owner) + : Condition.HashNotExists(_keys.Owner, field)); + private static async Task CommitAsync(ITransaction write, List queued) { if (!await write.ExecuteAsync().ConfigureAwait(false)) { throw new InvalidOperationException( "The Redis transaction holding this policy write was not committed, so nothing was " + - "stored. Reporting success here would record a rule that is not there."); + "stored. Reporting success here would record a rule that is not there. Another writer " + + "moved or removed the same rule in the meantime; write it again."); } await Task.WhenAll(queued).ConfigureAwait(false); diff --git a/DynamicWhere.ex.Policies.Redis/RedisTokenVault.cs b/DynamicWhere.ex.Policies.Redis/RedisTokenVault.cs index a4190a1..fc617f8 100644 --- a/DynamicWhere.ex.Policies.Redis/RedisTokenVault.cs +++ b/DynamicWhere.ex.Policies.Redis/RedisTokenVault.cs @@ -22,9 +22,12 @@ namespace DynamicWhere.ex.Policies.Redis; /// application-wide resource, and this is one of its users. /// /// -/// Guard this hash as you would guard the column it protects. Reading it turns every token in every -/// result back into the value behind it, which is the trade tokenization makes against hashing: the -/// secret is a store you can lock, move and revoke, rather than a salt sitting in configuration. +/// Guard this hash as you would guard the column it protects, and give the vault a key. Without one +/// a field is a plain digest of the value, and a tokenized value is nearly always drawn from a space +/// small enough to hash whole, so reading the hash turns every token in every result back into the +/// value behind it. With one a field is an HMAC, and the hash and the key have to be taken together. +/// Either way the trade tokenization makes against hashing stands: the mapping is a store you can +/// lock, move and revoke, rather than an algorithm anyone with the salt can run. /// /// public sealed class RedisTokenVault : IDwTokenVault @@ -32,6 +35,8 @@ public sealed class RedisTokenVault : IDwTokenVault private readonly IConnectionMultiplexer _redis; private readonly RedisPolicyKeys _keys; private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly byte[]? _key; + private readonly bool _retireUnkeyed; /// /// Initializes the vault. @@ -49,6 +54,44 @@ public RedisTokenVault(IConnectionMultiplexer redis, string? prefix = null) _keys = new RedisPolicyKeys(prefix); } + /// + /// Initializes a vault that stores its mappings under a key, so a copy of the hash gives no value back. + /// + /// A connected multiplexer. Not owned, and not disposed. + /// + /// The vault's secret, at least bytes, held where the hash + /// is not: configuration or a secret manager. Every instance sharing the hash takes the same one. + /// + /// The prefix the hash shares with this application's policy keys, or null for dw:policy. + /// + /// True to delete a value's unkeyed mapping the first time this vault meets the value, once its + /// keyed one is there, whether this vault wrote it or found it. Leave it false until every + /// instance sharing the hash has the key: an instance still running without one mints a new + /// token for a value whose unkeyed mapping is gone. + /// + /// + /// Without a key a field of the hash is a plain digest of the value, and a tokenized column is + /// nearly always drawn from a space small enough to hash whole, so a dump of the hash is a dump of + /// the column. Under a key it is an HMAC, and the hash and the key have to be taken together. + /// + /// A vault that already holds unkeyed mappings keeps every token it has issued. A value met for + /// the first time under the key is looked up under its unkeyed field too, and a token found there + /// is the one written under the keyed field, so yesterday's export still lines up with today's. + /// The unkeyed field is left in place until says otherwise, and + /// one for a value never met again stays until an operator removes it: + /// HSCAN the hash and delete what does not match hmac:*, knowing that a value whose + /// only mapping is removed gets a new token the next time it is met. + /// + /// + /// Thrown when or is null. + /// Thrown when is shorter than . + public RedisTokenVault(IConnectionMultiplexer redis, byte[] key, string? prefix = null, bool retireUnkeyed = false) + : this(redis, prefix) + { + _key = DwToken.RequireKey(key); + _retireUnkeyed = retireUnkeyed; + } + /// How many mappings this instance currently holds in process. /// /// The size of the cache, never the size of the vault. A fresh instance reports zero while @@ -69,7 +112,7 @@ public string GetOrCreate(string scope, string value) throw new ArgumentNullException(nameof(value)); } - string field = DwToken.KeyFor(scope, value); + string field = _key is null ? DwToken.KeyFor(scope, value) : DwToken.KeyFor(scope, value, _key); if (_cache.TryGetValue(field, out string? cached)) { @@ -78,6 +121,11 @@ public string GetOrCreate(string scope, string value) IDatabase db = _redis.GetDatabase(); + if (_key is not null) + { + return _cache.GetOrAdd(field, Keyed(db, scope, value, field)); + } + // Written before it is read, with NotExists, so two instances minting a token for the same // value at the same moment cannot both win. The loser's HashSet returns false and it reads // back whatever the winner stored, so both callers see one token. Reading first and writing @@ -108,6 +156,63 @@ public string GetOrCreate(string scope, string value) return _cache.GetOrAdd(field, existing!); } + /// + /// Resolves a value under the vault's key, adopting the token an unkeyed mapping already gave it. + /// + /// + /// Both fields are read in one round trip. A keyed mapping answers on its own. Without one, the + /// token to write is the unkeyed mapping's where there is one, so a value keeps the token it has + /// always had, and a fresh one otherwise; it is written with NotExists for the reason the + /// unkeyed path gives, and the loser of that race reads the winner's. + /// + private string Keyed(IDatabase db, string scope, string value, string field) + { + string unkeyedField = DwToken.KeyFor(scope, value); + + RedisValue[] held = db.HashGet(_keys.Tokens, new RedisValue[] { field, unkeyedField }); + + RedisValue unkeyed = held[1]; + string token; + + if (!held[0].IsNullOrEmpty) + { + token = held[0]!; + } + else + { + string candidate = unkeyed.IsNullOrEmpty ? DwToken.New() : (string)unkeyed!; + + if (db.HashSet(_keys.Tokens, field, candidate, When.NotExists)) + { + token = candidate; + } + else + { + RedisValue winner = db.HashGet(_keys.Tokens, field); + + if (winner.IsNullOrEmpty) + { + throw new InvalidOperationException( + $"The token for a value in scope '{scope}' was written and then could not be read " + + $"back from '{_keys.Tokens}'. Something is deleting from the token hash while " + + "queries are running; a token that is reissued is a column whose values stop " + + "matching the ones already handed out."); + } + + token = winner!; + } + } + + // Only once the keyed mapping is there to answer for it, and only when told every instance + // can read that one. + if (_retireUnkeyed && !unkeyed.IsNullOrEmpty) + { + db.HashDelete(_keys.Tokens, unkeyedField); + } + + return token; + } + /// Forgets every mapping this instance has cached, without touching Redis. /// /// For a test that wants to prove the vault reads what Redis holds rather than what it diff --git a/DynamicWhere.ex/Classes/Complex/Filter.cs b/DynamicWhere.ex/Classes/Complex/Filter.cs index 682c8dd..5480b3a 100644 --- a/DynamicWhere.ex/Classes/Complex/Filter.cs +++ b/DynamicWhere.ex/Classes/Complex/Filter.cs @@ -31,16 +31,37 @@ public class Filter /// Returns a deep copy, so a guarded query can be canonicalized and rewritten without the /// caller's own filter changing underneath them. /// + /// + /// A new filter. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// + /// Public because callers need the same thing the library needs: a request read a second time + /// with one part changed — the next page, another order — without editing the object a caller + /// handed in. Rebuilding a filter around the caller's own clauses shares those clauses, and a + /// later rewrite of either one then reaches both. + /// + /// The copy reaches every branch: the condition tree with its groups and conditions, the + /// projection list, each order, and the page. + /// + /// /// A null branch stays null rather than becoming an empty collection. The gate decides whether /// to synthesize a projection by testing for null, so the distinction /// carries meaning. + /// + /// + /// A null entry inside a list stays a null entry, here and on and + /// . A copy copies what is there: the request is malformed, and it is + /// for the method that runs it to refuse it, which it does with a LogicException. + /// /// - internal Filter Clone() => new() + public Filter Clone() => new() { ConditionGroup = ConditionGroup?.Clone(), Selects = Selects is null ? null : new List(Selects), - Orders = Orders?.ConvertAll(o => o.Clone()), + Orders = Orders?.ConvertAll(o => o?.Clone()!), Page = Page?.Clone() }; } diff --git a/DynamicWhere.ex/Classes/Complex/Segment.cs b/DynamicWhere.ex/Classes/Complex/Segment.cs index 1e2306f..ff2e8e8 100644 --- a/DynamicWhere.ex/Classes/Complex/Segment.cs +++ b/DynamicWhere.ex/Classes/Complex/Segment.cs @@ -30,15 +30,25 @@ public class Segment /// /// Returns a deep copy, cloning every condition set independently. /// + /// + /// A new segment. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// /// Policy applies to each set on its own, so the sets must not share a condition group: a /// rewrite aimed at one would otherwise land on all of them. + /// + /// Public for the same reason is: read a caller's request again with + /// one part changed, without editing what the caller handed in. + /// /// - internal Segment Clone() => new() + public Segment Clone() => new() { - ConditionSets = ConditionSets is null ? null! : ConditionSets.ConvertAll(s => s.Clone()), + ConditionSets = ConditionSets is null ? null! : ConditionSets.ConvertAll(s => s?.Clone()!), Selects = Selects is null ? null : new List(Selects), - Orders = Orders?.ConvertAll(o => o.Clone()), + Orders = Orders?.ConvertAll(o => o?.Clone()!), Page = Page?.Clone() }; } diff --git a/DynamicWhere.ex/Classes/Complex/Summary.cs b/DynamicWhere.ex/Classes/Complex/Summary.cs index f519741..e24c8a5 100644 --- a/DynamicWhere.ex/Classes/Complex/Summary.cs +++ b/DynamicWhere.ex/Classes/Complex/Summary.cs @@ -36,17 +36,27 @@ public class Summary /// /// Returns a deep copy. /// + /// + /// A new summary. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// /// A summary reaches a condition group twice — once through and /// again through . Both are cloned; a copy shaped like a filter's would /// leave the having clause shared with the caller. + /// + /// Public for the same reason is: read a caller's request again with + /// one part changed, without editing what the caller handed in. + /// /// - internal Summary Clone() => new() + public Summary Clone() => new() { ConditionGroup = ConditionGroup?.Clone(), GroupBy = GroupBy?.Clone(), Having = Having?.Clone(), - Orders = Orders?.ConvertAll(o => o.Clone()), + Orders = Orders?.ConvertAll(o => o?.Clone()!), Page = Page?.Clone() }; } diff --git a/DynamicWhere.ex/Classes/Core/ConditionGroup.cs b/DynamicWhere.ex/Classes/Core/ConditionGroup.cs index 60a12c5..0508fa0 100644 --- a/DynamicWhere.ex/Classes/Core/ConditionGroup.cs +++ b/DynamicWhere.ex/Classes/Core/ConditionGroup.cs @@ -38,9 +38,9 @@ public class ConditionGroup { Sort = Sort, Connector = Connector, - Conditions = Conditions is null ? null! : Conditions.ConvertAll(c => c.Clone()), + Conditions = Conditions is null ? null! : Conditions.ConvertAll(c => c?.Clone()!), SubConditionGroups = SubConditionGroups is null ? null! - : SubConditionGroups.ConvertAll(g => g.Clone()) + : SubConditionGroups.ConvertAll(g => g?.Clone()!) }; } diff --git a/DynamicWhere.ex/Classes/Core/GroupBy.cs b/DynamicWhere.ex/Classes/Core/GroupBy.cs index 7e26c4c..de23858 100644 --- a/DynamicWhere.ex/Classes/Core/GroupBy.cs +++ b/DynamicWhere.ex/Classes/Core/GroupBy.cs @@ -21,6 +21,6 @@ public class GroupBy internal GroupBy Clone() => new() { Fields = Fields is null ? null! : new List(Fields), - AggregateBy = AggregateBy is null ? null! : AggregateBy.ConvertAll(a => a.Clone()) + AggregateBy = AggregateBy is null ? null! : AggregateBy.ConvertAll(a => a?.Clone()!) }; } diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index b09bbbd..64f7e89 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1,6 +1,6 @@ # DynamicWhere.ex -**Version:** 3.2.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) +**Version:** 3.3.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) > A powerful and versatile library for dynamically creating complex filter, sort, paginate, group, aggregate, and set-operation expressions in Entity Framework Core applications — all driven by simple JSON objects from any front-end or API consumer. @@ -31,7 +31,7 @@ ## Installation ```bash -dotnet add package DynamicWhere.ex --version 3.2.0 +dotnet add package DynamicWhere.ex --version 3.3.0 ``` **Dependencies:** @@ -39,10 +39,13 @@ dotnet add package DynamicWhere.ex --version 3.2.0 |---------|---------| | `Microsoft.EntityFrameworkCore` | 6.0.22 | | `System.Linq.Dynamic.Core` | 1.6.7 | +| `Microsoft.Extensions.Caching.Memory` | 6.0.2 | | `Microsoft.Extensions.Configuration.Abstractions` | 6.0.0 | | `Microsoft.Extensions.Configuration.Binder` | 6.0.0 | | `Microsoft.Extensions.DependencyInjection.Abstractions` | 6.0.0 | +`Microsoft.Extensions.Caching.Memory` is named for its patched version (3.3.0) and is not used by the library directly: EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to CVE-2024-43483 (GHSA-qj66-m88j-hmgj), so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. Naming 6.0.2 raises that floor for all four; a host on EF Core 8 or later already resolves a newer one and sees no change. + The library parses every expression it builds with its own `ParsingConfig` — the parser's defaults with `AreContextKeywordsEnabled = false` — and does not read `ParsingConfig.Default`. See breaking point 15. **Companion packages** (optional, only for [field-level policies](#field-level-policies)): @@ -107,7 +110,7 @@ Specifies the logical data type of a condition value. The library uses this to c |-------|-------------|---------------------| | `Text` | String data | All text operators including case-insensitive variants (`I*`), `In`, `IsNull` | | `Guid` | GUID as string | `Equal`, `NotEqual`, `In`, `NotIn`, `IsNull`, `IsNotNull` | -| `Number` | Numeric value (byte → decimal) | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `In`, `NotIn`, `IsNull`, `IsNotNull` | +| `Number` | Numeric value (byte → decimal). The value is read as the expression parser reads it, in the invariant culture, and has to compare with the member (3.3.0) — see [Condition Validation Rules](#condition-validation-rules) | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `In`, `NotIn`, `IsNull`, `IsNotNull` | | `Boolean` | `true` / `false` | `Equal`, `NotEqual`, `IsNull`, `IsNotNull` | | `DateTime` | Full timestamp. Works on `DateTime` and `DateTimeOffset` members, nullable or not | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `IsNull`, `IsNotNull` | | `Date` | Calendar day, compared on both sides | Same as `DateTime` (compares the day only) | @@ -264,6 +267,7 @@ A single filter predicate. `Values` is `List` so the front-end can send heterogeneous JSON shapes without quoting every primitive: + ```json { "Field": "Price", @@ -296,6 +300,10 @@ The library normalizes every element before validation/build: **Backward compatibility:** callers previously sending `["abc"]` (quoted strings) keep working unchanged — strings deserialize into the `List` as string elements. C# callers that previously used `Values = new List {...}` must switch to `new List {...}` (or `.Cast().ToList()`). +A value is read once to validate its format and again to build the predicate, so pass values that do not change: one whose `ToString()` answers differently each time is validated as one value and queried as another. Anything decoded from JSON is such a value already. No policy decision reads a value's content — only how many there are — so nothing a guard decides rests on which read won. + +**A number is read as the expression parser reads it (3.3.0).** The builder writes a `Number` value into the generated expression unquoted, exactly as sent, so validation reads it the same way rather than through the host's culture. First the parser's grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no `NaN` and no `Infinity`; an integer must fit `UInt64`, or `Int64` when negative, while a real has no bound. Then, in a `Where` condition and for the operators that write the value into a comparison, whether that literal compares with the member the condition names — the parser itself is asked, against the member's declared type, so `1.5` is refused on an `int?` but not on an `int`, an exponent form on a `decimal`, an integer above `Int64.MaxValue` on a signed integral member, a negative number on a `ulong`, any number on a `string`, `bool`, `Guid`, `DateTime` or `char` member or on a collection of simple values, and a nullable enum under an ordering operator. A `Having` condition reads the grammar and stops, since an alias has no member type to ask about. Everything refused is `InvalidFormat`, the same in both policy tiers, and nothing that ran before is refused now. JavaScript writes `0.0000001` as `1e-7`, which a `decimal` member refuses; send it as the string `"0.0000001"`. See breaking point 38. + --- #### `ConditionGroup` @@ -382,6 +390,8 @@ Combines filtering, selecting, ordering, and pagination in a single object. | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. + --- #### `Segment` @@ -395,6 +405,8 @@ Combines multiple condition sets with set operations (Union / Intersect / Except | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. + --- #### `Summary` @@ -409,6 +421,8 @@ Combines filtering → grouping → having → ordering → pagination for aggre | `Orders` | `List?` | Sort on grouped result. Fields must be GroupBy fields or aggregate aliases | | `Page` | `PageBy?` | Optional pagination on grouped result | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. + --- ### Result Classes @@ -471,6 +485,7 @@ Projects only the specified fields into a new instance of `T`. Supports direct p **Validations:** - `query` and `fields` cannot be null. - `fields` must have at least one entry. +- No entry may be null or blank — `InvalidField` since 3.3.0, where it used to be an `ArgumentNullException` from the name lookup. - Every field must exist on `T` (case-insensitive, auto-normalized). - `T` must have a parameterless constructor. @@ -506,6 +521,7 @@ Multiple dotted fields sharing the same root segment are merged into the same ne **Validations:** - `query` and `fields` cannot be null. - `fields` must have at least one entry. +- No entry may be null or blank — `InvalidField` since 3.3.0, where it used to be an `ArgumentNullException` from the name lookup. - Every field must exist on `T` (case-insensitive, auto-normalized). **Returns:** `IQueryable` — a dynamic projected query where each element is an anonymous object. @@ -548,7 +564,7 @@ Applies a group of conditions joined by `And` / `Or`, with optional nested sub-g ### `.Group(GroupBy groupBy)` -Groups the query by the specified fields and applies aggregations. +Groups the query by the specified fields and applies aggregations. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). | Parameter | Type | Description | |-----------|------|-------------| @@ -572,7 +588,7 @@ Sorts the query by one or multiple criteria. - `Field` must be non-empty and valid on `T`. - `Field` may not end on a collection of entities/complex types (there is no single value to compare). -**Collection paths:** when `Field` crosses a collection navigation, the collection is reduced to one comparable value — the **smallest** element ascending, the **largest** descending. See [Ordering Across Collections](#14-ordering-across-collections). +**Collection paths:** when `Field` crosses a collection navigation, the collection is reduced to one comparable value — the **smallest** element ascending, the **largest** descending. See [Ordering Across Collections](#13-ordering-across-collections). **Returns:** `IQueryable` — ordered query. @@ -590,6 +606,8 @@ Paginates the query. - `PageNumber` must be > 0. - `PageSize` must be > 0. +The offset, `(PageNumber - 1) * PageSize`, is worked out in 64 bits and held to `int.MaxValue` (3.3.0), here and in the three summary methods. In 32 bits the product wrapped for a large enough page number: a negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. A page past the last row is an empty page however far past it is. The core sets no upper bound on either value; the policy layer caps `PageSize` through `MaxPageSize` and never `PageNumber`. + **Returns:** `IQueryable` — paged query. --- @@ -667,7 +685,7 @@ Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count an ### `.Summary(Summary summary)` -Applies where → group → having → order → page to a query. +Applies where → group → having → order → page to a query. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `IQueryable` — dynamic grouped query. @@ -675,7 +693,7 @@ Applies where → group → having → order → page to a query. ### `.ToList(Summary summary, bool getQueryString = false)` -Materializes a `Summary` and returns a `SummaryResult`. +Materializes a `Summary` and returns a `SummaryResult`. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped from the result; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `SummaryResult` @@ -683,7 +701,7 @@ Materializes a `Summary` and returns a `SummaryResult`. ### `.ToList(IEnumerable, Summary summary, bool getQueryString = false)` -In-memory variant for summary operations. +In-memory variant for summary operations. `ApplyPolicy` takes an `IEnumerable` too, and a summary read through it is floored like any other: groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `SummaryResult` @@ -691,7 +709,7 @@ In-memory variant for summary operations. ### `.ToListAsync(Summary summary, bool getQueryString = false)` -Async version of `ToList(Summary)`. On an EF Core query it counts the groups with EF Core's `CountAsync()` and reads them with EF Core's `ToListAsync()`. Until 3.2.0 the count ran synchronously and the read went through Dynamic LINQ's `ToDynamicListAsync()`, which had no token to pass on; on an EF Core query a canceled token now reaches the database. The count and the rows are the same. A source whose provider is not EF Core's, such as rows in memory through `AsQueryable()`, keeps the synchronous count and Dynamic LINQ's read, on the calling thread. +Async version of `ToList(Summary)`, and floored the same way: under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). On an EF Core query it counts the groups with EF Core's `CountAsync()` and reads them with EF Core's `ToListAsync()`. Until 3.2.0 the count ran synchronously and the read went through Dynamic LINQ's `ToDynamicListAsync()`, which had no token to pass on; on an EF Core query a canceled token now reaches the database. The count and the rows are the same. A source whose provider is not EF Core's, such as rows in memory through `AsQueryable()`, keeps the synchronous count and Dynamic LINQ's read, on the calling thread. Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count and the read: `.ToListAsync(Summary summary, CancellationToken cancellationToken)` and `.ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken)`. See [Cancellation](#cancellation). @@ -740,6 +758,8 @@ app.MapPost("/customers/search", async (Filter filter, AppDbContext db, Cancella ## Validation Rules +**Before any of these (3.3.0).** Every method that takes a shape walks its lists for a null entry first, with or without a policy, in both tiers, sync and async: the composables `Where(ConditionGroup)`, `Order(List)`, `Select`, `SelectDynamic`, `Group` and `Summary`, and every terminal for a `Filter`, a `Segment` and a `Summary`. `Filter` and `FilterDynamic` compose `Where`, `Order` and `Select`, so each list is walked as its clause is reached. Under `ApplyPolicy` the walk runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A null entry in `Conditions`, `SubConditionGroups`, `ConditionSets`, `Orders` or `AggregateBy` is `NullEntry(list)`; a `Selects` entry that is null or blank is `InvalidField`. A list that is itself null still means what it meant — most readers read it as empty. Before 3.3.0 a null entry surfaced as a `NullReferenceException` or an `ArgumentNullException` from inside the library. See breaking point 39. + ### Condition Validation Rules | Rule | Error Code | @@ -752,7 +772,7 @@ app.MapPost("/customers/search", async (Filter filter, AppDbContext db, Cancella | All other operators require exactly 1 value | `RequiredOneValue({Operator})` | | A null or blank value is **not** refused as such: it normalizes to `""`, which `Text` and `Enum` accept and every other DataType rejects on parsing | `InvalidFormat` — `ErrorCode.InvalidValue` exists but is never thrown | | `Guid` values must parse as `Guid` | `InvalidFormat` | -| `Number` values must parse as a numeric type | `InvalidFormat` | +| `Number` values must be a literal the expression parser reads — invariant, no thousands separator, no leading plus, no `NaN` — and, in a `Where` condition, one it can compare with the member the condition names (3.3.0) | `InvalidFormat` | | `Boolean` values must parse as `bool` | `InvalidFormat` | | `Date` / `DateTime` values must be ISO 8601, year-first, or a declared format | `InvalidFormat`, or `AmbiguousDateFormat` for a day/month-first date | @@ -1046,6 +1066,8 @@ The entire `Brands` collection is bound as-is. ### 6. `Group` — GroupBy with Aggregations +> Guarded, this is floored as a summary is: `DwCaps.MinGroupSize` defaults to **5**, and a smaller group is dropped rather than refused. + ```json { "fields": ["Category"], @@ -1121,6 +1143,8 @@ The entire `Brands` collection is bound as-is. ### 8. `Summary` / `ToList(Summary)` / `ToListAsync(Summary)` — Group + Aggregate + Having +> **A guarded summary drops small groups by default.** `DwCaps.MinGroupSize` ships **on, at 5**, so a group with fewer than five rows is removed from the result — not refused, and nothing in the answer says a group was dropped. That is right for anonymised reporting and surprising for an operational count, where five is a real number of orders. Set `Caps.MinGroupSize = 1` to switch the floor off, deliberately. An unguarded summary is never floored. See [k-anonymity](#k-anonymity--the-control-you-would-not-guess). + ```json { "conditionGroup": { @@ -1274,11 +1298,7 @@ The entire `Brands` collection is bound as-is. --- -### 10. Nested Collection Navigation - ---- - -### 11. `SelectDynamic` — Dynamic Field Projection +### 10. `SelectDynamic` — Dynamic Field Projection **Direct scalars:** ```json @@ -1394,7 +1414,7 @@ The entire `Brands` collection is bound as-is. --- -### 12. `FilterDynamic` / `ToListDynamic(Filter)` / `ToListAsyncDynamic(Filter)` — Full Dynamic Filter +### 11. `FilterDynamic` / `ToListDynamic(Filter)` / `ToListAsyncDynamic(Filter)` — Full Dynamic Filter Uses the same `Filter` JSON shape as example 7. The difference is the return type: `IQueryable` / `FilterResult` instead of `IQueryable` / `FilterResult`. @@ -1449,7 +1469,7 @@ Uses the same `Filter` JSON shape as example 7. The difference is the return typ --- -### 13. Nested Collection Navigation +### 12. Nested Collection Navigation When a field path traverses a collection property (e.g., `Orders.OrderItems.ProductName`), the library automatically wraps the inner segment in a `.Any()` lambda. @@ -1467,7 +1487,7 @@ When a field path traverses a collection property (e.g., `Orders.OrderItems.Prod --- -### 14. Ordering Across Collections +### 13. Ordering Across Collections A sort needs a single comparable value per row, so `.Any()` is not applicable to `OrderBy`. When an order field path crosses a collection property, each collection segment is reduced with an aggregate instead: **`Min` when sorting ascending, `Max` when sorting descending** — that is, rows are ordered by their *best matching* element in the requested direction. @@ -1519,7 +1539,8 @@ caller request → ApplyPolicy(ctx) → sanitize → existing engine → transfo ``` ```csharp -// Once, at startup. Refused on a second call: the tier is read by every request thread. +// At startup. A second call asking for the same posture does nothing; a different one is +// refused, because the tier is read by every request thread. See "Configuring twice". DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, @@ -1555,7 +1576,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella | `[DwDenied]` | member | Refuse all six | | `[DwNoWhere]` `[DwNoSelect]` `[DwNoOrder]` `[DwNoGroup]` `[DwNoAggregate]` | member | Refuse one feature each | | `[DwOperators(Allow =, Deny =)]` | member | Restrict which operators may target the member | -| `[DwAlias("name")]` | member | A public name, accepted anywhere a field path is, renamed back on output | +| `[DwAlias("name")]` | member | A public name, accepted anywhere a field path is, renamed back on output. A name spelled like another member of the same type is reported by `ValidateModel` (3.3.0): a generated row cannot carry one name twice, so the rename is not applied there and both columns keep their own names | | `[DwForceWhere(op, Value =, ContextValue =, AllowNull =)]` | member | A predicate ANDed into every guarded query. `AllowNull = true` lets rows whose member is null through as well — see [A forced predicate that lets null through](#a-forced-predicate-that-lets-null-through) | | `[DwRequireWhere(Operators =)]` | member | The caller must filter on this member | | `[DwMask(strategy)]` | member | `Full` `Partial` `Email` `Phone` `Regex` `Fixed` `Hash` `Null` `Tokenize` | @@ -1571,6 +1592,13 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella All six transform attributes also carry `AllowAggregate` and `MinGroupSize`. Every attribute except `[DwEntity]` derives from `DwPolicyAttribute` and so carries `Overridable`, which defaults to **false**. It decides nothing on `[DwOperators]`, whose lists are intersected, or on `[DwForceWhere]`, whose predicates are collected — no rule can widen either, with or without the flag. +Only public instance properties are read, through navigations and collection elements, up to paths of four segments. Two kinds of path lie outside that walk, and since 3.3.0 both are policed: + +- **Beneath a member whose type the framework declares.** `Salary.Value` and `Salary.HasValue` on a `decimal?`, `Secret.Length` on a `string`, `Born.Year` on a `DateTime`, `Bag.Count` on a dictionary, `Lines.Count` on an application's own collection class — the collection's own member, not an element's. No attribute can be placed there, so such a path takes every fragment of the member it reads, whichever provider supplied it: the deny effects per feature, the `[DwOperators]` restriction (intersected), the `[DwCost]` weight and the audited features. Not what is said to the caller about the member — the alias, the required filter (a filter on `TenantId.Value` does not satisfy a `[DwRequireWhere]` on `TenantId`), the forced scope, and the descriptive facts. A rule naming the sub-path itself still applies alongside. One feature is one feature: `[DwNoWhere] Born` refuses `WHERE Born.Year` and still allows `GROUP BY Born.Year`, and a member nothing denies is read beneath as before, so `Name.Length` still runs. Where the member is transformed there is no member beneath it to apply the chain to, so `Select`, `Group` and `Aggregate` on the path are refused. A member only a **subtype** of the navigated type declares is not such a path: it is decided by the fragments naming it, so a grant of `Zone` under a `"*"` deny does not grant what a subtype of Zone's type declares. A navigation into an application's own type is still a separate field, because its members can be decorated: a denial on `Contact` leaves `Contact.Email` open. +- **Past the walk's depth.** `Caps.MaxNavigationDepth` defaults to 4, the depth the walk reads to, and a host may raise it; a request naming five or more segments then reached what no fragment covered. The attributes of the member at the end of such a path are read directly now — the deny family, `[DwOperators]`, the transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and allowed values — by any resolver that reads attributes, which every resolver `DwPolicy.Configure` builds does. What is declared about the queried entity itself is left out there, as it is around a cycle: `[DwAlias]`, `[DwRequireWhere]`, `[DwForceWhere]`. A transformed member there is still a member, so `Selects` naming it returns it transformed; only a grouping key and an aggregated field are refused, because a summary's own transform finds a generated row's columns by the type's list, which stops at four segments. + +`[DwForceWhere]`, `[DwRequireWhere]` and `[DwAlias]` are left out around a cycle, on a type reached from itself, and apply on every other path within four segments. The walk used to return at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the same walk and the three were dropped from a shorter path reaching it directly — which of two members was declared first decided whether a tenant scope applied. Fixed in 3.3.0, so a query that ran unscoped is scoped and a required filter may now be demanded. + ### Precedence Six levels, lowest number wins: @@ -1661,6 +1689,9 @@ A dropped field leaves nothing behind in the data, so the trace is the only way - A name that matches nothing on the type is no longer refused as `LogicException` `ConditionMustHasValidFieldName` while the request is read. It is gated as a field denied for every feature, at the step a denial is raised — after the caps — so it receives the refusal a `[DwDenied]` field receives in that clause: `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`. A name padded with dots or blank segments, such as `NoSuchColumn....` or `. . . . X`, is normalized the way a real path is, so it is refused as a padded real field is rather than by `MaxNavigationDepth`. - Inside a segment every field refusal is `FieldDeniedForSegment`, with `Feature` `Segment`, whichever clause refused it — a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say `FieldDeniedForOrder` where a name that matches nothing says `FieldDeniedForSegment`. Filters and summaries keep their per-clause codes. - Every refusal carrying one of those six codes has `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field — a real denied field and an alias included — so its message is the same too. +- A blank name fails a guarded query as it fails an unguarded one, in every clause. A blank grouping key used to reach the resolver and fail with `ArgumentNullException`, which is neither a refusal nor the malformed-clause failure an endpoint turns into a four-hundred (3.3.0). +- `MaxNavigationDepth` counts the canonical path, so a name the caller wrote as one token — an alias — is refused as an unknown name is rather than with the cap's own code, which would say the token named something several navigations deep (3.3.0). A caller who wrote the path themselves meets the cap, as every over-long request does. +- Four more refusals name the clause rather than a field (3.3.0): an ambiguous name is refused as an unknown name is, and `AmbiguousGroupKey`, `TransformRequiresMaterialization`, `MissingHashSalt` and `MissingTokenVault` carry `FieldPath` `"*"`. All but the transform refusal drop their `SourceOrigin` too; that one's origin names a method rather than a field. - A `CapExceeded` refusal names no path either. `MaxNavigationDepth` used to return the canonical spelling of the path the caller wrote, which confirmed that it named something. `SourceOrigin` still names the cap. - `MissingContextValue` has `FieldPath` `"*"` and a null `SourceOrigin`, so it names neither the scope's column nor the context key it reads, which together describe how rows are partitioned. The trace keeps both, and an audited refusal records the scoped field. - `MaxQueryCost` is checked after every field has passed its gate. A field weighted by `[DwCost]` that the caller may not use is refused as denied before its weight counts, exactly as a name that matches nothing is, so the budget cannot tell the two apart. An allowed weighted field is still refused with `QueryCostExceeded`. @@ -1668,6 +1699,51 @@ A dropped field leaves nothing behind in the data, so the trace is the only way The convenience tier is unchanged: an unknown name fails validation with `LogicException` `ConditionMustHasValidFieldName`, a refusal names the field as the caller wrote it, with `RuleId` and `SourceOrigin` where a single source decided, and `MaxQueryCost` is checked before any field is gated. A dry run refuses no field, so an unknown name fails validation there in either tier. +**A member the query cannot compute is refused the same way** *(3.3.0)*. A member of the row's type is not always a value a database can produce. A shared type such as + +```csharp +public sealed class LocalizedText +{ + public string Ar { get; set; } = ""; + public string En { get; set; } = ""; + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); +} +``` + +gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause the database has to compute: a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. + +**`Selects` is not one of them.** A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so `Selects = ["Id", "Name.IsEmpty"]` returns the computed value exactly as it did before. Refusing it would take back a projection that has always worked. + +It is refused only where the whole set of members a container can produce is known: + +| Source | Read from | `Name.IsEmpty` | +|---|---|---| +| An entity | the EF Core model: columns, shadow properties, owned and complex members, navigations | Refused | +| A row a `Select` built before `ApplyPolicy` | the initializer's own assignments, at every level, both branches of a conditional included | Refused | +| …where the initializer assigns the member from something else: a method call, a captured value, a subquery, or two branches building it two ways | nothing — the assignment is not one this shape reads | Left alone, as it always did | +| …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused | +| Rows in memory | nothing — the getter runs | Runs, as it always did | +| Anything beneath a column, a converted one included | nothing — the converter decides | Left alone, as it always did | +| A framework member such as `Length`, `Year` or `HasValue` | nothing — the provider translates it | Runs, as it always did | +| A source the library cannot read | nothing | Left alone, as it always did | +| A provider in front of EF Core: an expression expander, a decompiler | nothing — it rewrites what EF Core cannot translate | Left alone, as it always did | +| A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused | +| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Left alone, as it always did | + +A shadow property is a separate matter and unchanged: a field path names CLR members, and a shadow property has none, so no clause can name one — guarded or not, before this release or after it. Map it to a property, or project it with `EF.Property` and name the projected member. + +A member assigned through a sequence operator — `Lines = o.Lines.ToList()`, `o.Lines.Where(...).ToList()`, or a subquery building rows of its own — is left alone: what the row holds is not always what the navigation holds, and reading it as the navigation would refuse a member the row carries. A projection that does not build its rows with an object initializer is left alone whole: an anonymous type, and a constructor with arguments, say nothing about which member each value sets, so no member of such a row is refused here and none is claimed. A projection is otherwise read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. + +**Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. + +An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. + +The rule is EF Core's own provider's — that exact type, from EF Core's own assembly. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike. A provider of any other type is left alone for the same reason turned around: the library cannot tell one that rewrites from one that passes straight through, and refusing on that guess would take back a query the rewriting host answers today. That covers a host registering its own provider through EF Core's `ReplaceService`, whose queries are read as another provider's and never refused here, however plain the provider is. A rewrite *inside* EF Core's own pipeline is a different matter: a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. + +A refusal here raises no `[DwAudit]` event, for the same reason an unknown name raises none: no field was read, and the refusal names none. `AuditRefusals` records it, and so does the trace. + +The rule is the model's: a member it maps nowhere is one the database cannot compute. A member some provider extension computes without a mapping is refused with the rest, so map it, or filter on the columns beneath it. + ### A navigation named in Selects A `Selects` entry can name a navigation, such as `"Lines"`, rather than the fields beneath it. With nothing denied beneath it, the entry is kept as written. With a denied field beneath it, the `Convenience` tier replaces the entry with the allowed fields beneath it, and the `Strict` tier refuses it with `FieldDeniedForSelect`. @@ -1774,7 +1850,9 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded`. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. That middleware does not drain with the request's abort token: it has a budget of its own, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast (3.3.0). Until then a client that closed the connection, as the rows arrived or the moment they had, cancelled the write that follows the response — the sink threw, the middleware logged it, and the events went with the context, an audited read with nothing written down for the price of a socket. A path beneath a member whose type the framework declares is audited as that member is (3.3.0): reading `Salary.Value` records what reading `Salary` records, where it used to record nothing. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. What it hands back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a value the source does not carry records nothing either. A dry run applies no projection, so everything the row carries is recorded there, a denied member included, with the effect the policy decided. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. An audited member the rows hand back where no path of the policy names it is recorded once the rows show it (3.3.0). The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about: handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down. The outbound walk's second pass reports each one it meets and the terminal records it — one `DwAuditEvent` per path per query, not per row, `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise, `EntityType` the queried type's full name, and `FieldPath` the path through the rows, such as `B.C.D.E.Five`, or `Hidden` for a subtype's member at the root. Only a member its own `[DwAudit]` audits for `Select`, and only where the projection carries it, since a member the projection left out is not a read. A member the declared types hold within four segments is the gate's and is left to it, and so is a path the projection spells out however long it is, so neither is recorded twice. It is recorded in a dry run too, read only by a resolver that reads attributes, and a model that declares neither an audit for `Select` nor a transform anywhere pays for no second pass. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld: under `Strict` outside a dry run the clause's own refusal with `FieldPath` `"*"` — `FieldDeniedForSegment` inside a segment — and `CapExceeded` otherwise, whose `SourceOrigin` names the cap and the undrained buffer. + +A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: @@ -1796,6 +1874,8 @@ It is off by default because it changes what reaches a sink: a deployment that r An optional store supplies rules at runtime. `InMemoryPolicyStore` ships in the core package; Redis and EF Core are separate packages, and all three pass one shared conformance suite. +`RedisPolicyStore.UpsertAsync` and `DeleteAsync` commit conditionally on the rule's owner entry (3.3.0). Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race gets the `InvalidOperationException` a failed commit always raised — its message ends *Another writer moved or removed the same rule in the meantime; write it again* — and should write again. + ```csharp var store = new EfPolicyStore(() => new DwPolicyDbContext(options)); var provider = await StorePolicyProvider.CreateAsync(store, policyOptions); @@ -1850,8 +1930,8 @@ new DwPolicyOptions |---|---|---| | Output | 64 hex characters (HMAC-SHA256) | 32 hex characters (16 random bytes) | | Derived from the value | yes | no | -| Reversed by | holding the salt | reading the vault | -| A weak secret | brute-forced offline | does not exist | +| Reversed by | holding the salt | reading the vault, and its key where it has one | +| A weak secret | brute-forced offline | only a vault key under 16 bytes, which is refused | | Survives a restart | always | only with a durable vault | | Discloses equality | yes | yes | @@ -1861,6 +1941,49 @@ process, which is right for a test and wrong for any column compared across rest mapping it resolves — a token is written once and never rewritten, so a cached answer cannot go stale. +**Give a durable vault a key (3.3.0).** A vault stores its mapping under the scope and a digest of +the value. Without a key that digest is a plain SHA-256, and a tokenized column is nearly always +drawn from a space small enough to hash whole — phone numbers, national identifiers, card numbers. +So a copy of the store, a backup or a replica or a dump, gives back every value in it, and with them +the value behind every token ever issued. Under a key held where the store is not, in configuration +or a secret manager, the digest is an HMAC-SHA256 and the store and the key have to be taken +together. Guard the store as you would guard the column it protects either way. + +```csharp +new DwPolicyOptions +{ + TokenVault = new RedisTokenVault(redis, key) // 16 bytes or more + // TokenVault = new EfTokenVault(() => new AppDbContext(opts), key) +} +``` + +The constructors that take no key are unchanged and unkeyed, and so is `DwToken.KeyFor(scope, +value)`. `DwToken.KeyFor(scope, value, key)` writes `hmac:{scope}:{64 lowercase hex}`, an HMAC-SHA256 +under the key over the scope, one zero byte and the value, so one value tokenized in two scopes +shares no digest; `DwToken.RequireKey` refuses a key that is null or shorter than +`DwToken.MinimumKeyLength` (16) and returns a copy of it, and `DwToken.KeyedPrefix` is the `hmac:` +an operator can tell the two kinds of key apart by. `InMemoryTokenVault` draws a random 32-byte key +of its own per instance — nothing to configure, no API change — since its mappings die with the +process anyway. + +**Adoption keeps every token already issued.** A keyed vault meeting a value with no keyed mapping +looks up the unkeyed mapping too, and the token found there is the one written under the keyed key, +so yesterday's export still lines up with today's. The unkeyed mapping stays until `retireUnkeyed` +is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the +keyed mapping or found it. **Roll out in two steps: give every instance the key, then turn +`retireUnkeyed` on.** An instance still running without the key mints a *new* token for a value +whose unkeyed mapping is gone, and a value first met while keyed and unkeyed instances run side by +side can end up with two tokens. Unkeyed mappings of values never met again stay until an operator +deletes them — `HSCAN` the Redis token hash and delete the fields that do not match `hmac:*`, or +delete the rows of `DwPolicyTokens` whose `Key` does not start with `hmac:` — knowing such a value +gets a new token the next time it is met. Changing the key re-issues every token, unless unkeyed +mappings remain to adopt from. + +The cost is small and there is no schema change. Redis reads both fields in one round trip, so a +value new to the store costs two round trips instead of one; EF Core costs one more read for a new +value, and in retire mode one more read per first-met value. A keyed key is at most 326 characters +against the 512 the `Key` column already holds. + Tokens are namespaced by the field's own path, so two columns holding the same value get different tokens. Name a shared `TokenScope` when you want them to match: @@ -1873,6 +1996,10 @@ public string NationalId { get; set; } that value's output and can then recognise it in every other row. That is inherent in preserving equality and no setting removes it. A field that cannot accept it wants `Fixed`, `Null`, or a denial. +**A value no path of the policy names.** The outbound walk transforms along the paths the policy names — the declared types, four segments deep — and a value can sit in the materialized rows where none of them goes: a `[DwMask]` member five segments down an included or in-memory graph, one only a subtype of the row's type declares (`Dog.Chip` on rows typed `Animal`, in memory or in a TPH hierarchy), one on an object a dictionary holds, and the far side of a cycle. Each came back exactly as stored, at the default caps, under `Strict`, with no `Selects`, with the navigation named whole in `Selects`, and in a dynamic projection holding a real object. Fixed (security) in 3.3.0: the rows are walked by run-time type as well, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once — an object reached both ways is not transformed twice. Only members that declare a transform or an audit for `Select`, or that can lead to one, are read, so a navigation whose type can reach neither is never touched and a lazy loader behind it is not woken, and a model that declares neither anywhere pays for no second pass. The same pass reports each audited member it meets where the policy names no path to it, which the terminal records as a read (see [Auditing](#auditing)). The transform is the member's own attributes: no rule can speak to such a member, since no path names it, and a resolver built over no `AttributePolicyProvider` reads no attribute here either. It obeys `Selects` as the first pass does, runs in a dry run as transforms always have, and fails the query with `InvalidOperationException` for a transformed member with no setter. The trace records the path with its stages and the note `(declared on the member; no path of the policy names it)`. A member typed `object`, or a collection that is not generic, still says nothing about what it holds and is not read into. + +**A query the caller runs.** `SelectDynamic`, `Group`, `FilterDynamic` and `Summary` on the guarded handle hand back a query the library never sees materialized, so they are refused with `TransformRequiresMaterialization` on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them — on a member only a subtype declares, one five segments down, one of an object a dictionary holds — got the query and its rows exactly as stored. Since 3.3.0 the refusal asks what a row of the type can hold as well, any transform attribute anywhere in what the type can reach, which only a resolver that reads attributes is asked; with no named column to list it names the clause, `FieldPath` `"*"`, in both tiers, where under `Convenience` it otherwise lists the transformed columns. A type nothing transforms anywhere still gets its query. Materialize through `ToListDynamic` or `ToList(Summary)`, or leave the policy deliberately with `AsUnguardedQueryable()`. + ### k-anonymity — the control you would not guess `SUM`, `MAX` and `MIN` execute **in SQL against the stored value**, before any transform applies. `GROUP BY Department` with `MAX(Salary)` over a department of one returns that person's exact salary. @@ -1897,6 +2024,8 @@ new DwPolicyOptions { Caps = { MinGroupSize = 1 } } // no floor, and meant new DwPolicyOptions { Caps = { MinGroupSize = 10 } } // stricter ``` +The floor appends its own `Count` aggregate under the reserved alias `__dwGroupSize`, and refuses a summary that already uses that name with `GroupTooSmall`. The walk over `Having` that looks for it reads a null `Conditions` or `SubConditionGroups` as an empty list (3.3.0): a request body sending `"conditions": null` or `"subConditionGroups": null` overwrites the list's initializer, and such a summary used to fail guarded with a `NullReferenceException` wherever the floor is on, which is the default, though the same summary ran unguarded. It runs, and the floor still applies. + ### Configuration | Cap | Default | Meaning | @@ -1909,7 +2038,7 @@ new DwPolicyOptions { Caps = { MinGroupSize = 10 } } // stricter | `MaxConditionValues` | 1000 | Values in any one condition. An `In` or `NotIn` is one comparison per value, so one condition could build a predicate of any size while spending one condition and one field. The condition carrying the most values is compared: a filter's conditions, a summary's conditions and its `Having`, every set of a segment. New in 3.1.0; see breaking point 19 | | `MaxAggregates` | 50 | `AggregateBy` entries in one summary, through the summary terminals and the composable `Group` and `Summary`. The count the group-size floor adds for itself is not counted. New in 3.1.0; see breaking point 19 | | `MaxOrderFields` | 10 | Order fields in one query | -| `MaxNavigationDepth` | 4 | How deep a field path may reach | +| `MaxNavigationDepth` | 4 | How deep a field path may reach. Also the depth the attribute walk reads to: raised above 4, a request can name a path no fragment of that walk reached, and the member at the end of it is read for its own attributes (3.3.0) | | `MaxQueryCost` | 1000 | Budget consumed by `[DwCost]` weights | | `DefaultFieldCost` | 1 | Charged for an unweighted field, and since 3.1.0 for an aggregate with no field, such as a `Count` | | `MaxAuditEvents` | 10000 | Audit buffer before draining | @@ -1936,7 +2065,7 @@ Options are frozen at startup. Every cap refuses a value below one, except two t | `POST` | `/simulate` | The sanitized clause, without executing or auditing | | `GET` | `/health` | Snapshot version, age, degraded state, last error | -A simulation, through `/simulate` or `PolicySimulator`, has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no `Selects`: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. See [A request that sends no Selects](#a-request-that-sends-no-selects). +A simulation, through `/simulate` or `PolicySimulator`, has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no `Selects`: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. For the same reason it cannot refuse a path no database can compute: that refusal is read from the model behind the source, which a simulation does not have, so a simulation shows such a request running where the strict query refuses it. See [A request that sends no Selects](#a-request-that-sends-no-selects). ### Schema discovery @@ -2032,6 +2161,44 @@ deliberate choice. secrets, an environment variable or a vault. A salt committed to `appsettings.json` is not a salt, and nothing here can tell the difference. +#### Configuring twice + +*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure` **asking for the posture +already in force does nothing and returns**; one asking for a different posture still throws +`InvalidOperationException`. A second `AddDwPolicies` binds and builds its options as ever, changes +no posture, and registers the one in force. The comparison happens inside the lock +that does the configuring, so a caller needs no lock and no `IsConfigured` check of its own — which +matters because that check is a check-then-act two hosts starting at once can both pass. + +This is what an integration suite needs. Several `WebApplicationFactory` hosts run the same +composition root, and before 3.3.0 the second one threw, so every such suite wrote the check itself +and re-registered `DwPolicy.Options` by hand. + +What counts as the same posture: + +| Compared | Not compared | +|---|---| +| `Tier`, `DryRun`, `AuditRefusals`, and `IncludeTraceInResult` by the value that applies | `TokenVault` | +| `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval` | `Services` | +| Every value on `Caps`, the floor that applies rather than whether it was written down | The provider *instances* | +| The exposed entity catalogue: the same types, every name each answers to, and the name each is reported under | | +| The provider *types*, in the order they were supplied | | + +`IncludeTraceInResult` is compared the way the group floor is: it defaults to the tier's own answer, and the +tiers are equal by then, so a host writing that answer out and a host leaving it null hand a caller +the same result. A type exposed under two names is a different matter — it is reported under the last +name it was given, so two catalogues that resolve every name alike still answer a schema request +differently, and the second posture is refused. + +The three on the right are objects a host builds for itself, and a second host builds its own, so +comparing them by reference would make every second call a refusal. They stay as the first call left +them: **a second host runs with the first host's vault, container and rule stores.** In one test +process that is what you want. Start a second host in production only if it is. + +`AddDwPolicies` registers the posture in force rather than the instance it has just built, so +whatever resolves `DwPolicyOptions` reads what the query path reads. The options handed to a second +call are frozen too, so nothing goes on setting values that decide nothing. + ### Performance There are two budgets, because there are two costs. Gating is paid **once per query**; @@ -2085,7 +2252,7 @@ DynamicWhere.ex caches all reflection lookups (property metadata, property paths | Component | Responsibility | |-----------|---------------| -| `CacheReflection` | Core reflection operations with caching | +| `CacheReflection` | Core reflection operations with caching. A lookup takes no lock and a hit allocates nothing (3.3.0): the configuration in force is read with one volatile read, where every lookup used to lock and copy it. `GetCacheConfigOptions()` still returns a copy | | `CacheDatabase` | Thread-safe `ConcurrentDictionary` stores & access tracking | | `CacheEviction` | FIFO / LRU / LFU eviction algorithms | | `CacheReporting` | Statistics, memory usage, performance reports | @@ -2204,7 +2371,8 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of | `ConditionsUniqueSort` | `AnyListOfConditionsMustHasUniqueSortValue` | Duplicate Sort in Conditions | | `SubConditionsGroupsUniqueSort` | `AnyListOfSubConditionsGroupsMustHasUniqueSortValue` | Duplicate Sort in SubConditionGroups | | `RequiredIntersection` | `ConditionsSetOfIndex[1-N]MustHasIntersection` | Missing Intersection on set index 1+ | -| `InvalidField` | `ConditionMustHasValidFieldName` | Empty or invalid field name. Under `ApplyPolicy` in the strict tier, outside a dry run, a name that matches nothing is refused as a `PolicyException` instead, like a denied field — see [Blocked-action semantics](#blocked-action-semantics) | +| `InvalidField` | `ConditionMustHasValidFieldName` | Empty or invalid field name, and since 3.3.0 a `Selects` entry that is null or blank, where it used to be an `ArgumentNullException` from the name lookup. Under `ApplyPolicy` in the strict tier, outside a dry run, a name that matches nothing is refused as a `PolicyException` instead, like a denied field — see [Blocked-action semantics](#blocked-action-semantics) | +| `NullEntry(list)` | `ListOf[{list}]MustNotHasNullEntry` | A list of the request shape holds a null entry — `Conditions`, `SubConditionGroups`, `ConditionSets`, `Orders` or `AggregateBy`, spelled as the shape declares it. New in 3.3.0: such an entry used to surface as a `NullReferenceException` from wherever it was first touched | | `StartsWithReservedName(path)` | `FieldPath[{path}]StartsWithReservedName` | A field path whose first segment is one of the expression parser's own words — `new`, `iif`, `np`, `isnull`, `is`, `as`, `cast`, `true`, `false`, `null`, whatever the letter case. Raised for every clause that takes a path, and for a `[DwAlias]` target. `LogicException.Subject` carries that first segment, trimmed. A `DefaultOrder` entry naming one is skipped like an unreadable entry, and reported by the startup scan. 3.1.0 | | `InvalidValue` | `ConditionValuesAreNullOrWhiteSpace` | Defined and never thrown. A null value normalizes to `""` and is judged by the DataType like any other string | | `RequiredValues` | `ConditionWithOperator[In-IIn-NotIn-INotIn]MustHasOneOrMoreValues` | In/NotIn with 0 values | @@ -2214,7 +2382,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of | `InvalidPageNumber` | `PageNumberMustBeGreaterThanZero` | PageNumber ≤ 0 | | `InvalidPageSize` | `PageSizeMustBeGreaterThanZero` | PageSize ≤ 0 | | `MustHaveFields` | `MustHasFields` | Empty fields list in Select | -| `InvalidFormat` | `InvalidFormat` | Value doesn't parse for declared DataType. For a date: not ISO 8601, year-first, or a declared format | +| `InvalidFormat` | `InvalidFormat` | Value doesn't parse for declared DataType. For a number (3.3.0): not a literal the expression parser reads, or not one it can compare with the member the condition names. For a date: not ISO 8601, year-first, or a declared format | | `AmbiguousDateFormat` | `AmbiguousDateFormat` | A date value that leads with a day or a month (`01/09/2026`) and matches no declared format, or one two accepted formats read differently. `LogicException.Subject` carries the field: its path, and under `ApplyPolicy` the name the caller wrote | | `SelectTypeMustHaveParameterlessConstructor` | `SelectTypeMustHaveParameterlessConstructor` | `Select` or `Filter.Selects` on a `T` the projection cannot construct. `LogicException.Subject` carries the type's name, `typeof(T).Name` | | `InvalidAlias` | `AggregationMustHasValidAlias` | Alias is not a plain identifier — empty, or carrying a dot, comma, space, or dash | @@ -2238,6 +2406,8 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of ### ⚠️ Breaking Points +These are numbered as this document numbers them. The website's [breaking-changes page](https://doc.dynamicwhere.com/docs/breaking-changes) carries the same points with its own numbering, which runs further, so follow a point by its title rather than by its number. + 1. **Parameterless Constructor Required for Select Projection** `Select(fields)` requires `T` to have a parameterless (default) constructor. If `T` does not have one — a positional record, most often — a `LogicException` is thrown whose `Message` is the stable code `SelectTypeMustHaveParameterlessConstructor` and whose `Subject` carries `typeof(T).Name`. Before 3.1.0 that message was an English sentence with the type name inside it. Most EF Core entity classes have parameterless constructors by default. A guarded query reaches the same refusal when a member carries `[DwNoSelect]`, because deny-select projects — since 3.2.0 whatever the member holds, and beneath another member when its value can reach the result (point 20). @@ -2269,6 +2439,8 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of Fixed in 3.1.0: validating a field path recorded its access for eviction before the path was validated. A path that fails adds no cache entry for eviction to remove, so under `LRU` (the default) or `LFU` every distinct invalid name a caller sent kept its record for the life of the process, and a caller sending unique invented names grew the process without limit. A path is now tracked only once it has validated. + Changed in 3.3.0: under `LRU` a read refreshes the entry's last-access time once it is a second old rather than on every read. Eviction only asks which entries are oldest, and an entry read a moment ago is already among the newest; writing the time on every read put every thread reading the same few entries into one queue. `LFU` still counts every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2,697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. + 10. **`getQueryString` Parameter Requires EF Core Provider** Passing `getQueryString: true` to `ToList` / `ToListAsync` calls `.ToQueryString()`, which needs an active EF Core database provider to produce SQL. On an in-memory `IEnumerable` it does not fail: `QueryString` holds a placeholder sentence where the SQL would be. @@ -2308,7 +2480,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of Before 3.1.0 every guarded terminal put its `PolicyTrace` on `FilterResult.Policy`, `SummaryResult.Policy` or `SegmentResult.Policy`, in both tiers. The trace names the fields a policy dropped, the attribute or rule that sealed each one, and every injected predicate — the detail the strict tier already refuses through `getQueryString` — and an API that serializes its result sends all of it to the caller. Under `DwTier.Strict`, `Policy` is now null unless `DwPolicyOptions.IncludeTraceInResult` is `true`; under `Convenience` it is still carried unless the option is `false`. `PolicyQueryable.LastTrace` still holds the trace, so a strict deployment that read `result.Policy` reads `LastTrace` instead, or sets `IncludeTraceInResult = true`. See [Results and the trace](#results-and-the-trace). 18. **Under the Strict Tier an Unknown Field and a Denied Field Answer Alike** - Before 3.1.0 a guarded query refused a field name matching nothing on the type with `LogicException` `ConditionMustHasValidFieldName`, and a denied field with a `PolicyException` carrying its path and, where one source decided, its `RuleId` and `SourceOrigin`. The two answers let a caller list the columns they may not see, one guess at a time. Under `DwTier.Strict`, outside a dry run, an unknown name is now gated as a field denied for every feature and receives the refusal a `[DwDenied]` field receives in that clause — `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`, and `FieldDeniedForSegment` anywhere in a segment — after the caps. Every refusal with one of those six codes carries `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field, and a `CapExceeded` refusal names no path either. The same tier closes the other ways to tell them apart: inside a segment every field refusal is `FieldDeniedForSegment`; a name padded with dots is normalized as a real path is; `MaxQueryCost` is checked after every field gate, so a `[DwCost]` weight cannot set a hidden field apart from a missing one; and `MissingContextValue` carries `FieldPath` `"*"` and no `SourceOrigin`. Code that caught `ConditionMustHasValidFieldName` from a strict guarded query, matched a clause's code inside a segment, or read `FieldPath`, `RuleId` or `SourceOrigin` off a strict refusal, reads `PolicyQueryable.LastTrace` instead, which keeps the real path and reason, or records refusals with `DwPolicyOptions.AuditRefusals`. The convenience tier and dry runs are unchanged. See [Blocked-action semantics](#blocked-action-semantics). + Before 3.1.0 a guarded query refused a field name matching nothing on the type with `LogicException` `ConditionMustHasValidFieldName`, and a denied field with a `PolicyException` carrying its path and, where one source decided, its `RuleId` and `SourceOrigin`. The two answers let a caller list the columns they may not see, one guess at a time. Under `DwTier.Strict`, outside a dry run, an unknown name is now gated as a field denied for every feature and receives the refusal a `[DwDenied]` field receives in that clause — `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`, and `FieldDeniedForSegment` anywhere in a segment — after the caps. Every refusal with one of those six codes carries `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field, and a `CapExceeded` refusal names no path either; since 3.3.0 the audit cap does not answer with that code under this tier at all, because only a real, audited field can reach it. The same tier closes the other ways to tell them apart: inside a segment every field refusal is `FieldDeniedForSegment`; a name padded with dots is normalized as a real path is; `MaxQueryCost` is checked after every field gate, so a `[DwCost]` weight cannot set a hidden field apart from a missing one; and `MissingContextValue` carries `FieldPath` `"*"` and no `SourceOrigin`. Code that caught `ConditionMustHasValidFieldName` from a strict guarded query, matched a clause's code inside a segment, or read `FieldPath`, `RuleId` or `SourceOrigin` off a strict refusal, reads `PolicyQueryable.LastTrace` instead, which keeps the real path and reason, or records refusals with `DwPolicyOptions.AuditRefusals`. The convenience tier and dry runs are unchanged. See [Blocked-action semantics](#blocked-action-semantics). 19. **`MaxConditionValues` and `MaxAggregates` Refuse Guarded Requests 3.0 Ran** Two more caps new in 3.1.0. `DwCaps.MaxConditionValues` (default 1000) bounds the values one condition carries — the largest condition of the where clause, a summary's `Having` and every segment set is the one compared — because an `In` is one comparison per value and so could build a predicate of any size for the price of one condition and one field. `DwCaps.MaxAggregates` (default 50) bounds the `AggregateBy` entries of one summary, through the summary terminals and the composable `Group` and `Summary`; the group-size floor's own count is not counted. A guarded request over either is refused in both tiers with `PolicyException` `CapExceeded`, `FieldPath` `"*"` and `SourceOrigin` `"MaxConditionValues cap (1000), request had 1001"` or `"MaxAggregates cap (50), request had 51"`, unless the deployment raises the cap. Both refuse a value below 1, freeze with the posture, and bind from `Caps:MaxConditionValues` and `Caps:MaxAggregates`. An aggregate with no field, such as a `Count`, is now charged `DefaultFieldCost` toward `MaxQueryCost`, where it cost nothing, so a summary that sat just under its budget can be refused with `QueryCostExceeded`. Every count cap is now checked before any field name is resolved, so an oversized request that also names a field that does not exist is refused with `CapExceeded`, where 3.0.0 resolved names first and answered `ConditionMustHasValidFieldName`. Only `ApplyPolicy` enforces them: an unguarded query is not affected. @@ -2344,6 +2516,60 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of 24. **A Type in a Namespace That Starts with `System` Is Policed** The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with `System` for the framework's, so an application namespace such as `SystemsCorp.Payroll` or `SystemX.Domain` got no policy beneath its types, and a `[DwDenied]` field on such a type, reached through a member, was returned, filterable and sortable. Fixed (security): only `System` and the namespaces beneath it are the framework's now, so a guarded request that filtered on, sorted by or selected such a field is refused or dropped, as for any denied field. +25. **Under `Strict`, a Path the Query Cannot Compute Is Refused** + Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, a query a provider in front of EF Core translates — an expression expander, a decompiler — the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through a member translator plugin or a replaced query preprocessor, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). + +26. **`LastTrace` Is Set Before a Request Is Sanitized** + Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. + +27. **Four More Refusals Name the Clause Under `Strict`** + A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type — masked, generalized, truncated or formatted — to a caller who named none of them, and now names the clause while keeping its origin, which names the method and what to call instead rather than any field. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now report `"*"` with no origin. All four are unchanged under `Convenience` and in a dry run, whichever switch declares it — the posture's or the caller's — where the tier names fields anyway. The refusal audit still records the real field: `AuditRefusals` writes the path the refusal was about, as it does for every refusal whose caller-facing path is `"*"`. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. + +28. **`[DwAudit]` Records a Read the Request Did Not Name** + A request that sends no `Selects` receives the row, and until 3.3.0 only a field it spelled out was recorded — so that caller read every audited member with nothing written down, one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is now recorded for `Select`: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field `[DwAudit]` names. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents`, which refuses rather than dropping a record, can be reached by traffic that did not reach it before: raise the cap, or drain per request with `app.UseDwPolicyAudit()`. + +29. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** + An audited field records one event per use, and the query is refused rather than the record dropped when `DwCaps.MaxAuditEvents` is reached. Until 3.3.0 that refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so the difference told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin. The request still fails, so the buffer still fails closed, and the trace still records which refusal it was. `Convenience` and a dry run still answer `CapExceeded`. Code switching on `CapExceeded` under `Strict` sees the change. + +30. **`Configure` Takes the Same Posture Twice** + Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). + + +31. **A Path Beneath a Framework-Typed Member Takes That Member's Policy** + The attribute walk descends into an application's own types and nowhere else, so no attribute can be placed beneath a member the framework declares the type of — `Salary.Value` and `Salary.HasValue` on a `decimal?`, `Secret.Length` on a `string`, `Born.Year` or `Born.Date.Year` on a `DateTime`, `Bag.Count` on a dictionary, `Lines.Count` on an application's own collection class. The pipeline validates each and the provider translates each, and no fragment named them, so they resolved as allowed. Fixed (security) in 3.3.0, in both tiers: until then a `[DwDenied] decimal?` was filtered on, sorted by, grouped by with its values as the group keys, aggregated as `MAX(Salary.Value)` and handed back by a dynamic projection under `Strict`; a transformed member gave its stored value the same way, an audited one was read with nothing recorded, a weighted one cost the default, and an operator restriction did not hold. Such a path now takes every fragment of the member it reads, whichever provider supplied it: the deny effects per feature, the `[DwOperators]` restriction (intersected), the `[DwCost]` weight and the audited features — never the alias, the required filter, the forced scope or the descriptive facts, which are about the member itself. A rule naming the sub-path still applies alongside. One feature is one feature: `[DwNoWhere] Born` refuses `WHERE Born.Year` and still allows `GROUP BY Born.Year`, and `Name.Length` on an undenied member still runs. Where the member is transformed, `Select`, `Group` and `Aggregate` on the path are refused, because there is no member beneath it to apply the chain to. A member only a subtype of the navigated type declares is not such a path. See [Attribute reference](#attribute-reference). + +32. **A Transformed Member No Path Reaches Is Transformed** + The outbound walk transforms along the paths the policy names — the declared types, four segments deep — and a value can sit in the materialized rows where none of them goes: a `[DwMask]` member five segments down an included or in-memory graph, one only a subtype of the row's type declares, one on an object a dictionary holds, one on the far side of a cycle. Each came back exactly as stored, in default configuration, at the default caps, under `Strict`. Fixed (security) in 3.3.0: the rows are walked by run-time type as well, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once. Results that used to carry stored values now carry transformed ones, and a transformed member with no setter there now fails the query with `InvalidOperationException`, as one along a named path always has — give the member a setter, or project into a type that has one. Only members that declare a transform or can lead to one are read, so a model with no transform attribute anywhere pays nothing and a navigation whose type can reach no transform is never touched. See [Hiding a value you still want to group by](#hiding-a-value-you-still-want-to-group-by). + +33. **A Forced Scope on a Type First Met at the Depth Limit Applies** + `[DwForceWhere]`, `[DwRequireWhere]` and `[DwAlias]` are left out around a cycle, where they are meaningless on a type reached from itself. The attribute walk returned at its depth limit with the type still marked as being inside it, so a type *first* met at the fourth segment read as a cycle wherever it was met again in the same walk, and all three were dropped from a shorter path reaching that type directly — which of two members was declared first decided whether a forced tenant scope applied. Fixed (security) in 3.3.0: the three apply on every path within four segments that is not around a cycle, as the documentation always said. A query that ran unscoped is now scoped and returns fewer rows, a `[DwRequireWhere]` that was never demanded may now be demanded with `RequiredFilterMissing`, and a member reachable only by its real path now also answers to its alias. + +34. **Paths Past Four Segments When `MaxNavigationDepth` Is Raised** + `Caps.MaxNavigationDepth` defaults to 4, the depth the attribute walk reads to, and a host may raise it. A request could then name a path of five or more segments that no attribute fragment reached, so a `[DwDenied]` member at segment five was filtered on, grouped by and returned under `Strict`. Default configuration was never exposed to this one. Since 3.3.0 the attributes of the member at the end of such a path are read directly — the deny family, `[DwOperators]`, the transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and allowed values — by any resolver that reads attributes, which every resolver `DwPolicy.Configure` builds does. What is declared about the queried entity itself is not read there, as it is not around a cycle: `[DwAlias]`, `[DwRequireWhere]`, `[DwForceWhere]`. A transformed member there is still a member, so `Selects` naming it returns it transformed, in a typed projection and in a generated row alike; only a grouping key and an aggregated field are refused, with `FieldDeniedForGroup` and `FieldDeniedForAggregate`, because a summary's own transform finds a generated row's columns by the type's list and that list stops at four segments. Filtering and ordering run on the stored value, as at any depth. + +35. **A Page Number Whose Offset Passes `Int32` Is an Empty Page** + The offset a page skips, `(PageNumber - 1) * PageSize`, was worked out in 32 bits, and for a large enough page number the product wrapped: a negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. Since 3.3.0 it is worked out in 64 bits and held to `int.MaxValue`, in `Page` and in the three summary methods, guarded or not, and a page past the last row is an empty page however far past it is. The policy layer caps `PageSize` through `MaxPageSize` and never `PageNumber`, so a guarded query took the same path. Code that read the five-hundred as the signal for an out-of-range page now gets an empty page. + +36. **A Query You Run Yourself Is Refused Where Only an Unnamed Member Is Transformed** + `SelectDynamic`, `Group`, `FilterDynamic` and `Summary` on the guarded handle hand back a query the library never sees materialized, so they are refused with `TransformRequiresMaterialization` on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them — on a member only a subtype declares, one five segments down, one of an object a dictionary holds — got the query, and its rows exactly as stored: the same gap point 32 closed for the terminals, one method call away from them. Fixed (security) in 3.3.0: the refusal asks what a row of the type can hold as well, which only a resolver that reads attributes is asked, and with no named column to list it names the clause — `FieldPath` `"*"` in both tiers, where under `Convenience` it otherwise lists the transformed columns. The origin, which names the method and what to call instead, is unchanged. A type nothing transforms anywhere still gets its query; a caller that composed one of the four on such a type materializes through `ToListDynamic` or `ToList(Summary)`, or leaves the policy deliberately with `AsUnguardedQueryable()`. + +37. **`[DwAudit]` Records a Member No Path Names** + Point 28 closed the read a request did not spell out; this closes the read the policy has no path for at all. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so, handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down. In a probe with four audited members, two were recorded. Fixed (security) in 3.3.0, in default configuration and both tiers: the outbound walk's second pass reports each audited member it meets where no path names it, and the terminal records it — one `DwAuditEvent` per path per query, not per row, `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise, and `FieldPath` the path through the rows. Only a member its own `[DwAudit]` audits for `Select`, and only where the projection carries it; a member the declared types hold within four segments is the gate's, and so is a path the projection spells out however long it is, so neither is recorded twice. Recorded in a dry run too, and read only by a resolver that reads attributes. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld: under `Strict` outside a dry run the clause's own refusal with `FieldPath` `"*"`, `FieldDeniedForSegment` inside a segment, and `CapExceeded` otherwise. A deployment already running the control sees more events for such models, and the cap can be reached by traffic that did not reach it before: raise it, or drain per request with `app.UseDwPolicyAudit()`. + +38. **A `Number` Value Is Read the Way the Expression Parser Reads It** + The predicate builder writes a `DataType.Number` value into the generated expression unquoted, exactly as sent, and validation checked it with `byte`/`short`/`int`/`long`/`float`/`double`/`decimal` `TryParse` in the host's culture. The two disagreed. `"1,000"`, `"5-"`, `"+5"`, `".5"`, `"5."`, `"-.5"`, `"1.e5"`, `"NaN"`, `"Infinity"`, `"-Infinity"` and an integer past `UInt64` — or below `Int64` when negative — all passed validation and then threw `System.Linq.Dynamic.Core.Exceptions.ParseException` when the query was built, which a host maps to a server error; `"1,5"` passed on a German host and was refused on an English one; and `"NaN"` and `"Infinity"` were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns instead of filtering. + + A value is read in two steps since 3.3.0. First the parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no `NaN` and no `Infinity`; an integer must fit `UInt64`, or `Int64` when negative, while a real has no bound, so `1e400` still reads as infinity. A suffix (`5L`, `5m`), hex and `- 5` are refused as they always were, though the parser would read them: nothing is accepted now that was not accepted before. Then, in a `Where` condition and for the operators that write the value into a comparison — `Equal`, `NotEqual`, `In`, `NotIn`, the four orderings, `Between` and `NotBetween` — the literal has to compare with the member the condition names, which the parser itself is asked, against the member's declared type. Refused there: a literal written with a point and no exponent (`1.5`) on a **nullable** integral member, where a non-nullable `int` still takes it; an exponent form (`1e5`, `1E-7`) on a `decimal` or `decimal?`, and a real with more digits than a `decimal` holds; an integer above `Int64.MaxValue` on a signed integral member, since such a literal reads as a `ulong` which none of them converts to; a negative number on a `ulong` or `ulong?`; any number on a `string`, `bool`, `Guid`, `DateTime` or `char` member, or on a collection of simple values such as `List`; and a nullable enum under an ordering operator, where equality still works. A `Having` condition reads the grammar and stops, since an alias has no member type to ask about. + + Every refusal is a `LogicException` with `InvalidFormat`, the same in both policy tiers, where a denied field is still refused by the gate before any value is read. Nothing that ran before is refused now: every value refused is one the parser refused. **Who is affected:** an endpoint that mapped `ParseException` to a five-hundred now gets a `LogicException` and a four-hundred, which is what it always should have been, and a client sending a locale-formatted number is refused on every host instead of working on some. A number a C# caller puts in `Values` is still written in the invariant culture and is unaffected, except that `double.NaN` is now `InvalidFormat`. JavaScript's `JSON.stringify(0.0000001)` is `1e-7`, which a `decimal` member refuses; send `"0.0000001"`. + +39. **A `null` Entry in a Request's List Is a Malformed Request** + A request body can say `"conditions": [null]`, `"subConditionGroups": [null]`, `"conditionSets": [null]`, `"orders": [null]`, `"aggregateBy": [null]` or `"selects": [null]`. Nothing read a list expecting that, so the null surfaced wherever it was first touched: a `NullReferenceException` from the sort-order check, from the ordering, or — under a policy — from inside the copy the sanitizer takes before it reads anything; and an `ArgumentNullException` for a null aggregate (parameter `"aggregate"`), a null summary order (parameter `"order"`) and, from the name lookup, a null or blank `Selects` entry (parameter `"name"`). A host maps those to a server error, for a request that was simply malformed. + + Since 3.3.0 each is a `LogicException`: `ListOf[Conditions]MustNotHasNullEntry`, `ListOf[SubConditionGroups]MustNotHasNullEntry`, `ListOf[ConditionSets]MustNotHasNullEntry`, `ListOf[Orders]MustNotHasNullEntry` and `ListOf[AggregateBy]MustNotHasNullEntry`. A `Selects` entry that is null **or** blank — empty or white space — is `ConditionMustHasValidFieldName`, the refusal a null or blank `GroupBy.Fields` entry has always had. The walk runs in every method that takes a shape, before anything else reads the lists, with or without a policy, in both tiers, sync and async; under `ApplyPolicy` it runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A list that is itself null still means what it meant, a `ConditionSet` whose `ConditionGroup` is null is still an `ArgumentNullException` as is a null `Summary.GroupBy`, and a null element inside `Condition.Values` still reads as the empty string. `Filter.Clone()`, `Segment.Clone()` and `Summary.Clone()` copy a null entry as a null entry instead of throwing, so the refusal belongs to the method that runs the request. + + **Who is affected:** any endpoint binding a request body it does not validate itself. Such a body used to produce a five-hundred and now produces a `LogicException`, which middleware written for this library already maps to a four-hundred. Code matching on `NullReferenceException`, or on the `ArgumentNullException` parameter names `"name"`, `"order"` or `"aggregate"`, to detect this needs updating. + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 80debba..854a47f 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -10,6 +10,13 @@ + + +