#nullable enable using System; using System.Linq; using GameCult.Caching; using MessagePack; using NUnit.Framework; namespace GameCult.Networking.Tests { // CultNet typed selection, Cut 1: behavioural tests for the Stryker survivors Soul triaged as real // against CultNetSelection.cs/CultNetSelectionEvaluator.cs (docs/cultnet-selection-cut.md, S-5/R-AA, // "Self's rulings for fix batch 4"). Each test targets one named survivor by the rule it pins, not // the mutated line's spelling - see the method-level comment for the rule and where the pre-fix // mutant differs in the answer, not in the code's shape. Fixtures are new where the survivor's own // absence-of-fixture is the reason it survived (a mixed-numeric alias, an index alias that differs // from its member name, two reference members that resolve to one target). public sealed class CultNetSelectionSurvivorTests { private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); // --------------------------------------------------------------------------------------------- // Survivor 1: CultNetSelection.cs, ValidateField - `declaring.Any(member => !member.IsNumeric)` // mutated to `.All(...)`. Rule (section 2, "Inheritance"): "a numeric comparison on an alias that // is numeric on one reachable leaf and a string on another is refused at the door, because the // row owner cannot compare a string." The mutant only refuses when EVERY reachable leaf declares // the alias non-numeric - the exact mixed case the rule exists for was untested. // --------------------------------------------------------------------------------------------- [Test] public void Validation_RefusesAComparisonOnAnAliasNumericOnOneReachableLeafAndStringOnAnother() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) }); var descriptors = registry.AllDescriptors.ToArray(); var selection = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "gt", Number = "5" } } }; var ex = Assert.Throws(() => selection.Validate(descriptors)); Assert.That(ex!.Field, Is.EqualTo("fields[0].index")); } // The any_of counterpart must still pass on the same mixed pair - only comparisons need the // alias numeric everywhere reachable. [Test] public void Validation_AllowsAnyOfOnAMixedNumericStringAliasAcrossReachableLeaves() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) }); var descriptors = registry.AllDescriptors.ToArray(); var selection = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "any_of", Values = new[] { "5" } } } }; Assert.DoesNotThrow(() => selection.Validate(descriptors)); } [CultDocument("cultnet.selection-tests.survivor_numeric_leaf", "cultnet.selection-tests.survivor_numeric_leaf.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorNumericLeaf { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultIndex("level")] public int Level; } [CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorStringLeaf { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultIndex("level")] public string Level = string.Empty; } // --------------------------------------------------------------------------------------------- // Survivor 2: CultNetSelection.cs, Validate - the `cites.target` blank-field door mutated `||` // to `&&`. A half-blank target (schemaId present, recordKey blank, or the reverse) must be // refused; the mutant only refuses when BOTH are blank. // --------------------------------------------------------------------------------------------- [TestCase("some-schema", "")] [TestCase("", "some-key")] public void Validation_RefusesACitesTargetWithOnlyOneOfSchemaIdOrRecordKeyBlank(string schemaId, string recordKey) { var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf) }).AllDescriptors.ToArray(); var selection = new CultNetSelection { Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemaId, RecordKey = recordKey } } }; var ex = Assert.Throws(() => selection.Validate(descriptors)); Assert.That(ex!.Field, Is.EqualTo("cites.target")); } // --------------------------------------------------------------------------------------------- // Survivor 3: `member.IndexAlias ?? member.MemberName` mutated to `member.MemberName` at all // four sites (CultNetSelectionEvaluator MatchesCitation, BuildIncomingIndex, ReferenceMembers; // CultNetSelection.AnySchemaDeclaresRole). Rule (section 2, "The hop"): "role = the member's // index alias or name". SurvivorAliasedCiter.Link declares alias "linkRole", which differs from // its member name "Link" - the exact case with no prior fixture. // --------------------------------------------------------------------------------------------- // Door: CultNetSelection.AnySchemaDeclaresRole (line 445). Only the declared alias satisfies // cited.role/cites.role - the bare member name does not. [TestCase("linkRole", true)] [TestCase("Link", false)] public void Validation_RoleDoorAcceptsTheDeclaredIndexAliasNotTheMemberName(string role, bool shouldValidate) { var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }).AllDescriptors.ToArray(); var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = role, Exists = true } }; if (shouldValidate) Assert.DoesNotThrow(() => selection.Validate(descriptors)); else Assert.Throws(() => selection.Validate(descriptors)); } // Evaluator, cites direction: MatchesCitation (role resolution) and ReferenceMembers (the role // filter) both read the alias. The citation names the role by its alias; the edge reported back // must also carry the alias, not the member name. [Test] public void Evaluator_CitesMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }); var target = new SurvivorAliasTarget { Name = "target" }; var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef(new CultRecordKey("target")) }; var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; var selection = new CultNetSelection { Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" }, Role = "linkRole" } }; var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" })); } // Evaluator, cited direction: BuildIncomingIndex (role resolution) and ReferenceMembers again - // the same alias, read on the incoming-edge path instead of the outgoing one. [Test] public void Evaluator_CitedMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }); var target = new SurvivorAliasTarget { Name = "target" }; var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef(new CultRecordKey("target")) }; var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "linkRole", Exists = true } }; var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" })); Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" })); } [CultDocument("cultnet.selection-tests.survivor_alias_target", "cultnet.selection-tests.survivor_alias_target.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorAliasTarget { [Key(0)] [CultName] public string Name = string.Empty; } [CultDocument("cultnet.selection-tests.survivor_aliased_citer", "cultnet.selection-tests.survivor_aliased_citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorAliasedCiter { [Key(0)] [CultName] public string Name = string.Empty; // The declared index alias ("linkRole") differs from the member name ("Link") - the exact // shape section 2's role-naming rule exists for, and no prior fixture had one. [Key(1)] [CultIndex("linkRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } // --------------------------------------------------------------------------------------------- // Survivor 4: CultNetSelectionEvaluator.EvaluateAll - `ThenBy`/`ThenByDescending` on schemaId // and recordKey mutated, on both the ascending and descending sort expressions. Rule (section 2, // "Order"): "(ordinal, schemaId, recordKey) ascending, or the reverse under descending." Every // prior test used distinct ordinals, so the schemaId/recordKey tiebreak comparators were never // actually invoked by the sort (LINQ's composed comparer short-circuits once a key differs). // Three rows share one ordinal so the sort must fall through to schemaId, then recordKey. // // R-AJ (Soul's SM-4): this test's earlier fixture (two SelLeafA rows "a"/"b", one SelLeafB row // "a") passed 269/269 even under a SchemaId<->Key swap mutant - not because the test is wrong // in principle, but because whether it discriminates the swap depends on which of SelLeafA's/ // SelLeafB's real SHA-256 schema ids happens to sort first, and on this repo's actual ids it // didn't (probed: reverting the fix and applying the swap by hand, this test still passed). // A loosening that cannot fail is a finding about the fixture, not license to keep it. The fix // below assigns keys *relative to the schema ids discovered at runtime* - the low-sorting // schema gets the high-sorting key and vice versa - so schema-first and key-first orderings // are forced to disagree regardless of which real schema id happens to be smaller. // --------------------------------------------------------------------------------------------- [Test] public void Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB) }); var schemaA = registry.GetRequired().SchemaId; var schemaB = registry.GetRequired().SchemaId; // sanity: schemaA/schemaB really are distinct, or this test would not exercise the tiebreak. Assert.That(schemaA, Is.Not.EqualTo(schemaB)); var schemaALowersFirst = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) < 0; var lowSchema = schemaALowersFirst ? typeof(CultNetSelectionEvaluatorTests.SelLeafA) : typeof(CultNetSelectionEvaluatorTests.SelLeafB); // The low-sorting schema's row gets the *high*-sorting key ("z"), and the high-sorting // schema's row gets the *low*-sorting key ("a") - anti-correlated on purpose, so // schema-first order ([low-schema, high-schema], independent of key) and key-first order // ([low-key, high-key] = [high-schema, low-schema]) are the exact reverse of each other no // matter which real id sorts lower. Two rows, two different schemas, two different keys: // the simplest shape that cannot degenerate into agreement between the two candidate rules. var rows = lowSchema == typeof(CultNetSelectionEvaluatorTests.SelLeafA) ? new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1), Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1) } : new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1), Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1) }; var expectedAscending = rows .OrderBy(r => r.Descriptor.SchemaId, CultNetCodePointComparer.Instance) .ThenBy(r => r.Key.Value, CultNetCodePointComparer.Instance) .Select(r => (r.Descriptor.SchemaId, r.Key.Value)) .ToArray(); // The anti-correlated construction above means the schema-first answer must put the // high-key row first (its schema sorts low) - if a key-first mutant instead put the // low-key row first, this sanity check catches the fixture degenerating, not just the // main assertions below. Assert.That(expectedAscending[0].Value, Is.EqualTo("z"), "fixture sanity: schema-first must not agree with key-first here"); var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1); Assert.That(ascending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending)); var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1); Assert.That(descending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending.Reverse())); } // --------------------------------------------------------------------------------------------- // Survivor 5: CultNetSelectionEvaluator.EdgesFor - the ordering keys after page-row position // (From.SchemaId, From.Key, Role, To.Key) each mutated ThenBy<->ThenByDescending. Rule (R-B): // "page-row order, then (from, role, to.recordKey) in code-point order." Two behavioural // angles: the From.SchemaId tiebreak (two differently-schema'd citers, same key, citing one // target under the same role), and the Role tiebreak (one citer with two reference members // that both resolve to the same target, so From and To tie and only Role differs). // // A fifth component, To.SchemaId, used to sit last in this chain. R-BA (docs/cultnet-selection // -cut.md, "Soul, the merge gate, fourth pass") deleted it from both runtimes' // comparators: TryResolveReferenceTarget/resolve_reference_target resolve purely from // (the citer's schema, role, recordKey) against the evaluation's own fixed candidate index, so // once From and Role already tie (pinning the citer's schema and the target type/leaves) and // To.RecordKey also ties, To.SchemaId is forced equal - no input can make it decide an order // the earlier keys did not already decide, so a Stryker mutant on it cannot be killed and the // component bought nothing. // --------------------------------------------------------------------------------------------- [Test] public void EdgesFor_BreaksATiedAnchorByFromSchemaIdWhenFromKeysAreEqual() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelCiter), typeof(SurvivorAltCiter) }); var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; // Both citers use record key "same" - only their schemaId differs, isolating that tiebreak // from the already-covered From.Key case (CultNetSelectionFixBatchTests, citer-a/citer-b). var citerOriginal = new CultNetSelectionEvaluatorTests.SelCiter { Name = "orig", Design = new CultRecordRef(new CultRecordKey("target")) }; var citerAlt = new SurvivorAltCiter { Name = "alt", Design = new CultRecordRef(new CultRecordKey("target")) }; var rows = new[] { Row(registry, target, "target", 1), Row(registry, citerOriginal, "same", 2), Row(registry, citerAlt, "same", 3) }; var schemaOriginal = registry.GetRequired().SchemaId; var schemaAlt = registry.GetRequired().SchemaId; var expectedFirst = CultNetCodePointComparer.Instance.Compare(schemaOriginal, schemaAlt) <= 0 ? schemaOriginal : schemaAlt; var expectedSecond = expectedFirst == schemaOriginal ? schemaAlt : schemaOriginal; var selection = new CultNetSelection { Schemas = new[] { registry.GetRequired().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } }; var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" })); Assert.That(evaluation.Edges.Select(e => e.From.Descriptor.SchemaId), Is.EqualTo(new[] { expectedFirst, expectedSecond })); } [Test] public void EdgesFor_BreaksATiedAnchorByRoleWhenFromAndToAreEqual() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorDualRoleCiter) }); var target = new SurvivorAliasTarget { Name = "target" }; // Both reference members on the one citer point at the same target row, so From and To tie // for both edges and only Role can order them. var citer = new SurvivorDualRoleCiter { Name = "citer", RefZ = new CultRecordRef(new CultRecordKey("target")), RefA = new CultRecordRef(new CultRecordKey("target")) }; var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; var selection = new CultNetSelection { Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" } // Role left null: matches any declared reference, so both RefZ (alias "zLastRole") // and RefA (alias "aFirstRole") contribute an edge. } }; var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); // "aFirstRole" sorts before "zLastRole" in code-point order - the opposite of RefZ/RefA's // declaration order (Key(1)/Key(2)), so a correct answer cannot come from preserving // declaration order by accident. Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "aFirstRole", "zLastRole" })); } // A sixth EdgesFor mutant Stryker reported alongside the five R-B names: OrderBy(pageRowIndex) // itself (the primary key, page-row position) mutated to OrderByDescending. Two page rows, each // the anchor for exactly one edge from a differently-keyed citer, where the citers' own // (from, role, to) order is the *opposite* of the pages' row order - so only the primary // page-position key, not the secondary from/role/to tiebreak, can produce the right answer. [Test] public void EdgesFor_OrdersByPageRowPositionBeforeFromRoleTo() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorPageOrderCiter) }); var targetZ = new SurvivorAliasTarget { Name = "z" }; var targetA = new SurvivorAliasTarget { Name = "a" }; var citerForZ = new SurvivorPageOrderCiter { Name = "cz", Link = new CultRecordRef(new CultRecordKey("z-target")) }; // "citer-a" sorts before "citer-z" in code-point order - the opposite of the page order below // (targetZ's ordinal puts it first), so a from/role/to-only sort would put this edge first. var citerForA = new SurvivorPageOrderCiter { Name = "ca", Link = new CultRecordRef(new CultRecordKey("a-target")) }; var rows = new[] { Row(registry, targetZ, "z-target", 1), Row(registry, targetA, "a-target", 2), Row(registry, citerForZ, "citer-z", 3), Row(registry, citerForA, "citer-a", 4) }; var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "hopRole", Exists = true } }; var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); // Page order is by ordinal: z-target (1) then a-target (2). Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "z-target", "a-target" })); // Edges must follow that same page order, not "citer-a" < "citer-z". Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-z", "citer-a" })); } // R-AJ (Soul's SM-4): the two tests above each isolate one component of the (From.SchemaId, // From.Key, Role, To.Key) tiebreak by tying every other one - so neither can // distinguish the *composition* "From.Key before Role" from "Role before From.Key", which is // exactly the mutant that survived (permuting Role ahead of From.Key, keeping every // component). This calls EdgesFor directly with two hand-built edges sharing one anchor, so // both From.Key and Role vary at once and the two orderings disagree: "aaa" < "zzz" as a key, // but the row keyed "zzz" carries the role "aaa" - a Role-first mutant would put that edge // first; the rule (R-B: "page-row order, then (from, role, to)") puts it second. [Test] public void EdgesFor_OrdersByFromKeyBeforeRoleWhenBothVaryAtTheSameAnchor() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget) }); var anchor = Row(registry, new SurvivorAliasTarget { Name = "anchor" }, "anchor-key", 1); var to = Row(registry, new SurvivorAliasTarget { Name = "to" }, "to-key", 2); var fromZ = Row(registry, new SurvivorAliasTarget { Name = "z" }, "zzz", 3); var fromA = Row(registry, new SurvivorAliasTarget { Name = "a" }, "aaa", 4); var edgeFromZRoleA = new CultNetSelectionEvaluator.EdgeMatch(fromZ, "aaa", to, null); var edgeFromARoleZ = new CultNetSelectionEvaluator.EdgeMatch(fromA, "zzz", to, null); var full = new CultNetSelectionEvaluator.FullEvaluation { Edges = new[] { (edgeFromZRoleA, anchor), (edgeFromARoleZ, anchor) } }; var edges = CultNetSelectionEvaluator.EdgesFor(new[] { anchor }, full); Assert.That(edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "aaa", "zzz" }), "From.Key must decide before Role - a Role-first composition would put the \"zzz\"-keyed edge first, since its role \"aaa\" sorts first"); Assert.That(edges.Select(e => e.Role), Is.EqualTo(new[] { "zzz", "aaa" })); } [CultDocument("cultnet.selection-tests.survivor_page_order_citer", "cultnet.selection-tests.survivor_page_order_citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorPageOrderCiter { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultIndex("hopRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } [CultDocument("cultnet.selection-tests.survivor_alt_citer", "cultnet.selection-tests.survivor_alt_citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorAltCiter { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultReference(typeof(CultNetSelectionEvaluatorTests.SelFixtureMiddle))] public CultRecordRef Design; } [CultDocument("cultnet.selection-tests.survivor_dual_role_citer", "cultnet.selection-tests.survivor_dual_role_citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorDualRoleCiter { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultIndex("zLastRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefZ; [Key(2)] [CultIndex("aFirstRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefA; } // --------------------------------------------------------------------------------------------- // Survivor 6: CultNetSelectionCursor.ComputeDigest - the `cites`/`cited` branches (and the // field-number presence flag) had no coverage at all. Rule (R-H): "every string and list is // length-prefixed, so no delimiter choice can make two different selections collide" - extended // here to the branch flags themselves: two selections differing only in whether they carry a // hop, or only in a field's number-vs-no-number flag, must not collide. // --------------------------------------------------------------------------------------------- [Test] public void ComputeDigest_DiffersWhenOnlyCitesPresenceOrItsRoleDiffers() { var withoutCites = new CultNetSelection(); var withCitesNoRole = new CultNetSelection { Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" } } }; var withCitesRole = new CultNetSelection { Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" }, Role = "r" } }; Assert.That( CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCites), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole))); Assert.That( CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesRole))); } [Test] public void ComputeDigest_DiffersWhenOnlyCitedPresenceOrItsExistsFlagDiffers() { var withoutCited = new CultNetSelection(); var citedExistsTrue = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = true } }; var citedExistsFalse = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = false } }; Assert.That( CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCited), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue))); Assert.That( CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsFalse))); } // The number-presence flag: Number = null (an any_of predicate never carries one) must not // digest the same as Number = "" (an empty-but-present string) - AppendString alone would render // both as a zero-length field, so the trailing '0'/'1' flag is the only thing that tells them // apart. [Test] public void ComputeDigest_DiffersBetweenAnAbsentFieldNumberAndAnEmptyOne() { var absentNumber = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" } } } }; var emptyNumber = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" }, Number = "" } } }; Assert.That( CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", absentNumber), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", emptyNumber))); } // Every "differs" assertion above is symmetric to a mutant that swaps which of two cases gets // which flag value (e.g. field.Number == null ? '0' : '1' mutated to != null) - both cases still // produce two different digests, just with the labels swapped, so inequality alone cannot catch // it. Nor can it catch deleting one AppendString/sb.Append call whose content happens not to be // the only thing distinguishing a particular pair. An independently reconstructed golden value - // built from ComputeDigest's own doc comments, not by calling its private helpers - pins the // exact byte-for-byte wire shape: every append call and every flag's polarity must be exactly // right for this to still match, so deleting or mislabelling any of them fails it. [Test] public void ComputeDigest_MatchesAnIndependentlyReconstructedGoldenValue() { var keyBytes = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); var key = new CultNetSelectionCursorKey(keyBytes); var selection = new CultNetSelection { Schemas = new[] { "schema-b", "schema-a" }, Keys = new[] { "key-a" }, Fields = new[] { new CultNetFieldPredicate { Index = "idx-a", Op = "any_of", Values = new[] { "v2", "v1" } }, new CultNetFieldPredicate { Index = "idx-b", Op = "gt", Number = "5" } }, Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "cite-schema", RecordKey = "cite-key" }, Role = "cite-role" }, Cited = new CultNetIncoming { Role = "cited-role", Exists = true }, Projection = CultNetSelectionProjections.Document, Descending = true }; var expected = IndependentDigest(asOf: 42, ordinal: 7, schemaId: "row-schema", recordKey: "row-key", selection, keyBytes); var actual = CultNetSelectionCursor.ComputeDigest(42, 7, "row-schema", "row-key", selection, key); Assert.That(actual, Is.EqualTo(expected)); } // Independent re-implementation of ComputeDigest's wire shape (R-H/R-Y), written from the // method's doc comments rather than by delegating to it or its private helpers. private static string IndependentDigest(ulong asOf, long ordinal, string schemaId, string recordKey, CultNetSelection selection, byte[] keyBytes) { var sb = new System.Text.StringBuilder(); void AppendString(string value) => sb.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); void AppendList(System.Collections.Generic.IReadOnlyList? values) { var ordered = (values ?? Array.Empty()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray(); sb.Append(ordered.Length).Append(':'); foreach (var v in ordered) AppendString(v); } AppendString(asOf.ToString(System.Globalization.CultureInfo.InvariantCulture)); AppendString(ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); AppendString(schemaId); AppendString(recordKey); AppendList(selection.Schemas); AppendList(selection.Keys); var fields = selection.Fields ?? Array.Empty(); sb.Append(fields.Length).Append(':'); foreach (var field in fields) { AppendString(field.Index); AppendString(field.Op); AppendList(field.Values); AppendString(field.Number ?? string.Empty); sb.Append(field.Number == null ? '0' : '1'); } if (selection.Cites != null) { sb.Append('1'); AppendString(selection.Cites.Target.SchemaId); AppendString(selection.Cites.Target.RecordKey); AppendString(selection.Cites.Role ?? string.Empty); sb.Append(selection.Cites.Role == null ? '0' : '1'); } else { sb.Append('0'); } if (selection.Cited != null) { sb.Append('1'); AppendString(selection.Cited.Role); sb.Append(selection.Cited.Exists ? '1' : '0'); } else { sb.Append('0'); } AppendString(selection.Projection); sb.Append(selection.Descending ? '1' : '0'); using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes); var bytes = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); var hex = new System.Text.StringBuilder(bytes.Length * 2); foreach (var b in bytes) hex.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); return hex.ToString(); } // --------------------------------------------------------------------------------------------- // Survivor 7: CultNetSelectionCursor.Parse / ReadLengthPrefixedFields had no coverage at all on // attacker-supplied base64. A length prefix claiming more bytes than the payload actually holds, // and trailing bytes left over after the five fields, must both refuse cursor_invalid rather // than throw an unhandled exception or silently accept the extra bytes. // --------------------------------------------------------------------------------------------- [Test] public void CursorParse_RefusesALengthPrefixClaimingMoreBytesThanArePresent() { // "999:" claims a 999-byte field but supplies only "x" - the door must refuse, not read // past the end of the buffer. var hostile = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes("999:x")); var ex = Assert.Throws(() => CultNetSelectionCursor.Parse(hostile)); Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); } [Test] public void CursorParse_RefusesTrailingBytesAfterTheFiveFields() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) }); var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) }; var selection = new CultNetSelection { Limit = 1 }; var key = CultNetSelectionCursorKey.Random(); var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); Assert.That(evaluation.NextCursor, Is.Null, "a single-row set with limit 1 has no next page to mint a cursor from"); // Mint a real cursor over a two-row set instead, so there is one to tamper with. var rows2 = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) }; var page = CultNetSelectionEvaluator.Select(registry, rows2, selection, asOf: 1, cursorKey: key); Assert.That(page.NextCursor, Is.Not.Null); var raw = Base64UrlDecode(page.NextCursor!); var withTrailingGarbage = new byte[raw.Length + 3]; Array.Copy(raw, withTrailingGarbage, raw.Length); withTrailingGarbage[raw.Length] = (byte)'x'; withTrailingGarbage[raw.Length + 1] = (byte)'y'; withTrailingGarbage[raw.Length + 2] = (byte)'z'; var tampered = Base64UrlEncode(withTrailingGarbage); var ex = Assert.Throws(() => CultNetSelectionCursor.Parse(tampered)); Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); } private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static byte[] Base64UrlDecode(string value) { var padded = value.Replace('-', '+').Replace('_', '/'); switch (padded.Length % 4) { case 2: padded += "=="; break; case 3: padded += "="; break; case 0: break; default: throw new FormatException("Invalid base64url string length."); } return Convert.FromBase64String(padded); } // --------------------------------------------------------------------------------------------- // Survivor 8: FindCursorPosition (descending walk) and ComparePosition (the schemaId-vs-key // tiebreak) had no test exercising an equal-ordinal boundary. Two angles: a full descending page // walk (mirroring the existing ascending-only walk test), and a cursor boundary where two rows // share both ordinal and record key but differ only by schemaId. // --------------------------------------------------------------------------------------------- [Test] public void Evaluator_PagesDescendingExactlyOnceEachRow() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) }); var rows = Enumerable.Range(0, 5) .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) .ToArray(); var selection = new CultNetSelection { Limit = 2, Descending = true }; var seen = new System.Collections.Generic.List(); string? cursor = null; for (var guard = 0; guard < 10; guard++) { selection.Cursor = cursor; var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); seen.AddRange(page.Rows.Select(r => r.Key.Value)); if (page.NextCursor == null) break; cursor = page.NextCursor; } Assert.That(seen, Is.EqualTo(new[] { "k4", "k3", "k2", "k1", "k0" })); } // ComparePosition's ordinal.CompareTo ties (both rows ordinal 1, both keyed "m") - only the // schemaId comparison can tell the cursor which row it already returned. [Test] public void Page_ResolvesAnEqualOrdinalEqualKeyCursorBoundaryBySchemaId() { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB) }); var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "m", 1), Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "m", 1) }; var schemaA = registry.GetRequired().SchemaId; var schemaB = registry.GetRequired().SchemaId; var expectedOrder = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) <= 0 ? new[] { schemaA, schemaB } : new[] { schemaB, schemaA }; var selection = new CultNetSelection { Limit = 1 }; var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(first.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[0] })); Assert.That(first.NextCursor, Is.Not.Null); selection.Cursor = first.NextCursor; var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); Assert.That(second.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[1] })); Assert.That(second.NextCursor, Is.Null); } // --------------------------------------------------------------------------------------------- // Survivor 9: CultNetCodePointComparer's private CodePointAt - the unpaired (lone) surrogate // branch. R-C's existing astral-key test always supplies a complete, valid surrogate pair, so // the "not actually a pair" branch (documented on the type: "a comparer must not throw on it") // was never reached: a high surrogate at the very end of the string (no following char at all), // and a high surrogate followed by a non-low-surrogate character. // --------------------------------------------------------------------------------------------- [Test] public void CodePointComparer_OrdersALoneHighSurrogateAtStringEndAsItsOwnCodeUnit() { // U+D800 (lone high surrogate) as its own value (55296) sorts before U+E000 (57344, BMP // private-use) - the same relative order code points would give, confirming the lone // surrogate is compared by its raw unit value rather than causing a crash or being skipped. var withLoneSurrogateAtEnd = "x\uD800"; var bmpPrivateUse = "x"; Assert.That(CultNetCodePointComparer.Instance.Compare(withLoneSurrogateAtEnd, bmpPrivateUse), Is.LessThan(0)); Assert.That(CultNetCodePointComparer.Instance.Compare(bmpPrivateUse, withLoneSurrogateAtEnd), Is.GreaterThan(0)); } [Test] public void CodePointComparer_TreatsAHighSurrogateNotFollowedByALowSurrogateAsOneCodeUnit() { // The high surrogate is immediately followed by an ordinary BMP character, not a low // surrogate - IsLowSurrogate must fail and the comparer must advance by one unit, not two, // continuing on to compare "A" vs "B" rather than treating \uD800 + 'A' as a surrogate pair. var first = "x\uD800A"; var second = "x\uD800B"; Assert.That(CultNetCodePointComparer.Instance.Compare(first, second), Is.LessThan(0)); Assert.That(CultNetCodePointComparer.Instance.Compare(second, first), Is.GreaterThan(0)); } } }