293 lines
11 KiB
C#
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));
|
|
}
|
|
}
|