Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Triggers/SourceTriggerDispatcherTests.cs

293 lines
11 KiB
C#

using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Handlers;
using StellaOps.Scanner.Sources.Persistence;
using StellaOps.Scanner.Sources.Triggers;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Triggers;
public sealed class SourceTriggerDispatcherTests
{
private static readonly FakeTimeProvider TimeProvider = new(
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
private static readonly JsonDocument MinimalConfig = JsonDocument.Parse("{}");
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task DispatchAsync_QueuesTargetsAndCompletesRun()
{
var guidProvider = new SequentialGuidProvider();
var source = CreateSource(guidProvider);
source.Activate("tester", TimeProvider);
var sourceRepo = new InMemorySourceRepository();
sourceRepo.Add(source);
var runRepo = new InMemoryRunRepository();
var handler = new InMemoryHandler(new[]
{
ScanTarget.Image("registry.example.com/app:1.0.0"),
ScanTarget.Image("registry.example.com/app:1.1.0")
});
var queue = new InMemoryScanJobQueue(guidProvider);
var dispatcher = new SourceTriggerDispatcher(
sourceRepo,
runRepo,
new[] { handler },
queue,
NullLogger<SourceTriggerDispatcher>.Instance,
TimeProvider,
guidProvider);
var result = await dispatcher.DispatchAsync(
source.SourceId,
SbomSourceRunTrigger.Manual,
"manual",
TestContext.Current.CancellationToken);
result.Success.Should().BeTrue();
result.JobsQueued.Should().Be(2);
result.Run.ItemsDiscovered.Should().Be(2);
result.Run.ItemsSucceeded.Should().Be(2);
result.Run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
queue.Requests.Should().HaveCount(2);
runRepo.Runs.Should().ContainKey(result.Run.RunId);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task ProcessScheduledSourcesAsync_DispatchesDueSources()
{
var guidProvider = new SequentialGuidProvider();
var source = CreateSource(guidProvider);
source.Activate("tester", TimeProvider);
var sourceRepo = new InMemorySourceRepository
{
DueSources = new List<SbomSource> { source }
};
sourceRepo.Add(source);
var runRepo = new InMemoryRunRepository();
var handler = new InMemoryHandler(new[] { ScanTarget.Image("registry.example.com/app:1.0.0") });
var queue = new InMemoryScanJobQueue(guidProvider);
var dispatcher = new SourceTriggerDispatcher(
sourceRepo,
runRepo,
new[] { handler },
queue,
NullLogger<SourceTriggerDispatcher>.Instance,
TimeProvider,
guidProvider);
var processed = await dispatcher.ProcessScheduledSourcesAsync(
TestContext.Current.CancellationToken);
processed.Should().Be(1);
runRepo.Runs.Should().HaveCount(1);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task DispatchAsync_DisabledSource_ReturnsFailedRun()
{
var guidProvider = new SequentialGuidProvider();
var source = CreateSource(guidProvider);
source.Disable("tester", TimeProvider);
var sourceRepo = new InMemorySourceRepository();
sourceRepo.Add(source);
var runRepo = new InMemoryRunRepository();
var handler = new InMemoryHandler(Array.Empty<ScanTarget>());
var queue = new InMemoryScanJobQueue(guidProvider);
var dispatcher = new SourceTriggerDispatcher(
sourceRepo,
runRepo,
new[] { handler },
queue,
NullLogger<SourceTriggerDispatcher>.Instance,
TimeProvider,
guidProvider);
var result = await dispatcher.DispatchAsync(
source.SourceId,
SbomSourceRunTrigger.Manual,
"manual",
TestContext.Current.CancellationToken);
result.Success.Should().BeFalse();
result.Error.Should().Contain("disabled");
result.Run.Status.Should().Be(SbomSourceRunStatus.Failed);
}
private static SbomSource CreateSource(IGuidProvider guidProvider)
{
return SbomSource.Create(
tenantId: "tenant-1",
name: "source-1",
sourceType: SbomSourceType.Docker,
configuration: MinimalConfig,
createdBy: "tester",
timeProvider: TimeProvider,
guidProvider: guidProvider);
}
private sealed class InMemorySourceRepository : ISbomSourceRepository
{
public Dictionary<Guid, SbomSource> Sources { get; } = new();
public IReadOnlyList<SbomSource> DueSources { get; set; } = Array.Empty<SbomSource>();
public void Add(SbomSource source) => Sources[source.SourceId] = source;
public Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
public Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
public Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
=> Task.FromResult(Sources.Values.FirstOrDefault(s => s.TenantId == tenantId && s.Name == name));
public Task<PagedResponse<SbomSource>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default)
=> throw new NotSupportedException("ListAsync is not used in these tests.");
public Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SbomSource>>(DueSources.Take(limit).ToList());
public Task CreateAsync(SbomSource source, CancellationToken ct = default)
{
Sources[source.SourceId] = source;
return Task.CompletedTask;
}
public Task UpdateAsync(SbomSource source, CancellationToken ct = default)
{
Sources[source.SourceId] = source;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
Sources.Remove(sourceId);
return Task.CompletedTask;
}
public Task<bool> NameExistsAsync(
string tenantId,
string name,
Guid? excludeSourceId = null,
CancellationToken ct = default)
=> Task.FromResult(Sources.Values.Any(s =>
s.TenantId == tenantId
&& s.Name == name
&& s.SourceId != excludeSourceId));
public Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SbomSource>>(Sources.Values
.Where(s => s.Name.Contains(name, StringComparison.OrdinalIgnoreCase))
.ToList());
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
=> Task.FromResult(DueSources);
}
private sealed class InMemoryRunRepository : ISbomSourceRunRepository
{
public Dictionary<Guid, SbomSourceRun> Runs { get; } = new();
public Task<SbomSourceRun?> GetByIdAsync(string tenantId, Guid runId, CancellationToken ct = default)
=> Task.FromResult(
Runs.TryGetValue(runId, out var run) && string.Equals(run.TenantId, tenantId, StringComparison.Ordinal)
? run
: null);
public Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default)
=> throw new NotSupportedException("ListForSourceAsync is not used in these tests.");
public Task CreateAsync(SbomSourceRun run, CancellationToken ct = default)
{
Runs[run.RunId] = run;
return Task.CompletedTask;
}
public Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default)
{
Runs[run.RunId] = run;
return Task.CompletedTask;
}
public Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
TimeSpan olderThan,
int limit = 100,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SbomSourceRun>>(Array.Empty<SbomSourceRun>());
public Task<SourceRunStats> GetStatsAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
=> Task.FromResult(new SourceRunStats());
}
private sealed class InMemoryScanJobQueue : IScanJobQueue
{
private readonly IGuidProvider _guidProvider;
public List<ScanJobRequest> Requests { get; } = new();
public InMemoryScanJobQueue(IGuidProvider guidProvider)
{
_guidProvider = guidProvider;
}
public Task<Guid> EnqueueAsync(ScanJobRequest request, CancellationToken ct = default)
{
Requests.Add(request);
return Task.FromResult(_guidProvider.NewGuid());
}
}
private sealed class InMemoryHandler : ISourceTypeHandler
{
private readonly IReadOnlyList<ScanTarget> _targets;
public InMemoryHandler(IReadOnlyList<ScanTarget> targets)
{
_targets = targets;
}
public SbomSourceType SourceType => SbomSourceType.Docker;
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default)
=> Task.FromResult(_targets);
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
=> ConfigValidationResult.Success();
public Task<ConnectionTestResult> TestConnectionAsync(SbomSource source, CancellationToken ct = default)
=> Task.FromResult(ConnectionTestResult.Succeeded(TimeProvider));
}
}