using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using Xunit; using StellaOps.TestKit; namespace StellaOps.Policy.Tests; public sealed class PolicyPreviewServiceTests { private readonly ITestOutputHelper _output; public PolicyPreviewServiceTests(ITestOutputHelper output) { _output = output ?? throw new ArgumentNullException(nameof(output)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PreviewAsync_ComputesDiffs_ForBlockingRule() { const string yaml = """ version: "1.0" rules: - name: Block Critical severity: [Critical] action: block """; var snapshotRepo = new InMemoryPolicySnapshotRepository(); var auditRepo = new InMemoryPolicyAuditRepository(); var timeProvider = new FakeTimeProvider(); var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger.Instance); await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None); var service = new PolicyPreviewService(store, NullLogger.Instance); var findings = ImmutableArray.Create( PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"), PolicyFinding.Create("finding-2", PolicySeverity.Low)); var baseline = ImmutableArray.Create( new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass), new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass)); var response = await service.PreviewAsync(new PolicyPreviewRequest( "sha256:abc", findings, baseline), CancellationToken.None); Assert.True(response.Success); Assert.Equal(1, response.ChangedCount); var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1")); Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status); Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status); Assert.Equal("Block Critical", diff1.Projected.RuleName); Assert.True(diff1.Projected.Score > 0); Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion); Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PreviewAsync_UsesProposedPolicy_WhenProvided() { const string yaml = """ version: "1.0" rules: - name: Ignore Dev environments: [dev] action: type: ignore justification: dev waiver """; var snapshotRepo = new InMemoryPolicySnapshotRepository(); var auditRepo = new InMemoryPolicyAuditRepository(); var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger.Instance); var service = new PolicyPreviewService(store, NullLogger.Instance); var findings = ImmutableArray.Create( PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev")); var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked)); var response = await service.PreviewAsync(new PolicyPreviewRequest( "sha256:def", findings, baseline, SnapshotOverride: null, ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")), CancellationToken.None); Assert.True(response.Success); var diff = Assert.Single(response.Diffs); Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status); Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status); Assert.Equal("Ignore Dev", diff.Projected.RuleName); Assert.True(diff.Projected.Score >= 0); Assert.Equal(1, response.ChangedCount); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid() { var snapshotRepo = new InMemoryPolicySnapshotRepository(); var auditRepo = new InMemoryPolicyAuditRepository(); var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger.Instance); var service = new PolicyPreviewService(store, NullLogger.Instance); const string invalid = "version: 1.0"; var request = new PolicyPreviewRequest( "sha256:ghi", ImmutableArray.Empty, ImmutableArray.Empty, SnapshotOverride: null, ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null)); var response = await service.PreviewAsync(request, CancellationToken.None); Assert.False(response.Success); Assert.NotEmpty(response.Issues); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn() { const string yaml = """ version: "1.0" rules: - name: Quiet Without VEX severity: [Low] quiet: true action: type: ignore """; var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml); if (!binding.Success) { foreach (var issue in binding.Issues) { _output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}"); } var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml }); _output.WriteLine(node?.ToJsonString() ?? ""); } Assert.True(binding.Success); Assert.Empty(binding.Issues); Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet")); Assert.True(binding.Document.Rules[0].Action.Quiet); var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger.Instance); await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None); var snapshot = await store.GetLatestAsync(); Assert.NotNull(snapshot); Assert.True(snapshot!.Document.Rules[0].Action.Quiet); Assert.Null(snapshot.Document.Rules[0].Action.RequireVex); Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type); var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low), out _); Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status); var service = new PolicyPreviewService(store, NullLogger.Instance); var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low)); var baseline = ImmutableArray.Empty; var response = await service.PreviewAsync(new PolicyPreviewRequest( "sha256:quiet", findings, baseline), CancellationToken.None); Assert.True(response.Success); var verdict = Assert.Single(response.Diffs).Projected; Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status); Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase); Assert.True(verdict.Score >= 0); } }