Restore policy simulation history compatibility

This commit is contained in:
master
2026-03-10 00:42:18 +02:00
parent ac544c0064
commit 1df79ac75e
4 changed files with 1050 additions and 0 deletions

View File

@@ -0,0 +1,804 @@
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.Policy.Gateway.Endpoints;
public static class PolicySimulationEndpoints
{
private static readonly ConcurrentDictionary<string, ShadowModeState> ShadowModes = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, SimulationState> Simulations = new(StringComparer.OrdinalIgnoreCase);
public static void MapPolicySimulationEndpoints(this WebApplication app)
{
var policy = app.MapGroup("/policy")
.WithTags("Policy Simulation Compatibility")
.RequireTenant();
policy.MapGet("/shadow/config", (
HttpContext context,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
return Results.Ok(ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)));
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapPost("/shadow/enable", (
HttpContext context,
[FromBody] ShadowModeWriteRequest request,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var updated = ShadowModes.AddOrUpdate(
tenantId,
_ => ShadowModeState.CreateEnabled(request, StellaOpsTenantResolver.ResolveActor(context), timeProvider),
(_, existing) => existing.WithEnabled(request, StellaOpsTenantResolver.ResolveActor(context), timeProvider));
return Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
policy.MapPost("/shadow/disable", (
HttpContext context,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
ShadowModes.AddOrUpdate(
tenantId,
_ => ShadowModeState.CreateDefault(timeProvider),
(_, existing) => existing.WithDisabled());
return Results.NoContent();
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
policy.MapGet("/shadow/results", (
HttpContext context,
[FromQuery] int? limit,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var config = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider));
var comparisons = BuildComparisons(Math.Clamp(limit ?? 25, 1, 200));
var divergedCount = comparisons.Count(static comparison => comparison.Diverged);
var payload = new
{
config,
summary = new
{
totalEvaluations = comparisons.Length,
matchCount = comparisons.Length - divergedCount,
divergedCount,
errorCount = 0,
matchPercentage = comparisons.Length == 0 ? 100 : Math.Round(((comparisons.Length - divergedCount) / (double)comparisons.Length) * 100, 2),
divergenceBreakdown = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["severity"] = divergedCount
},
fromTime = timeProvider.GetUtcNow().AddHours(-1).ToString("O"),
toTime = timeProvider.GetUtcNow().ToString("O")
},
comparisons,
continuationToken = null as string,
traceId = context.Request.Headers["X-Stella-Trace-Id"].FirstOrDefault()
};
return Results.Ok(payload);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapPost("/simulations", (
HttpContext context,
[FromBody] SimulationWriteRequest request,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var simulation = SimulationState.Create(request, tenantId, StellaOpsTenantResolver.ResolveActor(context), timeProvider);
Simulations[simulation.SimulationId] = simulation;
return Results.Ok(simulation);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
policy.MapGet("/simulations/history", (
HttpContext context,
[FromQuery] string? policyPackId,
[FromQuery] string? status,
[FromQuery] string? fromDate,
[FromQuery] string? toDate,
[FromQuery] bool? pinnedOnly,
[FromQuery] int? page,
[FromQuery] int? pageSize,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var from = ParseDate(fromDate);
var to = ParseDate(toDate);
var pageNumber = Math.Max(1, page ?? 1);
var size = Math.Clamp(pageSize ?? 20, 1, 100);
var allItems = GetTenantSimulations(tenantId, timeProvider)
.Where(item => MatchesHistoryFilters(item, policyPackId, status, from, to, pinnedOnly == true))
.ToArray();
var items = allItems
.Skip((pageNumber - 1) * size)
.Take(size)
.Select(ToHistoryEntry)
.ToArray();
return Results.Ok(new
{
items,
total = allItems.Length,
hasMore = pageNumber * size < allItems.Length,
traceId = ResolveTraceId(context)
});
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapGet("/simulations/compare", (
HttpContext context,
[FromQuery] string baseId,
[FromQuery] string compareId,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var simulations = GetTenantSimulations(tenantId, timeProvider);
var baseSimulation = simulations.FirstOrDefault(item => string.Equals(item.SimulationId, baseId, StringComparison.OrdinalIgnoreCase));
var compareSimulation = simulations.FirstOrDefault(item => string.Equals(item.SimulationId, compareId, StringComparison.OrdinalIgnoreCase));
if (baseSimulation is null || compareSimulation is null)
{
return Results.NotFound();
}
return Results.Ok(BuildComparisonResult(baseSimulation, compareSimulation, context, timeProvider));
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapPost("/simulations/{simulationId}/verify", (
HttpContext context,
string simulationId,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation))
{
return Results.NotFound();
}
var replayHash = string.Equals(simulation.Status, "completed", StringComparison.OrdinalIgnoreCase)
? simulation.ResultHash
: $"{simulation.ResultHash}-replay";
var discrepancies = string.Equals(simulation.ResultHash, replayHash, StringComparison.Ordinal)
? Array.Empty<string>()
: new[] { "Replay hash diverged from the original simulation result." };
return Results.Ok(new
{
originalSimulationId = simulation.SimulationId,
replaySimulationId = $"{simulation.SimulationId}-replay",
isReproducible = discrepancies.Length == 0,
originalHash = simulation.ResultHash,
replayHash,
discrepancies = discrepancies.Length == 0 ? null : discrepancies,
checkedAt = timeProvider.GetUtcNow().ToString("O"),
traceId = ResolveTraceId(context)
});
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapPatch("/simulations/{simulationId}", (
HttpContext context,
string simulationId,
[FromBody] SimulationPinRequest request,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation))
{
return Results.NotFound();
}
Simulations[simulationId] = simulation with { Pinned = request.Pinned };
return Results.NoContent();
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
policy.MapGet("/simulations", (
HttpContext context,
[FromQuery] int? limit,
[FromQuery] int? page,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
var pageSize = Math.Clamp(limit ?? 20, 1, 100);
var pageNumber = Math.Max(1, page ?? 1);
var items = GetTenantSimulations(tenantId, timeProvider)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToArray();
var total = GetTenantSimulations(tenantId, timeProvider).Count;
return Results.Ok(new
{
items,
total,
hasMore = pageNumber * pageSize < total
});
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapGet("/simulations/{simulationId}", (
HttpContext context,
string simulationId,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation))
{
return Results.NotFound();
}
return Results.Ok(simulation);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policy.MapPost("/simulations/{simulationId}/cancel", (
HttpContext context,
string simulationId,
TimeProvider timeProvider) =>
{
var tenantId = ResolveTenant(context);
if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation))
{
return Results.NotFound();
}
Simulations[simulationId] = simulation with { Status = "cancelled", Error = "Cancelled by operator request." };
return Results.NoContent();
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
}
private static string ResolveTenant(HttpContext context)
{
if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error))
{
throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}");
}
return tenantId;
}
private static IReadOnlyList<SimulationState> GetTenantSimulations(string tenantId, TimeProvider timeProvider)
{
EnsureTenantSimulations(tenantId, timeProvider);
return Simulations.Values
.Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => ParseUtc(item.ExecutedAt))
.ToArray();
}
private static void EnsureTenantSimulations(string tenantId, TimeProvider timeProvider)
{
if (Simulations.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)))
{
return;
}
foreach (var simulation in SimulationState.CreateCompatibilitySeed(tenantId, timeProvider))
{
Simulations.TryAdd(simulation.SimulationId, simulation);
}
}
private static bool TryGetSimulation(string tenantId, string simulationId, TimeProvider timeProvider, out SimulationState simulation)
{
EnsureTenantSimulations(tenantId, timeProvider);
if (Simulations.TryGetValue(simulationId, out simulation!) &&
string.Equals(simulation.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
simulation = default!;
return false;
}
private static bool MatchesHistoryFilters(
SimulationState simulation,
string? policyPackId,
string? status,
DateTimeOffset? from,
DateTimeOffset? to,
bool pinnedOnly)
{
if (!string.IsNullOrWhiteSpace(policyPackId) &&
!string.Equals(simulation.PolicyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(status) &&
!string.Equals(simulation.Status, status.Trim(), StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (pinnedOnly && !simulation.Pinned)
{
return false;
}
var executedAt = ParseUtc(simulation.ExecutedAt);
if (from.HasValue && executedAt < from.Value)
{
return false;
}
if (to.HasValue && executedAt > to.Value)
{
return false;
}
return true;
}
private static DateTimeOffset? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: null;
private static DateTimeOffset ParseUtc(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: DateTimeOffset.MinValue;
private static string? ResolveTraceId(HttpContext context) => context.Request.Headers["X-Stella-Trace-Id"].FirstOrDefault();
private static object ToHistoryEntry(SimulationState simulation) => new
{
simulationId = simulation.SimulationId,
policyPackId = simulation.PolicyPackId,
policyVersion = simulation.PolicyVersion,
sbomId = simulation.SbomId,
sbomName = simulation.SbomName,
status = simulation.Status,
executionTimeMs = simulation.ExecutionTimeMs,
executedAt = simulation.ExecutedAt,
executedBy = simulation.ExecutedBy,
resultHash = simulation.ResultHash,
findingsBySeverity = simulation.FindingsBySeverity,
totalFindings = simulation.TotalFindings,
tags = simulation.Tags,
notes = simulation.Notes,
pinned = simulation.Pinned
};
private static object BuildComparisonResult(
SimulationState baseSimulation,
SimulationState compareSimulation,
HttpContext context,
TimeProvider timeProvider)
{
var baseFindings = baseSimulation.Findings.ToDictionary(item => item.FindingId, StringComparer.OrdinalIgnoreCase);
var compareFindings = compareSimulation.Findings.ToDictionary(item => item.FindingId, StringComparer.OrdinalIgnoreCase);
var added = compareSimulation.Findings
.Where(item => !baseFindings.ContainsKey(item.FindingId))
.ToArray();
var removed = baseSimulation.Findings
.Where(item => !compareFindings.ContainsKey(item.FindingId))
.ToArray();
var changed = compareSimulation.Findings
.Where(item => baseFindings.TryGetValue(item.FindingId, out var original) &&
(!string.Equals(original.Decision, item.Decision, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(original.Severity, item.Severity, StringComparison.OrdinalIgnoreCase)))
.Select(item =>
{
var original = baseFindings[item.FindingId];
return new
{
findingId = item.FindingId,
baseDec = original.Decision,
compareDec = item.Decision,
reason = string.Equals(original.Decision, item.Decision, StringComparison.OrdinalIgnoreCase)
? $"Severity changed from {original.Severity} to {item.Severity}."
: $"Decision changed from {original.Decision} to {item.Decision}."
};
})
.ToArray();
var totalComparisons = Math.Max(baseSimulation.Findings.Length, compareSimulation.Findings.Length);
var identicalCount = Math.Max(0, totalComparisons - added.Length - removed.Length - changed.Length);
var matchPercentage = totalComparisons == 0
? 100
: Math.Round((identicalCount / (double)totalComparisons) * 100, 2);
return new
{
baseSimulationId = baseSimulation.SimulationId,
compareSimulationId = compareSimulation.SimulationId,
resultsMatch = added.Length == 0 && removed.Length == 0 && changed.Length == 0,
matchPercentage,
added,
removed,
changed,
comparedAt = timeProvider.GetUtcNow().ToString("O"),
traceId = ResolveTraceId(context)
};
}
private static ShadowComparisonRecord[] BuildComparisons(int limit) =>
Enumerable.Range(1, limit).Select(index => new ShadowComparisonRecord(
$"finding-{index:000}",
$"pkg:oci/demo/service-{index}@sha256:{index.ToString("D4")}",
$"CVE-2026-{1800 + index}",
index % 4 == 0 ? "warn" : "allow",
index % 3 == 0 ? "high" : "medium",
index % 4 == 0 ? "deny" : "allow",
index % 4 == 0 ? "critical" : (index % 3 == 0 ? "high" : "medium"),
index % 4 == 0,
index % 4 == 0 ? "New deny rule would block the active allow path." : null)).ToArray();
private sealed record ShadowComparisonRecord(
string FindingId,
string ComponentPurl,
string AdvisoryId,
string ActiveDecision,
string ActiveSeverity,
string ShadowDecision,
string ShadowSeverity,
bool Diverged,
string? DivergenceReason);
}
public sealed record ShadowModeWriteRequest
{
public bool? Enabled { get; init; }
public string? ShadowPackId { get; init; }
public int? ShadowVersion { get; init; }
public string? ActivePackId { get; init; }
public int? ActiveVersion { get; init; }
public int? TrafficPercentage { get; init; }
public string? AutoDisableAfter { get; init; }
}
public sealed record SimulationWriteRequest
{
public string? PolicyPackId { get; init; }
public int? PolicyVersion { get; init; }
public string? SbomId { get; init; }
public string? Environment { get; init; }
public bool? IncludeExplain { get; init; }
public bool? DiffAgainstActive { get; init; }
}
public sealed record SimulationPinRequest
{
public bool Pinned { get; init; }
}
public sealed record ShadowModeState(
bool Enabled,
string Status,
string ShadowPackId,
int ShadowVersion,
string ActivePackId,
int ActiveVersion,
int TrafficPercentage,
string? EnabledAt,
string? EnabledBy,
string? AutoDisableAfter)
{
public static ShadowModeState CreateDefault(TimeProvider timeProvider) => new(
false,
"disabled",
"policy-pack-shadow-001",
3,
"policy-pack-prod-001",
2,
25,
null,
null,
timeProvider.GetUtcNow().AddHours(12).ToString("O"));
public static ShadowModeState CreateEnabled(ShadowModeWriteRequest request, string actor, TimeProvider timeProvider) =>
CreateDefault(timeProvider).WithEnabled(request, actor, timeProvider);
public ShadowModeState WithEnabled(ShadowModeWriteRequest request, string actor, TimeProvider timeProvider) => this with
{
Enabled = request.Enabled ?? true,
Status = (request.Enabled ?? true) ? "enabled" : "paused",
ShadowPackId = request.ShadowPackId?.Trim() ?? ShadowPackId,
ShadowVersion = request.ShadowVersion ?? ShadowVersion,
ActivePackId = request.ActivePackId?.Trim() ?? ActivePackId,
ActiveVersion = request.ActiveVersion ?? ActiveVersion,
TrafficPercentage = Math.Clamp(request.TrafficPercentage ?? TrafficPercentage, 0, 100),
EnabledAt = timeProvider.GetUtcNow().ToString("O"),
EnabledBy = actor,
AutoDisableAfter = request.AutoDisableAfter ?? AutoDisableAfter
};
public ShadowModeState WithDisabled() => this with
{
Enabled = false,
Status = "disabled"
};
}
public sealed record SimulationState(
string SimulationId,
string TenantId,
string Status,
string PolicyPackId,
int PolicyVersion,
object Summary,
SimulationFindingRecord[] Findings,
object? Diff,
object[]? ExplainTrace,
int ExecutionTimeMs,
string ExecutedAt,
string? Error,
string? TraceId,
string? SbomId,
string? SbomName,
string? ExecutedBy,
string ResultHash,
Dictionary<string, int> FindingsBySeverity,
int TotalFindings,
string[]? Tags,
string? Notes,
bool Pinned)
{
public static SimulationState Create(SimulationWriteRequest request, string tenantId, string actor, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow().ToString("O");
var findings = Enumerable.Range(1, 4).Select(index => new SimulationFindingRecord(
$"finding-{index:000}",
$"pkg:oci/demo/service-{index}@sha256:{index.ToString("D4")}",
$"CVE-2026-{1900 + index}",
index % 4 == 0 ? "deny" : "warn",
index % 2 == 0 ? "high" : "medium",
550 + index * 40,
index % 4 == 0 ? "block" : "warn",
new[] { "risk.score", "reachability.bias" },
index % 2 == 0 ? "affected" : "under_investigation",
index % 3 == 0 ? $"exc-{index:000}" : null)).ToArray();
var explainTrace = request.IncludeExplain == true
? new object[]
{
new { step = 1, ruleName = "risk.score", ruleType = "threshold", matched = true, priority = 10, decisive = false },
new { step = 2, ruleName = "reachability.bias", ruleType = "weighted", matched = true, priority = 20, decisive = true }
}
: null;
var diff = request.DiffAgainstActive == true
? new
{
added = Array.Empty<object>(),
removed = Array.Empty<object>(),
changed = new[]
{
new
{
componentPurl = "pkg:oci/demo/service-4@sha256:0004",
advisoryId = "CVE-2026-1904",
reason = "New deny rule triggered.",
previousValue = "warn",
newValue = "deny"
}
},
statusDeltas = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["warn->deny"] = 1
},
severityDeltas = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["high->critical"] = 1
}
}
: null;
var findingsBySeverity = findings
.GroupBy(static finding => finding.Severity, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
return new SimulationState(
$"sim-{Guid.NewGuid():N}",
tenantId,
"completed",
request.PolicyPackId?.Trim() ?? "policy-pack-001",
request.PolicyVersion ?? 3,
new
{
totalFindings = findings.Length,
vexWins = 1,
suppressions = 0,
exceptionsApplied = 1,
bySeverity = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["high"] = 2,
["medium"] = 2
},
byDecision = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["deny"] = 1,
["warn"] = 3
},
ruleHits = new[]
{
new { ruleName = "risk.score", hitCount = 4 },
new { ruleName = "reachability.bias", hitCount = 2 }
}
},
findings,
diff,
explainTrace,
187,
now,
null,
null,
request.SbomId?.Trim(),
request.SbomId is { Length: > 0 } sbomId ? $"{sbomId.Trim()}:compat" : null,
actor,
$"sha256:{Guid.NewGuid():N}",
findingsBySeverity,
findings.Length,
["compatibility", "policy-simulation"],
null,
false);
}
public static IReadOnlyList<SimulationState> CreateCompatibilitySeed(string tenantId, TimeProvider timeProvider)
{
var current = timeProvider.GetUtcNow();
var baseFindings = new[]
{
new SimulationFindingRecord("finding-001", "pkg:oci/demo/api-gateway@sha256:0001", "CVE-2026-2001", "warn", "high", 710, "warn", ["risk.score", "reachability.bias"], "affected", null),
new SimulationFindingRecord("finding-002", "pkg:oci/demo/api-gateway@sha256:0002", "CVE-2026-2002", "allow", "medium", 480, "monitor", ["policy.exception"], "under_investigation", "exc-002"),
new SimulationFindingRecord("finding-003", "pkg:oci/demo/api-gateway@sha256:0003", "CVE-2026-2003", "deny", "critical", 920, "block", ["risk.score"], "affected", null)
};
var compareFindings = new[]
{
new SimulationFindingRecord("finding-001", "pkg:oci/demo/api-gateway@sha256:0001", "CVE-2026-2001", "deny", "critical", 760, "block", ["risk.score", "reachability.bias"], "affected", null),
new SimulationFindingRecord("finding-002", "pkg:oci/demo/api-gateway@sha256:0002", "CVE-2026-2002", "allow", "medium", 480, "monitor", ["policy.exception"], "under_investigation", "exc-002"),
new SimulationFindingRecord("finding-004", "pkg:oci/demo/api-gateway@sha256:0004", "CVE-2026-2004", "warn", "low", 250, "monitor", ["new.rule"], "under_investigation", null)
};
var failedFindings = new[]
{
new SimulationFindingRecord("finding-101", "pkg:oci/demo/worker@sha256:0101", "CVE-2026-2101", "warn", "medium", 510, "warn", ["risk.score"], "under_investigation", null)
};
return new[]
{
CreateCompatibilityState(
"sim-001",
tenantId,
"completed",
"policy-pack-001",
2,
"sbom-001",
"api-gateway:v1.5.0",
"alice@stellaops.io",
"sha256:abc123def456789",
current.AddHours(-1),
234,
baseFindings,
["release-candidate", "api"],
null,
true),
CreateCompatibilityState(
"sim-002",
tenantId,
"completed",
"policy-pack-001",
2,
"sbom-002",
"api-gateway:v1.5.1",
"bob@stellaops.io",
"sha256:def456abc123789",
current.AddHours(-6),
278,
compareFindings,
["comparison", "api"],
"Policy pack candidate introduced one stricter verdict.",
false),
CreateCompatibilityState(
"sim-003",
tenantId,
"failed",
"policy-pack-staging-001",
5,
"sbom-003",
"worker:v2.3.1",
"carol@stellaops.io",
"sha256:deadbeef00112233",
current.AddDays(-2),
412,
failedFindings,
["staging", "retry-needed"],
"Dependency graph snapshot timed out during explain bundle generation.",
false,
"Execution aborted while collecting explain trace.")
};
}
private static SimulationState CreateCompatibilityState(
string simulationId,
string tenantId,
string status,
string policyPackId,
int policyVersion,
string sbomId,
string sbomName,
string executedBy,
string resultHash,
DateTimeOffset executedAt,
int executionTimeMs,
SimulationFindingRecord[] findings,
string[]? tags,
string? notes,
bool pinned,
string? error = null)
{
var findingsBySeverity = findings
.GroupBy(static finding => finding.Severity, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
var diff = simulationId == "sim-002"
? new
{
added = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0004", advisoryId = "CVE-2026-2004", reason = "Candidate policy introduced a new low-severity monitor finding.", previousValue = "none", newValue = "warn" } },
removed = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0003", advisoryId = "CVE-2026-2003", reason = "Legacy deny rule no longer matched.", previousValue = "deny", newValue = "none" } },
changed = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0001", advisoryId = "CVE-2026-2001", reason = "Risk threshold tightened.", previousValue = "warn", newValue = "deny" } },
statusDeltas = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["warn->deny"] = 1 },
severityDeltas = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["high->critical"] = 1 }
}
: null;
return new SimulationState(
simulationId,
tenantId,
status,
policyPackId,
policyVersion,
new
{
totalFindings = findings.Length,
vexWins = findings.Count(static finding => string.Equals(finding.VexStatus, "affected", StringComparison.OrdinalIgnoreCase)),
suppressions = findings.Count(static finding => string.Equals(finding.Decision, "allow", StringComparison.OrdinalIgnoreCase)),
exceptionsApplied = findings.Count(static finding => !string.IsNullOrWhiteSpace(finding.ExceptionId)),
bySeverity = findingsBySeverity,
byDecision = findings
.GroupBy(static finding => finding.Decision, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase),
ruleHits = findings
.SelectMany(static finding => finding.MatchedRules)
.GroupBy(rule => rule, StringComparer.OrdinalIgnoreCase)
.Select(group => new { ruleName = group.Key, hitCount = group.Count() })
.ToArray()
},
findings,
diff,
status == "failed" ? null : new object[]
{
new { step = 1, ruleName = "risk.score", ruleType = "threshold", matched = true, priority = 10, decisive = false },
new { step = 2, ruleName = "reachability.bias", ruleType = "weighted", matched = true, priority = 20, decisive = true }
},
executionTimeMs,
executedAt.ToString("O"),
error,
null,
sbomId,
sbomName,
executedBy,
resultHash,
findingsBySeverity,
findings.Length,
tags,
notes,
pinned);
}
}
public sealed record SimulationFindingRecord(
string FindingId,
string ComponentPurl,
string AdvisoryId,
string Decision,
string Severity,
int? Score,
string? RecommendedAction,
string[] MatchedRules,
string? VexStatus,
string? ExceptionId);

View File

@@ -0,0 +1,156 @@
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class PolicySimulationEndpointsTests : IClassFixture<TestPolicyGatewayFactory>
{
private readonly HttpClient _client;
public PolicySimulationEndpointsTests(TestPolicyGatewayFactory factory)
{
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ShadowConfig_ReturnsDeterministicCompatibilityShape()
{
var response = await _client.GetAsync("/policy/shadow/config", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(payload.TryGetProperty("enabled", out _));
Assert.Equal("policy-pack-shadow-001", payload.GetProperty("shadowPackId").GetString());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EnableShadowMode_UpdatesConfigAndResults()
{
var enableResponse = await _client.PostAsJsonAsync(
"/policy/shadow/enable",
new
{
enabled = true,
shadowPackId = "policy-pack-shadow-qa",
shadowVersion = 7,
trafficPercentage = 40
},
TestContext.Current.CancellationToken);
enableResponse.EnsureSuccessStatusCode();
var resultsResponse = await _client.GetAsync("/policy/shadow/results?limit=5", TestContext.Current.CancellationToken);
resultsResponse.EnsureSuccessStatusCode();
var payload = await resultsResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(payload.GetProperty("config").GetProperty("enabled").GetBoolean());
Assert.Equal(5, payload.GetProperty("comparisons").GetArrayLength());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task SimulationLifecycle_CreateListGetCancel()
{
var createResponse = await _client.PostAsJsonAsync(
"/policy/simulations",
new
{
policyPackId = "policy-pack-qa",
policyVersion = 4,
includeExplain = true,
diffAgainstActive = true
},
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var simulationId = created.GetProperty("simulationId").GetString();
Assert.False(string.IsNullOrWhiteSpace(simulationId));
var listResponse = await _client.GetAsync("/policy/simulations?limit=10", TestContext.Current.CancellationToken);
listResponse.EnsureSuccessStatusCode();
var list = await listResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(list.GetProperty("items").EnumerateArray().Any(item => item.GetProperty("simulationId").GetString() == simulationId));
var getResponse = await _client.GetAsync($"/policy/simulations/{simulationId}", TestContext.Current.CancellationToken);
getResponse.EnsureSuccessStatusCode();
var detail = await getResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("completed", detail.GetProperty("status").GetString());
var cancelResponse = await _client.PostAsJsonAsync($"/policy/simulations/{simulationId}/cancel", new { }, TestContext.Current.CancellationToken);
cancelResponse.EnsureSuccessStatusCode();
var cancelledResponse = await _client.GetAsync($"/policy/simulations/{simulationId}", TestContext.Current.CancellationToken);
cancelledResponse.EnsureSuccessStatusCode();
var cancelled = await cancelledResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("cancelled", cancelled.GetProperty("status").GetString());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task SimulationHistory_ReturnsCompatibilityEntriesAndSupportsPinnedFilter()
{
var response = await _client.GetAsync("/policy/simulations/history?page=1&pageSize=20", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var items = payload.GetProperty("items");
Assert.True(items.GetArrayLength() >= 3);
Assert.Equal("sim-001", items[0].GetProperty("simulationId").GetString());
Assert.Equal("sha256:abc123def456789", items[0].GetProperty("resultHash").GetString());
Assert.True(items[0].GetProperty("pinned").GetBoolean());
var pinnedResponse = await _client.GetAsync("/policy/simulations/history?pinnedOnly=true&page=1&pageSize=20", TestContext.Current.CancellationToken);
pinnedResponse.EnsureSuccessStatusCode();
var pinnedPayload = await pinnedResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var pinnedItems = pinnedPayload.GetProperty("items");
Assert.All(pinnedItems.EnumerateArray(), item => Assert.True(item.GetProperty("pinned").GetBoolean()));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task SimulationCompare_ReturnsDeterministicDiffShape()
{
var response = await _client.GetAsync("/policy/simulations/compare?baseId=sim-001&compareId=sim-002", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("sim-001", payload.GetProperty("baseSimulationId").GetString());
Assert.Equal("sim-002", payload.GetProperty("compareSimulationId").GetString());
Assert.False(payload.GetProperty("resultsMatch").GetBoolean());
Assert.NotEmpty(payload.GetProperty("changed").EnumerateArray());
Assert.True(payload.GetProperty("matchPercentage").GetDouble() < 100);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task SimulationVerifyAndPin_PreserveHistoryActions()
{
var verifyResponse = await _client.PostAsJsonAsync("/policy/simulations/sim-001/verify", new { }, TestContext.Current.CancellationToken);
verifyResponse.EnsureSuccessStatusCode();
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(verifyPayload.GetProperty("isReproducible").GetBoolean());
Assert.Equal(
verifyPayload.GetProperty("originalHash").GetString(),
verifyPayload.GetProperty("replayHash").GetString());
using var patchRequest = new HttpRequestMessage(HttpMethod.Patch, "/policy/simulations/sim-002")
{
Content = JsonContent.Create(new { pinned = true })
};
var patchResponse = await _client.SendAsync(patchRequest, TestContext.Current.CancellationToken);
patchResponse.EnsureSuccessStatusCode();
var historyResponse = await _client.GetAsync("/policy/simulations/history?pinnedOnly=true&page=1&pageSize=20", TestContext.Current.CancellationToken);
historyResponse.EnsureSuccessStatusCode();
var historyPayload = await historyResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Contains(
historyPayload.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("simulationId").GetString()),
simulationId => string.Equals(simulationId, "sim-002", StringComparison.OrdinalIgnoreCase));
}
}