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