Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -26,8 +26,70 @@ public sealed class PolicyRunModelsTests
|
||||
Assert.Equal(JsonValueKind.True, inputs.Environment["sealed"].ValueKind);
|
||||
Assert.Equal("internet", inputs.Environment["exposure"].GetString());
|
||||
Assert.Equal("global", inputs.Environment["region"].GetString());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicySimulationWebhookPayloadFactory_ComputesSucceeded()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var job = CreateJob(PolicyRunJobStatus.Completed, now);
|
||||
var status = PolicyRunStatusFactory.Create(job, now);
|
||||
|
||||
var payload = PolicySimulationWebhookPayloadFactory.Create(status, now);
|
||||
|
||||
Assert.Equal(succeeded, payload.Result);
|
||||
Assert.Equal(status, payload.Simulation);
|
||||
Assert.Null(payload.Reason);
|
||||
Assert.NotNull(payload.LatencySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicySimulationWebhookPayloadFactory_ComputesFailureReason()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var job = CreateJob(PolicyRunJobStatus.Failed, now) with { LastError = timeout };
|
||||
var status = PolicyRunStatusFactory.Create(job, now);
|
||||
|
||||
var payload = PolicySimulationWebhookPayloadFactory.Create(status, now);
|
||||
|
||||
Assert.Equal(failed, payload.Result);
|
||||
Assert.Equal(timeout, payload.Reason);
|
||||
}
|
||||
|
||||
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, DateTimeOffset timestamp)
|
||||
{
|
||||
return new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: job,
|
||||
TenantId: tenant,
|
||||
PolicyId: policy,
|
||||
PolicyVersion: 1,
|
||||
Mode: PolicyRunMode.Simulate,
|
||||
Priority: PolicyRunPriority.Normal,
|
||||
PriorityRank: 0,
|
||||
RunId: run,
|
||||
RequestedBy: tester,
|
||||
CorrelationId: corr,
|
||||
Metadata: null,
|
||||
Inputs: PolicyRunInputs.Empty,
|
||||
QueuedAt: timestamp,
|
||||
Status: status,
|
||||
AttemptCount: 1,
|
||||
LastAttemptAt: timestamp,
|
||||
LastError: status == PolicyRunJobStatus.Failed ? error : null,
|
||||
CreatedAt: timestamp,
|
||||
UpdatedAt: timestamp,
|
||||
AvailableAt: timestamp,
|
||||
SubmittedAt: timestamp,
|
||||
CompletedAt: status == PolicyRunJobStatus.Completed ? timestamp : null,
|
||||
LeaseOwner: null,
|
||||
LeaseExpiresAt: null,
|
||||
CancellationRequested: status == PolicyRunJobStatus.Cancelled,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: status == PolicyRunJobStatus.Cancelled ? timestamp : null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyRunStatus_ThrowsOnNegativeAttempts()
|
||||
{
|
||||
|
||||
@@ -56,10 +56,11 @@ public sealed class GraphJobEventPublisherTests
|
||||
|
||||
await publisher.PublishAsync(notification, CancellationToken.None);
|
||||
|
||||
var message = Assert.Single(loggerProvider.Messages);
|
||||
Assert.Contains("\"kind\":\"scheduler.graph.job.completed\"", message);
|
||||
Assert.Contains("\"tenant\":\"tenant-alpha\"", message);
|
||||
Assert.Contains("\"resultUri\":\"oras://result\"", message);
|
||||
Assert.Contains(loggerProvider.Messages, message => message.Contains("unsupported driver", StringComparison.OrdinalIgnoreCase));
|
||||
var eventPayload = loggerProvider.Messages.FirstOrDefault(message => message.Contains("\"kind\":\"scheduler.graph.job.completed\"", StringComparison.Ordinal));
|
||||
Assert.NotNull(eventPayload);
|
||||
Assert.Contains("\"tenant\":\"tenant-alpha\"", eventPayload);
|
||||
Assert.Contains("\"resultUri\":\"oras://result\"", eventPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public PolicySimulationEndpointTests(WebApplicationFactory<Program> 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<string, string> { ["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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<string, long>
|
||||
{
|
||||
["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<IPolicySimulationMetricsProvider>(stub);
|
||||
services.AddSingleton<IPolicySimulationMetricsRecorder>(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<JsonElement>();
|
||||
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<JsonElement>()).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<JsonElement>();
|
||||
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<JsonElement>()).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<JsonElement>();
|
||||
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<JsonElement>()).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<string, string?>("Scheduler:Storage:ConnectionString", runner.ConnectionString),
|
||||
new KeyValuePair<string, string?>("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<JsonElement>()).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<JsonElement>();
|
||||
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<string, long>()),
|
||||
new PolicySimulationLatencyMetrics(0, null, null, null, null, null));
|
||||
|
||||
public List<double> RecordedLatencies { get; } = new();
|
||||
|
||||
public Task<PolicySimulationMetricsResponse> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
@@ -17,7 +28,7 @@ public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Progr
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-runs");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
|
||||
|
||||
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
@@ -66,11 +77,11 @@ public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Progr
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewImpactForSchedule()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-preview");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview");
|
||||
public async Task PreviewImpactForSchedule()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-preview");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
|
||||
|
||||
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
@@ -96,9 +107,224 @@ public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Progr
|
||||
sampleSize = 3
|
||||
});
|
||||
|
||||
previewResponse.EnsureSuccessStatusCode();
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(preview.GetProperty("total").GetInt32() >= 0);
|
||||
Assert.True(preview.GetProperty("sample").GetArrayLength() <= 3);
|
||||
}
|
||||
}
|
||||
previewResponse.EnsureSuccessStatusCode();
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(preview.GetProperty("total").GetInt32() >= 0);
|
||||
Assert.True(preview.GetProperty("sample").GetArrayLength() <= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryRunCreatesNewRun()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-retry");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
|
||||
|
||||
var scheduleId = await CreateScheduleAsync(client, "RetrySchedule");
|
||||
|
||||
var createRun = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
|
||||
{
|
||||
scheduleId,
|
||||
trigger = "manual"
|
||||
});
|
||||
|
||||
createRun.EnsureSuccessStatusCode();
|
||||
var runJson = await createRun.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(runId));
|
||||
|
||||
var cancelResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/cancel", null);
|
||||
cancelResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var retryResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/retry", content: null);
|
||||
retryResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal(System.Net.HttpStatusCode.Created, retryResponse.StatusCode);
|
||||
|
||||
var retryJson = await retryResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var retryRun = retryJson.GetProperty("run");
|
||||
Assert.Equal("planning", retryRun.GetProperty("state").GetString());
|
||||
Assert.Equal(runId, retryRun.GetProperty("retryOf").GetString());
|
||||
Assert.Equal("manual", retryRun.GetProperty("trigger").GetString());
|
||||
Assert.Contains("retry-of:", retryRun.GetProperty("reason").GetProperty("manualReason").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRunDeltasReturnsMetadata()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-deltas");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
|
||||
|
||||
var scheduleId = await CreateScheduleAsync(client, "DeltaSchedule");
|
||||
|
||||
var runResponse = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
|
||||
{
|
||||
scheduleId,
|
||||
trigger = "manual"
|
||||
});
|
||||
|
||||
runResponse.EnsureSuccessStatusCode();
|
||||
var runJson = await runResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runId = runJson.GetProperty("run").GetProperty("id").GetString()!;
|
||||
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IRunRepository>();
|
||||
var existing = await repository.GetAsync("tenant-deltas", runId);
|
||||
Assert.NotNull(existing);
|
||||
|
||||
var deltas = ImmutableArray.Create(new DeltaSummary(
|
||||
"sha256:" + new string('a', 64),
|
||||
newFindings: 2,
|
||||
newCriticals: 1,
|
||||
newHigh: 1,
|
||||
newMedium: 0,
|
||||
newLow: 0));
|
||||
|
||||
var updated = new Run(
|
||||
existing!.Id,
|
||||
existing.TenantId,
|
||||
existing.Trigger,
|
||||
existing.State,
|
||||
existing.Stats,
|
||||
existing.CreatedAt,
|
||||
existing.Reason,
|
||||
existing.ScheduleId,
|
||||
existing.StartedAt,
|
||||
existing.FinishedAt,
|
||||
existing.Error,
|
||||
deltas,
|
||||
existing.RetryOf,
|
||||
existing.SchemaVersion);
|
||||
|
||||
await repository.UpdateAsync(updated);
|
||||
}
|
||||
|
||||
var deltasResponse = await client.GetAsync($"/api/v1/scheduler/runs/{runId}/deltas");
|
||||
deltasResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var deltasJson = await deltasResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(1, deltasJson.GetProperty("deltas").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueLagSummaryReturnsDepth()
|
||||
{
|
||||
SchedulerQueueMetrics.RecordDepth("redis", "scheduler-runner", 7);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-queue");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read scheduler.runs.manage");
|
||||
|
||||
var queueResponse = await client.GetAsync("/api/v1/scheduler/runs/queue/lag");
|
||||
queueResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var summary = await queueResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(summary.GetProperty("totalDepth").GetInt64() >= 7);
|
||||
Assert.True(summary.GetProperty("queues").EnumerateArray().Any());
|
||||
}
|
||||
finally
|
||||
{
|
||||
SchedulerQueueMetrics.RemoveDepth("redis", "scheduler-runner");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamRunEmitsInitialEvent()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-stream");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview scheduler.runs.manage");
|
||||
|
||||
var scheduleId = await CreateScheduleAsync(client, "StreamSchedule");
|
||||
|
||||
var runResponse = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
|
||||
{
|
||||
scheduleId,
|
||||
trigger = "manual"
|
||||
});
|
||||
|
||||
runResponse.EnsureSuccessStatusCode();
|
||||
var runJson = await runResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(runId));
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scheduler/runs/{runId}/stream");
|
||||
request.Headers.Accept.ParseAdd("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);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var seenRetry = false;
|
||||
var seenInitial = false;
|
||||
var seenQueueLag = false;
|
||||
var seenHeartbeat = false;
|
||||
|
||||
while (!cts.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 was not observed.");
|
||||
Assert.True(seenInitial, "Initial snapshot was not observed.");
|
||||
Assert.True(seenQueueLag, "Queue lag event was not observed.");
|
||||
Assert.True(seenHeartbeat, "Heartbeat event was not observed.");
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScheduleAsync(HttpClient client, string name)
|
||||
{
|
||||
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
name,
|
||||
cronExpression = "0 1 * * *",
|
||||
timezone = "UTC",
|
||||
mode = "analysis-only",
|
||||
selection = new { scope = "all-images" }
|
||||
});
|
||||
|
||||
scheduleResponse.EnsureSuccessStatusCode();
|
||||
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(scheduleId));
|
||||
return scheduleId!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
@@ -41,6 +43,13 @@ public sealed class SchedulerWebApplicationFactory : WebApplicationFactory<Progr
|
||||
options.Webhooks.Excitor.HmacSecret = "excitor-secret";
|
||||
options.Webhooks.Excitor.Enabled = true;
|
||||
});
|
||||
|
||||
services.PostConfigure<RunStreamOptions>(options =>
|
||||
{
|
||||
options.PollInterval = TimeSpan.FromMilliseconds(100);
|
||||
options.QueueLagInterval = TimeSpan.FromMilliseconds(200);
|
||||
options.HeartbeatInterval = TimeSpan.FromMilliseconds(150);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
@@ -17,4 +18,4 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -4,12 +4,13 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
@@ -6,10 +6,11 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -46,11 +47,12 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var webhook = new RecordingPolicySimulationWebhookClient();
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
@@ -63,26 +65,29 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
Assert.Single(webhook.Payloads);
|
||||
Assert.Equal("cancelled", webhook.Payloads[0].Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient
|
||||
{
|
||||
Result = PolicyRunSubmissionResult.Succeeded("run:P-7:2025", DateTimeOffset.Parse("2025-10-28T10:01:00Z"))
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
var client = new StubPolicyRunClient
|
||||
{
|
||||
Result = PolicyRunSubmissionResult.Succeeded("run:P-7:2025", DateTimeOffset.Parse("2025-10-28T10:01:00Z"))
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var webhook = new RecordingPolicySimulationWebhookClient();
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
@@ -93,11 +98,12 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Submitted, result.UpdatedJob.Status);
|
||||
Assert.Equal("run:P-7:2025", result.UpdatedJob.RunId);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Null(result.UpdatedJob.LastError);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal(PolicyRunJobStatus.Submitted, result.UpdatedJob.Status);
|
||||
Assert.Equal("run:P-7:2025", result.UpdatedJob.RunId);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Null(result.UpdatedJob.LastError);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Empty(webhook.Payloads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,13 +115,14 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
Result = PolicyRunSubmissionResult.Failed("timeout")
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var webhook = new RecordingPolicySimulationWebhookClient();
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
@@ -127,9 +134,10 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Equal("timeout", result.UpdatedJob.LastError);
|
||||
Assert.True(result.UpdatedJob.AvailableAt > job.AvailableAt);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Equal("timeout", result.UpdatedJob.LastError);
|
||||
Assert.True(result.UpdatedJob.AvailableAt > job.AvailableAt);
|
||||
Assert.Empty(webhook.Payloads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -144,12 +152,13 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
optionsValue.Policy.Dispatch.MaxAttempts = 1;
|
||||
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var webhook = new RecordingPolicySimulationWebhookClient();
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
|
||||
{
|
||||
@@ -157,11 +166,13 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Failed, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Failed, result.UpdatedJob.Status);
|
||||
Assert.Equal("bad request", result.UpdatedJob.LastError);
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Failed, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Failed, result.UpdatedJob.Status);
|
||||
Assert.Equal("bad request", result.UpdatedJob.LastError);
|
||||
Assert.Single(webhook.Payloads);
|
||||
Assert.Equal("failed", webhook.Payloads[0].Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -172,11 +183,12 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.NoWork(job, "empty")
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.NoWork(job, "empty")
|
||||
};
|
||||
var webhook = new RecordingPolicySimulationWebhookClient();
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
|
||||
{
|
||||
@@ -186,10 +198,12 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.NoOp, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Completed, result.UpdatedJob.Status);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
Assert.Equal(PolicyRunExecutionResultType.NoOp, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Completed, result.UpdatedJob.Status);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
Assert.Single(webhook.Payloads);
|
||||
Assert.Equal("succeeded", webhook.Payloads[0].Result);
|
||||
}
|
||||
|
||||
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
|
||||
@@ -253,15 +267,23 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
|
||||
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
|
||||
},
|
||||
Targeting = new SchedulerWorkerOptions.PolicyOptions.TargetingOptions
|
||||
{
|
||||
Enabled = WorkerOptions.Policy.Targeting.Enabled,
|
||||
MaxSboms = WorkerOptions.Policy.Targeting.MaxSboms,
|
||||
DefaultUsageOnly = WorkerOptions.Policy.Targeting.DefaultUsageOnly
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Targeting = new SchedulerWorkerOptions.PolicyOptions.TargetingOptions
|
||||
{
|
||||
Enabled = WorkerOptions.Policy.Targeting.Enabled,
|
||||
MaxSboms = WorkerOptions.Policy.Targeting.MaxSboms,
|
||||
DefaultUsageOnly = WorkerOptions.Policy.Targeting.DefaultUsageOnly
|
||||
},
|
||||
Webhook = new SchedulerWorkerOptions.PolicyOptions.WebhookOptions
|
||||
{
|
||||
Enabled = WorkerOptions.Policy.Webhook.Enabled,
|
||||
Endpoint = WorkerOptions.Policy.Webhook.Endpoint,
|
||||
ApiKeyHeader = WorkerOptions.Policy.Webhook.ApiKeyHeader,
|
||||
ApiKey = WorkerOptions.Policy.Webhook.ApiKey,
|
||||
TimeoutSeconds = WorkerOptions.Policy.Webhook.TimeoutSeconds
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
|
||||
{
|
||||
@@ -271,8 +293,19 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
|
||||
{
|
||||
private sealed class RecordingPolicySimulationWebhookClient : IPolicySimulationWebhookClient
|
||||
{
|
||||
public List<PolicySimulationWebhookPayload> Payloads { get; } = new();
|
||||
|
||||
public Task NotifyAsync(PolicySimulationWebhookPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
Payloads.Add(payload);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
|
||||
{
|
||||
public bool ReplaceCalled { get; private set; }
|
||||
public string? ExpectedLeaseOwner { get; private set; }
|
||||
public PolicyRunJob? LastJob { get; private set; }
|
||||
@@ -280,17 +313,20 @@ public sealed class PolicyRunExecutionServiceTests
|
||||
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task<PolicyRunJob?> GetByRunIdAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task InsertAsync(PolicyRunJob job, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastJob = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PolicyRunJob?> LeaseAsync(string leaseOwner, DateTimeOffset now, TimeSpan leaseDuration, int maxAttempts, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
public Task<PolicyRunJob?> GetByRunIdAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task InsertAsync(PolicyRunJob job, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastJob = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> CountAsync(string tenantId, PolicyRunMode mode, IReadOnlyCollection<PolicyRunJobStatus> statuses, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0L);
|
||||
|
||||
public Task<PolicyRunJob?> LeaseAsync(string leaseOwner, DateTimeOffset now, TimeSpan leaseDuration, int maxAttempts, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PolicySimulationWebhookClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NotifyAsync_Disabled_DoesNotInvokeEndpoint()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(handler);
|
||||
var options = CreateOptions();
|
||||
var client = new HttpPolicySimulationWebhookClient(httpClient, options, NullLogger<HttpPolicySimulationWebhookClient>.Instance);
|
||||
|
||||
var payload = PolicySimulationWebhookPayloadFactory.Create(CreateStatus(), DateTimeOffset.UtcNow);
|
||||
await client.NotifyAsync(payload, CancellationToken.None);
|
||||
|
||||
Assert.False(handler.WasInvoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_SendsPayload_WhenEnabled()
|
||||
{
|
||||
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Accepted));
|
||||
using var httpClient = new HttpClient(handler);
|
||||
var options = CreateOptions(o =>
|
||||
{
|
||||
o.Policy.Webhook.Enabled = true;
|
||||
o.Policy.Webhook.Endpoint = "https://example.org/webhooks/policy";
|
||||
o.Policy.Webhook.ApiKeyHeader = "X-Test-Key";
|
||||
o.Policy.Webhook.ApiKey = "secret";
|
||||
o.Policy.Webhook.TimeoutSeconds = 5;
|
||||
});
|
||||
|
||||
var client = new HttpPolicySimulationWebhookClient(httpClient, options, NullLogger<HttpPolicySimulationWebhookClient>.Instance);
|
||||
|
||||
var observedAt = DateTimeOffset.UtcNow;
|
||||
var payload = PolicySimulationWebhookPayloadFactory.Create(CreateStatus(), observedAt);
|
||||
await client.NotifyAsync(payload, CancellationToken.None);
|
||||
|
||||
Assert.True(handler.WasInvoked);
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("https://example.org/webhooks/policy", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.True(handler.LastRequest.Headers.Contains("X-Test-Key"));
|
||||
Assert.True(handler.LastRequest.Headers.Contains("X-StellaOps-Run-Id"));
|
||||
Assert.Equal("secret", handler.LastRequest.Headers.GetValues("X-Test-Key").Single());
|
||||
}
|
||||
|
||||
private static PolicyRunStatus CreateStatus()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var job = new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: "job",
|
||||
TenantId: "tenant",
|
||||
PolicyId: "policy",
|
||||
PolicyVersion: 1,
|
||||
Mode: PolicyRunMode.Simulate,
|
||||
Priority: PolicyRunPriority.Normal,
|
||||
PriorityRank: 0,
|
||||
RunId: "run:policy:123",
|
||||
RequestedBy: "tester",
|
||||
CorrelationId: "corr",
|
||||
Metadata: null,
|
||||
Inputs: PolicyRunInputs.Empty,
|
||||
QueuedAt: now,
|
||||
Status: PolicyRunJobStatus.Completed,
|
||||
AttemptCount: 1,
|
||||
LastAttemptAt: now,
|
||||
LastError: null,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
AvailableAt: now,
|
||||
SubmittedAt: now,
|
||||
CompletedAt: now,
|
||||
LeaseOwner: null,
|
||||
LeaseExpiresAt: null,
|
||||
CancellationRequested: false,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: null);
|
||||
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<SchedulerWorkerOptions> CreateOptions(Action<SchedulerWorkerOptions>? configure = null)
|
||||
{
|
||||
var value = new SchedulerWorkerOptions();
|
||||
configure?.Invoke(value);
|
||||
return new StaticOptionsMonitor<SchedulerWorkerOptions>(value);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public RecordingHandler(HttpResponseMessage? response = null)
|
||||
{
|
||||
_response = response ?? new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
public bool WasInvoked { get; private set; }
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
WasInvoked = true;
|
||||
LastRequest = request;
|
||||
return Task.FromResult(_response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly IDisposable Instance = new NoopDisposable();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user