Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.PolicyDsl.Tests/Properties/PolicyDslRoundtripPropertyTests.cs

636 lines
23 KiB
C#

// 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;
/// <summary>
/// Property-based tests for Policy DSL parser roundtrips.
/// Verifies that parse → print → parse produces equivalent documents.
/// </summary>
public sealed class PolicyDslRoundtripPropertyTests
{
private readonly PolicyCompiler _compiler = new();
/// <summary>
/// Property: Valid policy sources roundtrip through parse → print → parse.
/// </summary>
[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");
});
}
/// <summary>
/// Property: Policy names are preserved through roundtrip.
/// </summary>
[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}'");
});
}
/// <summary>
/// Property: Rule count is preserved through roundtrip.
/// </summary>
[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}");
});
}
/// <summary>
/// Property: Metadata keys are preserved through roundtrip.
/// </summary>
[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");
});
}
/// <summary>
/// Property: Checksum is stable for identical documents.
/// </summary>
[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}");
});
}
/// <summary>
/// Sprint 8200.0012.0003: Score-based conditions roundtrip correctly.
/// </summary>
[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");
});
}
/// <summary>
/// Sprint 8200.0012.0003: Each score condition type parses successfully.
/// </summary>
[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");
});
}
/// <summary>
/// Sprint 8200.0012.0003: Score expression structure preserved through roundtrip.
/// </summary>
[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}'");
});
}
/// <summary>
/// Property: Different policies produce different checksums.
/// </summary>
[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;
}
}
/// <summary>
/// Custom FsCheck arbitraries for Policy DSL types.
/// </summary>
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<string> ValidPolicyName() =>
Arb.From(Gen.Elements(
"Test Policy",
"Production Baseline",
"Staging Rules",
"Critical Only Policy",
"VEX-Aware Evaluation"
));
public static Arbitrary<string> ValidPolicySource() =>
Arb.From(
from name in Gen.Elements(ValidIdentifiers)
from ruleCount in Gen.Choose(1, 3)
from rules in Gen.ArrayOf<string>(GenRule(), ruleCount)
select BuildPolicy(name, rules));
public static Arbitrary<string> ValidPolicyWithRules() =>
Arb.From(
from name in Gen.Elements(ValidIdentifiers)
from ruleCount in Gen.Choose(1, 5)
from rules in Gen.ArrayOf<string>(GenRule(), ruleCount)
select BuildPolicy(name, rules));
public static Arbitrary<string> ValidPolicyWithMetadata() =>
Arb.From(
from name in Gen.Elements(ValidIdentifiers)
from hasVersion in ArbMap.Default.GeneratorFor<bool>()
from hasAuthor in ArbMap.Default.GeneratorFor<bool>()
from rules in Gen.ArrayOf<string>(GenRule(), 1)
select BuildPolicyWithMetadata(name, hasVersion, hasAuthor, rules));
/// <summary>
/// Sprint 8200.0012.0003: Generates policies with score-based conditions.
/// </summary>
public static Arbitrary<string> ValidPolicyWithScoreConditions() =>
Arb.From(
from name in Gen.Elements(ValidIdentifiers)
from ruleCount in Gen.Choose(1, 3)
from rules in Gen.ArrayOf<string>(GenScoreRule(), ruleCount)
select BuildPolicy(name, rules));
/// <summary>
/// Sprint 8200.0012.0003: Generates a specific score condition for targeted testing.
/// </summary>
public static Arbitrary<string> ScoreCondition() =>
Arb.From(Gen.Elements(ScoreConditions));
private static Gen<string> 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"
}
""";
}
/// <summary>
/// Sprint 8200.0012.0003: Generates rules with score-based conditions.
/// </summary>
private static Gen<string> 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<string>();
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}}
}
""";
}
}
/// <summary>
/// Policy IR printer for roundtrip testing.
/// Generates DSL source from PolicyIrDocument.
/// </summary>
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");
}
}