Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,142 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using Xunit;
namespace StellaOps.Scheduler.ImpactIndex.Tests;
public sealed class FixtureImpactIndexTests
{
[Fact]
public async Task ResolveByPurls_UsesEmbeddedFixtures()
{
var selector = new Selector(SelectorScope.AllImages);
var (impactIndex, loggerFactory) = CreateImpactIndex();
using var _ = loggerFactory;
var result = await impactIndex.ResolveByPurlsAsync(
new[] { "pkg:apk/alpine/openssl@3.2.2-r0?arch=x86_64" },
usageOnly: false,
selector);
result.UsageOnly.Should().BeFalse();
result.Images.Should().ContainSingle();
var image = result.Images.Single();
image.ImageDigest.Should().Be("sha256:8f47d7c6b538c0d9533b78913cba3d5e671e7c4b4e7c6a2bb9a1a1c4d4f8e123");
image.Registry.Should().Be("docker.io");
image.Repository.Should().Be("library/nginx");
image.Tags.Should().ContainSingle(tag => tag == "1.25.4");
image.UsedByEntrypoint.Should().BeTrue();
result.GeneratedAt.Should().Be(DateTimeOffset.Parse("2025-10-19T00:00:00Z"));
result.SchemaVersion.Should().Be(SchedulerSchemaVersions.ImpactSet);
}
[Fact]
public async Task ResolveByPurls_UsageOnlyFiltersInventoryOnlyComponents()
{
var selector = new Selector(SelectorScope.AllImages);
var (impactIndex, loggerFactory) = CreateImpactIndex();
using var _ = loggerFactory;
var inventoryOnlyPurl = "pkg:apk/alpine/pcre2@10.42-r1?arch=x86_64";
var runtimeResult = await impactIndex.ResolveByPurlsAsync(
new[] { inventoryOnlyPurl },
usageOnly: true,
selector);
runtimeResult.Images.Should().BeEmpty();
var inventoryResult = await impactIndex.ResolveByPurlsAsync(
new[] { inventoryOnlyPurl },
usageOnly: false,
selector);
inventoryResult.Images.Should().ContainSingle();
inventoryResult.Images.Single().UsedByEntrypoint.Should().BeFalse();
}
[Fact]
public async Task ResolveAll_ReturnsDeterministicFixtureSet()
{
var selector = new Selector(SelectorScope.AllImages);
var (impactIndex, loggerFactory) = CreateImpactIndex();
using var _ = loggerFactory;
var first = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
first.Images.Should().HaveCount(6);
var second = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
second.Images.Should().HaveCount(6);
second.Images.Should().Equal(first.Images);
}
[Fact]
public async Task ResolveByVulnerabilities_ReturnsEmptySet()
{
var selector = new Selector(SelectorScope.AllImages);
var (impactIndex, loggerFactory) = CreateImpactIndex();
using var _ = loggerFactory;
var result = await impactIndex.ResolveByVulnerabilitiesAsync(
new[] { "CVE-2025-0001" },
usageOnly: false,
selector);
result.Images.Should().BeEmpty();
}
[Fact]
public async Task FixtureDirectoryOption_LoadsFromFileSystem()
{
var selector = new Selector(SelectorScope.AllImages);
var samplesDirectory = LocateSamplesDirectory();
var (impactIndex, loggerFactory) = CreateImpactIndex(options =>
{
options.FixtureDirectory = samplesDirectory;
});
using var _ = loggerFactory;
var result = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
result.Images.Should().HaveCount(6);
}
private static (FixtureImpactIndex ImpactIndex, ILoggerFactory LoggerFactory) CreateImpactIndex(
Action<ImpactIndexStubOptions>? configure = null)
{
var options = new ImpactIndexStubOptions();
configure?.Invoke(options);
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var logger = loggerFactory.CreateLogger<FixtureImpactIndex>();
var impactIndex = new FixtureImpactIndex(options, TimeProvider.System, logger);
return (impactIndex, loggerFactory);
}
private static string LocateSamplesDirectory()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrWhiteSpace(current))
{
var candidate = Path.Combine(current, "samples", "scanner", "images");
if (Directory.Exists(candidate))
{
return candidate;
}
current = Directory.GetParent(current)?.FullName;
}
throw new InvalidOperationException("Unable to locate 'samples/scanner/images'.");
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.Collections.Immutable;
using System.IO;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.ImpactIndex.Ingestion;
using StellaOps.Scheduler.Models;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Index;
using Xunit;
namespace StellaOps.Scheduler.ImpactIndex.Tests;
public sealed class RoaringImpactIndexTests
{
[Fact]
public async Task IngestAsync_RegistersComponentsAndUsage()
{
var (stream, digest) = CreateBomIndex(
ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0"),
ComponentUsage.Create(true, new[] { "/app/start.sh" }));
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
var request = new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = digest,
Registry = "docker.io",
Repository = "library/alpine",
Namespaces = ImmutableArray.Create("team-a"),
Tags = ImmutableArray.Create("3.20"),
Labels = ImmutableSortedDictionary.CreateRange(StringComparer.OrdinalIgnoreCase, new[]
{
new KeyValuePair<string, string>("env", "prod")
}),
BomIndexStream = stream,
};
await index.IngestAsync(request, CancellationToken.None);
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var impactSet = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: false, selector);
impactSet.Images.Should().HaveCount(1);
impactSet.Images[0].ImageDigest.Should().Be(digest);
impactSet.Images[0].Tags.Should().ContainSingle(tag => tag == "3.20");
impactSet.Images[0].UsedByEntrypoint.Should().BeTrue();
var usageOnly = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
usageOnly.Images.Should().HaveCount(1);
}
[Fact]
public async Task IngestAsync_ReplacesExistingImageData()
{
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
var (initialStream, digest) = CreateBomIndex(component, ComponentUsage.Create(false));
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = digest,
Registry = "docker.io",
Repository = "library/alpine",
Tags = ImmutableArray.Create("v1"),
BomIndexStream = initialStream,
});
var (updatedStream, _) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }), digest);
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = digest,
Registry = "docker.io",
Repository = "library/alpine",
Tags = ImmutableArray.Create("v2"),
BomIndexStream = updatedStream,
});
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var impactSet = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
impactSet.Images.Should().HaveCount(1);
impactSet.Images[0].Tags.Should().ContainSingle(tag => tag == "v2");
impactSet.Images[0].UsedByEntrypoint.Should().BeTrue();
}
[Fact]
public async Task ResolveByPurlsAsync_RespectsTenantNamespaceAndTagFilters()
{
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
var (tenantStream, tenantDigest) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }));
var (otherStream, otherDigest) = CreateBomIndex(component, ComponentUsage.Create(false));
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = tenantDigest,
Registry = "docker.io",
Repository = "library/service",
Namespaces = ImmutableArray.Create("team-alpha"),
Tags = ImmutableArray.Create("prod-eu"),
BomIndexStream = tenantStream,
});
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-beta",
ImageDigest = otherDigest,
Registry = "docker.io",
Repository = "library/service",
Namespaces = ImmutableArray.Create("team-beta"),
Tags = ImmutableArray.Create("staging-us"),
BomIndexStream = otherStream,
});
var selector = new Selector(
SelectorScope.AllImages,
tenantId: "tenant-alpha",
namespaces: new[] { "team-alpha" },
includeTags: new[] { "prod-*" });
var result = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
result.Images.Should().ContainSingle(image => image.ImageDigest == tenantDigest);
result.Images[0].Tags.Should().Contain("prod-eu");
}
[Fact]
public async Task ResolveAllAsync_UsageOnlyFiltersEntrypointImages()
{
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
var (entryStream, entryDigest) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }));
var nonEntryDigestValue = "sha256:" + new string('1', 64);
var (nonEntryStream, nonEntryDigest) = CreateBomIndex(component, ComponentUsage.Create(false), nonEntryDigestValue);
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = entryDigest,
Registry = "docker.io",
Repository = "library/service",
BomIndexStream = entryStream,
});
await index.IngestAsync(new ImpactIndexIngestionRequest
{
TenantId = "tenant-alpha",
ImageDigest = nonEntryDigest,
Registry = "docker.io",
Repository = "library/service",
BomIndexStream = nonEntryStream,
});
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var usageOnly = await index.ResolveAllAsync(selector, usageOnly: true);
usageOnly.Images.Should().ContainSingle(image => image.ImageDigest == entryDigest);
var allImages = await index.ResolveAllAsync(selector, usageOnly: false);
allImages.Images.Should().HaveCount(2);
}
private static (Stream Stream, string Digest) CreateBomIndex(ComponentIdentity identity, ComponentUsage usage, string? digest = null)
{
var layer = LayerComponentFragment.Create(
"sha256:layer1",
new[]
{
new ComponentRecord
{
Identity = identity,
LayerDigest = "sha256:layer1",
Usage = usage,
}
});
var graph = ComponentGraphBuilder.Build(new[] { layer });
var effectiveDigest = digest ?? "sha256:" + Guid.NewGuid().ToString("N");
var builder = new BomIndexBuilder();
var artifact = builder.Build(new BomIndexBuildRequest
{
ImageDigest = effectiveDigest,
Graph = graph,
GeneratedAt = DateTimeOffset.UtcNow,
});
return (new MemoryStream(artifact.Bytes, writable: false), effectiveDigest);
}
}

View File

@@ -0,0 +1,27 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Scheduler.Models.Tests;
public sealed class AuditRecordTests
{
[Fact]
public void AuditRecordNormalizesMetadataAndIdentifiers()
{
var actor = new AuditActor(actorId: "user_admin", displayName: "Cluster Admin", kind: "user");
var metadata = new[]
{
new KeyValuePair<string, string>("details", "schedule paused"),
new KeyValuePair<string, string>("Details", "should be overridden"), // duplicate with different casing
new KeyValuePair<string, string>("reason", "maintenance"),
};
var record = new AuditRecord(
id: "audit_001",
tenantId: "tenant-alpha",
category: "scheduler",
action: "pause",
occurredAt: DateTimeOffset.Parse("2025-10-18T05:00:00Z"),
actor: actor,
scheduleId: "sch_001",
runId: null,
correlationId: "corr-123",
metadata: metadata,
message: "Paused via API");
Assert.Equal("tenant-alpha", record.TenantId);
Assert.Equal("scheduler", record.Category);
Assert.Equal(2, record.Metadata.Count);
Assert.Equal("schedule paused", record.Metadata["details"]);
Assert.Equal("maintenance", record.Metadata["reason"]);
var json = CanonicalJsonSerializer.Serialize(record);
Assert.Contains("\"category\":\"scheduler\"", json, StringComparison.Ordinal);
Assert.Contains("\"metadata\":{\"details\":\"schedule paused\",\"reason\":\"maintenance\"}", json, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,171 @@
namespace StellaOps.Scheduler.Models.Tests;
public sealed class GraphJobStateMachineTests
{
[Theory]
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Pending, true)]
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Queued, true)]
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Running, true)]
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Completed, false)]
[InlineData(GraphJobStatus.Queued, GraphJobStatus.Running, true)]
[InlineData(GraphJobStatus.Queued, GraphJobStatus.Completed, false)]
[InlineData(GraphJobStatus.Running, GraphJobStatus.Completed, true)]
[InlineData(GraphJobStatus.Running, GraphJobStatus.Pending, false)]
[InlineData(GraphJobStatus.Completed, GraphJobStatus.Failed, false)]
public void CanTransition_ReturnsExpectedResult(GraphJobStatus from, GraphJobStatus to, bool expected)
{
Assert.Equal(expected, GraphJobStateMachine.CanTransition(from, to));
}
[Fact]
public void EnsureTransition_UpdatesBuildJobLifecycle()
{
var createdAt = new DateTimeOffset(2025, 10, 26, 12, 0, 0, TimeSpan.Zero);
var job = new GraphBuildJob(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom_1",
sbomVersionId: "sbom_ver_1",
sbomDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: createdAt);
var queuedAt = createdAt.AddSeconds(5);
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Queued, queuedAt);
Assert.Equal(GraphJobStatus.Queued, job.Status);
Assert.Null(job.StartedAt);
Assert.Null(job.CompletedAt);
var runningAt = queuedAt.AddSeconds(5);
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Running, runningAt, attempts: job.Attempts + 1);
Assert.Equal(GraphJobStatus.Running, job.Status);
Assert.Equal(runningAt, job.StartedAt);
Assert.Null(job.CompletedAt);
var completedAt = runningAt.AddSeconds(30);
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Completed, completedAt);
Assert.Equal(GraphJobStatus.Completed, job.Status);
Assert.Equal(runningAt, job.StartedAt);
Assert.Equal(completedAt, job.CompletedAt);
Assert.Null(job.Error);
}
[Fact]
public void EnsureTransition_ToFailedRequiresError()
{
var job = new GraphBuildJob(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom_1",
sbomVersionId: "sbom_ver_1",
sbomDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
status: GraphJobStatus.Running,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
startedAt: DateTimeOffset.UtcNow);
Assert.Throws<InvalidOperationException>(() => GraphJobStateMachine.EnsureTransition(
job,
GraphJobStatus.Failed,
DateTimeOffset.UtcNow));
}
[Fact]
public void EnsureTransition_ToFailedSetsError()
{
var job = new GraphOverlayJob(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "graph_snap_1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@latest",
status: GraphJobStatus.Running,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
startedAt: DateTimeOffset.UtcNow);
var failed = GraphJobStateMachine.EnsureTransition(
job,
GraphJobStatus.Failed,
DateTimeOffset.UtcNow,
errorMessage: "cartographer timeout");
Assert.Equal(GraphJobStatus.Failed, failed.Status);
Assert.NotNull(failed.CompletedAt);
Assert.Equal("cartographer timeout", failed.Error);
}
[Fact]
public void Validate_RequiresCompletedAtForTerminalState()
{
var job = new GraphOverlayJob(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "graph_snap_1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@latest",
status: GraphJobStatus.Completed,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow);
Assert.Throws<InvalidOperationException>(() => GraphJobStateMachine.Validate(job));
}
[Fact]
public void GraphOverlayJob_NormalizesSubjectsAndMetadata()
{
var createdAt = DateTimeOffset.UtcNow;
var job = new GraphOverlayJob(
id: "goj_norm",
tenantId: "tenant-alpha",
graphSnapshotId: "graph_snap_norm",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@norm",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: createdAt,
subjects: new[]
{
"artifact/service-api",
"artifact/service-ui",
"artifact/service-api"
},
metadata: new[]
{
new KeyValuePair<string, string>("PolicyRunId", "run-123"),
new KeyValuePair<string, string>("policyRunId", "run-123")
});
Assert.Equal(2, job.Subjects.Length);
Assert.Collection(
job.Subjects,
subject => Assert.Equal("artifact/service-api", subject),
subject => Assert.Equal("artifact/service-ui", subject));
Assert.Single(job.Metadata);
Assert.Equal("run-123", job.Metadata["policyrunid"]);
}
[Fact]
public void GraphBuildJob_NormalizesDigestAndMetadata()
{
var job = new GraphBuildJob(
id: "gbj_norm",
tenantId: "tenant-alpha",
sbomId: "sbom_norm",
sbomVersionId: "sbom_ver_norm",
sbomDigest: "SHA256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890",
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.Manual,
createdAt: DateTimeOffset.UtcNow,
metadata: new[]
{
new KeyValuePair<string, string>("SBoMEventId", "evt-42")
});
Assert.Equal("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", job.SbomDigest);
Assert.Single(job.Metadata);
Assert.Equal("evt-42", job.Metadata["sbomeventid"]);
}
}

View File

@@ -0,0 +1,55 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class ImpactSetTests
{
[Fact]
public void ImpactSetSortsImagesByDigest()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var images = new[]
{
new ImpactImage(
imageDigest: "sha256:bbbb",
registry: "registry.internal",
repository: "app/api",
namespaces: new[] { "team-a" },
tags: new[] { "prod", "latest" },
usedByEntrypoint: true,
labels: new Dictionary<string, string>
{
["env"] = "prod",
}),
new ImpactImage(
imageDigest: "sha256:aaaa",
registry: "registry.internal",
repository: "app/api",
namespaces: new[] { "team-a" },
tags: new[] { "prod" },
usedByEntrypoint: false),
};
var impactSet = new ImpactSet(
selector,
images,
usageOnly: true,
generatedAt: DateTimeOffset.Parse("2025-10-18T05:04:03Z"),
total: 2,
snapshotId: "snap-001");
Assert.Equal(SchedulerSchemaVersions.ImpactSet, impactSet.SchemaVersion);
Assert.Equal(new[] { "sha256:aaaa", "sha256:bbbb" }, impactSet.Images.Select(i => i.ImageDigest));
Assert.True(impactSet.UsageOnly);
Assert.Equal(2, impactSet.Total);
var json = CanonicalJsonSerializer.Serialize(impactSet);
Assert.Contains("\"snapshotId\":\"snap-001\"", json, StringComparison.Ordinal);
}
[Fact]
public void ImpactImageRejectsInvalidDigest()
{
Assert.Throws<ArgumentException>(() => new ImpactImage("sha1:not-supported", "registry", "repo"));
}
}

View File

@@ -0,0 +1,83 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class PolicyRunModelsTests
{
[Fact]
public void PolicyRunInputs_NormalizesEnvironmentKeys()
{
var inputs = new PolicyRunInputs(
sbomSet: new[] { "sbom:two", "sbom:one" },
env: new[]
{
new KeyValuePair<string, object?>("Sealed", true),
new KeyValuePair<string, object?>("Exposure", "internet"),
new KeyValuePair<string, object?>("region", JsonSerializer.SerializeToElement("global"))
},
captureExplain: true);
Assert.Equal(new[] { "sbom:one", "sbom:two" }, inputs.SbomSet);
Assert.True(inputs.CaptureExplain);
Assert.Equal(3, inputs.Environment.Count);
Assert.True(inputs.Environment.ContainsKey("sealed"));
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 PolicyRunStatus_ThrowsOnNegativeAttempts()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new PolicyRunStatus(
runId: "run:test",
tenantId: "tenant-alpha",
policyId: "P-1",
policyVersion: 1,
mode: PolicyRunMode.Full,
status: PolicyRunExecutionStatus.Queued,
priority: PolicyRunPriority.Normal,
queuedAt: DateTimeOffset.UtcNow,
attempts: -1));
}
[Fact]
public void PolicyDiffSummary_NormalizesSeverityKeys()
{
var summary = new PolicyDiffSummary(
added: 1,
removed: 2,
unchanged: 3,
bySeverity: new[]
{
new KeyValuePair<string, PolicyDiffSeverityDelta>("critical", new PolicyDiffSeverityDelta(1, 0)),
new KeyValuePair<string, PolicyDiffSeverityDelta>("HIGH", new PolicyDiffSeverityDelta(0, 1))
});
Assert.True(summary.BySeverity.ContainsKey("Critical"));
Assert.True(summary.BySeverity.ContainsKey("High"));
}
[Fact]
public void PolicyExplainTrace_LowercasesMetadataKeys()
{
var trace = new PolicyExplainTrace(
findingId: "finding:alpha",
policyId: "P-1",
policyVersion: 1,
tenantId: "tenant-alpha",
runId: "run:test",
verdict: new PolicyExplainVerdict(PolicyVerdictStatus.Passed, SeverityRank.Low, quiet: false, score: 0, rationale: "ok"),
evaluatedAt: DateTimeOffset.UtcNow,
metadata: ImmutableSortedDictionary.CreateRange(new[]
{
new KeyValuePair<string, string>("TraceId", "trace-1"),
new KeyValuePair<string, string>("ComponentPurl", "pkg:npm/a@1.0.0")
}));
Assert.Equal("trace-1", trace.Metadata["traceid"]);
Assert.Equal("pkg:npm/a@1.0.0", trace.Metadata["componentpurl"]);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class RescanDeltaEventSampleTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public void RescanDeltaEventSampleAlignsWithContracts()
{
const string fileName = "scheduler.rescan.delta@1.sample.json";
var json = LoadSample(fileName);
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
Assert.NotNull(notifyEvent);
Assert.Equal(NotifyEventKinds.SchedulerRescanDelta, notifyEvent!.Kind);
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
Assert.NotNull(notifyEvent.Payload);
Assert.Null(notifyEvent.Scope);
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
var scheduleId = Assert.IsAssignableFrom<JsonValue>(payload["scheduleId"]).GetValue<string>();
Assert.Equal("rescan-weekly-critical", scheduleId);
var digests = Assert.IsType<JsonArray>(payload["impactedDigests"]);
Assert.Equal(2, digests.Count);
foreach (var digestNode in digests)
{
var digest = Assert.IsAssignableFrom<JsonValue>(digestNode).GetValue<string>();
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
}
var summary = Assert.IsType<JsonObject>(payload["summary"]);
Assert.Equal(0, summary["newCritical"]!.GetValue<int>());
Assert.Equal(1, summary["newHigh"]!.GetValue<int>());
Assert.Equal(4, summary["total"]!.GetValue<int>());
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
Assert.True(JsonNode.DeepEquals(sampleNode, canonicalNode), "Rescan delta event sample must remain canonical.");
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -0,0 +1,108 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class RunStateMachineTests
{
[Fact]
public void EnsureTransition_FromQueuedToRunningSetsStartedAt()
{
var run = new Run(
id: "run-queued",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Queued,
stats: RunStats.Empty,
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"));
var transitionTime = DateTimeOffset.Parse("2025-10-18T03:05:00Z");
var updated = RunStateMachine.EnsureTransition(
run,
RunState.Running,
transitionTime,
mutateStats: builder => builder.SetQueued(1));
Assert.Equal(RunState.Running, updated.State);
Assert.Equal(transitionTime.ToUniversalTime(), updated.StartedAt);
Assert.Equal(1, updated.Stats.Queued);
Assert.Null(updated.Error);
}
[Fact]
public void EnsureTransition_ToCompletedPopulatesFinishedAt()
{
var run = new Run(
id: "run-running",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Running,
stats: RunStats.Empty,
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
var completedAt = DateTimeOffset.Parse("2025-10-18T03:10:00Z");
var updated = RunStateMachine.EnsureTransition(
run,
RunState.Completed,
completedAt,
mutateStats: builder =>
{
builder.SetQueued(1);
builder.SetCompleted(1);
});
Assert.Equal(RunState.Completed, updated.State);
Assert.Equal(completedAt.ToUniversalTime(), updated.FinishedAt);
Assert.Equal(1, updated.Stats.Completed);
}
[Fact]
public void EnsureTransition_ErrorRequiresMessage()
{
var run = new Run(
id: "run-running",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Running,
stats: RunStats.Empty,
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
var timestamp = DateTimeOffset.Parse("2025-10-18T03:06:00Z");
var ex = Assert.Throws<InvalidOperationException>(
() => RunStateMachine.EnsureTransition(run, RunState.Error, timestamp));
Assert.Contains("requires a non-empty error message", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_ThrowsWhenTerminalWithoutFinishedAt()
{
var run = new Run(
id: "run-bad",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Completed,
stats: RunStats.Empty,
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
Assert.Throws<InvalidOperationException>(() => RunStateMachine.Validate(run));
}
[Fact]
public void RunReasonExtension_NormalizesImpactWindow()
{
var reason = new RunReason(manualReason: "delta");
var from = DateTimeOffset.Parse("2025-10-18T01:00:00+02:00");
var to = DateTimeOffset.Parse("2025-10-18T03:30:00+02:00");
var updated = reason.WithImpactWindow(from, to);
Assert.Equal(from.ToUniversalTime().ToString("O"), updated.ImpactWindowFrom);
Assert.Equal(to.ToUniversalTime().ToString("O"), updated.ImpactWindowTo);
}
}

View File

@@ -0,0 +1,78 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class RunValidationTests
{
[Fact]
public void RunStatsRejectsNegativeValues()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(candidates: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deduped: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(queued: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(completed: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deltas: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newCriticals: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newHigh: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newMedium: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newLow: -1));
}
[Fact]
public void DeltaSummarySortsTopFindingsBySeverityThenId()
{
var summary = new DeltaSummary(
imageDigest: "sha256:0011",
newFindings: 3,
newCriticals: 1,
newHigh: 1,
newMedium: 1,
newLow: 0,
kevHits: new[] { "CVE-2025-0002", "CVE-2025-0001" },
topFindings: new[]
{
new DeltaFinding("pkg:maven/b", "CVE-2025-0002", SeverityRank.High),
new DeltaFinding("pkg:maven/a", "CVE-2024-0001", SeverityRank.Critical),
new DeltaFinding("pkg:maven/c", "CVE-2025-0008", SeverityRank.Medium),
},
reportUrl: "https://ui.example/reports/sha256:0011",
attestation: new DeltaAttestation(uuid: "rekor-1", verified: true),
detectedAt: DateTimeOffset.Parse("2025-10-18T00:01:02Z"));
Assert.Equal(new[] { "pkg:maven/a", "pkg:maven/b", "pkg:maven/c" }, summary.TopFindings.Select(f => f.Purl));
Assert.Equal(new[] { "CVE-2025-0001", "CVE-2025-0002" }, summary.KevHits);
}
[Fact]
public void RunSerializationIncludesDeterministicOrdering()
{
var stats = new RunStats(candidates: 10, deduped: 8, queued: 8, completed: 5, deltas: 3, newCriticals: 2);
var run = new Run(
id: "run_001",
tenantId: "tenant-alpha",
trigger: RunTrigger.Feedser,
state: RunState.Running,
stats: stats,
reason: new RunReason(feedserExportId: "exp-123"),
scheduleId: "sch_001",
createdAt: DateTimeOffset.Parse("2025-10-18T01:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T01:00:05Z"),
finishedAt: null,
error: null,
deltas: new[]
{
new DeltaSummary(
imageDigest: "sha256:aaa",
newFindings: 1,
newCriticals: 1,
newHigh: 0,
newMedium: 0,
newLow: 0)
});
var json = CanonicalJsonSerializer.Serialize(run);
Assert.Equal(SchedulerSchemaVersions.Run, run.SchemaVersion);
Assert.Contains("\"trigger\":\"feedser\"", json, StringComparison.Ordinal);
Assert.Contains("\"stats\":{\"candidates\":10,\"deduped\":8,\"queued\":8,\"completed\":5,\"deltas\":3,\"newCriticals\":2,\"newHigh\":0,\"newMedium\":0,\"newLow\":0}", json, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class SamplePayloadTests
{
private static readonly string SamplesRoot = LocateSamplesRoot();
[Fact]
public void ScheduleSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("schedule.json");
var schedule = CanonicalJsonSerializer.Deserialize<Schedule>(json);
Assert.Equal("sch_20251018a", schedule.Id);
Assert.Equal("tenant-alpha", schedule.TenantId);
var canonical = CanonicalJsonSerializer.Serialize(schedule);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void RunSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("run.json");
var run = CanonicalJsonSerializer.Deserialize<Run>(json);
Assert.Equal(RunState.Running, run.State);
Assert.Equal(42, run.Stats.Deltas);
var canonical = CanonicalJsonSerializer.Serialize(run);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void ImpactSetSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("impact-set.json");
var impact = CanonicalJsonSerializer.Deserialize<ImpactSet>(json);
Assert.True(impact.UsageOnly);
Assert.Single(impact.Images);
var canonical = CanonicalJsonSerializer.Serialize(impact);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void AuditSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("audit.json");
var audit = CanonicalJsonSerializer.Deserialize<AuditRecord>(json);
Assert.Equal("scheduler", audit.Category);
Assert.Equal("pause", audit.Action);
var canonical = CanonicalJsonSerializer.Serialize(audit);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void GraphBuildJobSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("graph-build-job.json");
var job = CanonicalJsonSerializer.Deserialize<GraphBuildJob>(json);
Assert.Equal(GraphJobStatus.Running, job.Status);
Assert.Equal(GraphBuildJobTrigger.SbomVersion, job.Trigger);
var canonical = CanonicalJsonSerializer.Serialize(job);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void GraphOverlayJobSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("graph-overlay-job.json");
var job = CanonicalJsonSerializer.Deserialize<GraphOverlayJob>(json);
Assert.Equal(GraphJobStatus.Queued, job.Status);
Assert.Equal(GraphOverlayJobTrigger.Policy, job.Trigger);
Assert.Equal(GraphOverlayKind.Policy, job.OverlayKind);
var canonical = CanonicalJsonSerializer.Serialize(job);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void PolicyRunRequestSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("policy-run-request.json");
var request = CanonicalJsonSerializer.Deserialize<PolicyRunRequest>(json);
Assert.Equal("default", request.TenantId);
Assert.Equal(PolicyRunMode.Incremental, request.Mode);
Assert.Equal("run:P-7:2025-10-26:auto", request.RunId);
Assert.True(request.Inputs.CaptureExplain);
Assert.Equal(2, request.Inputs.SbomSet.Length);
var canonical = CanonicalJsonSerializer.Serialize(request);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void PolicyRunStatusSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("policy-run-status.json");
var status = CanonicalJsonSerializer.Deserialize<PolicyRunStatus>(json);
Assert.Equal(PolicyRunExecutionStatus.Succeeded, status.Status);
Assert.Equal(1742, status.Stats.Components);
Assert.Equal("scheduler", status.Metadata["orchestrator"]);
var canonical = CanonicalJsonSerializer.Serialize(status);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void PolicyDiffSummarySample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("policy-diff-summary.json");
var summary = CanonicalJsonSerializer.Deserialize<PolicyDiffSummary>(json);
Assert.Equal(12, summary.Added);
Assert.Contains(summary.BySeverity.Keys, key => string.Equals(key, "critical", StringComparison.OrdinalIgnoreCase));
Assert.Equal(2, summary.RuleHits.Length);
var canonical = CanonicalJsonSerializer.Serialize(summary);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void PolicyExplainTraceSample_RoundtripsThroughCanonicalSerializer()
{
var json = ReadSample("policy-explain-trace.json");
var trace = CanonicalJsonSerializer.Deserialize<PolicyExplainTrace>(json);
Assert.Equal("finding:sbom:S-42/pkg:npm/lodash@4.17.21", trace.FindingId);
Assert.Equal(PolicyVerdictStatus.Blocked, trace.Verdict.Status);
Assert.Equal(2, trace.RuleChain.Length);
Assert.Equal(2, trace.Evidence.Length);
var canonical = CanonicalJsonSerializer.Serialize(trace);
AssertJsonEquivalent(json, canonical);
}
[Fact]
public void PolicyRunJob_RoundtripsThroughCanonicalSerializer()
{
var metadata = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadata["source"] = "cli";
metadata["trigger"] = "manual";
var job = new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_20251026T140500Z",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.High,
PriorityRank: -1,
RunId: "run:P-7:2025-10-26:auto",
RequestedBy: "user:cli",
CorrelationId: "req-20251026T140500Z",
Metadata: metadata.ToImmutable(),
Inputs: new PolicyRunInputs(
sbomSet: new[] { "sbom:S-42", "sbom:S-318" },
advisoryCursor: DateTimeOffset.Parse("2025-10-26T13:59:00Z"),
captureExplain: true),
QueuedAt: DateTimeOffset.Parse("2025-10-26T14:05:00Z"),
Status: PolicyRunJobStatus.Pending,
AttemptCount: 1,
LastAttemptAt: DateTimeOffset.Parse("2025-10-26T14:05:30Z"),
LastError: "transient network error",
CreatedAt: DateTimeOffset.Parse("2025-10-26T14:04:50Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-26T14:05:10Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-26T14:05:20Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: "worker-01",
LeaseExpiresAt: DateTimeOffset.Parse("2025-10-26T14:06:30Z"),
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
var canonical = CanonicalJsonSerializer.Serialize(job);
var roundtrip = CanonicalJsonSerializer.Deserialize<PolicyRunJob>(canonical);
Assert.Equal(job.TenantId, roundtrip.TenantId);
Assert.Equal(job.PolicyId, roundtrip.PolicyId);
Assert.Equal(job.Priority, roundtrip.Priority);
Assert.Equal(job.Status, roundtrip.Status);
Assert.Equal(job.RunId, roundtrip.RunId);
Assert.Equal("cli", roundtrip.Metadata!["source"]);
}
private static string ReadSample(string fileName)
{
var path = Path.Combine(SamplesRoot, fileName);
return File.ReadAllText(path);
}
private static string LocateSamplesRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "samples", "api", "scheduler");
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.Equals(parent, current, StringComparison.Ordinal))
{
break;
}
current = parent;
}
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
}
private static void AssertJsonEquivalent(string expected, string actual)
{
var normalizedExpected = NormalizeJson(expected);
var normalizedActual = NormalizeJson(actual);
Assert.Equal(normalizedExpected, normalizedActual);
}
private static string NormalizeJson(string json)
{
using var document = JsonDocument.Parse(json);
return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions
{
WriteIndented = false
});
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class ScheduleSerializationTests
{
[Fact]
public void ScheduleSerialization_IsDeterministicRegardlessOfInputOrdering()
{
var selectionA = new Selector(
SelectorScope.ByNamespace,
tenantId: "tenant-alpha",
namespaces: new[] { "team-b", "team-a" },
repositories: new[] { "app/service-api", "app/service-web" },
digests: new[] { "sha256:bb", "sha256:aa" },
includeTags: new[] { "prod", "canary" },
labels: new[]
{
new LabelSelector("env", new[] { "prod", "staging" }),
new LabelSelector("app", new[] { "web", "api" }),
},
resolvesTags: true);
var selectionB = new Selector(
scope: SelectorScope.ByNamespace,
tenantId: "tenant-alpha",
namespaces: new[] { "team-a", "team-b" },
repositories: new[] { "app/service-web", "app/service-api" },
digests: new[] { "sha256:aa", "sha256:bb" },
includeTags: new[] { "canary", "prod" },
labels: new[]
{
new LabelSelector("app", new[] { "api", "web" }),
new LabelSelector("env", new[] { "staging", "prod" }),
},
resolvesTags: true);
var scheduleA = new Schedule(
id: "sch_001",
tenantId: "tenant-alpha",
name: "Nightly Prod",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: selectionA,
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 7, policyRevision: "policy@42"),
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
limits: new ScheduleLimits(maxJobs: 1000, ratePerSecond: 25, parallelism: 4),
createdAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"),
createdBy: "svc_scheduler",
updatedAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"),
updatedBy: "svc_scheduler");
var scheduleB = new Schedule(
id: scheduleA.Id,
tenantId: scheduleA.TenantId,
name: scheduleA.Name,
enabled: scheduleA.Enabled,
cronExpression: scheduleA.CronExpression,
timezone: scheduleA.Timezone,
mode: scheduleA.Mode,
selection: selectionB,
onlyIf: scheduleA.OnlyIf,
notify: scheduleA.Notify,
limits: scheduleA.Limits,
createdAt: scheduleA.CreatedAt,
createdBy: scheduleA.CreatedBy,
updatedAt: scheduleA.UpdatedAt,
updatedBy: scheduleA.UpdatedBy,
subscribers: scheduleA.Subscribers);
var jsonA = CanonicalJsonSerializer.Serialize(scheduleA);
var jsonB = CanonicalJsonSerializer.Serialize(scheduleB);
Assert.Equal(jsonA, jsonB);
using var doc = JsonDocument.Parse(jsonA);
var root = doc.RootElement;
Assert.Equal(SchedulerSchemaVersions.Schedule, root.GetProperty("schemaVersion").GetString());
Assert.Equal("analysis-only", root.GetProperty("mode").GetString());
Assert.Equal("tenant-alpha", root.GetProperty("tenantId").GetString());
var namespaces = root.GetProperty("selection").GetProperty("namespaces").EnumerateArray().Select(e => e.GetString()).ToArray();
Assert.Equal(new[] { "team-a", "team-b" }, namespaces);
}
[Theory]
[InlineData("")]
[InlineData("not-a-timezone")]
public void Schedule_ThrowsWhenTimezoneInvalid(string timezone)
{
var selection = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
Assert.ThrowsAny<Exception>(() => new Schedule(
id: "sch_002",
tenantId: "tenant-alpha",
name: "Invalid timezone",
enabled: true,
cronExpression: "0 3 * * *",
timezone: timezone,
mode: ScheduleMode.AnalysisOnly,
selection: selection,
onlyIf: null,
notify: null,
limits: null,
createdAt: DateTimeOffset.UtcNow,
createdBy: "svc",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "svc"));
}
}

View File

@@ -0,0 +1,171 @@
using System.Text.Json.Nodes;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class SchedulerSchemaMigrationTests
{
[Fact]
public void UpgradeSchedule_DefaultsSchemaVersionWhenMissing()
{
var schedule = new Schedule(
id: "sch-01",
tenantId: "tenant-alpha",
name: "Nightly",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: null,
notify: null,
limits: null,
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
createdBy: "svc-scheduler",
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedBy: "svc-scheduler");
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(schedule))!.AsObject();
json.Remove("schemaVersion");
var result = SchedulerSchemaMigration.UpgradeSchedule(json);
Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion);
Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion);
Assert.Empty(result.Warnings);
}
[Fact]
public void UpgradeRun_StrictModeRemovesUnknownProperties()
{
var run = new Run(
id: "run-01",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Queued,
stats: RunStats.Empty,
createdAt: DateTimeOffset.Parse("2025-10-18T01:10:00Z"));
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(run))!.AsObject();
json["extraField"] = "to-be-removed";
var result = SchedulerSchemaMigration.UpgradeRun(json, strict: true);
Assert.Contains(result.Warnings, warning => warning.Contains("extraField", StringComparison.Ordinal));
}
[Fact]
public void UpgradeImpactSet_ThrowsForUnsupportedVersion()
{
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, "tenant-alpha"),
images: Array.Empty<ImpactImage>(),
usageOnly: false,
generatedAt: DateTimeOffset.Parse("2025-10-18T02:00:00Z"));
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(impactSet))!.AsObject();
json["schemaVersion"] = "scheduler.impact-set@99";
var ex = Assert.Throws<NotSupportedException>(() => SchedulerSchemaMigration.UpgradeImpactSet(json));
Assert.Contains("Unsupported scheduler schema version", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void UpgradeSchedule_Legacy0_UpgradesToLatestVersion()
{
var legacy = new JsonObject
{
["schemaVersion"] = SchedulerSchemaVersions.ScheduleLegacy0,
["id"] = "sch-legacy",
["tenantId"] = "tenant-alpha",
["name"] = "Legacy Nightly",
["enabled"] = true,
["cronExpression"] = "0 2 * * *",
["timezone"] = "UTC",
["mode"] = "analysis-only",
["selection"] = new JsonObject
{
["scope"] = "all-images",
["tenantId"] = "tenant-alpha",
},
["notify"] = new JsonObject
{
["onNewFindings"] = "true",
["minSeverity"] = "HIGH",
},
["limits"] = new JsonObject
{
["maxJobs"] = "5",
["parallelism"] = -2,
},
["subscribers"] = "ops-team",
["createdAt"] = "2025-10-10T00:00:00Z",
["createdBy"] = "system",
["updatedAt"] = "2025-10-10T01:00:00Z",
["updatedBy"] = "system",
};
var result = SchedulerSchemaMigration.UpgradeSchedule(legacy, strict: true);
Assert.Equal(SchedulerSchemaVersions.ScheduleLegacy0, result.FromVersion);
Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion);
Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion);
Assert.True(result.Value.Notify.IncludeKev);
Assert.Empty(result.Value.Subscribers);
Assert.Contains(result.Warnings, warning => warning.Contains("schedule.limits.parallelism", StringComparison.Ordinal));
Assert.Contains(result.Warnings, warning => warning.Contains("schedule.subscribers", StringComparison.Ordinal));
}
[Fact]
public void UpgradeRun_Legacy0_BackfillsMissingStats()
{
var legacy = new JsonObject
{
["schemaVersion"] = SchedulerSchemaVersions.RunLegacy0,
["id"] = "run-legacy",
["tenantId"] = "tenant-alpha",
["trigger"] = "manual",
["state"] = "queued",
["stats"] = new JsonObject
{
["candidates"] = "4",
["queued"] = 2,
},
["createdAt"] = "2025-10-10T02:00:00Z",
};
var result = SchedulerSchemaMigration.UpgradeRun(legacy, strict: true);
Assert.Equal(SchedulerSchemaVersions.RunLegacy0, result.FromVersion);
Assert.Equal(SchedulerSchemaVersions.Run, result.ToVersion);
Assert.Equal(SchedulerSchemaVersions.Run, result.Value.SchemaVersion);
Assert.Equal(4, result.Value.Stats.Candidates);
Assert.Equal(0, result.Value.Stats.NewMedium);
Assert.Equal(RunState.Queued, result.Value.State);
Assert.Empty(result.Value.Deltas);
Assert.Contains(result.Warnings, warning => warning.Contains("run.stats.newMedium", StringComparison.Ordinal));
}
[Fact]
public void UpgradeImpactSet_Legacy0_ComputesTotal()
{
var legacy = new JsonObject
{
["schemaVersion"] = SchedulerSchemaVersions.ImpactSetLegacy0,
["selector"] = JsonNode.Parse("""{"scope":"all-images","tenantId":"tenant-alpha"}"""),
["images"] = new JsonArray(
JsonNode.Parse("""{"imageDigest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","registry":"docker.io","repository":"library/nginx"}"""),
JsonNode.Parse("""{"imageDigest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","registry":"docker.io","repository":"library/httpd"}""")),
["usageOnly"] = "false",
["generatedAt"] = "2025-10-10T03:00:00Z",
};
var result = SchedulerSchemaMigration.UpgradeImpactSet(legacy, strict: true);
Assert.Equal(SchedulerSchemaVersions.ImpactSetLegacy0, result.FromVersion);
Assert.Equal(SchedulerSchemaVersions.ImpactSet, result.ToVersion);
Assert.Equal(2, result.Value.Total);
Assert.Equal(2, result.Value.Images.Length);
Assert.Contains(result.Warnings, warning => warning.Contains("impact set total", StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\docs\events\samples\scheduler.rescan.delta@1.sample.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scheduler.Models;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class PlannerAndRunnerMessageTests
{
[Fact]
public void PlannerMessage_CanonicalSerialization_RoundTrips()
{
var schedule = new Schedule(
id: "sch-tenant-nightly",
tenantId: "tenant-alpha",
name: "Nightly Deltas",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 3),
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
limits: new ScheduleLimits(maxJobs: 10, ratePerSecond: 5, parallelism: 3),
createdAt: DateTimeOffset.Parse("2025-10-01T02:00:00Z"),
createdBy: "system",
updatedAt: DateTimeOffset.Parse("2025-10-02T02:00:00Z"),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty,
schemaVersion: "1.0.0");
var run = new Run(
id: "run-123",
tenantId: "tenant-alpha",
trigger: RunTrigger.Cron,
state: RunState.Planning,
stats: new RunStats(candidates: 5, deduped: 4, queued: 0, completed: 0, deltas: 0),
createdAt: DateTimeOffset.Parse("2025-10-02T02:05:00Z"),
reason: new RunReason(manualReason: null, feedserExportId: null, vexerExportId: null, cursor: null)
with { ImpactWindowFrom = "2025-10-01T00:00:00Z", ImpactWindowTo = "2025-10-02T00:00:00Z" },
scheduleId: "sch-tenant-nightly");
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" },
usedByEntrypoint: true,
labels: new[] { KeyValuePair.Create("team", "appsec") })
},
usageOnly: true,
generatedAt: DateTimeOffset.Parse("2025-10-02T02:06:00Z"),
total: 1,
snapshotId: "snap-001");
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-1");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
[Fact]
public void RunnerSegmentMessage_RequiresAtLeastOneDigest()
{
var act = () => new RunnerSegmentQueueMessage(
segmentId: "segment-empty",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: Array.Empty<string>());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void RunnerSegmentMessage_CanonicalSerialization_RoundTrips()
{
var message = new RunnerSegmentQueueMessage(
segmentId: "segment-01",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-tenant-nightly",
ratePerSecond: 25,
usageOnly: true,
attributes: new Dictionary<string, string>
{
["plannerShard"] = "0",
["priority"] = "kev"
},
correlationId: "corr-2");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
}

View File

@@ -0,0 +1,346 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue.Redis;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class RedisSchedulerQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisSchedulerQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex) when (IsDockerUnavailable(ex))
{
_skipReason = $"Docker engine is not available for Redis-backed tests: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task PlannerQueue_EnqueueLeaseAck_RemovesMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
var enqueue = await queue.EnqueueAsync(message);
enqueue.Deduplicated.Should().BeFalse();
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", batchSize: 5, options.DefaultLeaseDuration));
leases.Should().HaveCount(1);
var lease = leases[0];
lease.Message.Run.Id.Should().Be(message.Run.Id);
lease.TenantId.Should().Be(message.TenantId);
lease.ScheduleId.Should().Be(message.ScheduleId);
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", 5, options.DefaultLeaseDuration));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task RunnerQueue_Retry_IncrementsDeliveryAttempt()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
options.RetryInitialBackoff = TimeSpan.Zero;
options.RetryMaxBackoff = TimeSpan.Zero;
await using var queue = new RedisSchedulerRunnerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerRunnerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreateRunnerMessage();
await queue.EnqueueAsync(message);
var firstLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
firstLease.Should().ContainSingle();
var lease = firstLease[0];
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
var secondLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
secondLease.Should().ContainSingle();
secondLease[0].Attempt.Should().Be(2);
}
[Fact]
public async Task PlannerQueue_ClaimExpired_ReassignsLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
await queue.EnqueueAsync(message);
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-a", 1, options.DefaultLeaseDuration));
leases.Should().ContainSingle();
await Task.Delay(50);
var reclaimed = await queue.ClaimExpiredAsync(new SchedulerQueueClaimOptions("planner-b", batchSize: 1, minIdleTime: TimeSpan.Zero));
reclaimed.Should().ContainSingle();
reclaimed[0].Consumer.Should().Be("planner-b");
reclaimed[0].RunId.Should().Be(message.Run.Id);
await reclaimed[0].AcknowledgeAsync();
}
[Fact]
public async Task PlannerQueue_RecordsDepthMetrics()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
await queue.EnqueueAsync(message);
var depths = SchedulerQueueMetrics.SnapshotDepths();
depths.TryGetValue(("redis", "planner"), out var plannerDepth)
.Should().BeTrue();
plannerDepth.Should().Be(1);
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-depth", 1, options.DefaultLeaseDuration));
leases.Should().ContainSingle();
await leases[0].AcknowledgeAsync();
depths = SchedulerQueueMetrics.SnapshotDepths();
depths.TryGetValue(("redis", "planner"), out plannerDepth).Should().BeTrue();
plannerDepth.Should().Be(0);
}
[Fact]
public async Task RunnerQueue_DropWhenDeadLetterDisabled()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
options.MaxDeliveryAttempts = 1;
options.DeadLetterEnabled = false;
await using var queue = new RedisSchedulerRunnerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerRunnerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreateRunnerMessage();
await queue.EnqueueAsync(message);
var lease = (await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-drop", 1, options.DefaultLeaseDuration)))[0];
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
var depths = SchedulerQueueMetrics.SnapshotDepths();
depths.TryGetValue(("redis", "runner"), out var runnerDepth).Should().BeTrue();
runnerDepth.Should().Be(0);
}
private SchedulerQueueOptions CreateOptions()
{
var unique = Guid.NewGuid().ToString("N");
return new SchedulerQueueOptions
{
Kind = SchedulerQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 5,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
Redis = new SchedulerRedisQueueOptions
{
ConnectionString = _redis.ConnectionString,
Database = 0,
InitializationTimeout = TimeSpan.FromSeconds(10),
Planner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:planner:{unique}",
ConsumerGroup = $"planner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:planner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:planner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
},
Runner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:runner:{unique}",
ConsumerGroup = $"runner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:runner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:runner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
}
}
};
}
private bool SkipIfUnavailable()
{
if (_skipReason is not null)
{
return true;
}
return false;
}
private static bool IsDockerUnavailable(Exception exception)
{
while (exception is AggregateException aggregate && aggregate.InnerException is not null)
{
exception = aggregate.InnerException;
}
return exception is TimeoutException
|| exception.GetType().Name.Contains("Docker", StringComparison.OrdinalIgnoreCase);
}
private static class TestData
{
public static PlannerQueueMessage CreatePlannerMessage()
{
var schedule = new Schedule(
id: "sch-test",
tenantId: "tenant-alpha",
name: "Test",
enabled: true,
cronExpression: "0 0 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: DateTimeOffset.UtcNow,
createdBy: "tests",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "tests");
var run = new Run(
id: "run-test",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: DateTimeOffset.UtcNow,
reason: RunReason.Empty,
scheduleId: schedule.Id);
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" })
},
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow,
total: 1);
return new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-test");
}
public static RunnerSegmentQueueMessage CreateRunnerMessage()
{
return new RunnerSegmentQueueMessage(
segmentId: "segment-test",
runId: "run-test",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-test",
ratePerSecond: 10,
usageOnly: true,
attributes: new Dictionary<string, string> { ["priority"] = "kev" },
correlationId: "corr-runner");
}
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Queue.Nats;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class SchedulerQueueServiceCollectionExtensionsTests
{
[Fact]
public async Task AddSchedulerQueues_RegistersNatsTransport()
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(_ => NullLoggerFactory.Instance);
services.AddSchedulerQueues(new ConfigurationBuilder().Build());
var optionsDescriptor = services.First(descriptor => descriptor.ServiceType == typeof(SchedulerQueueOptions));
var options = (SchedulerQueueOptions)optionsDescriptor.ImplementationInstance!;
options.Kind = SchedulerQueueTransportKind.Nats;
options.Nats.Url = "nats://localhost:4222";
await using var provider = services.BuildServiceProvider();
var plannerQueue = provider.GetRequiredService<ISchedulerPlannerQueue>();
var runnerQueue = provider.GetRequiredService<ISchedulerRunnerQueue>();
plannerQueue.Should().BeOfType<NatsSchedulerPlannerQueue>();
runnerQueue.Should().BeOfType<NatsSchedulerRunnerQueue>();
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsHealthy_WhenTransportsReachable()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: false),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Healthy);
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsUnhealthy_WhenRunnerPingFails()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: true),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Unhealthy);
result.Description.Should().Contain("runner transport unreachable");
}
private abstract class FakeQueue<TMessage> : ISchedulerQueue<TMessage>, ISchedulerQueueTransportDiagnostics
{
private readonly bool _failPing;
protected FakeQueue(bool failPing)
{
_failPing = failPing;
}
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new SchedulerQueueEnqueueResult("stub", false));
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask PingAsync(CancellationToken cancellationToken)
=> _failPing
? ValueTask.FromException(new InvalidOperationException("ping failed"))
: ValueTask.CompletedTask;
}
private sealed class FakePlannerQueue : FakeQueue<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public FakePlannerQueue(bool failPing) : base(failPing)
{
}
}
private sealed class FakeRunnerQueue : FakeQueue<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public FakeRunnerQueue(bool failPing) : base(failPing)
{
}
}
}

View File

@@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
global using System.Text.Json;
global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Logging.Abstractions;
global using Microsoft.Extensions.Options;
global using Mongo2Go;
global using MongoDB.Bson;
global using MongoDB.Driver;
global using StellaOps.Scheduler.Models;
global using StellaOps.Scheduler.Storage.Mongo.Internal;
global using StellaOps.Scheduler.Storage.Mongo.Migrations;
global using StellaOps.Scheduler.Storage.Mongo.Options;
global using Xunit;

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration;
public sealed class SchedulerMongoRoundTripTests : IDisposable
{
private readonly MongoDbRunner _runner;
private readonly SchedulerMongoContext _context;
public SchedulerMongoRoundTripTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_roundtrip_{Guid.NewGuid():N}"
};
_context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(_context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
[Fact]
public async Task SamplesRoundTripThroughMongoWithoutLosingCanonicalShape()
{
var samplesRoot = LocateSamplesRoot();
var scheduleJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "schedule.json"), CancellationToken.None);
await AssertRoundTripAsync(
scheduleJson,
_context.Options.SchedulesCollection,
CanonicalJsonSerializer.Deserialize<Schedule>,
schedule => schedule.Id);
var runJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "run.json"), CancellationToken.None);
await AssertRoundTripAsync(
runJson,
_context.Options.RunsCollection,
CanonicalJsonSerializer.Deserialize<Run>,
run => run.Id);
var impactJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "impact-set.json"), CancellationToken.None);
await AssertRoundTripAsync(
impactJson,
_context.Options.ImpactSnapshotsCollection,
CanonicalJsonSerializer.Deserialize<ImpactSet>,
_ => null);
var auditJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "audit.json"), CancellationToken.None);
await AssertRoundTripAsync(
auditJson,
_context.Options.AuditCollection,
CanonicalJsonSerializer.Deserialize<AuditRecord>,
audit => audit.Id);
}
private async Task AssertRoundTripAsync<TModel>(
string json,
string collectionName,
Func<string, TModel> deserialize,
Func<TModel, string?> resolveId)
{
ArgumentNullException.ThrowIfNull(deserialize);
ArgumentNullException.ThrowIfNull(resolveId);
var model = deserialize(json);
var canonical = CanonicalJsonSerializer.Serialize(model);
var document = BsonDocument.Parse(canonical);
var identifier = resolveId(model);
if (!string.IsNullOrEmpty(identifier))
{
document["_id"] = identifier;
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
await collection.InsertOneAsync(document, cancellationToken: CancellationToken.None);
var filter = identifier is null ? Builders<BsonDocument>.Filter.Empty : Builders<BsonDocument>.Filter.Eq("_id", identifier);
var stored = await collection.Find(filter).FirstOrDefaultAsync();
Assert.NotNull(stored);
var sanitized = stored!.DeepClone().AsBsonDocument;
sanitized.Remove("_id");
var storedJson = sanitized.ToJson();
var parsedExpected = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical node null.");
var parsedActual = JsonNode.Parse(storedJson) ?? throw new InvalidOperationException("Stored node null.");
Assert.True(JsonNode.DeepEquals(parsedExpected, parsedActual), "Document changed shape after Mongo round-trip.");
}
private static string LocateSamplesRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "samples", "api", "scheduler");
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.Equals(parent, current, StringComparison.Ordinal))
{
break;
}
current = parent;
}
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
}
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -0,0 +1,106 @@
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Migrations;
public sealed class SchedulerMongoMigrationTests : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoMigrationTests()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
}
[Fact]
public async Task RunAsync_CreatesCollectionsAndIndexes()
{
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
var context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
await runner.RunAsync(CancellationToken.None);
var cursor = await context.Database.ListCollectionNamesAsync(cancellationToken: CancellationToken.None);
var collections = await cursor.ToListAsync();
Assert.Contains(options.SchedulesCollection, collections);
Assert.Contains(options.RunsCollection, collections);
Assert.Contains(options.ImpactSnapshotsCollection, collections);
Assert.Contains(options.AuditCollection, collections);
Assert.Contains(options.LocksCollection, collections);
Assert.Contains(options.MigrationsCollection, collections);
await AssertScheduleIndexesAsync(context, options);
await AssertRunIndexesAsync(context, options);
await AssertImpactSnapshotIndexesAsync(context, options);
await AssertAuditIndexesAsync(context, options);
await AssertLockIndexesAsync(context, options);
}
private static async Task AssertScheduleIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.SchedulesCollection));
Assert.Contains("tenant_enabled", names);
Assert.Contains("cron_timezone", names);
}
private static async Task AssertRunIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var collection = context.Database.GetCollection<BsonDocument>(options.RunsCollection);
var indexes = await ListIndexesAsync(collection);
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "tenant_createdAt_desc", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "state_lookup", StringComparison.Ordinal));
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "schedule_createdAt_desc", StringComparison.Ordinal));
var ttl = indexes.FirstOrDefault(doc => doc.TryGetValue("name", out var name) && name == "finishedAt_ttl");
Assert.NotNull(ttl);
Assert.Equal(options.CompletedRunRetention.TotalSeconds, ttl!["expireAfterSeconds"].ToDouble());
}
private static async Task AssertImpactSnapshotIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.ImpactSnapshotsCollection));
Assert.Contains("selector_tenant_scope", names);
Assert.Contains("snapshotId_unique", names);
}
private static async Task AssertAuditIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.AuditCollection));
Assert.Contains("tenant_occurredAt_desc", names);
Assert.Contains("correlation_lookup", names);
}
private static async Task AssertLockIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
{
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.LocksCollection));
Assert.Contains("tenant_resource_unique", names);
Assert.Contains("expiresAt_ttl", names);
}
private static async Task<IReadOnlyList<string>> ListIndexNamesAsync(IMongoCollection<BsonDocument> collection)
{
var documents = await ListIndexesAsync(collection);
return documents.Select(doc => doc["name"].AsString).ToArray();
}
private static async Task<List<BsonDocument>> ListIndexesAsync(IMongoCollection<BsonDocument> collection)
{
using var cursor = await collection.Indexes.ListAsync();
return await cursor.ToListAsync();
}
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class AuditRepositoryTests
{
[Fact]
public async Task InsertAndListAsync_ReturnsTenantScopedEntries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var record1 = TestDataFactory.CreateAuditRecord("tenant-alpha", "1");
var record2 = TestDataFactory.CreateAuditRecord("tenant-alpha", "2");
var otherTenant = TestDataFactory.CreateAuditRecord("tenant-beta", "3");
await repository.InsertAsync(record1);
await repository.InsertAsync(record2);
await repository.InsertAsync(otherTenant);
var results = await repository.ListAsync("tenant-alpha");
Assert.Equal(2, results.Count);
Assert.DoesNotContain(results, record => record.TenantId == "tenant-beta");
}
[Fact]
public async Task ListAsync_AppliesFilters()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new AuditRepository(harness.Context);
var older = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"old",
occurredAt: DateTimeOffset.UtcNow.AddMinutes(-30),
scheduleId: "sch-a");
var newer = TestDataFactory.CreateAuditRecord(
"tenant-alpha",
"new",
occurredAt: DateTimeOffset.UtcNow,
scheduleId: "sch-a");
await repository.InsertAsync(older);
await repository.InsertAsync(newer);
var options = new AuditQueryOptions
{
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
ScheduleId = "sch-a",
Limit = 5
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("audit_new", results.Single().Id);
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class ImpactSnapshotRepositoryTests
{
[Fact]
public async Task UpsertAndGetAsync_RoundTripsSnapshot()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var snapshot = TestDataFactory.CreateImpactSet("tenant-alpha", "impact-1", DateTimeOffset.UtcNow.AddMinutes(-5));
await repository.UpsertAsync(snapshot, cancellationToken: CancellationToken.None);
var stored = await repository.GetBySnapshotIdAsync("impact-1", cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(snapshot.SnapshotId, stored!.SnapshotId);
Assert.Equal(snapshot.Images[0].ImageDigest, stored.Images[0].ImageDigest);
}
[Fact]
public async Task GetLatestBySelectorAsync_ReturnsMostRecent()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ImpactSnapshotRepository(harness.Context);
var selectorTenant = "tenant-alpha";
var first = TestDataFactory.CreateImpactSet(selectorTenant, "impact-old", DateTimeOffset.UtcNow.AddMinutes(-10));
var latest = TestDataFactory.CreateImpactSet(selectorTenant, "impact-new", DateTimeOffset.UtcNow);
await repository.UpsertAsync(first);
await repository.UpsertAsync(latest);
var resolved = await repository.GetLatestBySelectorAsync(latest.Selector);
Assert.NotNull(resolved);
Assert.Equal("impact-new", resolved!.SnapshotId);
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class RunRepositoryTests
{
[Fact]
public async Task InsertAndGetAsync_RoundTripsRun()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_1", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(run.TenantId, run.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(run.State, stored!.State);
Assert.Equal(run.Trigger, stored.Trigger);
}
[Fact]
public async Task UpdateAsync_ChangesStateAndStats()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run = TestDataFactory.CreateRun("run_update", "tenant-alpha", RunState.Planning);
await repository.InsertAsync(run);
var updated = run with
{
State = RunState.Completed,
FinishedAt = DateTimeOffset.UtcNow,
Stats = new RunStats(candidates: 10, deduped: 10, queued: 10, completed: 10, deltas: 2)
};
var result = await repository.UpdateAsync(updated);
Assert.True(result);
var stored = await repository.GetAsync(updated.TenantId, updated.Id);
Assert.NotNull(stored);
Assert.Equal(RunState.Completed, stored!.State);
Assert.Equal(10, stored.Stats.Completed);
}
[Fact]
public async Task ListAsync_FiltersByStateAndSchedule()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new RunRepository(harness.Context);
var run1 = TestDataFactory.CreateRun("run_state_1", "tenant-alpha", RunState.Planning, scheduleId: "sch_a");
var run2 = TestDataFactory.CreateRun("run_state_2", "tenant-alpha", RunState.Running, scheduleId: "sch_a");
var run3 = TestDataFactory.CreateRun("run_state_3", "tenant-alpha", RunState.Completed, scheduleId: "sch_b");
await repository.InsertAsync(run1);
await repository.InsertAsync(run2);
await repository.InsertAsync(run3);
var options = new RunQueryOptions
{
ScheduleId = "sch_a",
States = new[] { RunState.Running }.ToImmutableArray(),
Limit = 10
};
var results = await repository.ListAsync("tenant-alpha", options);
Assert.Single(results);
Assert.Equal("run_state_2", results.Single().Id);
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Threading;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
public sealed class ScheduleRepositoryTests
{
[Fact]
public async Task UpsertAsync_PersistsScheduleWithCanonicalShape()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_unit_1", "tenant-alpha");
await repository.UpsertAsync(schedule, cancellationToken: CancellationToken.None);
var stored = await repository.GetAsync(schedule.TenantId, schedule.Id, cancellationToken: CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(schedule.Id, stored!.Id);
Assert.Equal(schedule.Name, stored.Name);
Assert.Equal(schedule.Selection.Scope, stored.Selection.Scope);
}
[Fact]
public async Task ListAsync_ExcludesDisabledAndDeletedByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var tenantId = "tenant-alpha";
var enabled = TestDataFactory.CreateSchedule("sch_enabled", tenantId, enabled: true, name: "Enabled");
var disabled = TestDataFactory.CreateSchedule("sch_disabled", tenantId, enabled: false, name: "Disabled");
await repository.UpsertAsync(enabled);
await repository.UpsertAsync(disabled);
await repository.SoftDeleteAsync(tenantId, enabled.Id, "svc_scheduler", DateTimeOffset.UtcNow);
var results = await repository.ListAsync(tenantId);
Assert.Empty(results);
var includeDisabled = await repository.ListAsync(
tenantId,
new ScheduleQueryOptions { IncludeDisabled = true, IncludeDeleted = true });
Assert.Equal(2, includeDisabled.Count);
Assert.Contains(includeDisabled, schedule => schedule.Id == enabled.Id);
Assert.Contains(includeDisabled, schedule => schedule.Id == disabled.Id);
}
[Fact]
public async Task SoftDeleteAsync_SetsMetadataAndExcludesFromQueries()
{
using var harness = new SchedulerMongoTestHarness();
var repository = new ScheduleRepository(harness.Context);
var schedule = TestDataFactory.CreateSchedule("sch_delete", "tenant-beta");
await repository.UpsertAsync(schedule);
var deletedAt = DateTimeOffset.UtcNow;
var deleted = await repository.SoftDeleteAsync(schedule.TenantId, schedule.Id, "svc_delete", deletedAt);
Assert.True(deleted);
var retrieved = await repository.GetAsync(schedule.TenantId, schedule.Id);
Assert.Null(retrieved);
var includeDeleted = await repository.ListAsync(
schedule.TenantId,
new ScheduleQueryOptions { IncludeDeleted = true, IncludeDisabled = true });
Assert.Single(includeDeleted);
Assert.Equal("sch_delete", includeDeleted[0].Id);
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Threading;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
internal sealed class SchedulerMongoTestHarness : IDisposable
{
private readonly MongoDbRunner _runner;
public SchedulerMongoTestHarness()
{
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
var options = new SchedulerMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = $"scheduler_tests_{Guid.NewGuid():N}"
};
Context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
var migrations = new ISchedulerMongoMigration[]
{
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
new EnsureSchedulerIndexesMigration()
};
var runner = new SchedulerMongoMigrationRunner(Context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
}
public SchedulerMongoContext Context { get; }
public void Dispose()
{
_runner.Dispose();
}
}

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
public sealed class RunSummaryServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly RunSummaryRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly RunSummaryService _service;
public RunSummaryServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new RunSummaryRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T10:00:00Z"));
_service = new RunSummaryService(_repository, _timeProvider, NullLogger<RunSummaryService>.Instance);
}
[Fact]
public async Task ProjectAsync_FirstRunCreatesProjection()
{
var run = TestDataFactory.CreateRun("run-1", "tenant-alpha", RunState.Planning, "sch-alpha");
var projection = await _service.ProjectAsync(run, CancellationToken.None);
Assert.Equal("tenant-alpha", projection.TenantId);
Assert.Equal("sch-alpha", projection.ScheduleId);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Planning, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Total);
Assert.Equal(1, projection.Counters.Planning);
Assert.Equal(0, projection.Counters.Completed);
Assert.Single(projection.Recent);
Assert.Equal(run.Id, projection.Recent[0].RunId);
}
[Fact]
public async Task ProjectAsync_UpdateRunReplacesExistingEntry()
{
var createdAt = DateTimeOffset.Parse("2025-10-26T09:55:00Z");
var run = TestDataFactory.CreateRun(
"run-update",
"tenant-alpha",
RunState.Planning,
"sch-alpha",
createdAt: createdAt,
startedAt: createdAt.AddMinutes(1));
await _service.ProjectAsync(run, CancellationToken.None);
var updated = run with
{
State = RunState.Completed,
StartedAt = run.StartedAt,
FinishedAt = run.CreatedAt.AddMinutes(5),
Stats = new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 10, deltas: 2, newCriticals: 1)
};
_timeProvider.Advance(TimeSpan.FromMinutes(10));
var projection = await _service.ProjectAsync(updated, CancellationToken.None);
Assert.NotNull(projection.LastRun);
Assert.Equal(RunState.Completed, projection.LastRun!.State);
Assert.Equal(1, projection.Counters.Completed);
Assert.Equal(0, projection.Counters.Planning);
Assert.Single(projection.Recent);
Assert.Equal(updated.Stats.Completed, projection.LastRun!.Stats.Completed);
Assert.True(projection.UpdatedAt > run.CreatedAt);
}
[Fact]
public async Task ProjectAsync_TrimsRecentEntriesBeyondLimit()
{
var baseTime = DateTimeOffset.Parse("2025-10-26T00:00:00Z");
for (var i = 0; i < 25; i++)
{
var run = TestDataFactory.CreateRun(
$"run-{i}",
"tenant-alpha",
RunState.Completed,
"sch-alpha",
stats: new RunStats(candidates: 5, deduped: 4, queued: 3, completed: 5, deltas: 1),
createdAt: baseTime.AddMinutes(i));
await _service.ProjectAsync(run, CancellationToken.None);
}
var projections = await _service.ListAsync("tenant-alpha", CancellationToken.None);
Assert.Single(projections);
var projection = projections[0];
Assert.Equal(20, projection.Recent.Length);
Assert.Equal(20, projection.Counters.Total);
Assert.Equal("run-24", projection.Recent[0].RunId);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
public sealed class SchedulerAuditServiceTests : IDisposable
{
private readonly SchedulerMongoTestHarness _harness;
private readonly AuditRepository _repository;
private readonly StubTimeProvider _timeProvider;
private readonly SchedulerAuditService _service;
public SchedulerAuditServiceTests()
{
_harness = new SchedulerMongoTestHarness();
_repository = new AuditRepository(_harness.Context);
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T11:30:00Z"));
_service = new SchedulerAuditService(_repository, _timeProvider, NullLogger<SchedulerAuditService>.Instance);
}
[Fact]
public async Task WriteAsync_PersistsRecordWithGeneratedId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "create",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
CorrelationId: "corr-1",
Metadata: new Dictionary<string, string>
{
["Reason"] = "initial",
},
Message: "created schedule");
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.StartsWith("audit_", record.Id, StringComparison.Ordinal);
Assert.Equal(_timeProvider.GetUtcNow(), record.OccurredAt);
var stored = await _repository.ListAsync("tenant-alpha", new AuditQueryOptions { ScheduleId = "sch-alpha" }, session: null, CancellationToken.None);
Assert.Single(stored);
Assert.Equal(record.Id, stored[0].Id);
Assert.Equal("created schedule", stored[0].Message);
Assert.Contains(stored[0].Metadata, pair => pair.Key == "reason" && pair.Value == "initial");
}
[Fact]
public async Task WriteAsync_HonoursProvidedAuditId()
{
var auditEvent = new SchedulerAuditEvent(
TenantId: "tenant-alpha",
Category: "scheduler",
Action: "update",
Actor: new AuditActor("user_admin", "Admin", "user"),
ScheduleId: "sch-alpha",
AuditId: "audit_custom_1",
OccurredAt: DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
Assert.Equal("audit_custom_1", record.Id);
Assert.Equal(DateTimeOffset.Parse("2025-10-26T12:00:00Z"), record.OccurredAt);
}
public void Dispose()
{
_harness.Dispose();
}
private sealed class StubTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public StubTimeProvider(DateTimeOffset initial)
=> _utcNow = initial;
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,35 @@
using System.Threading;
using MongoDB.Driver;
using StellaOps.Scheduler.Storage.Mongo.Sessions;
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Sessions;
public sealed class SchedulerMongoSessionFactoryTests
{
[Fact]
public async Task StartSessionAsync_UsesCausalConsistencyByDefault()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
using var session = await factory.StartSessionAsync(cancellationToken: CancellationToken.None);
Assert.True(session.Options.CausalConsistency.GetValueOrDefault());
}
[Fact]
public async Task StartSessionAsync_AllowsOverridingOptions()
{
using var harness = new SchedulerMongoTestHarness();
var factory = new SchedulerMongoSessionFactory(harness.Context);
var options = new SchedulerMongoSessionOptions
{
CausalConsistency = false,
ReadPreference = ReadPreference.PrimaryPreferred
};
using var session = await factory.StartSessionAsync(options);
Assert.False(session.Options.CausalConsistency.GetValueOrDefault(true));
Assert.Equal(ReadPreference.PrimaryPreferred, session.Options.DefaultTransactionOptions?.ReadPreference);
}
}

View File

@@ -0,0 +1,24 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<None Include="../../samples/api/scheduler/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,98 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
internal static class TestDataFactory
{
public static Schedule CreateSchedule(
string id,
string tenantId,
bool enabled = true,
string name = "Nightly Prod")
{
var now = DateTimeOffset.UtcNow;
return new Schedule(
id,
tenantId,
name,
enabled,
"0 2 * * *",
"UTC",
ScheduleMode.AnalysisOnly,
new Selector(SelectorScope.AllImages, tenantId),
ScheduleOnlyIf.Default,
ScheduleNotify.Default,
ScheduleLimits.Default,
now,
"svc_scheduler",
now,
"svc_scheduler",
ImmutableArray<string>.Empty,
SchedulerSchemaVersions.Schedule);
}
public static Run CreateRun(
string id,
string tenantId,
RunState state,
string? scheduleId = null,
RunTrigger trigger = RunTrigger.Manual,
RunStats? stats = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? startedAt = null)
{
var resolvedStats = stats ?? new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 0, deltas: 2);
var created = createdAt ?? DateTimeOffset.UtcNow;
return new Run(
id,
tenantId,
trigger,
state,
resolvedStats,
created,
scheduleId: scheduleId,
reason: new RunReason(manualReason: "test"),
startedAt: startedAt ?? created);
}
public static ImpactSet CreateImpactSet(string tenantId, string snapshotId, DateTimeOffset? generatedAt = null, bool usageOnly = true)
{
var selector = new Selector(SelectorScope.AllImages, tenantId);
var image = new ImpactImage(
"sha256:" + Guid.NewGuid().ToString("N"),
"registry",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "prod" },
usedByEntrypoint: true);
return new ImpactSet(
selector,
new[] { image },
usageOnly: usageOnly,
generatedAt ?? DateTimeOffset.UtcNow,
total: 1,
snapshotId: snapshotId,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
public static AuditRecord CreateAuditRecord(
string tenantId,
string idSuffix,
DateTimeOffset? occurredAt = null,
string? scheduleId = null,
string? category = null,
string? action = null)
{
return new AuditRecord(
$"audit_{idSuffix}",
tenantId,
category ?? "scheduler",
action ?? "create",
occurredAt ?? DateTimeOffset.UtcNow,
new AuditActor("user_admin", "Admin", "user"),
scheduleId: scheduleId ?? $"sch_{idSuffix}",
message: "created");
}
}

View File

@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.WebService.GraphJobs;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class CartographerWebhookClientTests
{
[Fact]
public async Task NotifyAsync_PostsPayload_WhenEnabled()
{
var handler = new RecordingHandler();
var httpClient = new HttpClient(handler);
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions
{
Webhook =
{
Enabled = true,
Endpoint = "https://cartographer.local/hooks/graph-completed",
ApiKeyHeader = "X-Api-Key",
ApiKey = "secret"
}
});
using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug());
var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub<SchedulerCartographerOptions>(options), loggerFactory.CreateLogger<CartographerWebhookClient>());
var job = new GraphBuildJob(
id: "gbj_test",
tenantId: "tenant-alpha",
sbomId: "sbom",
sbomVersionId: "sbom_v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Completed,
trigger: GraphBuildJobTrigger.Backfill,
createdAt: DateTimeOffset.UtcNow,
graphSnapshotId: "snap",
attempts: 1,
cartographerJobId: "carto-123",
correlationId: "corr-1",
startedAt: null,
completedAt: DateTimeOffset.UtcNow,
error: null,
metadata: Array.Empty<KeyValuePair<string, string>>());
var notification = new GraphJobCompletionNotification(
job.TenantId,
GraphJobQueryType.Build,
GraphJobStatus.Completed,
DateTimeOffset.UtcNow,
GraphJobResponse.From(job),
"oras://snap/result",
"corr-1",
null);
await client.NotifyAsync(notification, CancellationToken.None);
Assert.NotNull(handler.LastRequest);
Assert.Equal("https://cartographer.local/hooks/graph-completed", handler.LastRequest.RequestUri!.ToString());
Assert.True(handler.LastRequest.Headers.TryGetValues("X-Api-Key", out var values) && values!.Single() == "secret");
var json = JsonSerializer.Deserialize<JsonElement>(handler.LastPayload!);
Assert.Equal("gbj_test", json.GetProperty("jobId").GetString());
Assert.Equal("tenant-alpha", json.GetProperty("tenantId").GetString());
}
[Fact]
public async Task NotifyAsync_Skips_WhenDisabled()
{
var handler = new RecordingHandler();
var httpClient = new HttpClient(handler);
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions());
using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug());
var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub<SchedulerCartographerOptions>(options), loggerFactory.CreateLogger<CartographerWebhookClient>());
var job = new GraphOverlayJob(
id: "goj-test",
tenantId: "tenant-alpha",
graphSnapshotId: "snap",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Completed,
trigger: GraphOverlayJobTrigger.Manual,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 1,
correlationId: null,
startedAt: null,
completedAt: DateTimeOffset.UtcNow,
error: null,
metadata: Array.Empty<KeyValuePair<string, string>>());
var notification = new GraphJobCompletionNotification(
job.TenantId,
GraphJobQueryType.Overlay,
GraphJobStatus.Completed,
DateTimeOffset.UtcNow,
GraphJobResponse.From(job),
null,
null,
null);
await client.NotifyAsync(notification, CancellationToken.None);
Assert.Null(handler.LastRequest);
}
private sealed class RecordingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastPayload { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastPayload = request.Content is null ? null : request.Content.ReadAsStringAsync(cancellationToken).Result;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private sealed class OptionsMonitorStub<T> : IOptionsMonitor<T> where T : class
{
private readonly IOptions<T> _options;
public OptionsMonitorStub(IOptions<T> options)
{
_options = options;
}
public T CurrentValue => _options.Value;
public T Get(string? name) => _options.Value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class EventWebhookEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
static EventWebhookEndpointTests()
{
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__HmacSecret", FeedserSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__Enabled", "true");
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__HmacSecret", VexerSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__Enabled", "true");
}
private const string FeedserSecret = "feedser-secret";
private const string VexerSecret = "vexer-secret";
private readonly WebApplicationFactory<Program> _factory;
public EventWebhookEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task FeedserWebhook_AcceptsValidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "feedser-exp-1",
changedProductKeys = new[] { "pkg:rpm/openssl", "pkg:deb/nginx" },
kev = new[] { "CVE-2024-0001" },
window = new { from = DateTimeOffset.UtcNow.AddHours(-1), to = DateTimeOffset.UtcNow }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(FeedserSecret, json));
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
[Fact]
public async Task FeedserWebhook_RejectsInvalidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "feedser-exp-2",
changedProductKeys = new[] { "pkg:nuget/log4net" }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", "sha256=invalid");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task VexerWebhook_HonoursRateLimit()
{
using var restrictedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Scheduler:Events:Webhooks:Vexer:RateLimitRequests"] = "1",
["Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds"] = "60"
});
});
});
using var client = restrictedFactory.CreateClient();
var payload = new
{
exportId = "vexer-exp-1",
changedClaims = new[]
{
new { productKey = "pkg:deb/openssl", vulnerabilityId = "CVE-2024-1234", status = "affected" }
}
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var first = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
var firstResponse = await client.SendAsync(first);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
using var second = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
var secondResponse = await client.SendAsync(second);
Assert.Equal((HttpStatusCode)429, secondResponse.StatusCode);
Assert.True(secondResponse.Headers.Contains("Retry-After"));
}
private static string ComputeSignature(string secret, string payload)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,6 @@
global using System.Net.Http.Json;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Threading.Tasks;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Xunit;

View File

@@ -0,0 +1,110 @@
using StellaOps.Auth.Abstractions;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class GraphJobEndpointTests : IClassFixture<SchedulerWebApplicationFactory>
{
private readonly SchedulerWebApplicationFactory _factory;
public GraphJobEndpointTests(SchedulerWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task CreateGraphBuildJob_RequiresGraphWriteScope()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-alpha");
var response = await client.PostAsJsonAsync("/graphs/build", new
{
sbomId = "sbom-test",
sbomVersionId = "sbom-ver",
sbomDigest = "sha256:" + new string('a', 64)
});
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task CreateGraphBuildJob_AndList()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-alpha");
client.DefaultRequestHeaders.Add("X-Scopes", string.Join(' ', StellaOpsScopes.GraphWrite, StellaOpsScopes.GraphRead));
var createResponse = await client.PostAsJsonAsync("/graphs/build", new
{
sbomId = "sbom-alpha",
sbomVersionId = "sbom-alpha-v1",
sbomDigest = "sha256:" + new string('b', 64),
metadata = new { source = "test" }
});
createResponse.EnsureSuccessStatusCode();
var listResponse = await client.GetAsync("/graphs/jobs");
listResponse.EnsureSuccessStatusCode();
var json = await listResponse.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.TryGetProperty("jobs", out var jobs));
Assert.True(jobs.GetArrayLength() >= 1);
var first = jobs[0];
Assert.Equal("build", first.GetProperty("kind").GetString());
Assert.Equal("tenant-alpha", first.GetProperty("tenantId").GetString());
Assert.Equal("pending", first.GetProperty("status").GetString());
}
[Fact]
public async Task CompleteOverlayJob_UpdatesStatusAndMetrics()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-bravo");
client.DefaultRequestHeaders.Add("X-Scopes", string.Join(' ', StellaOpsScopes.GraphWrite, StellaOpsScopes.GraphRead));
var createOverlay = await client.PostAsJsonAsync("/graphs/overlays", new
{
graphSnapshotId = "graph_snap_20251026",
overlayKind = "policy",
overlayKey = "policy@2025-10-01",
subjects = new[] { "artifact/service-api" }
});
createOverlay.EnsureSuccessStatusCode();
var createdJson = await createOverlay.Content.ReadAsStringAsync();
using var createdDoc = JsonDocument.Parse(createdJson);
var jobId = createdDoc.RootElement.GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(jobId));
var completeResponse = await client.PostAsJsonAsync("/graphs/hooks/completed", new
{
jobId = jobId,
jobType = "Overlay",
status = "Completed",
occurredAt = DateTimeOffset.UtcNow,
correlationId = "corr-123",
resultUri = "oras://cartographer/snapshots/graph_snap_20251026"
});
completeResponse.EnsureSuccessStatusCode();
var completedJson = await completeResponse.Content.ReadAsStringAsync();
using var completedDoc = JsonDocument.Parse(completedJson);
Assert.Equal("completed", completedDoc.RootElement.GetProperty("status").GetString());
var metricsResponse = await client.GetAsync("/graphs/overlays/lag");
metricsResponse.EnsureSuccessStatusCode();
var metricsJson = await metricsResponse.Content.ReadAsStringAsync();
using var metricsDoc = JsonDocument.Parse(metricsJson);
var metricsRoot = metricsDoc.RootElement;
Assert.Equal("tenant-bravo", metricsRoot.GetProperty("tenantId").GetString());
Assert.True(metricsRoot.GetProperty("completed").GetInt32() >= 1);
var recent = metricsRoot.GetProperty("recentCompleted");
Assert.True(recent.GetArrayLength() >= 1);
var entry = recent[0];
Assert.Equal(jobId, entry.GetProperty("jobId").GetString());
Assert.Equal("corr-123", entry.GetProperty("correlationId").GetString());
}
}

View File

@@ -0,0 +1,151 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.WebService.GraphJobs;
using StellaOps.Scheduler.WebService.GraphJobs.Events;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class GraphJobEventPublisherTests
{
[Fact]
public async Task PublishAsync_WritesEventJson_WhenEnabled()
{
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions
{
GraphJobs = { Enabled = true }
});
var loggerProvider = new ListLoggerProvider();
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider));
var publisher = new GraphJobEventPublisher(new OptionsMonitorStub<SchedulerEventsOptions>(options), loggerFactory.CreateLogger<GraphJobEventPublisher>());
var buildJob = new GraphBuildJob(
id: "gbj_test",
tenantId: "tenant-alpha",
sbomId: "sbom",
sbomVersionId: "sbom_v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Completed,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
graphSnapshotId: "graph_snap",
attempts: 1,
cartographerJobId: "carto",
correlationId: "corr",
startedAt: DateTimeOffset.UtcNow.AddSeconds(-30),
completedAt: DateTimeOffset.UtcNow,
error: null,
metadata: Array.Empty<KeyValuePair<string, string>>());
var notification = new GraphJobCompletionNotification(
buildJob.TenantId,
GraphJobQueryType.Build,
GraphJobStatus.Completed,
DateTimeOffset.UtcNow,
GraphJobResponse.From(buildJob),
"oras://result",
"corr",
null);
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);
}
[Fact]
public async Task PublishAsync_Suppressed_WhenDisabled()
{
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions());
var loggerProvider = new ListLoggerProvider();
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider));
var publisher = new GraphJobEventPublisher(new OptionsMonitorStub<SchedulerEventsOptions>(options), loggerFactory.CreateLogger<GraphJobEventPublisher>());
var overlayJob = new GraphOverlayJob(
id: "goj_test",
tenantId: "tenant-alpha",
graphSnapshotId: "graph_snap",
buildJobId: null,
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
subjects: Array.Empty<string>(),
status: GraphJobStatus.Completed,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
attempts: 1,
correlationId: null,
startedAt: DateTimeOffset.UtcNow.AddSeconds(-10),
completedAt: DateTimeOffset.UtcNow,
error: null,
metadata: Array.Empty<KeyValuePair<string, string>>());
var notification = new GraphJobCompletionNotification(
overlayJob.TenantId,
GraphJobQueryType.Overlay,
GraphJobStatus.Completed,
DateTimeOffset.UtcNow,
GraphJobResponse.From(overlayJob),
null,
null,
null);
await publisher.PublishAsync(notification, CancellationToken.None);
Assert.DoesNotContain(loggerProvider.Messages, message => message.Contains(GraphJobEventKinds.GraphJobCompleted, StringComparison.Ordinal));
}
private sealed class OptionsMonitorStub<T> : IOptionsMonitor<T> where T : class
{
private readonly IOptions<T> _options;
public OptionsMonitorStub(IOptions<T> options)
{
_options = options;
}
public T CurrentValue => _options.Value;
public T Get(string? name) => _options.Value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class ListLoggerProvider : ILoggerProvider
{
private readonly ListLogger _logger = new();
public IList<string> Messages => _logger.Messages;
public ILogger CreateLogger(string categoryName) => _logger;
public void Dispose()
{
}
private sealed class ListLogger : ILogger
{
public IList<string> Messages { get; } = new List<string>();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class PolicyRunEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public PolicyRunEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateListGetPolicyRun()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-policy");
client.DefaultRequestHeaders.Add("X-Scopes", "policy:run");
var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/policy/runs", new
{
policyId = "P-7",
policyVersion = 4,
mode = "incremental",
priority = "normal",
metadata = new { source = "cli" },
inputs = new
{
sbomSet = new[] { "sbom:S-42", "sbom:S-99" },
advisoryCursor = "2025-10-26T13:59:00+00:00",
vexCursor = "2025-10-26T13:58:30+00:00",
environment = new { @sealed = false, exposure = "internet" },
captureExplain = true
}
});
createResponse.EnsureSuccessStatusCode();
Assert.Equal(System.Net.HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
var runJson = created.GetProperty("run");
var runId = runJson.GetProperty("runId").GetString();
Assert.False(string.IsNullOrEmpty(runId));
Assert.Equal("queued", runJson.GetProperty("status").GetString());
Assert.Equal("P-7", runJson.GetProperty("policyId").GetString());
Assert.True(runJson.GetProperty("inputs").GetProperty("captureExplain").GetBoolean());
var listResponse = await client.GetAsync("/api/v1/scheduler/policy/runs?policyId=P-7");
listResponse.EnsureSuccessStatusCode();
var list = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
var runsArray = list.GetProperty("runs");
Assert.True(runsArray.GetArrayLength() >= 1);
var getResponse = await client.GetAsync($"/api/v1/scheduler/policy/runs/{runId}");
getResponse.EnsureSuccessStatusCode();
var retrieved = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(runId, retrieved.GetProperty("run").GetProperty("runId").GetString());
}
[Fact]
public async Task MissingScopeReturnsForbidden()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-policy");
var response = await client.GetAsync("/api/v1/scheduler/policy/runs");
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View File

@@ -0,0 +1,104 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public RunEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateListCancelRun()
{
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");
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "RunSchedule",
cronExpression = "0 3 * * *",
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));
var createRun = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
{
scheduleId,
trigger = "manual"
});
createRun.EnsureSuccessStatusCode();
Assert.Equal(System.Net.HttpStatusCode.Created, createRun.StatusCode);
var runJson = await createRun.Content.ReadFromJsonAsync<JsonElement>();
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(runId));
Assert.Equal("planning", runJson.GetProperty("run").GetProperty("state").GetString());
var listResponse = await client.GetAsync("/api/v1/scheduler/runs");
listResponse.EnsureSuccessStatusCode();
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(listJson.GetProperty("runs").EnumerateArray().Any());
var cancelResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/cancel", null);
cancelResponse.EnsureSuccessStatusCode();
var cancelled = await cancelResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", cancelled.GetProperty("run").GetProperty("state").GetString());
var getResponse = await client.GetAsync($"/api/v1/scheduler/runs/{runId}");
getResponse.EnsureSuccessStatusCode();
var runDetail = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cancelled", runDetail.GetProperty("run").GetProperty("state").GetString());
}
[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");
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "PreviewSchedule",
cronExpression = "0 5 * * *",
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));
var previewResponse = await client.PostAsJsonAsync("/api/v1/scheduler/runs/preview", new
{
scheduleId,
usageOnly = true,
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);
}
}

View File

@@ -0,0 +1,88 @@
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class ScheduleEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ScheduleEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateListAndRetrieveSchedule()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-schedules");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read");
var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "Nightly",
cronExpression = "0 2 * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
},
notify = new
{
onNewFindings = true,
minSeverity = "medium",
includeKev = true
}
});
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = created.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var listResponse = await client.GetAsync("/api/v1/scheduler/schedules");
listResponse.EnsureSuccessStatusCode();
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
var schedules = listJson.GetProperty("schedules");
Assert.True(schedules.GetArrayLength() >= 1);
var getResponse = await client.GetAsync($"/api/v1/scheduler/schedules/{scheduleId}");
getResponse.EnsureSuccessStatusCode();
var scheduleJson = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Nightly", scheduleJson.GetProperty("schedule").GetProperty("name").GetString());
}
[Fact]
public async Task PauseAndResumeSchedule()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-controls");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read");
var create = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
{
name = "PauseResume",
cronExpression = "*/5 * * * *",
timezone = "UTC",
mode = "analysis-only",
selection = new
{
scope = "all-images"
}
});
create.EnsureSuccessStatusCode();
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
var scheduleId = created.GetProperty("schedule").GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(scheduleId));
var pauseResponse = await client.PostAsync($"/api/v1/scheduler/schedules/{scheduleId}/pause", null);
pauseResponse.EnsureSuccessStatusCode();
var paused = await pauseResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.False(paused.GetProperty("schedule").GetProperty("enabled").GetBoolean());
var resumeResponse = await client.PostAsync($"/api/v1/scheduler/schedules/{scheduleId}/resume", null);
resumeResponse.EnsureSuccessStatusCode();
var resumed = await resumeResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(resumed.GetProperty("schedule").GetProperty("enabled").GetBoolean());
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.WebService.Options;
using Xunit;
namespace StellaOps.Scheduler.WebService.Tests;
public class SchedulerPluginHostFactoryTests
{
[Fact]
public void Build_usesDefaults_whenOptionsEmpty()
{
var options = new SchedulerOptions.PluginOptions();
var contentRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(contentRoot);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRoot);
var expectedBase = Path.GetFullPath(Path.Combine(contentRoot, ".."));
var expectedPlugins = Path.Combine(expectedBase, "plugins", "scheduler");
Assert.Equal(expectedBase, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "StellaOps.Scheduler.Plugin.*.dll");
Assert.True(hostOptions.EnsureDirectoryExists);
Assert.False(hostOptions.RecursiveSearch);
Assert.Empty(hostOptions.PluginOrder);
}
finally
{
Directory.Delete(contentRoot, recursive: true);
}
}
[Fact]
public void Build_respectsConfiguredValues()
{
var options = new SchedulerOptions.PluginOptions
{
BaseDirectory = Path.Combine(Path.GetTempPath(), "scheduler-options", Guid.NewGuid().ToString("N")),
Directory = Path.Combine("custom", "plugins"),
RecursiveSearch = true,
EnsureDirectoryExists = false
};
options.SearchPatterns.Add("Custom.Plugin.*.dll");
options.OrderedPlugins.Add("StellaOps.Scheduler.Plugin.Alpha");
Directory.CreateDirectory(options.BaseDirectory!);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRootPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var expectedPlugins = Path.GetFullPath(Path.Combine(options.BaseDirectory!, options.Directory!));
Assert.Equal(options.BaseDirectory, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "Custom.Plugin.*.dll");
Assert.Single(hostOptions.PluginOrder, "StellaOps.Scheduler.Plugin.Alpha");
Assert.True(hostOptions.RecursiveSearch);
Assert.False(hostOptions.EnsureDirectoryExists);
}
finally
{
Directory.Delete(options.BaseDirectory!, recursive: true);
}
}
}

View File

@@ -0,0 +1,46 @@
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;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class SchedulerWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string?>("Scheduler:Authority:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Cartographer:Webhook:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:GraphJobs:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:HmacSecret", "feedser-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitWindowSeconds", "60"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:HmacSecret", "vexer-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds", "60")
});
});
builder.ConfigureServices(services =>
{
services.Configure<SchedulerEventsOptions>(options =>
{
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
options.Webhooks.Feedser.HmacSecret = "feedser-secret";
options.Webhooks.Feedser.Enabled = true;
options.Webhooks.Vexer.HmacSecret = "vexer-secret";
options.Webhooks.Vexer.Enabled = true;
});
});
}
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<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" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
global using System.Collections.Immutable;
global using StellaOps.Scheduler.ImpactIndex;
global using StellaOps.Scheduler.Models;
global using StellaOps.Scheduler.Worker;
global using Xunit;

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
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 Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphBuildExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Equal(0, repository.ReplaceCalls);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
Result = new CartographerBuildResult(
GraphJobStatus.Completed,
CartographerJobId: "carto-1",
GraphSnapshotId: "graph_snap",
ResultUri: "oras://graph/result",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(10)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal(job.Id, notification.JobId);
Assert.Equal("Build", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/result", notification.ResultUri);
Assert.Equal("graph_snap", notification.GraphSnapshotId);
Assert.Null(notification.Error);
Assert.Equal(1, cartographer.CallCount);
Assert.True(repository.ReplaceCalls >= 1);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
ExceptionToThrow = new InvalidOperationException("network")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
Assert.Equal(2, cartographer.CallCount);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("network", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
private static GraphBuildJob CreateGraphJob() => new(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom-1",
sbomVersionId: "sbom-1-v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public int ReplaceCalls { get; private set; }
public bool ShouldReplaceSucceed { get; set; } = true;
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
ReplaceCalls++;
return Task.FromResult(true);
}
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerBuildClient : ICartographerBuildClient
{
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
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 Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphOverlayExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
Result = new CartographerOverlayResult(
GraphJobStatus.Completed,
GraphSnapshotId: "graph_snap_2",
ResultUri: "oras://graph/overlay",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(5)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal("Overlay", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/overlay", notification.ResultUri);
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterRetries()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
ExceptionToThrow = new InvalidOperationException("overlay failed")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("overlay failed", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
private static GraphOverlayJob CreateOverlayJob() => new(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "snap-1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public bool ShouldReplaceSucceed { get; set; } = true;
public int RunningReplacements { get; private set; }
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
RunningReplacements++;
return Task.FromResult(true);
}
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
{
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker.Execution;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class HttpScannerReportClientTests
{
[Fact]
public async Task ExecuteAsync_WhenReportReturnsFindings_ProducesDeltaSummary()
{
var handler = new StubHttpMessageHandler(request =>
{
if (request.RequestUri?.AbsolutePath.EndsWith("/api/v1/reports", StringComparison.OrdinalIgnoreCase) == true)
{
var payload = new
{
report = new
{
reportId = "report-123",
imageDigest = "sha256:abc",
generatedAt = DateTimeOffset.UtcNow,
verdict = "warn",
policy = new { revisionId = "rev-1", digest = "digest-1" },
summary = new { total = 3, blocked = 2, warned = 1, ignored = 0, quieted = 0 }
},
dsse = new
{
payloadType = "application/vnd.in-toto+json",
payload = "eyJkYXRhIjoidGVzdCJ9",
signatures = new[] { new { keyId = "test", algorithm = "ed25519", signature = "c2ln" } }
}
};
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(payload)
});
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://scanner.example")
};
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions());
options.Value.Runner.Scanner.BaseAddress = httpClient.BaseAddress;
options.Value.Runner.Scanner.EnableContentRefresh = false;
var client = new HttpScannerReportClient(httpClient, options, NullLogger<HttpScannerReportClient>.Instance);
var result = await client.ExecuteAsync(
new ScannerReportRequest("tenant-1", "run-1", "sha256:abc", ScheduleMode.AnalysisOnly, true, new Dictionary<string, string>()),
CancellationToken.None);
Assert.Equal("sha256:abc", result.ImageDigest);
Assert.NotNull(result.Delta);
Assert.Equal(3, result.Delta!.NewFindings);
Assert.Equal(2, result.Delta.NewCriticals);
Assert.Equal(1, result.Delta.NewHigh);
Assert.Equal(0, result.Delta.NewMedium);
Assert.Equal(0, result.Delta.NewLow);
Assert.Equal("report-123", result.Report.ReportId);
Assert.Equal("rev-1", result.Report.PolicyRevisionId);
Assert.NotNull(result.Dsse);
}
[Fact]
public async Task ExecuteAsync_WhenReportFails_RetriesAndThrows()
{
var callCount = 0;
var handler = new StubHttpMessageHandler(_ =>
{
callCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://scanner.example")
};
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions());
options.Value.Runner.Scanner.BaseAddress = httpClient.BaseAddress;
options.Value.Runner.Scanner.EnableContentRefresh = false;
options.Value.Runner.Scanner.MaxRetryAttempts = 2;
options.Value.Runner.Scanner.RetryBaseDelay = TimeSpan.FromMilliseconds(1);
var client = new HttpScannerReportClient(httpClient, options, NullLogger<HttpScannerReportClient>.Instance);
await Assert.ThrowsAsync<HttpRequestException>(() => client.ExecuteAsync(
new ScannerReportRequest("tenant-1", "run-1", "sha256:abc", ScheduleMode.AnalysisOnly, true, new Dictionary<string, string>()),
CancellationToken.None));
Assert.Equal(3, callCount);
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _handler;
public StubHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> _handler(request);
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class ImpactShardPlannerTests
{
[Fact]
public void PlanShards_ReturnsSingleShardWhenParallelismNotSpecified()
{
var impactSet = CreateImpactSet(count: 3);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: null);
Assert.Single(shards);
Assert.Equal(3, shards[0].Count);
}
[Fact]
public void PlanShards_RespectsMaxJobsLimit()
{
var impactSet = CreateImpactSet(count: 5);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: 2, parallelism: 4);
Assert.Equal(2, shards.Sum(shard => shard.Count));
Assert.True(shards.All(shard => shard.Count <= 1));
}
[Fact]
public void PlanShards_DistributesImagesEvenly()
{
var impactSet = CreateImpactSet(count: 10);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: 3);
Assert.Equal(3, shards.Length);
var counts = shards.Select(shard => shard.Count).OrderBy(count => count).ToArray();
Assert.Equal(new[] {3, 3, 4}, counts);
var flattened = shards.SelectMany(shard => shard.Images).ToArray();
Assert.Equal(impactSet.Images, flattened, ImpactImageEqualityComparer.Instance);
}
private static ImpactSet CreateImpactSet(int count)
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var images = Enumerable.Range(0, count)
.Select(i => new ImpactImage(
$"sha256:{i:D64}",
"registry",
"repo/app",
namespaces: new[] { "team" },
tags: new[] { "prod" },
usedByEntrypoint: true))
.ToImmutableArray();
return new ImpactSet(selector, images, usageOnly: true, DateTimeOffset.UtcNow, total: count, snapshotId: null, schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private sealed class ImpactImageEqualityComparer : IEqualityComparer<ImpactImage>
{
public static ImpactImageEqualityComparer Instance { get; } = new();
public bool Equals(ImpactImage? x, ImpactImage? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.ImageDigest, y.ImageDigest, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(ImpactImage obj)
=> StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ImageDigest);
}
}

View File

@@ -0,0 +1,217 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.ImpactIndex;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class ImpactTargetingServiceTests
{
[Fact]
public async Task ResolveByPurlsAsync_DeduplicatesKeysAndInvokesIndex()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var expected = CreateEmptyImpactSet(selector, usageOnly: false);
IEnumerable<string>? capturedKeys = null;
var index = new StubImpactIndex
{
OnResolveByPurls = (purls, usageOnly, sel, _) =>
{
capturedKeys = purls.ToArray();
Assert.False(usageOnly);
Assert.Equal(selector, sel);
return ValueTask.FromResult(expected);
}
};
var service = new ImpactTargetingService(index);
var result = await service.ResolveByPurlsAsync(
new[] { "pkg:npm/a", "pkg:npm/A ", null!, "pkg:npm/b" },
usageOnly: false,
selector);
Assert.Equal(expected, result);
Assert.Equal(new[] { "pkg:npm/a", "pkg:npm/b" }, capturedKeys);
}
[Fact]
public async Task ResolveByVulnerabilitiesAsync_ReturnsEmptyWhenNoIds()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var index = new StubImpactIndex();
var service = new ImpactTargetingService(index);
var result = await service.ResolveByVulnerabilitiesAsync(Array.Empty<string>(), usageOnly: true, selector);
Assert.Empty(result.Images);
Assert.True(result.UsageOnly);
Assert.Null(index.LastVulnerabilityIds);
}
[Fact]
public async Task ResolveAllAsync_DelegatesToIndex()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var expected = CreateEmptyImpactSet(selector, usageOnly: true);
var index = new StubImpactIndex
{
OnResolveAll = (sel, usageOnly, _) =>
{
Assert.Equal(selector, sel);
Assert.True(usageOnly);
return ValueTask.FromResult(expected);
}
};
var service = new ImpactTargetingService(index);
var result = await service.ResolveAllAsync(selector, usageOnly: true);
Assert.Equal(expected, result);
}
[Fact]
public async Task ResolveByPurlsAsync_DeduplicatesImpactImagesByDigest()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var indexResult = new ImpactSet(
selector,
new[]
{
new ImpactImage(
"sha256:111",
"registry-1",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "v1" },
usedByEntrypoint: false,
labels: new[] { KeyValuePair.Create("env", "prod") }),
new ImpactImage(
"sha256:111",
"registry-1",
"repo/app",
namespaces: new[] { "team-b" },
tags: new[] { "v2" },
usedByEntrypoint: true,
labels: new[]
{
KeyValuePair.Create("env", "prod"),
KeyValuePair.Create("component", "api")
})
},
usageOnly: false,
DateTimeOffset.UtcNow,
total: 2,
snapshotId: "snap-1",
schemaVersion: SchedulerSchemaVersions.ImpactSet);
var index = new StubImpactIndex
{
OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult)
};
var service = new ImpactTargetingService(index);
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: false, selector);
Assert.Single(result.Images);
var image = result.Images[0];
Assert.Equal("sha256:111", image.ImageDigest);
Assert.Equal(new[] { "team-a", "team-b" }, image.Namespaces);
Assert.Equal(new[] { "v1", "v2" }, image.Tags);
Assert.True(image.UsedByEntrypoint);
Assert.Equal("registry-1", image.Registry);
Assert.Equal("repo/app", image.Repository);
Assert.Equal(2, result.Total);
Assert.Equal("prod", image.Labels["env"]);
Assert.Equal("api", image.Labels["component"]);
}
[Fact]
public async Task ResolveByPurlsAsync_FiltersImagesBySelectorConstraints()
{
var selector = new Selector(
SelectorScope.ByNamespace,
tenantId: "tenant-alpha",
namespaces: new[] { "team-a" },
includeTags: new[] { "prod-*" },
labels: new[] { new LabelSelector("env", new[] { "prod" }) });
var matching = new ImpactImage(
"sha256:aaa",
"registry-1",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "prod-202510" },
usedByEntrypoint: true,
labels: new[] { KeyValuePair.Create("env", "prod") });
var nonMatching = new ImpactImage(
"sha256:bbb",
"registry-1",
"repo/app",
namespaces: new[] { "team-b" },
tags: new[] { "dev" },
usedByEntrypoint: false,
labels: new[] { KeyValuePair.Create("env", "dev") });
var indexResult = new ImpactSet(
selector,
new[] { matching, nonMatching },
usageOnly: true,
DateTimeOffset.UtcNow,
total: 2,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
var index = new StubImpactIndex
{
OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult)
};
var service = new ImpactTargetingService(index);
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: true, selector);
Assert.Single(result.Images);
Assert.Equal("sha256:aaa", result.Images[0].ImageDigest);
}
private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
=> new(
selector,
ImmutableArray<ImpactImage>.Empty,
usageOnly,
DateTimeOffset.UtcNow,
0,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
private sealed class StubImpactIndex : IImpactIndex
{
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByPurls { get; set; }
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByVulnerabilities { get; set; }
public Func<Selector, bool, CancellationToken, ValueTask<ImpactSet>>? OnResolveAll { get; set; }
public IEnumerable<string>? LastVulnerabilityIds { get; private set; }
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> purls, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> OnResolveByPurls?.Invoke(purls, usageOnly, selector, cancellationToken)
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
{
LastVulnerabilityIds = vulnerabilityIds;
return OnResolveByVulnerabilities?.Invoke(vulnerabilityIds, usageOnly, selector, cancellationToken)
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
}
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
=> OnResolveAll?.Invoke(selector, usageOnly, cancellationToken)
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
}
}

View File

@@ -0,0 +1,411 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Planning;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerBackgroundServiceTests
{
[Fact]
public async Task ExecuteAsync_RespectsTenantFairnessCap()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T12:00:00Z"));
var runs = new[]
{
CreateRun("run-a1", "tenant-a", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(1), "schedule-a"),
CreateRun("run-a2", "tenant-a", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(2), "schedule-a"),
CreateRun("run-b1", "tenant-b", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(3), "schedule-b"),
CreateRun("run-c1", "tenant-c", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(4), "schedule-c"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 2);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= 2);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-a1", "run-b1" }, processedIds);
}
[Fact]
public async Task ExecuteAsync_PrioritizesManualAndEventTriggers()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T18:00:00Z"));
var runs = new[]
{
CreateRun("run-cron", "tenant-alpha", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(1), "schedule-cron"),
CreateRun("run-feedser", "tenant-bravo", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(2), "schedule-feedser"),
CreateRun("run-manual", "tenant-charlie", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(3), "schedule-manual"),
CreateRun("run-vexer", "tenant-delta", RunTrigger.Vexer, timeProvider.GetUtcNow().AddMinutes(4), "schedule-vexer"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 4);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= runs.Length);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-manual", "run-feedser", "run-vexer", "run-cron" }, processedIds);
}
private static SchedulerWorkerOptions CreateOptions(int maxConcurrentTenants)
{
return new SchedulerWorkerOptions
{
Planner =
{
BatchSize = 20,
PollInterval = TimeSpan.FromMilliseconds(1),
IdleDelay = TimeSpan.FromMilliseconds(1),
MaxConcurrentTenants = maxConcurrentTenants,
MaxRunsPerMinute = int.MaxValue,
QueueLeaseDuration = TimeSpan.FromMinutes(5)
}
};
}
private static Run CreateRun(
string id,
string tenantId,
RunTrigger trigger,
DateTimeOffset createdAt,
string scheduleId)
=> new(
id: id,
tenantId: tenantId,
trigger: trigger,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: createdAt,
reason: RunReason.Empty,
scheduleId: scheduleId);
private static Schedule CreateSchedule(string scheduleId, string tenantId, DateTimeOffset now)
=> new(
id: scheduleId,
tenantId: tenantId,
name: $"Schedule-{scheduleId}",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: now,
createdBy: "system",
updatedAt: now,
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
private static async Task WaitForConditionAsync(Func<bool> predicate, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(1));
while (!predicate())
{
if (DateTime.UtcNow > deadline)
{
throw new TimeoutException("Planner background service did not reach expected state within the allotted time.");
}
await Task.Delay(10);
}
}
private sealed class TestRunRepository : IRunRepository
{
private readonly Queue<IReadOnlyList<Run>> _responses;
private readonly ConcurrentQueue<Run> _updates = new();
private int _updateCount;
public TestRunRepository(params IReadOnlyList<Run>[] responses)
{
if (responses is null)
{
throw new ArgumentNullException(nameof(responses));
}
_responses = new Queue<IReadOnlyList<Run>>(responses.Select(static runs => (IReadOnlyList<Run>)runs.ToArray()));
}
public int UpdateCount => Volatile.Read(ref _updateCount);
public IReadOnlyList<Run> UpdatedRuns => _updates.ToArray();
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_updates.Enqueue(run);
Interlocked.Increment(ref _updateCount);
return Task.FromResult(true);
}
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<Run?>(null);
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
if (state != RunState.Planning)
{
return Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
}
var next = _responses.Count > 0 ? _responses.Dequeue() : Array.Empty<Run>();
if (next.Count > limit)
{
next = next.Take(limit).ToArray();
}
return Task.FromResult(next);
}
}
private sealed class TestScheduleRepository : IScheduleRepository
{
public TestScheduleRepository(IEnumerable<Schedule> schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
_schedules = new Dictionary<(string TenantId, string ScheduleId), Schedule>();
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
}
}
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _schedules;
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var results = _schedules.Values.Where(schedule => schedule.TenantId == tenantId).ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(results);
}
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var removed = _schedules.Remove((tenantId, scheduleId));
return Task.FromResult(removed);
}
}
private sealed class StubImpactSnapshotRepository : IImpactSnapshotRepository
{
public ImpactSet? LastSnapshot { get; private set; }
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
LastSnapshot = snapshot;
return Task.CompletedTask;
}
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
}
private sealed class StubRunSummaryService : IRunSummaryService
{
private readonly TimeProvider _timeProvider;
public StubRunSummaryService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var projection = new RunSummaryProjection(
run.TenantId,
run.ScheduleId ?? string.Empty,
_timeProvider.GetUtcNow(),
null,
ImmutableArray<RunSummarySnapshot>.Empty,
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
=> Task.FromResult<RunSummaryProjection?>(null);
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
}
private sealed class StubImpactTargetingService : IImpactTargetingService
{
private static readonly string DefaultDigest = "sha256:" + new string('a', 64);
private readonly TimeProvider _timeProvider;
public StubImpactTargetingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
{
var image = new ImpactImage(
DefaultDigest,
registry: "registry.test",
repository: "repo/sample",
namespaces: new[] { selector.TenantId ?? "unknown" },
tags: new[] { "latest" },
usedByEntrypoint: true);
var impactSet = new ImpactSet(
selector,
ImmutableArray.Create(image),
usageOnly,
_timeProvider.GetUtcNow(),
total: 1,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
return ValueTask.FromResult(impactSet);
}
}
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
{
private readonly ConcurrentQueue<PlannerQueueMessage> _messages = new();
public IReadOnlyList<PlannerQueueMessage> Messages => _messages.ToArray();
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
{
_messages.Enqueue(message);
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(message.Run.Id, Deduplicated: false));
}
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset initial)
{
_now = initial;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -0,0 +1,321 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using MongoDB.Driver;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Planning;
using StellaOps.Scheduler.Worker.Observability;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerExecutionServiceTests
{
[Fact]
public async Task ProcessAsync_WithImpactedImages_QueuesPlannerMessage()
{
var schedule = CreateSchedule();
var run = CreateRun(schedule.Id);
var impactSet = CreateImpactSet(schedule.Selection, images: 2);
var scheduleRepository = new StubScheduleRepository(schedule);
var runRepository = new InMemoryRunRepository(run);
var snapshotRepository = new RecordingImpactSnapshotRepository();
var runSummaryService = new RecordingRunSummaryService();
var targetingService = new StubImpactTargetingService(impactSet);
var plannerQueue = new RecordingPlannerQueue();
using var metrics = new SchedulerWorkerMetrics();
var service = new PlannerExecutionService(
scheduleRepository,
runRepository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
new SchedulerWorkerOptions(),
TimeProvider.System,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.Enqueued, result.Status);
Assert.Single(plannerQueue.Messages);
Assert.NotNull(snapshotRepository.LastSnapshot);
Assert.Equal(RunState.Queued, result.UpdatedRun!.State);
Assert.Equal(impactSet.Images.Length, result.UpdatedRun.Stats.Queued);
}
[Fact]
public async Task ProcessAsync_WithNoImpactedImages_CompletesWithoutWork()
{
var schedule = CreateSchedule();
var run = CreateRun(schedule.Id);
var impactSet = CreateImpactSet(schedule.Selection, images: 0);
var service = CreateService(schedule, run, impactSet, out var plannerQueue);
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.CompletedWithoutWork, result.Status);
Assert.Equal(RunState.Completed, result.UpdatedRun!.State);
Assert.Empty(plannerQueue.Messages);
}
[Fact]
public async Task ProcessAsync_WhenScheduleMissing_MarksRunAsFailed()
{
var run = CreateRun(scheduleId: "missing");
var scheduleRepository = new StubScheduleRepository(); // empty repository
var runRepository = new InMemoryRunRepository(run);
var snapshotRepository = new RecordingImpactSnapshotRepository();
var runSummaryService = new RecordingRunSummaryService();
var targetingService = new StubImpactTargetingService(CreateImpactSet(new Selector(SelectorScope.AllImages, run.TenantId), 0));
var plannerQueue = new RecordingPlannerQueue();
using var metrics = new SchedulerWorkerMetrics();
var service = new PlannerExecutionService(
scheduleRepository,
runRepository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
new SchedulerWorkerOptions(),
TimeProvider.System,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.Failed, result.Status);
Assert.Equal(RunState.Error, result.UpdatedRun!.State);
Assert.Empty(plannerQueue.Messages);
}
private static PlannerExecutionService CreateService(
Schedule schedule,
Run run,
ImpactSet impactSet,
out RecordingPlannerQueue plannerQueue)
{
var scheduleRepository = new StubScheduleRepository(schedule);
var runRepository = new InMemoryRunRepository(run);
var snapshotRepository = new RecordingImpactSnapshotRepository();
var runSummaryService = new RecordingRunSummaryService();
var targetingService = new StubImpactTargetingService(impactSet);
plannerQueue = new RecordingPlannerQueue();
return new PlannerExecutionService(
scheduleRepository,
runRepository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
new SchedulerWorkerOptions(),
TimeProvider.System,
new SchedulerWorkerMetrics(),
NullLogger<PlannerExecutionService>.Instance);
}
private static Run CreateRun(string scheduleId)
=> new(
id: "run_001",
tenantId: "tenant-alpha",
trigger: RunTrigger.Cron,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5),
scheduleId: scheduleId);
private static Schedule CreateSchedule()
=> new(
id: "sch_001",
tenantId: "tenant-alpha",
name: "Nightly",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
createdBy: "system",
updatedAt: DateTimeOffset.UtcNow.AddHours(-1),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
private static ImpactSet CreateImpactSet(Selector selector, int images)
{
var imageList = Enumerable.Range(0, images)
.Select(index => new ImpactImage(
imageDigest: $"sha256:{index:D64}",
registry: "registry",
repository: "repo/api",
namespaces: new[] { "team-alpha" },
tags: new[] { "latest" },
usedByEntrypoint: true))
.ToImmutableArray();
return new ImpactSet(
selector,
imageList,
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow.AddSeconds(-10),
total: imageList.Length,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private sealed class StubScheduleRepository : IScheduleRepository
{
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _store;
public StubScheduleRepository(params Schedule[] schedules)
{
_store = schedules.ToDictionary(schedule => (schedule.TenantId, schedule.Id), schedule => schedule);
}
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_store[(schedule.TenantId, schedule.Id)] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_store.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Schedule>>(_store.Values.Where(schedule => schedule.TenantId == tenantId).ToArray());
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult(_store.Remove((tenantId, scheduleId)));
}
private sealed class InMemoryRunRepository : IRunRepository
{
private readonly ConcurrentDictionary<(string Tenant, string RunId), Run> _runs = new();
public InMemoryRunRepository(params Run[] runs)
{
foreach (var run in runs)
{
_runs[(run.TenantId, run.Id)] = run;
}
}
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs[(run.TenantId, run.Id)] = run;
return Task.CompletedTask;
}
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs[(run.TenantId, run.Id)] = run;
return Task.FromResult(true);
}
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs.TryGetValue((tenantId, runId), out var run);
return Task.FromResult(run);
}
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.TenantId == tenantId).ToArray());
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.State == state).Take(limit).ToArray());
}
private sealed class RecordingImpactSnapshotRepository : IImpactSnapshotRepository
{
public ImpactSet? LastSnapshot { get; private set; }
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
LastSnapshot = snapshot;
return Task.CompletedTask;
}
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
}
private sealed class RecordingRunSummaryService : IRunSummaryService
{
public Run? LastRun { get; private set; }
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
LastRun = run;
return Task.FromResult(new RunSummaryProjection(
run.TenantId,
run.ScheduleId ?? string.Empty,
DateTimeOffset.UtcNow,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)));
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
=> Task.FromResult<RunSummaryProjection?>(null);
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
}
private sealed class StubImpactTargetingService : IImpactTargetingService
{
private readonly ImpactSet _result;
public StubImpactTargetingService(ImpactSet result)
{
_result = result;
}
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> new(_result);
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> new(_result);
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
=> new(_result);
}
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
{
public List<PlannerQueueMessage> Messages { get; } = new();
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
{
Messages.Add(message);
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(Guid.NewGuid().ToString(), Deduplicated: false));
}
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Worker.Planning;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerQueueDispatchServiceTests
{
[Fact]
public async Task DispatchAsync_EnqueuesRunnerSegmentsDeterministically()
{
var run = CreateRun();
var schedule = CreateSchedule(parallelism: 2, maxJobs: 4, ratePerSecond: 11);
var impactSet = CreateImpactSet(run.TenantId, count: 5);
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-123");
var shardPlanner = new ImpactShardPlanner();
var runnerQueue = new RecordingRunnerQueue();
var service = new PlannerQueueDispatchService(
shardPlanner,
runnerQueue,
new SchedulerWorkerOptions(),
NullLogger<PlannerQueueDispatchService>.Instance);
var result = await service.DispatchAsync(message, CancellationToken.None);
Assert.Equal(PlannerQueueDispatchStatus.DispatchCompleted, result.Status);
Assert.Equal(2, result.SegmentCount);
Assert.Equal(4, runnerQueue.Messages.Sum(msg => msg.ImageDigests.Count));
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.Id, msg.RunId));
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.TenantId, msg.TenantId));
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.ScheduleId, msg.ScheduleId));
Assert.All(runnerQueue.Messages, msg => Assert.Equal(impactSet.UsageOnly, msg.UsageOnly));
Assert.All(runnerQueue.Messages, msg => Assert.Equal(11, msg.RatePerSecond));
Assert.Collection(
runnerQueue.Messages.OrderBy(msg => msg.SegmentId),
first =>
{
Assert.Equal($"{run.Id}:0000", first.SegmentId);
Assert.Equal(2, first.ImageDigests.Count);
},
second =>
{
Assert.Equal($"{run.Id}:0001", second.SegmentId);
Assert.Equal(2, second.ImageDigests.Count);
});
}
[Fact]
public async Task DispatchAsync_NoImages_ReturnsNoWork()
{
var run = CreateRun();
var schedule = CreateSchedule();
var impactSet = new ImpactSet(
new Selector(SelectorScope.AllImages, run.TenantId),
ImmutableArray<ImpactImage>.Empty,
usageOnly: true,
DateTimeOffset.UtcNow,
total: 0,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
var message = new PlannerQueueMessage(run, impactSet, schedule);
var shardPlanner = new StubImpactShardPlanner(ImmutableArray<ImpactShard>.Empty);
var runnerQueue = new RecordingRunnerQueue();
var service = new PlannerQueueDispatchService(
shardPlanner,
runnerQueue,
new SchedulerWorkerOptions(),
NullLogger<PlannerQueueDispatchService>.Instance);
var result = await service.DispatchAsync(message, CancellationToken.None);
Assert.Equal(PlannerQueueDispatchStatus.NoWork, result.Status);
Assert.Empty(runnerQueue.Messages);
}
private static Run CreateRun()
=> new(
id: "run-123",
tenantId: "tenant-abc",
trigger: RunTrigger.Cron,
state: RunState.Queued,
stats: new RunStats(candidates: 6, deduped: 5, queued: 5),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5),
scheduleId: "sched-789");
private static Schedule CreateSchedule(int? parallelism = null, int? maxJobs = null, int? ratePerSecond = null)
=> new(
id: "sched-789",
tenantId: "tenant-abc",
name: "Nightly",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-abc"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: new ScheduleLimits(maxJobs, ratePerSecond, parallelism),
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
createdBy: "system",
updatedAt: DateTimeOffset.UtcNow.AddHours(-1),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
private static ImpactSet CreateImpactSet(string tenantId, int count)
{
var selector = new Selector(SelectorScope.AllImages, tenantId);
var images = Enumerable.Range(0, count)
.Select(index => new ImpactImage(
imageDigest: $"sha256:{index:D64}",
registry: "registry.example.com",
repository: "service/api",
namespaces: new[] { "team-a" },
tags: new[] { $"v{index}" },
usedByEntrypoint: index % 2 == 0))
.ToImmutableArray();
return new ImpactSet(
selector,
images,
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow.AddMinutes(-2),
total: count,
snapshotId: "snapshot-xyz",
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private sealed class RecordingRunnerQueue : ISchedulerRunnerQueue
{
public List<RunnerSegmentQueueMessage> Messages { get; } = new();
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(RunnerSegmentQueueMessage message, CancellationToken cancellationToken = default)
{
Messages.Add(message);
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(Guid.NewGuid().ToString(), Deduplicated: false));
}
public ValueTask<IReadOnlyList<ISchedulerQueueLease<RunnerSegmentQueueMessage>>> LeaseAsync(
SchedulerQueueLeaseRequest request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<ISchedulerQueueLease<RunnerSegmentQueueMessage>>> ClaimExpiredAsync(
SchedulerQueueClaimOptions options,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class StubImpactShardPlanner : IImpactShardPlanner
{
private readonly ImmutableArray<ImpactShard> _result;
public StubImpactShardPlanner(ImmutableArray<ImpactShard> result)
{
_result = result;
}
public ImmutableArray<ImpactShard> PlanShards(ImpactSet impactSet, int? maxJobs, int? parallelism) => _result;
}
}

View File

@@ -0,0 +1,328 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunExecutionServiceTests
{
private static readonly SchedulerWorkerOptions WorkerOptions = new()
{
Policy =
{
Dispatch =
{
LeaseOwner = "test-dispatch",
BatchSize = 1,
LeaseDuration = TimeSpan.FromMinutes(1),
IdleDelay = TimeSpan.FromMilliseconds(10),
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromSeconds(30)
},
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/api/policy/policies/{policyId}/runs",
SimulatePath = "/api/policy/policies/{policyId}/simulate"
}
}
};
[Fact]
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
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 job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
CancellationRequested = true,
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
Assert.True(repository.ReplaceCalled);
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
}
[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 job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
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);
}
[Fact]
public async Task ExecuteAsync_RetriesJob_OnFailure()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
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 job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
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);
}
[Fact]
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("bad request")
};
var optionsValue = CloneOptions();
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);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
{
LeaseOwner = "test-dispatch",
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);
}
[Fact]
public async Task ExecuteAsync_NoWork_CompletesJob()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
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 job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
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);
}
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
{
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.Normal,
PriorityRank: -1,
RunId: "run:P-7:2025",
RequestedBy: "user:cli",
CorrelationId: "corr-1",
Metadata: metadata,
Inputs: resolvedInputs,
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
Status: status,
AttemptCount: attemptCount,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private static SchedulerWorkerOptions CloneOptions()
{
return new SchedulerWorkerOptions
{
Policy = new SchedulerWorkerOptions.PolicyOptions
{
Enabled = WorkerOptions.Policy.Enabled,
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
{
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
},
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
{
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
RunsPath = WorkerOptions.Policy.Api.RunsPath,
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
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
}
}
};
}
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
{
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
}
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
{
public bool ReplaceCalled { get; private set; }
public string? ExpectedLeaseOwner { get; private set; }
public PolicyRunJob? LastJob { get; private set; }
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<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
ReplaceCalled = true;
ExpectedLeaseOwner = expectedLeaseOwner;
LastJob = job;
return Task.FromResult(true);
}
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
}
private sealed class StubPolicyRunClient : IPolicyRunClient
{
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunTargetingServiceTests
{
[Fact]
public async Task EnsureTargetsAsync_ReturnsUnchanged_ForNonIncrementalJob()
{
var service = CreateService();
var job = CreateJob(mode: PolicyRunMode.Full);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
Assert.Equal(job, result.Job);
}
[Fact]
public async Task EnsureTargetsAsync_ReturnsUnchanged_WhenSbomSetAlreadyPresent()
{
var service = CreateService();
var inputs = new PolicyRunInputs(sbomSet: new[] { "sbom:S-1" });
var job = CreateJob(inputs: inputs);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
}
[Fact]
public async Task EnsureTargetsAsync_ReturnsNoWork_WhenNoCandidatesResolved()
{
var impact = new StubImpactTargetingService();
var service = CreateService(impact);
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.purls", "pkg:npm/leftpad");
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.NoWork, result.Status);
Assert.Equal("no_matches", result.Reason);
}
[Fact]
public async Task EnsureTargetsAsync_TargetsDirectSboms()
{
var service = CreateService();
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.sboms", "sbom:S-2, sbom:S-1, sbom:S-2");
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
Assert.Equal(new[] { "sbom:S-1", "sbom:S-2" }, result.Job.Inputs.SbomSet);
}
[Fact]
public async Task EnsureTargetsAsync_TargetsUsingImpactIndex()
{
var impact = new StubImpactTargetingService
{
OnResolveByPurls = (keys, usageOnly, selector, _) =>
{
var image = new ImpactImage(
"sha256:111",
"registry",
"repo",
labels: ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal).Add("sbomId", "sbom:S-42"));
var impactSet = new ImpactSet(
selector,
new[] { image },
usageOnly,
DateTimeOffset.UtcNow,
total: 1,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
return ValueTask.FromResult(impactSet);
}
};
var service = CreateService(impact);
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.purls", "pkg:npm/example");
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
Assert.Equal(new[] { "sbom:S-42" }, result.Job.Inputs.SbomSet);
}
[Fact]
public async Task EnsureTargetsAsync_FallsBack_WhenLimitExceeded()
{
var service = CreateService(configure: options => options.MaxSboms = 1);
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.sboms", "sbom:S-1,sbom:S-2");
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
}
[Fact]
public async Task EnsureTargetsAsync_FallbacksToDigest_WhenLabelMissing()
{
var impact = new StubImpactTargetingService
{
OnResolveByVulnerabilities = (ids, usageOnly, selector, _) =>
{
var image = new ImpactImage("sha256:aaa", "registry", "repo");
var impactSet = new ImpactSet(
selector,
new[] { image },
usageOnly,
DateTimeOffset.UtcNow,
total: 1,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
return ValueTask.FromResult(impactSet);
}
};
var service = CreateService(impact);
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.vulns", "CVE-2025-1234");
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
Assert.Equal(new[] { "sbom:sha256:aaa" }, result.Job.Inputs.SbomSet);
}
private static PolicyRunTargetingService CreateService(
IImpactTargetingService? impact = null,
Action<SchedulerWorkerOptions.PolicyOptions.TargetingOptions>? configure = null)
{
impact ??= new StubImpactTargetingService();
var options = CreateOptions(configure);
return new PolicyRunTargetingService(
impact,
Microsoft.Extensions.Options.Options.Create(options),
timeProvider: null,
NullLogger<PolicyRunTargetingService>.Instance);
}
private static SchedulerWorkerOptions CreateOptions(Action<SchedulerWorkerOptions.PolicyOptions.TargetingOptions>? configure)
{
var options = new SchedulerWorkerOptions
{
Policy =
{
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/runs",
SimulatePath = "/simulate"
}
}
};
configure?.Invoke(options.Policy.Targeting);
return options;
}
private static PolicyRunJob CreateJob(
PolicyRunMode mode = PolicyRunMode.Incremental,
ImmutableSortedDictionary<string, string>? metadata = null,
PolicyRunInputs? inputs = null)
{
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job-1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: mode,
Priority: PolicyRunPriority.Normal,
PriorityRank: 0,
RunId: null,
RequestedBy: null,
CorrelationId: null,
Metadata: metadata ?? ImmutableSortedDictionary<string, string>.Empty,
Inputs: inputs ?? PolicyRunInputs.Empty,
QueuedAt: DateTimeOffset.UtcNow,
Status: PolicyRunJobStatus.Dispatching,
AttemptCount: 0,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.UtcNow,
UpdatedAt: DateTimeOffset.UtcNow,
AvailableAt: DateTimeOffset.UtcNow,
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: "lease",
LeaseExpiresAt: DateTimeOffset.UtcNow.AddMinutes(1),
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private sealed class StubImpactTargetingService : IImpactTargetingService
{
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByPurls { get; set; }
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByVulnerabilities { get; set; }
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
{
if (OnResolveByPurls is null)
{
return ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
}
return OnResolveByPurls(productKeys, usageOnly, selector, cancellationToken);
}
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
{
if (OnResolveByVulnerabilities is null)
{
return ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
}
return OnResolveByVulnerabilities(vulnerabilityIds, usageOnly, selector, cancellationToken);
}
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
{
return new ImpactSet(
selector,
ImmutableArray<ImpactImage>.Empty,
usageOnly,
DateTimeOffset.UtcNow,
total: 0,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
}
}

View File

@@ -0,0 +1,327 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Worker.Events;
using StellaOps.Scheduler.Worker.Execution;
using StellaOps.Scheduler.Worker.Observability;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class RunnerExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_UpdatesRunStatsAndDeltas()
{
var run = CreateRun();
var repository = new InMemoryRunRepository(run);
var summaryService = new RecordingRunSummaryService();
var impactRepository = new InMemoryImpactSnapshotRepository(run.Id,
new[]
{
CreateImpactImage("sha256:1111111111111111111111111111111111111111111111111111111111111111", "registry-1", "repo-1"),
CreateImpactImage("sha256:2222222222222222222222222222222222222222222222222222222222222222", "registry-1", "repo-2")
});
var scannerClient = new StubScannerReportClient(new Dictionary<string, RunnerImageResult>
{
["sha256:1111111111111111111111111111111111111111111111111111111111111111"] = CreateRunnerImageResult(
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
new DeltaSummary(
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
newFindings: 2,
newCriticals: 1,
newHigh: 0,
newMedium: 1,
newLow: 0,
kevHits: ImmutableArray.Create("CVE-2025-0001"),
topFindings: ImmutableArray.Create(new DeltaFinding("pkg:purl", "CVE-2025-0001", SeverityRank.Critical)),
reportUrl: "https://scanner/reports/1",
attestation: null,
detectedAt: DateTimeOffset.UtcNow)),
["sha256:2222222222222222222222222222222222222222222222222222222222222222"] = CreateRunnerImageResult(
"sha256:2222222222222222222222222222222222222222222222222222222222222222",
delta: null)
});
var eventPublisher = new RecordingSchedulerEventPublisher();
using var metrics = new SchedulerWorkerMetrics();
var service = new RunnerExecutionService(
repository,
summaryService,
impactRepository,
scannerClient,
eventPublisher,
metrics,
TimeProvider.System,
NullLogger<RunnerExecutionService>.Instance);
var message = new RunnerSegmentQueueMessage(
segmentId: "run-123:0000",
runId: run.Id,
tenantId: run.TenantId,
imageDigests: new[]
{
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
"sha256:2222222222222222222222222222222222222222222222222222222222222222"
},
scheduleId: run.ScheduleId,
ratePerSecond: null,
usageOnly: true,
attributes: new Dictionary<string, string>
{
["scheduleMode"] = ScheduleMode.AnalysisOnly.ToString(),
["impactSnapshotId"] = $"impact::{run.Id}"
},
correlationId: "corr-xyz");
var result = await service.ExecuteAsync(message, CancellationToken.None);
Assert.Equal(RunnerSegmentExecutionStatus.Completed, result.Status);
Assert.True(result.RunCompleted);
Assert.Equal(1, result.DeltaImages);
var persisted = repository.GetSnapshot(run.TenantId, run.Id);
Assert.NotNull(persisted);
Assert.Equal(2, persisted!.Stats.Completed);
Assert.Equal(1, persisted.Stats.Deltas);
Assert.Equal(1, persisted.Stats.NewCriticals);
Assert.Equal(1, persisted.Stats.NewMedium);
Assert.Contains(persisted.Deltas, delta => delta.ImageDigest == "sha256:1111111111111111111111111111111111111111111111111111111111111111");
Assert.Equal(persisted, summaryService.LastProjected);
Assert.Equal(2, eventPublisher.ReportReady.Count);
Assert.Single(eventPublisher.RescanDeltaPayloads);
}
[Fact]
public async Task ExecuteAsync_WhenRunMissing_ReturnsRunMissing()
{
var repository = new InMemoryRunRepository();
var impactRepository = new InMemoryImpactSnapshotRepository("run-123", Array.Empty<ImpactImage>());
var eventPublisher = new RecordingSchedulerEventPublisher();
using var metrics = new SchedulerWorkerMetrics();
var service = new RunnerExecutionService(
repository,
new RecordingRunSummaryService(),
impactRepository,
new StubScannerReportClient(new Dictionary<string, RunnerImageResult>()),
eventPublisher,
metrics,
TimeProvider.System,
NullLogger<RunnerExecutionService>.Instance);
var message = new RunnerSegmentQueueMessage(
segmentId: "run-123:0000",
runId: "run-123",
tenantId: "tenant-abc",
imageDigests: new[] { "sha256:3333333333333333333333333333333333333333333333333333333333333333" },
scheduleId: "sched-1",
ratePerSecond: null,
usageOnly: true,
attributes: new Dictionary<string, string>(),
correlationId: null);
var result = await service.ExecuteAsync(message, CancellationToken.None);
Assert.Equal(RunnerSegmentExecutionStatus.RunMissing, result.Status);
}
private static Run CreateRun()
=> new(
id: "run-123",
tenantId: "tenant-abc",
trigger: RunTrigger.Cron,
state: RunState.Queued,
stats: new RunStats(
candidates: 4,
deduped: 4,
queued: 2,
completed: 0,
deltas: 0,
newCriticals: 0,
newHigh: 0,
newMedium: 0,
newLow: 0),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10),
scheduleId: "sched-1");
private static ImpactImage CreateImpactImage(string digest, string registry, string repository)
=> new(
imageDigest: digest,
registry: registry,
repository: repository,
namespaces: null,
tags: null,
usedByEntrypoint: false,
labels: null);
private static RunnerImageResult CreateRunnerImageResult(string digest, DeltaSummary? delta)
{
var newTotal = delta?.NewFindings ?? 0;
var summary = new RunnerReportSummary(
Total: newTotal,
Blocked: delta?.NewCriticals ?? 0,
Warned: delta?.NewHigh ?? 0,
Ignored: delta?.NewLow ?? 0,
Quieted: 0);
var snapshot = new RunnerReportSnapshot(
ReportId: $"report-{digest[^4..]}",
ImageDigest: digest,
Verdict: "warn",
GeneratedAt: DateTimeOffset.UtcNow,
Summary: summary,
PolicyRevisionId: "pol-rev",
PolicyDigest: "pol-digest");
return new RunnerImageResult(
digest,
delta,
ContentRefreshed: false,
snapshot,
Dsse: null);
}
private sealed class InMemoryRunRepository : IRunRepository
{
private readonly ConcurrentDictionary<(string Tenant, string RunId), Run> _runs = new();
public InMemoryRunRepository(params Run[] runs)
{
foreach (var run in runs)
{
_runs[(run.TenantId, run.Id)] = run;
}
}
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs[(run.TenantId, run.Id)] = run;
return Task.CompletedTask;
}
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs[(run.TenantId, run.Id)] = run;
return Task.FromResult(true);
}
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_runs.TryGetValue((tenantId, runId), out var run);
return Task.FromResult(run);
}
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.TenantId == tenantId).ToArray());
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.State == state).Take(limit).ToArray());
public Run? GetSnapshot(string tenantId, string runId)
{
_runs.TryGetValue((tenantId, runId), out var run);
return run;
}
}
private sealed class InMemoryImpactSnapshotRepository : IImpactSnapshotRepository
{
private readonly string _snapshotId;
private readonly ImpactSet _snapshot;
public InMemoryImpactSnapshotRepository(string runId, IEnumerable<ImpactImage> images)
{
_snapshotId = $"impact::{runId}";
var imageArray = images.ToImmutableArray();
_snapshot = new ImpactSet(
new Selector(SelectorScope.AllImages, "tenant-abc"),
imageArray,
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow,
total: imageArray.Length);
}
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(string.Equals(snapshotId, _snapshotId, StringComparison.Ordinal) ? _snapshot : null);
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(_snapshot);
}
private sealed class RecordingSchedulerEventPublisher : ISchedulerEventPublisher
{
public List<(Run run, RunnerImageResult result)> ReportReady { get; } = new();
public List<(Run run, IReadOnlyList<DeltaSummary> deltas)> RescanDeltaPayloads { get; } = new();
public Task PublishReportReadyAsync(Run run, RunnerSegmentQueueMessage message, RunnerImageResult result, ImpactImage? impactImage, CancellationToken cancellationToken)
{
ReportReady.Add((run, result));
return Task.CompletedTask;
}
public Task PublishRescanDeltaAsync(Run run, RunnerSegmentQueueMessage message, IReadOnlyList<DeltaSummary> deltas, IReadOnlyDictionary<string, ImpactImage> impactLookup, CancellationToken cancellationToken)
{
RescanDeltaPayloads.Add((run, deltas));
return Task.CompletedTask;
}
}
private sealed class RecordingRunSummaryService : IRunSummaryService
{
public Run? LastProjected { get; private set; }
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
LastProjected = run;
return Task.FromResult(new RunSummaryProjection(
run.TenantId,
run.ScheduleId ?? string.Empty,
DateTimeOffset.UtcNow,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)));
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
=> Task.FromResult<RunSummaryProjection?>(null);
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
}
private sealed class StubScannerReportClient : IScannerReportClient
{
private readonly IReadOnlyDictionary<string, RunnerImageResult> _responses;
public StubScannerReportClient(IReadOnlyDictionary<string, RunnerImageResult> responses)
{
_responses = responses;
}
public Task<RunnerImageResult> ExecuteAsync(ScannerReportRequest request, CancellationToken cancellationToken = default)
{
if (_responses.TryGetValue(request.ImageDigest, out var result))
{
return Task.FromResult(result);
}
return Task.FromResult(CreateRunnerImageResult(request.ImageDigest, delta: null));
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Worker.Events;
using StellaOps.Scheduler.Worker.Execution;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class SchedulerEventPublisherTests
{
[Fact]
public async Task PublishReportReadyAsync_EnqueuesNotifyEvent()
{
var queue = new RecordingNotifyEventQueue();
var options = new NotifyEventQueueOptions();
var publisher = new SchedulerEventPublisher(queue, options, TimeProvider.System, NullLogger<SchedulerEventPublisher>.Instance);
var run = CreateRun();
var message = CreateMessage(run);
var delta = new DeltaSummary(
run.Id,
newFindings: 2,
newCriticals: 1,
newHigh: 1,
newMedium: 0,
newLow: 0);
var result = CreateRunnerImageResult(run.Id, delta);
var impact = new ImpactImage(run.Id, "registry", "repository");
await publisher.PublishReportReadyAsync(run, message, result, impact, CancellationToken.None);
Assert.Single(queue.Messages);
var notifyEvent = queue.Messages[0].Event;
Assert.Equal(NotifyEventKinds.ScannerReportReady, notifyEvent.Kind);
Assert.Equal(run.TenantId, notifyEvent.Tenant);
Assert.NotNull(notifyEvent.Scope);
Assert.Equal("repository", notifyEvent.Scope!.Repo);
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
Assert.Equal(result.Report.ReportId, payload["reportId"]!.GetValue<string>());
Assert.Equal("warn", payload["verdict"]!.GetValue<string>());
var deltaNode = Assert.IsType<JsonObject>(payload["delta"]);
Assert.Equal(1, deltaNode["newCritical"]!.GetValue<int>());
}
[Fact]
public async Task PublishRescanDeltaAsync_EnqueuesDeltaEvent()
{
var queue = new RecordingNotifyEventQueue();
var options = new NotifyEventQueueOptions();
var publisher = new SchedulerEventPublisher(queue, options, TimeProvider.System, NullLogger<SchedulerEventPublisher>.Instance);
var run = CreateRun();
var message = CreateMessage(run);
var delta = new DeltaSummary(run.Id, 1, 1, 0, 0, 0);
var impactLookup = new Dictionary<string, ImpactImage>
{
[run.Id] = new ImpactImage(run.Id, "registry", "repository")
};
await publisher.PublishRescanDeltaAsync(run, message, new[] { delta }, impactLookup, CancellationToken.None);
Assert.Single(queue.Messages);
var notifyEvent = queue.Messages[0].Event;
Assert.Equal(NotifyEventKinds.SchedulerRescanDelta, notifyEvent.Kind);
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
var digests = Assert.IsType<JsonArray>(payload["impactedDigests"]);
Assert.Equal(run.Id, digests[0]!.GetValue<string>());
}
private const string SampleDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
private static Run CreateRun()
=> new(
id: SampleDigest,
tenantId: "tenant-1",
trigger: RunTrigger.Cron,
state: RunState.Running,
stats: new RunStats(queued: 1, completed: 0),
createdAt: DateTimeOffset.UtcNow,
scheduleId: "schedule-1");
private static RunnerSegmentQueueMessage CreateMessage(Run run)
=> new(
segmentId: $"{run.Id}:0000",
runId: run.Id,
tenantId: run.TenantId,
imageDigests: new[] { run.Id },
scheduleId: run.ScheduleId,
ratePerSecond: null,
usageOnly: true,
attributes: new Dictionary<string, string>(StringComparer.Ordinal)
{
["scheduleMode"] = ScheduleMode.AnalysisOnly.ToString()
});
private static RunnerImageResult CreateRunnerImageResult(string digest, DeltaSummary? delta)
{
var summary = new RunnerReportSummary(
Total: delta?.NewFindings ?? 0,
Blocked: delta?.NewCriticals ?? 0,
Warned: delta?.NewHigh ?? 0,
Ignored: delta?.NewLow ?? 0,
Quieted: 0);
var snapshot = new RunnerReportSnapshot(
ReportId: $"report-{digest[^4..]}",
ImageDigest: digest,
Verdict: "warn",
GeneratedAt: DateTimeOffset.UtcNow,
Summary: summary,
PolicyRevisionId: null,
PolicyDigest: null);
return new RunnerImageResult(digest, delta, ContentRefreshed: false, snapshot, Dsse: null);
}
private sealed class RecordingNotifyEventQueue : INotifyEventQueue
{
public List<NotifyQueueEventMessage> Messages { get; } = new();
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
{
Messages.Add(message);
return ValueTask.FromResult(new NotifyQueueEnqueueResult(Guid.NewGuid().ToString("N"), false));
}
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Worker/StellaOps.Scheduler.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
</ItemGroup>
</Project>