// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-FileCopyrightText: 2025 StellaOps Contributors using FluentAssertions; using FsCheck; using FsCheck.Fluent; using FsCheck.Xunit; using StellaOps.PolicyDsl; using Xunit; namespace StellaOps.PolicyDsl.Tests.Properties; /// /// Property-based tests for Policy DSL parser roundtrips. /// Verifies that parse → print → parse produces equivalent documents. /// public sealed class PolicyDslRoundtripPropertyTests { private readonly PolicyCompiler _compiler = new(); /// /// Property: Valid policy sources roundtrip through parse → print → parse. /// [Property(MaxTest = 50)] public Property ValidPolicy_Roundtrips_ThroughParsePrintParse() { return Prop.ForAll( PolicyDslArbs.ValidPolicySource(), source => { // Parse the original source var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label("Skip: Source doesn't parse cleanly"); } // Print the document back to source var printed = PolicyIrPrinter.Print(result1.Document); // Parse the printed source var result2 = _compiler.Compile(printed); // Both should succeed if (!result2.Success || result2.Document is null) { return false.ToProperty().Label($"Roundtrip failed: {string.Join("; ", result2.Diagnostics.Select(d => d.Message))}"); } // Documents should be semantically equivalent var equivalent = AreDocumentsEquivalent(result1.Document, result2.Document); return equivalent .ToProperty().Label($"Documents should be equivalent after roundtrip"); }); } /// /// Property: Policy names are preserved through roundtrip. /// [Property(MaxTest = 50)] public Property PolicyName_PreservedThroughRoundtrip() { return Prop.ForAll( PolicyDslArbs.ValidPolicyName(), name => { var source = $$""" policy "{{name}}" syntax "stella-dsl@1" { rule test priority 1 { when true then severity := "low" because "test" } } """; var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label("Skip: Name causes parse failure"); } var printed = PolicyIrPrinter.Print(result1.Document); var result2 = _compiler.Compile(printed); if (!result2.Success || result2.Document is null) { return false.ToProperty().Label("Roundtrip parse failed"); } return (result1.Document.Name == result2.Document.Name) .ToProperty().Label($"Name should be preserved: '{result1.Document.Name}' vs '{result2.Document.Name}'"); }); } /// /// Property: Rule count is preserved through roundtrip. /// [Property(MaxTest = 50)] public Property RuleCount_PreservedThroughRoundtrip() { return Prop.ForAll( PolicyDslArbs.ValidPolicyWithRules(), source => { var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label("Skip: Source doesn't parse"); } var printed = PolicyIrPrinter.Print(result1.Document); var result2 = _compiler.Compile(printed); if (!result2.Success || result2.Document is null) { return false.ToProperty().Label("Roundtrip parse failed"); } return (result1.Document.Rules.Length == result2.Document.Rules.Length) .ToProperty().Label($"Rule count should be preserved: {result1.Document.Rules.Length} vs {result2.Document.Rules.Length}"); }); } /// /// Property: Metadata keys are preserved through roundtrip. /// [Property(MaxTest = 50)] public Property MetadataKeys_PreservedThroughRoundtrip() { return Prop.ForAll( PolicyDslArbs.ValidPolicyWithMetadata(), source => { var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label("Skip: Source doesn't parse"); } var printed = PolicyIrPrinter.Print(result1.Document); var result2 = _compiler.Compile(printed); if (!result2.Success || result2.Document is null) { return false.ToProperty().Label("Roundtrip parse failed"); } var keysMatch = result1.Document.Metadata.Keys .OrderBy(k => k) .SequenceEqual(result2.Document.Metadata.Keys.OrderBy(k => k)); return keysMatch .ToProperty().Label($"Metadata keys should be preserved"); }); } /// /// Property: Checksum is stable for identical documents. /// [Property(MaxTest = 50)] public Property Checksum_StableForIdenticalDocuments() { return Prop.ForAll( PolicyDslArbs.ValidPolicySource(), source => { var result1 = _compiler.Compile(source); var result2 = _compiler.Compile(source); if (!result1.Success || !result2.Success) { return true.ToProperty().Label("Skip: Parse failures"); } return (result1.Checksum == result2.Checksum) .ToProperty().Label($"Checksum should be stable: {result1.Checksum} vs {result2.Checksum}"); }); } /// /// Sprint 8200.0012.0003: Score-based conditions roundtrip correctly. /// [Property(MaxTest = 50)] public Property ScoreConditions_RoundtripCorrectly() { return Prop.ForAll( PolicyDslArbs.ValidPolicyWithScoreConditions(), source => { var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label("Skip: Score policy doesn't parse"); } var printed = PolicyIrPrinter.Print(result1.Document); var result2 = _compiler.Compile(printed); if (!result2.Success || result2.Document is null) { return false.ToProperty().Label($"Score policy roundtrip failed: {string.Join("; ", result2.Diagnostics.Select(d => d.Message))}"); } return AreDocumentsEquivalent(result1.Document, result2.Document) .ToProperty().Label("Score policy documents should be equivalent after roundtrip"); }); } /// /// Sprint 8200.0012.0003: Each score condition type parses successfully. /// [Property(MaxTest = 50)] public Property IndividualScoreConditions_ParseSuccessfully() { return Prop.ForAll( PolicyDslArbs.ScoreCondition(), condition => { var source = $$""" policy "ScoreTest" syntax "stella-dsl@1" { rule test priority 1 { when {{condition}} then severity := "high" because "Score condition test" } } """; var result = _compiler.Compile(source); return (result.Success && result.Document is not null) .ToProperty().Label($"Score condition '{condition}' should parse successfully"); }); } /// /// Sprint 8200.0012.0003: Score expression structure preserved through roundtrip. /// [Property(MaxTest = 50)] public Property ScoreExpressionStructure_PreservedThroughRoundtrip() { return Prop.ForAll( PolicyDslArbs.ScoreCondition(), condition => { var source = $$""" policy "ScoreTest" syntax "stella-dsl@1" { rule test priority 1 { when {{condition}} then severity := "high" because "Score test" } } """; var result1 = _compiler.Compile(source); if (!result1.Success || result1.Document is null) { return true.ToProperty().Label($"Skip: Condition '{condition}' doesn't parse"); } var printed = PolicyIrPrinter.Print(result1.Document); var result2 = _compiler.Compile(printed); if (!result2.Success || result2.Document is null) { return false.ToProperty().Label($"Roundtrip failed for '{condition}'"); } // Verify rule count matches return (result1.Document.Rules.Length == result2.Document.Rules.Length) .ToProperty().Label($"Rule count preserved for condition '{condition}'"); }); } /// /// Property: Different policies produce different checksums. /// [Property(MaxTest = 50)] public Property DifferentPolicies_ProduceDifferentChecksums() { return Prop.ForAll( PolicyDslArbs.ValidPolicySource(), PolicyDslArbs.ValidPolicySource(), (source1, source2) => { var result1 = _compiler.Compile(source1); var result2 = _compiler.Compile(source2); if (!result1.Success || !result2.Success) { return true.ToProperty().Label("Skip: Parse failures"); } // If sources are identical, checksums should match if (source1 == source2) { return (result1.Checksum == result2.Checksum) .ToProperty().Label("Identical sources should have same checksum"); } // Different sources may produce different checksums (not guaranteed if semantically equal) return true.ToProperty().Label("Different sources checked"); }); } private static bool AreDocumentsEquivalent(PolicyIrDocument doc1, PolicyIrDocument doc2) { if (doc1.Name != doc2.Name) return false; if (doc1.Syntax != doc2.Syntax) return false; if (doc1.Rules.Length != doc2.Rules.Length) return false; if (doc1.Metadata.Count != doc2.Metadata.Count) return false; // Check metadata keys (values may have different literal representations) if (!doc1.Metadata.Keys.SequenceEqual(doc2.Metadata.Keys)) return false; // Check rules (by name and priority) var rules1 = doc1.Rules.OrderBy(r => r.Name).ToList(); var rules2 = doc2.Rules.OrderBy(r => r.Name).ToList(); for (var i = 0; i < rules1.Count; i++) { if (rules1[i].Name != rules2[i].Name) return false; if (rules1[i].Priority != rules2[i].Priority) return false; } return true; } } /// /// Custom FsCheck arbitraries for Policy DSL types. /// internal static class PolicyDslArbs { private static readonly string[] ValidIdentifiers = [ "test", "production", "staging", "baseline", "strict", "high_priority", "low_risk", "critical_only", "vex_aware" ]; private static readonly string[] ValidPriorities = ["1", "2", "3", "4", "5", "10", "100"]; private static readonly string[] ValidConditions = [ "true", "severity == \"critical\"", "severity == \"high\"", "severity == \"low\"", "status == \"blocked\"" ]; // Sprint 8200.0012.0003: Score-based conditions for EWS integration private static readonly string[] ScoreConditions = [ "score >= 70", "score > 80", "score <= 50", "score < 40", "score == 75", "score.is_act_now", "score.is_schedule_next", "score.is_investigate", "score.is_watchlist", "score.bucket == \"ActNow\"", "score.rch > 0.8", "score.xpl > 0.7", "score.has_flag(\"kev\")", "score.has_flag(\"live-signal\")", "score.between(60, 80)", "score >= 70 and score.is_schedule_next", "score > 80 or score.has_flag(\"kev\")", "score.rch > 0.8 and score.xpl > 0.7" ]; private static readonly string[] ValidActions = [ "severity := \"info\"", "severity := \"low\"", "severity := \"medium\"", "severity := \"high\"", "severity := \"critical\"" ]; public static Arbitrary ValidPolicyName() => Arb.From(Gen.Elements( "Test Policy", "Production Baseline", "Staging Rules", "Critical Only Policy", "VEX-Aware Evaluation" )); public static Arbitrary ValidPolicySource() => Arb.From( from name in Gen.Elements(ValidIdentifiers) from ruleCount in Gen.Choose(1, 3) from rules in Gen.ArrayOf(GenRule(), ruleCount) select BuildPolicy(name, rules)); public static Arbitrary ValidPolicyWithRules() => Arb.From( from name in Gen.Elements(ValidIdentifiers) from ruleCount in Gen.Choose(1, 5) from rules in Gen.ArrayOf(GenRule(), ruleCount) select BuildPolicy(name, rules)); public static Arbitrary ValidPolicyWithMetadata() => Arb.From( from name in Gen.Elements(ValidIdentifiers) from hasVersion in ArbMap.Default.GeneratorFor() from hasAuthor in ArbMap.Default.GeneratorFor() from rules in Gen.ArrayOf(GenRule(), 1) select BuildPolicyWithMetadata(name, hasVersion, hasAuthor, rules)); /// /// Sprint 8200.0012.0003: Generates policies with score-based conditions. /// public static Arbitrary ValidPolicyWithScoreConditions() => Arb.From( from name in Gen.Elements(ValidIdentifiers) from ruleCount in Gen.Choose(1, 3) from rules in Gen.ArrayOf(GenScoreRule(), ruleCount) select BuildPolicy(name, rules)); /// /// Sprint 8200.0012.0003: Generates a specific score condition for targeted testing. /// public static Arbitrary ScoreCondition() => Arb.From(Gen.Elements(ScoreConditions)); private static Gen GenRule() { return from nameIndex in Gen.Choose(0, ValidIdentifiers.Length - 1) from priorityIndex in Gen.Choose(0, ValidPriorities.Length - 1) from conditionIndex in Gen.Choose(0, ValidConditions.Length - 1) from actionIndex in Gen.Choose(0, ValidActions.Length - 1) let name = ValidIdentifiers[nameIndex] let priority = ValidPriorities[priorityIndex] let condition = ValidConditions[conditionIndex] let action = ValidActions[actionIndex] select $$""" rule {{name}} priority {{priority}} { when {{condition}} then {{action}} because "Generated test rule" } """; } /// /// Sprint 8200.0012.0003: Generates rules with score-based conditions. /// private static Gen GenScoreRule() { return from nameIndex in Gen.Choose(0, ValidIdentifiers.Length - 1) from priorityIndex in Gen.Choose(0, ValidPriorities.Length - 1) from conditionIndex in Gen.Choose(0, ScoreConditions.Length - 1) from actionIndex in Gen.Choose(0, ValidActions.Length - 1) let name = ValidIdentifiers[nameIndex] let priority = ValidPriorities[priorityIndex] let condition = ScoreConditions[conditionIndex] let action = ValidActions[actionIndex] select $$""" rule {{name}} priority {{priority}} { when {{condition}} then {{action}} because "Score-based rule" } """; } private static string BuildPolicy(string name, string[] rules) { var rulesText = string.Join("\n", rules); return $$""" policy "{{name}}" syntax "stella-dsl@1" { {{rulesText}} } """; } private static string BuildPolicyWithMetadata(string name, bool hasVersion, bool hasAuthor, string[] rules) { var metadataLines = new List(); if (hasVersion) metadataLines.Add(" version = \"1.0.0\""); if (hasAuthor) metadataLines.Add(" author = \"test\""); var metadata = metadataLines.Count > 0 ? $$""" metadata { {{string.Join("\n", metadataLines)}} } """ : ""; var rulesText = string.Join("\n", rules); return $$""" policy "{{name}}" syntax "stella-dsl@1" { {{metadata}} {{rulesText}} } """; } } /// /// Policy IR printer for roundtrip testing. /// Generates DSL source from PolicyIrDocument. /// internal static class PolicyIrPrinter { public static string Print(PolicyIrDocument document) { var sb = new System.Text.StringBuilder(); sb.AppendLine($"policy \"{document.Name}\" syntax \"{document.Syntax}\" {{"); // Print metadata if (document.Metadata.Count > 0) { sb.AppendLine(" metadata {"); foreach (var kvp in document.Metadata) { var valueStr = PrintLiteral(kvp.Value); sb.AppendLine($" {kvp.Key} = {valueStr}"); } sb.AppendLine(" }"); } // Print profiles foreach (var profile in document.Profiles) { sb.AppendLine($" profile {profile.Name} {{"); foreach (var scalar in profile.Scalars) { sb.AppendLine($" {scalar.Name} = {PrintLiteral(scalar.Value)}"); } sb.AppendLine(" }"); } // Print rules foreach (var rule in document.Rules) { sb.AppendLine($" rule {rule.Name} priority {rule.Priority} {{"); sb.AppendLine($" when {PrintExpression(rule.When)}"); sb.AppendLine(" then"); foreach (var action in rule.ThenActions) { sb.AppendLine($" {PrintAction(action)}"); } sb.AppendLine($" because \"{EscapeString(rule.Because)}\""); sb.AppendLine(" }"); } sb.AppendLine("}"); return sb.ToString(); } private static string PrintLiteral(PolicyIrLiteral literal) => literal switch { PolicyIrStringLiteral s => $"\"{EscapeString(s.Value)}\"", PolicyIrNumberLiteral n => n.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), PolicyIrBooleanLiteral b => b.Value ? "true" : "false", PolicyIrListLiteral list => $"[{string.Join(", ", list.Items.Select(PrintLiteral))}]", _ => "null" }; private static string PrintExpression(PolicyExpression expr) => expr switch { PolicyLiteralExpression lit => PrintExprLiteral(lit.Value), PolicyIdentifierExpression id => id.Name, PolicyBinaryExpression bin => $"{PrintExpression(bin.Left)} {PrintBinaryOp(bin.Operator)} {PrintExpression(bin.Right)}", PolicyUnaryExpression un => $"{PrintUnaryOp(un.Operator)}{PrintExpression(un.Operand)}", PolicyMemberAccessExpression mem => $"{PrintExpression(mem.Target)}.{mem.Member}", PolicyInvocationExpression call => $"{PrintExpression(call.Target)}({string.Join(", ", call.Arguments.Select(PrintExpression))})", PolicyListExpression list => $"[{string.Join(", ", list.Items.Select(PrintExpression))}]", PolicyIndexerExpression idx => $"{PrintExpression(idx.Target)}[{PrintExpression(idx.Index)}]", _ => "true" }; private static string PrintBinaryOp(PolicyBinaryOperator op) => op switch { PolicyBinaryOperator.And => "and", PolicyBinaryOperator.Or => "or", PolicyBinaryOperator.Equal => "==", PolicyBinaryOperator.NotEqual => "!=", PolicyBinaryOperator.LessThan => "<", PolicyBinaryOperator.LessThanOrEqual => "<=", PolicyBinaryOperator.GreaterThan => ">", PolicyBinaryOperator.GreaterThanOrEqual => ">=", PolicyBinaryOperator.In => "in", PolicyBinaryOperator.NotIn => "not in", _ => "==" }; private static string PrintUnaryOp(PolicyUnaryOperator op) => op switch { PolicyUnaryOperator.Not => "not ", _ => "" }; private static string PrintExprLiteral(object? value) => value switch { string s => $"\"{EscapeString(s)}\"", bool b => b ? "true" : "false", decimal d => d.ToString(System.Globalization.CultureInfo.InvariantCulture), int i => i.ToString(), double dbl => dbl.ToString(System.Globalization.CultureInfo.InvariantCulture), null => "null", _ => value.ToString() ?? "null" }; private static string PrintAction(PolicyIrAction action) => action switch { PolicyIrAssignmentAction assign => $"{string.Join(".", assign.Target)} := {PrintExpression(assign.Value)}", PolicyIrAnnotateAction annotate => $"annotate {string.Join(".", annotate.Target)} with {PrintExpression(annotate.Value)}", PolicyIrIgnoreAction ignore => ignore.Because is not null ? $"ignore because \"{EscapeString(ignore.Because)}\"" : "ignore", PolicyIrEscalateAction escalate => "escalate", PolicyIrRequireVexAction vex => "require vex", PolicyIrWarnAction warn => "warn", PolicyIrDeferAction defer => "defer", _ => "// unknown action" }; private static string EscapeString(string s) { return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); } }