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,79 @@
# Sprint 20260309-011 - Platform Live Remaining Route Contract Repair
## Topic & Scope
- Repair the remaining authenticated live frontdoor route failures exposed by the full scratch rebuild after the shared gateway/runtime regressions were already cleared.
- Fix root causes in the correct layer: Authority scope semantics, Platform compatibility read models, Policy governance/simulation surfaces, Signals compatibility endpoints, JobEngine SQL fallback behavior, and the remaining frontend response-shape adapters.
- Keep the iteration driven by real Playwright evidence from `https://stella-ops.local`, then rebuild and redeploy the touched services before rerunning the authenticated sweep.
- Working directory: `src/Platform/StellaOps.Platform.WebService`.
- Allowed coordination edits: `src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/**`, `src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/**`, `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Infrastructure/**`, `src/Policy/StellaOps.Policy.Gateway/**`, `src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/**`, `src/Signals/StellaOps.Signals/**`, `src/Signals/__Tests/StellaOps.Signals.Tests/**`, `src/Web/StellaOps.Web/**`, `docs/modules/platform/**`, `docs/modules/policy/**`, `docs/modules/signals/**`, `docs/modules/ui/console-architecture.md`, `docs/implplan/SPRINT_20260309_011_Platform_live_remaining_route_contract_repair.md`.
- Expected evidence: targeted unit/integration test runs against individual `.csproj` files, rebuilt service images, redeployed live stack, refreshed authenticated Playwright route/action artifacts.
## Dependencies & Concurrency
- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the scratch rebuild baseline, `SPRINT_20260309_006_Platform_rebuild_runtime_contract_repairs.md` for the migration/binding recovery, `SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md` for the cleared gateway health flap, and `SPRINT_20260309_010_FE_live_auth_scope_console_and_policy_alignment.md` for the already-isolated frontend route inventory.
- Safe parallelism: avoid unrelated component-revival and search work outside the paths listed above; do not revert unrelated dirty files in the shared worktree.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/policy/architecture.md`
- `docs/modules/signals/guides/unknowns-registry.md`
- `docs/modules/ui/console-architecture.md`
## Delivery Tracker
### LIVE-REPAIR-011-001 - Repair remaining authenticated route contracts at the source
Status: DOING
Dependency: none
Owners: Developer, Test Automation
Task description:
- Fix the confirmed live contract defects behind the remaining failed routes: quota authorization OR-scope semantics, dead-letter summary SQL fallback coverage, missing Platform console/AOC compatibility endpoints, missing Policy governance and shadow-mode/simulation endpoints, missing Signals compatibility list/stats endpoints, and the remaining frontend adapters for pack-registry and notifications.
- Favor durable compatibility/read-model layers and tests over route-local workarounds.
Completion criteria:
- [ ] `/ops/operations/quotas`, `/ops/operations/dead-letter`, `/ops/operations/aoc`, `/ops/operations/signals`, `/ops/operations/packs`, `/ops/operations/notifications`, `/ops/operations/status`, `/ops/policy/simulation`, `/ops/policy/trust-weights`, and `/ops/policy/staleness` stop failing for the currently confirmed source-level reasons.
- [ ] Targeted tests against the touched `.csproj` and frontend spec files fail before the fix and pass after it.
- [ ] Updated docs describe any new compatibility contract that is now part of the live platform.
### LIVE-REPAIR-011-002 - Rebuild and redeploy the repaired service slice
Status: TODO
Dependency: LIVE-REPAIR-011-001
Owners: Developer, QA
Task description:
- Rebuild every touched service and the web bundle from the repaired source, redeploy them into the local compose stack, and verify direct service readiness before rerunning Playwright.
Completion criteria:
- [ ] Changed images and the web bundle are rebuilt from current source.
- [ ] The live compose stack is redeployed without disturbing unrelated in-flight work.
- [ ] Direct service probes succeed for the repaired compatibility surfaces before the browser sweep resumes.
### LIVE-REPAIR-011-003 - Reverify the authenticated frontdoor with Playwright
Status: TODO
Dependency: LIVE-REPAIR-011-002
Owners: QA
Task description:
- Rerun the authenticated frontdoor Playwright checks from the rebuilt stack, verify the previously failing pages load cleanly, and record any remaining route/action defects for the next iteration instead of declaring premature all-clear.
Completion criteria:
- [ ] Authenticated Playwright auth bootstrap and canonical route sweep are rerun against `https://stella-ops.local`.
- [ ] Targeted page/action rechecks are captured for the repaired route family.
- [ ] Remaining failures, if any, are documented with current artifacts and triaged to the next sprint item.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-09 | Sprint created after the rebuilt live stack still failed 10 authenticated canonical routes due to confirmed source-level contract gaps across Authority, Platform, JobEngine, Policy, Signals, and Web. | Developer |
| 2026-03-09 | Policy simulation compatibility handlers now serve history, compare, verify, and pin contracts in the Policy gateway; targeted xUnit v3 class execution passed, and live frontdoor retesting isolated the remaining failure to router translation gaps for `/policy/simulations*` rather than missing service endpoints. | Developer |
## Decisions & Risks
- Decision: keep quota backward compatibility in Authority authorization semantics rather than diluting Platform policy names or broadening token issuance.
- Decision: add deterministic compatibility/read-model endpoints where the rebuilt frontend already depends on stable contracts (`/api/console/status`, `/api/v1/aoc/*`, `/api/v1/governance/*`, `/policy/shadow/*`, `/api/v1/signals*`) instead of replacing live HTTP clients with mocks.
- Decision: treat Policy simulation history tools as a two-layer repair. First restore the backend compatibility contract inside `StellaOps.Policy.Gateway`; then handle the frontdoor router translation for `/policy/simulations*` as a separate iteration so service and routing fixes remain independently auditable.
- Risk: the notifications health `400` remains the least-certain defect in the current set; if the direct service probe still disagrees with the frontdoor after the rebuild, isolate it in the Notify slice rather than masking it in Playwright expectations.
- Audit note: one external web lookup was attempted earlier in the session before the repo web-fetch policy was re-read; no external code or configuration was imported, and implementation continued using local docs and source only.
## Next Checkpoints
- 2026-03-09: land scoped source/test fixes for the remaining authenticated route cluster.
- 2026-03-09: rebuild the changed services and web bundle from source.
- 2026-03-09: rerun authenticated Playwright sweeps and either commit the repaired iteration or record the remaining defects for the next pass.

View File

@@ -38,6 +38,17 @@ Non-goals: policy authoring UI (handled by Console), ingestion or advisory norma
- Translation sources are layered deterministically: shared embedded `common` bundle -> Policy embedded bundle (`Translations/*.policy.json`) -> Platform runtime override bundle.
- The rollout localizes selected request validation and readiness responses for `en-US` and `de-DE`.
### 1.2 · Simulation compatibility contract (Sprint 20260309_011)
- The Policy Gateway exposes a deterministic compatibility surface for the Console simulation history workflow while the deeper Policy Engine read models continue to evolve.
- Compatibility endpoints under `/policy` include:
- `GET /policy/simulations/history`
- `GET /policy/simulations/compare`
- `POST /policy/simulations/{simulationId}/verify`
- `PATCH /policy/simulations/{simulationId}`
- These endpoints return tenant-scoped history entries, comparison diffs, reproducibility checks, and pin state with stable field names that match the live Console contract (`resultHash`, `findingsBySeverity`, `pinned`, `matchPercentage`, `discrepancies`).
- The compatibility layer is intentionally stateful per tenant so operators can exercise history actions end to end in the live shell without client-side mocks.
---
## 2·High-Level Architecture

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));
}
}