up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,32 +1,32 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
sources: [NVD]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.0", result.Document.Version);
|
||||
Assert.Single(result.Document.Rules);
|
||||
Assert.Empty(result.Issues);
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
sources: [NVD]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.0", result.Document.Version);
|
||||
Assert.Single(result.Document.Rules);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,59 +99,59 @@ public sealed class PolicyBinderTests
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Invalid Severity
|
||||
severity: [Nope]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Warning
|
||||
sources: ["", "NVD"]
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
var cli = new PolicyValidationCli(output, error);
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = new[] { path },
|
||||
Strict = true,
|
||||
};
|
||||
|
||||
var exitCode = await cli.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("WARNING", output.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Invalid Severity
|
||||
severity: [Nope]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Warning
|
||||
sources: ["", "NVD"]
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
var cli = new PolicyValidationCli(output, error);
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = new[] { path },
|
||||
Strict = true,
|
||||
};
|
||||
|
||||
var exitCode = await cli.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("WARNING", output.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockMedium",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Medium),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockMedium",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Medium),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-medium",
|
||||
PolicySeverity.Medium,
|
||||
source: "community",
|
||||
tags: ImmutableArray.Create("reachability:indirect"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-medium",
|
||||
PolicySeverity.Medium,
|
||||
source: "community",
|
||||
tags: ImmutableArray.Create("reachability:indirect"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(19.5, verdict.Score, 3);
|
||||
|
||||
@@ -48,43 +48,43 @@ public sealed class PolicyEvaluationTests
|
||||
Assert.NotNull(explanation);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, explanation!.Decision);
|
||||
Assert.Equal("BlockMedium", explanation.RuleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
var requireVexOptions = new PolicyRequireVexOptions(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
|
||||
var rule = PolicyRule.Create(
|
||||
"QuietIgnore",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
var requireVexOptions = new PolicyRequireVexOptions(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
|
||||
var rule = PolicyRule.Create(
|
||||
"QuietIgnore",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-critical",
|
||||
PolicySeverity.Critical,
|
||||
tags: ImmutableArray.Create("reachability:entrypoint"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-critical",
|
||||
PolicySeverity.Critical,
|
||||
tags: ImmutableArray.Create("reachability:entrypoint"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
|
||||
Assert.True(verdict.Quiet);
|
||||
Assert.Equal("QuietIgnore", verdict.QuietedBy);
|
||||
@@ -97,39 +97,39 @@ public sealed class PolicyEvaluationTests
|
||||
|
||||
Assert.NotNull(explanation);
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, explanation!.Decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockUnknown",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Unknown),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockUnknown",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Unknown),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-unknown",
|
||||
PolicySeverity.Unknown,
|
||||
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-unknown",
|
||||
PolicySeverity.Unknown,
|
||||
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
|
||||
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
|
||||
|
||||
@@ -1,185 +1,185 @@
|
||||
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 Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyPreviewServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[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<PolicySnapshotStore>.Instance);
|
||||
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.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);
|
||||
}
|
||||
|
||||
[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<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
const string invalid = "version: 1.0";
|
||||
var request = new PolicyPreviewRequest(
|
||||
"sha256:ghi",
|
||||
ImmutableArray<PolicyFinding>.Empty,
|
||||
ImmutableArray<PolicyVerdict>.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);
|
||||
}
|
||||
|
||||
[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() ?? "<null>");
|
||||
}
|
||||
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<PolicySnapshotStore>.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);
|
||||
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 Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyPreviewServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[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<PolicySnapshotStore>.Instance);
|
||||
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.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);
|
||||
}
|
||||
|
||||
[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<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
const string invalid = "version: 1.0";
|
||||
var request = new PolicyPreviewRequest(
|
||||
"sha256:ghi",
|
||||
ImmutableArray<PolicyFinding>.Empty,
|
||||
ImmutableArray<PolicyVerdict>.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);
|
||||
}
|
||||
|
||||
[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() ?? "<null>");
|
||||
}
|
||||
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<PolicySnapshotStore>.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<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
var baseline = ImmutableArray<PolicyVerdict>.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);
|
||||
}
|
||||
}
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
var baseline = ImmutableArray<PolicyVerdict>.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("1.0", config.Version);
|
||||
Assert.NotEmpty(config.SeverityWeights);
|
||||
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
|
||||
Assert.True(config.QuietPenalty > 0);
|
||||
Assert.NotEmpty(config.ReachabilityBuckets);
|
||||
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
|
||||
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
|
||||
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.True(binding.Success);
|
||||
Assert.NotNull(binding.Config);
|
||||
|
||||
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
|
||||
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("1.0", config.Version);
|
||||
Assert.NotEmpty(config.SeverityWeights);
|
||||
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
|
||||
Assert.True(config.QuietPenalty > 0);
|
||||
Assert.NotEmpty(config.ReachabilityBuckets);
|
||||
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
|
||||
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
|
||||
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.True(binding.Success);
|
||||
Assert.NotNull(binding.Config);
|
||||
|
||||
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
|
||||
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
{
|
||||
private const string BasePolicyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Created);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
|
||||
Assert.Equal(result.Digest, result.Snapshot.Digest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
Assert.Equal(result.Snapshot, latest);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
Assert.Equal(result.Digest, audits[0].Digest);
|
||||
Assert.Equal("snapshot.created", audits[0].Action);
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
var first = await store.SaveAsync(content, CancellationToken.None);
|
||||
Assert.True(first.Created);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var second = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.False(second.Created);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
const string invalidYaml = "version: '1.0'\nrules: []";
|
||||
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Created);
|
||||
Assert.Null(result.Snapshot);
|
||||
|
||||
var audits = await auditRepo.ListAsync(5);
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
{
|
||||
private const string BasePolicyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Created);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
|
||||
Assert.Equal(result.Digest, result.Snapshot.Digest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
Assert.Equal(result.Snapshot, latest);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
Assert.Equal(result.Digest, audits[0].Digest);
|
||||
Assert.Equal("snapshot.created", audits[0].Action);
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
var first = await store.SaveAsync(content, CancellationToken.None);
|
||||
Assert.True(first.Created);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var second = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.False(second.Created);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
const string invalidYaml = "version: '1.0'\nrules: []";
|
||||
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Created);
|
||||
Assert.Null(result.Snapshot);
|
||||
|
||||
var audits = await auditRepo.ListAsync(5);
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user