Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compile_BaselinePolicy_Succeeds()
|
||||
{
|
||||
const string source = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
|
||||
}
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
|
||||
Assert.NotEmpty(result.CanonicalRepresentation);
|
||||
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
|
||||
|
||||
var document = Assert.IsType<PolicyIrDocument>(result.Document);
|
||||
Assert.Equal("Baseline Production Policy", document.Name);
|
||||
Assert.Equal("stella-dsl@1", document.Syntax);
|
||||
Assert.Equal(4, document.Rules.Length);
|
||||
Assert.Single(document.Profiles);
|
||||
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
|
||||
Assert.Equal("status", firstAction.Target[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_MissingBecause_ReportsDiagnostic()
|
||||
{
|
||||
const string source = """
|
||||
policy "Incomplete" syntax "stella-dsl@1" {
|
||||
rule missing_because {
|
||||
when true
|
||||
then status := "suppressed"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
Assert.False(result.Success);
|
||||
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyEvaluatorTests
|
||||
{
|
||||
private static readonly string BaselinePolicy = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private readonly PolicyCompiler compiler = new();
|
||||
private readonly PolicyEvaluationService evaluationService = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_BlockCriticalRuleMatches()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var context = CreateContext(severity: "Critical", exposure: "internal");
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_critical", result.RuleName);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EscalateAdjustsSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var context = CreateContext(severity: "High", exposure: "internet");
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("escalate_high_internet", result.RuleName);
|
||||
Assert.Equal("affected", result.Status);
|
||||
Assert.Equal("Critical", result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VexOverrideSetsStatusAndAnnotation()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var statements = ImmutableArray.Create(
|
||||
new PolicyEvaluationVexStatement("not_affected", "component_not_present", "stmt-001"));
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Vex = new PolicyEvaluationVexEvidence(statements)
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("require_vex_justification", result.RuleName);
|
||||
Assert.Equal("not_affected", result.Status);
|
||||
Assert.Equal("stmt-001", result.Annotations["winning_statement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WarnRuleEmitsWarning()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var tags = ImmutableHashSet.Create("runtime:eol");
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(tags)
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("alert_warn_eol_runtime", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionSuppressesCriticalFinding()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var effect = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical",
|
||||
Name: "Critical Break Glass",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "secops",
|
||||
MaxDurationDays: 7,
|
||||
Description: null);
|
||||
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-001",
|
||||
EffectId: effect.Id,
|
||||
Scope: scope,
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
|
||||
ImmutableArray.Create(instance));
|
||||
var context = CreateContext("Critical", "internal", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_critical", result.RuleName);
|
||||
Assert.Equal("suppressed", result.Status);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("exc-001", result.AppliedException!.ExceptionId);
|
||||
Assert.Equal("suppress-critical", result.AppliedException!.EffectId);
|
||||
Assert.Equal("blocked", result.AppliedException!.OriginalStatus);
|
||||
Assert.Equal("suppressed", result.AppliedException!.AppliedStatus);
|
||||
Assert.Equal("suppressed", result.Annotations["exception.status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionDowngradesSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var effect = new PolicyExceptionEffect(
|
||||
Id: "downgrade-internet",
|
||||
Name: "Downgrade High Internet",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Medium,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
var scope = PolicyEvaluationExceptionScope.Create(
|
||||
ruleNames: new[] { "escalate_high_internet" },
|
||||
severities: new[] { "High" },
|
||||
sources: new[] { "GHSA" });
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-200",
|
||||
EffectId: effect.Id,
|
||||
Scope: scope,
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 2, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
|
||||
ImmutableArray.Create(instance));
|
||||
var context = CreateContext("High", "internet", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("escalate_high_internet", result.RuleName);
|
||||
Assert.Equal("affected", result.Status);
|
||||
Assert.Equal("Medium", result.Severity);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("Critical", result.AppliedException!.OriginalSeverity);
|
||||
Assert.Equal("Medium", result.AppliedException!.AppliedSeverity);
|
||||
Assert.Equal("Medium", result.Annotations["exception.severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MoreSpecificExceptionWins()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var suppressGlobal = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical-global",
|
||||
Name: "Global Critical Suppress",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
var suppressRule = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical-rule",
|
||||
Name: "Rule Critical Suppress",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
|
||||
var globalInstance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-global",
|
||||
EffectId: suppressGlobal.Id,
|
||||
Scope: PolicyEvaluationExceptionScope.Create(severities: new[] { "Critical" }),
|
||||
CreatedAt: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var ruleInstance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-rule",
|
||||
EffectId: suppressRule.Id,
|
||||
Scope: PolicyEvaluationExceptionScope.Create(
|
||||
ruleNames: new[] { "block_critical" },
|
||||
severities: new[] { "Critical" }),
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty.Add("requestedBy", "alice"));
|
||||
|
||||
var effects = ImmutableDictionary<string, PolicyExceptionEffect>.Empty
|
||||
.Add(suppressGlobal.Id, suppressGlobal)
|
||||
.Add(suppressRule.Id, suppressRule);
|
||||
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
effects,
|
||||
ImmutableArray.Create(globalInstance, ruleInstance));
|
||||
|
||||
var context = CreateContext("Critical", "internal", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("suppressed", result.Status);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
|
||||
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
|
||||
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
|
||||
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
|
||||
}
|
||||
|
||||
private PolicyIrDocument CompileBaseline()
|
||||
{
|
||||
var compilation = compiler.Compile(BaselinePolicy);
|
||||
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
|
||||
return Assert.IsType<PolicyIrDocument>(compilation.Document);
|
||||
}
|
||||
|
||||
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
|
||||
{
|
||||
return new PolicyEvaluationContext(
|
||||
new PolicyEvaluationSeverity(severity),
|
||||
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exposure"] = exposure
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyPackRepositoryTests
|
||||
{
|
||||
private readonly InMemoryPolicyPackRepository repository = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
|
||||
{
|
||||
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
|
||||
Assert.NotNull(result.Revision);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
|
||||
Assert.Single(result.Revision.Approvals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
|
||||
{
|
||||
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
|
||||
Assert.Single(first.Revision.Approvals);
|
||||
|
||||
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
|
||||
|
||||
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
|
||||
Assert.Equal(2, second.Revision.Approvals.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,548 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Polly.Utilities;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class GatewayActivationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/5:activate",
|
||||
new ActivatePolicyRevisionRequest("rollout window start"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
var issuedTokens = tokenClient.RequestCount;
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " +
|
||||
$"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }.");
|
||||
}
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal(HttpMethod.Post, forwardedRequest!.Method);
|
||||
Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter);
|
||||
Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled.");
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "activated" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "activated" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseFactory(_ =>
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Unauthorized",
|
||||
Detail = "Caller token rejected.",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
});
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/2:activate",
|
||||
new ActivatePolicyRevisionRequest("failure path"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "unauthorized" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "unauthorized" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseFactory(_ =>
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Upstream error",
|
||||
Detail = "Policy Engine returned 502.",
|
||||
Status = StatusCodes.Status502BadGateway
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
});
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/3:activate",
|
||||
new ActivatePolicyRevisionRequest("upstream failure"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "error" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "error" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RetriesOnTooManyRequests()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var recordedDelays = new List<TimeSpan>();
|
||||
var originalSleep = SystemClock.SleepAsync;
|
||||
SystemClock.SleepAsync = (delay, cancellationToken) =>
|
||||
{
|
||||
recordedDelays.Add(delay);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseSequence(new[]
|
||||
{
|
||||
CreateThrottleResponse(),
|
||||
CreateThrottleResponse(),
|
||||
RecordingPolicyEngineHandler.CreateSuccessResponse()
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/7:activate",
|
||||
new ActivatePolicyRevisionRequest("retry after throttle"));
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses.");
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
Assert.Equal(3, recordingHandler.RequestCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SystemClock.SleepAsync = originalSleep;
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateThrottleResponse()
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Too many requests",
|
||||
Detail = "Slow down.",
|
||||
Status = StatusCodes.Status429TooManyRequests
|
||||
};
|
||||
|
||||
var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10));
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning",
|
||||
["PolicyGateway:ResourceServer:Authority"] = "https://authority.test",
|
||||
["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false",
|
||||
["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32",
|
||||
["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128",
|
||||
["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate",
|
||||
["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false"
|
||||
};
|
||||
|
||||
configurationBuilder.AddInMemoryCollection(settings);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IStellaOpsTokenClient>();
|
||||
services.AddSingleton<StubTokenClient>();
|
||||
services.AddSingleton<IStellaOpsTokenClient>(sp => sp.GetRequiredService<StubTokenClient>());
|
||||
|
||||
services.RemoveAll<PolicyEngineClient>();
|
||||
services.RemoveAll<IPolicyEngineClient>();
|
||||
services.AddSingleton<RecordingPolicyEngineHandler>();
|
||||
services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>()
|
||||
.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://policy-engine.test/");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<RecordingPolicyEngineHandler>());
|
||||
|
||||
services.AddSingleton<IStartupFilter>(new RemoteIpStartupFilter());
|
||||
|
||||
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration
|
||||
{
|
||||
Issuer = "https://authority.test",
|
||||
TokenEndpoint = "https://authority.test/token"
|
||||
};
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = false,
|
||||
SignatureValidator = (token, parameters) => new JsonWebToken(token)
|
||||
};
|
||||
options.BackchannelHttpHandler = new NoOpBackchannelHandler();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RemoteIpStartupFilter : IStartupFilter
|
||||
{
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
{
|
||||
return app =>
|
||||
{
|
||||
app.Use(async (context, innerNext) =>
|
||||
{
|
||||
context.Connection.RemoteIpAddress ??= IPAddress.Loopback;
|
||||
await innerNext().ConfigureAwait(false);
|
||||
});
|
||||
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicyEngineHandler : HttpMessageHandler
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public int RequestCount { get; private set; }
|
||||
private Func<HttpRequestMessage, HttpResponseMessage>? responseFactory;
|
||||
private Queue<HttpResponseMessage>? responseQueue;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
RequestCount++;
|
||||
|
||||
if (responseQueue is { Count: > 0 })
|
||||
{
|
||||
return Task.FromResult(responseQueue.Dequeue());
|
||||
}
|
||||
|
||||
var response = responseFactory is not null
|
||||
? responseFactory(request)
|
||||
: CreateSuccessResponse();
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
LastRequest = null;
|
||||
RequestCount = 0;
|
||||
responseFactory = null;
|
||||
responseQueue?.Clear();
|
||||
responseQueue = null;
|
||||
}
|
||||
|
||||
public void SetResponseFactory(Func<HttpRequestMessage, HttpResponseMessage>? factory)
|
||||
{
|
||||
responseFactory = factory;
|
||||
}
|
||||
|
||||
public void SetResponseSequence(IEnumerable<HttpResponseMessage> responses)
|
||||
{
|
||||
responseQueue = new Queue<HttpResponseMessage>(responses ?? Array.Empty<HttpResponseMessage>());
|
||||
}
|
||||
|
||||
public static HttpResponseMessage CreateSuccessResponse()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var payload = new PolicyRevisionActivationDto(
|
||||
"activated",
|
||||
new PolicyRevisionDto(
|
||||
5,
|
||||
"activated",
|
||||
false,
|
||||
now,
|
||||
now,
|
||||
Array.Empty<PolicyActivationApprovalDto>()));
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpBackchannelHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
RequestCount = 0;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public class PolicyEngineClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.ClientCredentials.Enabled = true;
|
||||
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
|
||||
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Clear();
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
|
||||
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor(options);
|
||||
var tokenClient = new StubTokenClient();
|
||||
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
|
||||
|
||||
using var recordingHandler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(recordingHandler)
|
||||
{
|
||||
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
|
||||
};
|
||||
|
||||
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
|
||||
|
||||
var request = new ActivatePolicyRevisionRequest("comment");
|
||||
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(recordingHandler.LastRequest);
|
||||
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
|
||||
Assert.NotNull(authorization);
|
||||
Assert.Equal("Bearer", authorization!.Scheme);
|
||||
Assert.Equal("service-token", authorization.Parameter);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metrics_RecordActivation_EmitsExpectedTags()
|
||||
{
|
||||
using var metrics = new PolicyGatewayMetrics();
|
||||
using var listener = new MeterListener();
|
||||
var measurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencies = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
metrics.RecordActivation("activated", "service", 42.5);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
|
||||
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.test/"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Development";
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenDpopDisabled()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = false;
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(AppContext.BaseDirectory),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
|
||||
|
||||
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenKeyFileMissing()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<FileNotFoundException>(() =>
|
||||
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
|
||||
|
||||
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = keyPath;
|
||||
options.PolicyEngine.Dpop.Algorithm = "ES384";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
const string accessToken = "sample-access-token";
|
||||
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
|
||||
|
||||
Assert.Equal("dpop+jwt", token.Header.Typ);
|
||||
Assert.Equal("ES384", token.Header.Alg);
|
||||
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
|
||||
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
|
||||
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
|
||||
Assert.True(epoch > 0);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("jti", out var jti));
|
||||
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("ath", out var ath));
|
||||
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
|
||||
Assert.Equal(expectedHash, ath?.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.example"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(curve);
|
||||
var privateKey = ecdsa.ExportPkcs8PrivateKey();
|
||||
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
|
||||
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public StubHostEnvironment(string contentRootPath)
|
||||
{
|
||||
ContentRootPath = contentRootPath;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
|
||||
public string ContentRootPath { get; set; }
|
||||
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
157
src/Policy/__Tests/StellaOps.Policy.Tests/PolicyBinderTests.cs
Normal file
157
src/Policy/__Tests/StellaOps.Policy.Tests/PolicyBinderTests.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_ExceptionsConfigured_ParsesDefinitions()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
exceptions:
|
||||
effects:
|
||||
- id: suppress-temp
|
||||
name: Temporary Suppress
|
||||
effect: suppress
|
||||
routingTemplate: secops
|
||||
maxDurationDays: 30
|
||||
- id: downgrade-ops
|
||||
name: Downgrade To Low
|
||||
effect: downgrade
|
||||
downgradeSeverity: Low
|
||||
routingTemplates:
|
||||
- id: secops
|
||||
authorityRouteId: route-secops
|
||||
requireMfa: true
|
||||
rules:
|
||||
- name: Allow
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var effects = result.Document.Exceptions.Effects;
|
||||
Assert.Equal(2, effects.Length);
|
||||
|
||||
var suppress = effects.Single(effect => effect.Id == "suppress-temp");
|
||||
Assert.Equal(PolicyExceptionEffectType.Suppress, suppress.Effect);
|
||||
Assert.Equal("Temporary Suppress", suppress.Name);
|
||||
Assert.Equal("secops", suppress.RoutingTemplate);
|
||||
Assert.Equal(30, suppress.MaxDurationDays);
|
||||
|
||||
var downgrade = effects.Single(effect => effect.Id == "downgrade-ops");
|
||||
Assert.Equal(PolicyExceptionEffectType.Downgrade, downgrade.Effect);
|
||||
Assert.Equal("Downgrade To Low", downgrade.Name);
|
||||
Assert.Equal(PolicySeverity.Low, downgrade.DowngradeSeverity);
|
||||
|
||||
var routing = result.Document.Exceptions.RoutingTemplates;
|
||||
Assert.Single(routing);
|
||||
Assert.Equal("secops", routing[0].Id);
|
||||
Assert.Equal("route-secops", routing[0].AuthorityRouteId);
|
||||
Assert.True(routing[0].RequireMfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
exceptions:
|
||||
effects:
|
||||
- id: downgrade-invalid
|
||||
effect: downgrade
|
||||
routingTemplates: []
|
||||
rules:
|
||||
- name: Allow
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(19.5, verdict.Score, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(50, inputs["severityWeight"]);
|
||||
Assert.Equal(0.65, inputs["trustWeight"], 3);
|
||||
Assert.Equal(0.6, inputs["reachabilityWeight"], 3);
|
||||
Assert.Equal(19.5, inputs["baseScore"], 3);
|
||||
}
|
||||
|
||||
[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 verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
|
||||
Assert.True(verdict.Quiet);
|
||||
Assert.Equal("QuietIgnore", verdict.QuietedBy);
|
||||
Assert.Equal(10, verdict.Score, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(90, inputs["baseScore"], 3);
|
||||
Assert.Equal(config.IgnorePenalty, inputs["ignorePenalty"]);
|
||||
Assert.Equal(config.QuietPenalty, inputs["quietPenalty"]);
|
||||
}
|
||||
|
||||
[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 verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
|
||||
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
|
||||
Assert.Equal("medium", verdict.ConfidenceBand);
|
||||
Assert.Equal(5, verdict.UnknownAgeDays ?? 0, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(0.55, inputs["unknownConfidence"], 3);
|
||||
Assert.Equal(5, inputs["unknownAgeDays"], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user