using System.Net; using System.Net.Http.Headers; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Mongo2Go; using StellaOps.Scheduler.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scheduler.WebService.PolicySimulations; using System.Collections.Generic; using System.Threading; namespace StellaOps.Scheduler.WebService.Tests; public sealed class PolicySimulationEndpointTests : IClassFixture> { private readonly WebApplicationFactory _factory; public PolicySimulationEndpointTests(WebApplicationFactory factory) { _factory = factory; } [Fact] public async Task CreateListGetSimulation() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-alpha", policyVersion = 3, metadata = new Dictionary { ["requestedBy"] = "unit-test" }, inputs = new { sbomSet = new[] { "sbom://alpha", "sbom://bravo" }, captureExplain = true } }); createResponse.EnsureSuccessStatusCode(); Assert.Equal(System.Net.HttpStatusCode.Created, createResponse.StatusCode); var created = await createResponse.Content.ReadFromJsonAsync(); var runId = created.GetProperty("simulation").GetProperty("runId").GetString(); Assert.False(string.IsNullOrEmpty(runId)); Assert.Equal("simulate", created.GetProperty("simulation").GetProperty("mode").GetString()); var listResponse = await client.GetAsync("/api/v1/scheduler/policies/simulations?limit=5"); listResponse.EnsureSuccessStatusCode(); var list = await listResponse.Content.ReadFromJsonAsync(); Assert.True(list.GetProperty("simulations").EnumerateArray().Any()); var getResponse = await client.GetAsync($"/api/v1/scheduler/policies/simulations/{runId}"); getResponse.EnsureSuccessStatusCode(); var simulation = await getResponse.Content.ReadFromJsonAsync(); Assert.Equal(runId, simulation.GetProperty("simulation").GetProperty("runId").GetString()); } [Fact] public async Task MetricsEndpointWithoutProviderReturns501() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-metrics-missing"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var response = await client.GetAsync("/api/v1/scheduler/policies/simulations/metrics"); Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode); } [Fact] public async Task MetricsEndpointReturnsSummary() { var stub = new StubPolicySimulationMetricsProvider { Response = new PolicySimulationMetricsResponse( new PolicySimulationQueueDepth( 3, new Dictionary { ["pending"] = 2, ["dispatching"] = 1 }), new PolicySimulationLatencyMetrics( Samples: 2, P50: 1.5, P90: 2.5, P95: 3.5, P99: 4.0, Mean: 2.0)) }; await using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { services.AddSingleton(stub); services.AddSingleton(stub); }); }); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-metrics"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var response = await client.GetAsync("/api/v1/scheduler/policies/simulations/metrics"); response.EnsureSuccessStatusCode(); var payload = await response.Content.ReadFromJsonAsync(); Assert.Equal(3, payload.GetProperty("policy_simulation_queue_depth").GetProperty("total").GetInt32()); Assert.Equal(2, payload.GetProperty("policy_simulation_latency").GetProperty("samples").GetInt32()); Assert.Equal(2.0, payload.GetProperty("policy_simulation_latency").GetProperty("mean_seconds").GetDouble()); } [Fact] public async Task CreateSimulationRequiresScopeHeader() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-auth"); var response = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-auth", policyVersion = 1 }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task CreateSimulationRequiresPolicySimulateScope() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-authz"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:run"); var response = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-authz", policyVersion = 2 }); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task CancelSimulationMarksStatus() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-cancel"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var create = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-bravo", policyVersion = 2 }); create.EnsureSuccessStatusCode(); var runId = (await create.Content.ReadFromJsonAsync()).GetProperty("simulation").GetProperty("runId").GetString(); var cancel = await client.PostAsJsonAsync($"/api/v1/scheduler/policies/simulations/{runId}/cancel", new { reason = "user-request" }); cancel.EnsureSuccessStatusCode(); var cancelled = await cancel.Content.ReadFromJsonAsync(); Assert.True(cancelled.GetProperty("simulation").GetProperty("cancellationRequested").GetBoolean()); } [Fact] public async Task RetrySimulationCreatesNewRun() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-retry"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var create = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-charlie", policyVersion = 5 }); create.EnsureSuccessStatusCode(); var runId = (await create.Content.ReadFromJsonAsync()).GetProperty("simulation").GetProperty("runId").GetString(); // Mark as cancelled to allow retry await client.PostAsJsonAsync($"/api/v1/scheduler/policies/simulations/{runId}/cancel", new { reason = "cleanup" }); var retry = await client.PostAsync($"/api/v1/scheduler/policies/simulations/{runId}/retry", content: null); retry.EnsureSuccessStatusCode(); Assert.Equal(System.Net.HttpStatusCode.Created, retry.StatusCode); var retried = await retry.Content.ReadFromJsonAsync(); var newRunId = retried.GetProperty("simulation").GetProperty("runId").GetString(); Assert.False(string.IsNullOrEmpty(newRunId)); Assert.NotEqual(runId, newRunId); var metadata = retried.GetProperty("simulation").GetProperty("metadata"); Assert.True(metadata.TryGetProperty("retry-of", out var retryOf)); Assert.Equal(runId, retryOf.GetString()); } [Fact] public async Task StreamSimulationEmitsCoreEvents() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-stream"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var create = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-delta", policyVersion = 7 }); create.EnsureSuccessStatusCode(); var runId = (await create.Content.ReadFromJsonAsync()).GetProperty("simulation").GetProperty("runId").GetString(); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scheduler/policies/simulations/{runId}/stream"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); var seenRetry = false; var seenInitial = false; var seenQueueLag = false; var seenHeartbeat = false; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); while (!cts.Token.IsCancellationRequested && !(seenRetry && seenInitial && seenQueueLag && seenHeartbeat)) { var readTask = reader.ReadLineAsync(); var completed = await Task.WhenAny(readTask, Task.Delay(200, cts.Token)); if (completed != readTask) { continue; } var line = await readTask; if (line is null) { break; } if (line.Length == 0) { continue; } if (line.StartsWith("retry:", StringComparison.Ordinal)) { seenRetry = true; } else if (line.StartsWith("event: initial", StringComparison.Ordinal)) { seenInitial = true; } else if (line.StartsWith("event: queueLag", StringComparison.Ordinal)) { seenQueueLag = true; } else if (line.StartsWith("event: heartbeat", StringComparison.Ordinal)) { seenHeartbeat = true; } } Assert.True(seenRetry, "Retry directive should be emitted before events."); Assert.True(seenInitial, "Initial event was not observed."); Assert.True(seenQueueLag, "Queue lag event was not observed."); Assert.True(seenHeartbeat, "Heartbeat event was not observed."); } [Fact] public async Task MongoBackedCreateSimulationPersists() { using var runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet"); await using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new[] { new KeyValuePair("Scheduler:Storage:ConnectionString", runner.ConnectionString), new KeyValuePair("Scheduler:Storage:Database", $"scheduler_web_tests_{Guid.NewGuid():N}") }); }); }); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-sim-mongo"); client.DefaultRequestHeaders.Add("X-Scopes", "policy:simulate"); var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/policies/simulations", new { policyId = "policy-mongo", policyVersion = 11 }); createResponse.EnsureSuccessStatusCode(); var runId = (await createResponse.Content.ReadFromJsonAsync()).GetProperty("simulation").GetProperty("runId").GetString(); Assert.False(string.IsNullOrEmpty(runId)); var fetched = await client.GetAsync($"/api/v1/scheduler/policies/simulations/{runId}"); fetched.EnsureSuccessStatusCode(); var payload = await fetched.Content.ReadFromJsonAsync(); Assert.Equal(runId, payload.GetProperty("simulation").GetProperty("runId").GetString()); } private sealed class StubPolicySimulationMetricsProvider : IPolicySimulationMetricsProvider, IPolicySimulationMetricsRecorder { public PolicySimulationMetricsResponse Response { get; set; } = new( new PolicySimulationQueueDepth(0, new Dictionary()), new PolicySimulationLatencyMetrics(0, null, null, null, null, null)); public List RecordedLatencies { get; } = new(); public Task CaptureAsync(string tenantId, CancellationToken cancellationToken) => Task.FromResult(Response); public void RecordLatency(PolicyRunStatus status, DateTimeOffset observedAt) { var finishedAt = status.FinishedAt ?? observedAt; var latency = (finishedAt - status.QueuedAt).TotalSeconds; if (latency >= 0) { RecordedLatencies.Add(latency); } } } }