product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2025 StellaOps Contributors
|
||||
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
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.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.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
|
||||
.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.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.Label("Roundtrip parse failed");
|
||||
}
|
||||
|
||||
return (result1.Document.Name == result2.Document.Name)
|
||||
.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.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.Label("Roundtrip parse failed");
|
||||
}
|
||||
|
||||
return (result1.Document.Rules.Length == result2.Document.Rules.Length)
|
||||
.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.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.Label("Roundtrip parse failed");
|
||||
}
|
||||
|
||||
var keysMatch = result1.Document.Metadata.Keys
|
||||
.OrderBy(k => k)
|
||||
.SequenceEqual(result2.Document.Metadata.Keys.OrderBy(k => k));
|
||||
|
||||
return keysMatch
|
||||
.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.Label("Skip: Parse failures");
|
||||
}
|
||||
|
||||
return (result1.Checksum == result2.Checksum)
|
||||
.Label($"Checksum should be stable: {result1.Checksum} vs {result2.Checksum}");
|
||||
});
|
||||
}
|
||||
|
||||
/// <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.Label("Skip: Parse failures");
|
||||
}
|
||||
|
||||
// If sources are identical, checksums should match
|
||||
if (source1 == source2)
|
||||
{
|
||||
return (result1.Checksum == result2.Checksum)
|
||||
.Label("Identical sources should have same checksum");
|
||||
}
|
||||
|
||||
// Different sources may produce different checksums (not guaranteed if semantically equal)
|
||||
return true.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\""
|
||||
];
|
||||
|
||||
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(ruleCount, GenRule())
|
||||
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(ruleCount, GenRule())
|
||||
select BuildPolicy(name, rules));
|
||||
|
||||
public static Arbitrary<string> ValidPolicyWithMetadata() =>
|
||||
Arb.From(
|
||||
from name in Gen.Elements(ValidIdentifiers)
|
||||
from hasVersion in Arb.Generate<bool>()
|
||||
from hasAuthor in Arb.Generate<bool>()
|
||||
from rules in Gen.ArrayOf(1, GenRule())
|
||||
select BuildPolicyWithMetadata(name, hasVersion, hasAuthor, rules));
|
||||
|
||||
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"
|
||||
}}
|
||||
""";
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user