feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -1,78 +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);
|
||||
}
|
||||
}
|
||||
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.Conselier,
|
||||
state: RunState.Running,
|
||||
stats: stats,
|
||||
reason: new RunReason(conselierExportId: "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\":\"conselier\"", 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +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());
|
||||
}
|
||||
}
|
||||
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, conselierExportId: null, excitorExportId: 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +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();
|
||||
}
|
||||
}
|
||||
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__Conselier__HmacSecret", ConselierSecret);
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Conselier__Enabled", "true");
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__HmacSecret", ExcitorSecret);
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__Enabled", "true");
|
||||
}
|
||||
|
||||
private const string ConselierSecret = "conselier-secret";
|
||||
private const string ExcitorSecret = "excitor-secret";
|
||||
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public EventWebhookEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConselierWebhook_AcceptsValidSignature()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "conselier-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/conselier-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ConselierSecret, json));
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConselierWebhook_RejectsInvalidSignature()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "conselier-exp-2",
|
||||
changedProductKeys = new[] { "pkg:nuget/log4net" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/conselier-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 ExcitorWebhook_HonoursRateLimit()
|
||||
{
|
||||
using var restrictedFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Scheduler:Events:Webhooks:Excitor:RateLimitRequests"] = "1",
|
||||
["Scheduler:Events:Webhooks:Excitor:RateLimitWindowSeconds"] = "60"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
using var client = restrictedFactory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "excitor-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/excitor-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, json));
|
||||
var firstResponse = await client.SendAsync(first);
|
||||
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
|
||||
|
||||
using var second = new HttpRequestMessage(HttpMethod.Post, "/events/excitor-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
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:Conselier:Enabled", "true"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:HmacSecret", "conselier-secret"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:RateLimitRequests", "20"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:RateLimitWindowSeconds", "60"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:Enabled", "true"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:HmacSecret", "excitor-secret"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:RateLimitRequests", "20"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:RateLimitWindowSeconds", "60")
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.Configure<SchedulerEventsOptions>(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
options.Webhooks.Conselier.HmacSecret = "conselier-secret";
|
||||
options.Webhooks.Conselier.Enabled = true;
|
||||
options.Webhooks.Excitor.HmacSecret = "excitor-secret";
|
||||
options.Webhooks.Excitor.Enabled = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,411 +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);
|
||||
}
|
||||
}
|
||||
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.Conselier, 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-conselier", "tenant-bravo", RunTrigger.Conselier, timeProvider.GetUtcNow().AddMinutes(2), "schedule-conselier"),
|
||||
CreateRun("run-manual", "tenant-charlie", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(3), "schedule-manual"),
|
||||
CreateRun("run-excitor", "tenant-delta", RunTrigger.Excitor, timeProvider.GetUtcNow().AddMinutes(4), "schedule-excitor"),
|
||||
};
|
||||
|
||||
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-conselier", "run-excitor", "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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user