audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -1,10 +1,12 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistryDiscoveryService and ScanJobEmitterService
using System.Globalization;
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using StellaOps.SbomService.Models;
@@ -38,7 +40,12 @@ public class RegistryDiscoveryServiceTests
_service = new RegistryDiscoveryService(
_sourceRepoMock.Object,
httpClientFactory.Object,
NullLogger<RegistryDiscoveryService>.Instance);
NullLogger<RegistryDiscoveryService>.Instance,
Options.Create(new RegistryHttpOptions
{
AllowedHosts = new List<string> { "test-registry.example.com" },
AllowInlineCredentials = true
}));
}
[Trait("Category", "Unit")]
@@ -61,7 +68,7 @@ public class RegistryDiscoveryServiceTests
public async Task DiscoverRepositoriesAsync_WithUnknownSource_ReturnsFailure()
{
// Arrange
var sourceId = Guid.NewGuid();
var sourceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
_sourceRepoMock
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
@@ -100,6 +107,26 @@ public class RegistryDiscoveryServiceTests
result.Repositories.Should().Contain("library/nginx");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithDisallowedHost_ReturnsFailure()
{
// Arrange
var source = CreateTestSource();
source.RegistryUrl = "https://blocked.example.com";
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
// Act
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("allowlisted");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithRepositoryDenylist_ExcludesMatches()
@@ -187,12 +214,14 @@ public class RegistryDiscoveryServiceTests
private static RegistrySource CreateTestSource() => new()
{
Id = Guid.NewGuid(),
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Name = "Test Registry",
Type = RegistrySourceType.Harbor,
RegistryUrl = "https://test-registry.example.com",
AuthRefUri = "authref://vault/registry#credentials",
Status = RegistrySourceStatus.Active
Status = RegistrySourceStatus.Active,
CreatedAt = DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture),
UpdatedAt = DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture)
};
private void SetupHttpResponse(HttpStatusCode statusCode, string content)

View File

@@ -1,8 +1,10 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistrySourceService
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
@@ -22,10 +24,22 @@ public class RegistrySourceServiceTests
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = CreateGuidProvider();
var httpOptions = Options.Create(new RegistryHttpOptions
{
AllowedHosts = new List<string> { "harbor.example.com", "registry.example.com", "test-registry.example.com" }
});
var queryOptions = Options.Create(new RegistrySourceQueryOptions());
_service = new RegistrySourceService(
_sourceRepoMock.Object,
_runRepoMock.Object,
NullLogger<RegistrySourceService>.Instance);
NullLogger<RegistrySourceService>.Instance,
timeProvider,
guidProvider,
httpOptions,
queryOptions);
}
[Trait("Category", "Unit")]
@@ -90,7 +104,7 @@ public class RegistrySourceServiceTests
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.CreateAsync(request, null, null);
var result = await _service.CreateAsync(request, null, "tenant-1");
// Assert
result.RegistryUrl.Should().Be("https://registry.example.com");
@@ -107,7 +121,7 @@ public class RegistrySourceServiceTests
.ReturnsAsync(source);
// Act
var result = await _service.GetByIdAsync(source.Id);
var result = await _service.GetByIdAsync(source.Id, "tenant-1");
// Assert
result.Should().NotBeNull();
@@ -125,7 +139,7 @@ public class RegistrySourceServiceTests
.ReturnsAsync((RegistrySource?)null);
// Act
var result = await _service.GetByIdAsync(id);
var result = await _service.GetByIdAsync(id, "tenant-1");
// Assert
result.Should().BeNull();
@@ -153,7 +167,7 @@ public class RegistrySourceServiceTests
var request = new ListRegistrySourcesRequest(Type: RegistrySourceType.Harbor);
// Act
var result = await _service.ListAsync(request, null);
var result = await _service.ListAsync(request, "tenant-1");
// Assert
result.Items.Should().HaveCount(2);
@@ -188,7 +202,7 @@ public class RegistrySourceServiceTests
Tags: null);
// Act
var result = await _service.UpdateAsync(source.Id, request, "updater@example.com");
var result = await _service.UpdateAsync(source.Id, request, "updater@example.com", "tenant-1");
// Assert
result.Should().NotBeNull();
@@ -213,7 +227,7 @@ public class RegistrySourceServiceTests
WebhookSecretRefUri: null, Status: null, Tags: null);
// Act
var result = await _service.UpdateAsync(id, request, "user");
var result = await _service.UpdateAsync(id, request, "user", "tenant-1");
// Assert
result.Should().BeNull();
@@ -234,7 +248,7 @@ public class RegistrySourceServiceTests
.Returns(Task.CompletedTask);
// Act
var result = await _service.DeleteAsync(source.Id, "deleter@example.com");
var result = await _service.DeleteAsync(source.Id, "deleter@example.com", "tenant-1");
// Assert
result.Should().BeTrue();
@@ -262,7 +276,7 @@ public class RegistrySourceServiceTests
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com");
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com", "tenant-1");
// Assert
result.Should().NotBeNull();
@@ -288,7 +302,7 @@ public class RegistrySourceServiceTests
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com");
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com", "tenant-1");
// Assert
result.Should().NotBeNull();
@@ -312,7 +326,7 @@ public class RegistrySourceServiceTests
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.ResumeAsync(source.Id, "admin@example.com");
var result = await _service.ResumeAsync(source.Id, "admin@example.com", "tenant-1");
// Assert
result.Should().NotBeNull();
@@ -337,7 +351,7 @@ public class RegistrySourceServiceTests
.ReturnsAsync(runs);
// Act
var result = await _service.GetRunHistoryAsync(sourceId, 50);
var result = await _service.GetRunHistoryAsync(sourceId, 50, "tenant-1");
// Assert
result.Should().HaveCount(3);
@@ -348,23 +362,39 @@ public class RegistrySourceServiceTests
private static RegistrySource CreateTestSource(RegistrySourceType type = RegistrySourceType.Harbor) => new()
{
Id = Guid.NewGuid(),
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Name = "Test Registry",
Type = type,
RegistryUrl = "https://test-registry.example.com",
Status = RegistrySourceStatus.Pending,
TriggerMode = RegistryTriggerMode.Manual
TriggerMode = RegistryTriggerMode.Manual,
CreatedAt = FixedNow,
UpdatedAt = FixedNow,
TenantId = "tenant-1"
};
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
{
Id = Guid.NewGuid(),
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
SourceId = sourceId,
Status = RegistryRunStatus.Completed,
TriggerType = "manual",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt = DateTimeOffset.UtcNow
StartedAt = FixedNow.AddMinutes(-5),
CompletedAt = FixedNow
};
private static QueueGuidProvider CreateGuidProvider()
{
return new QueueGuidProvider(new[]
{
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc")
});
}
private static DateTimeOffset FixedNow =>
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
#endregion
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistryWebhookService
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -28,7 +29,7 @@ public class RegistryWebhookServiceTests
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
_sourceServiceMock = new Mock<IRegistrySourceService>();
_clockMock = new Mock<IClock>();
_clockMock.Setup(c => c.UtcNow).Returns(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
_clockMock.Setup(c => c.UtcNow).Returns(FixedNow);
_service = new RegistryWebhookService(
_sourceRepoMock.Object,
@@ -59,7 +60,7 @@ public class RegistryWebhookServiceTests
public async Task ProcessWebhookAsync_WithUnknownSource_ReturnsFailure()
{
// Arrange
var sourceId = Guid.NewGuid();
var sourceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
_sourceRepoMock
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
@@ -113,6 +114,7 @@ public class RegistryWebhookServiceTests
"webhook",
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedRun);
@@ -183,27 +185,29 @@ public class RegistryWebhookServiceTests
private static RegistrySource CreateTestSource() => new()
{
Id = Guid.NewGuid(),
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Name = "Test Harbor",
Type = RegistrySourceType.Harbor,
RegistryUrl = "https://harbor.example.com",
Status = RegistrySourceStatus.Active,
TriggerMode = RegistryTriggerMode.Webhook
TriggerMode = RegistryTriggerMode.Webhook,
CreatedAt = FixedNow,
UpdatedAt = FixedNow
};
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
{
Id = Guid.NewGuid(),
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
SourceId = sourceId,
Status = RegistryRunStatus.Running,
TriggerType = "webhook",
StartedAt = DateTimeOffset.UtcNow
StartedAt = FixedNow
};
private static string CreateHarborPushPayload(string repository, string tag) => JsonSerializer.Serialize(new
{
type = "PUSH_ARTIFACT",
occur_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
occur_at = FixedNow.ToUnixTimeSeconds(),
@operator = "admin",
event_data = new
{
@@ -225,5 +229,8 @@ public class RegistryWebhookServiceTests
}
});
private static DateTimeOffset FixedNow =>
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
#endregion
}

View File

@@ -0,0 +1,69 @@
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
using Xunit;
namespace StellaOps.SbomService.Tests;
public sealed class SbomLedgerServiceTests
{
[Trait("Category", "Unit")]
[Fact]
public async Task AddVersionAsync_UsesGuidProviderAndClock()
{
// Arrange
var repository = new InMemorySbomLedgerRepository();
var clock = new FixedClock(FixedNow);
var guidProvider = new QueueGuidProvider(new[]
{
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
});
var service = new SbomLedgerService(
repository,
clock,
Options.Create(new SbomLedgerOptions()),
lineageEdgeRepository: null,
logger: NullLogger<SbomLedgerService>.Instance,
guidProvider: guidProvider);
var submission = new SbomLedgerSubmission(
ArtifactRef: "acme/app:1.0",
Digest: "sha256:deadbeef",
Format: "spdx",
FormatVersion: "2.3",
Source: "upload",
Provenance: null,
Components: new List<SbomNormalizedComponent>
{
new("pkg:npm/lodash", "lodash", "4.17.21", "pkg:npm/lodash@4.17.21", "MIT")
},
ParentVersionId: null);
// Act
var version = await service.AddVersionAsync(submission, CancellationToken.None);
// Assert
version.ChainId.Should().Be(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
version.VersionId.Should().Be(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"));
version.CreatedAtUtc.Should().Be(FixedNow);
}
private sealed class FixedClock : IClock
{
public FixedClock(DateTimeOffset utcNow)
{
UtcNow = utcNow;
}
public DateTimeOffset UtcNow { get; }
}
private static DateTimeOffset FixedNow =>
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,121 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Services;
using Xunit;
namespace StellaOps.SbomService.Tests;
public sealed class ScanJobEmitterServiceTests
{
[Trait("Category", "Unit")]
[Fact]
public async Task SubmitScanAsync_RejectsDisallowedScannerHost()
{
// Arrange
var configuration = BuildConfiguration("http://blocked.example.com");
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(new HttpClient(new Mock<HttpMessageHandler>().Object));
var service = new ScanJobEmitterService(
httpClientFactory.Object,
configuration,
NullLogger<ScanJobEmitterService>.Instance,
Options.Create(new ScannerHttpOptions
{
AllowedHosts = new List<string> { "scanner.local" }
}),
new QueueGuidProvider(new[] { Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") }));
var request = new ScanJobRequest(
ImageReference: "library/nginx:latest",
Digest: "sha256:deadbeef",
Platform: "linux/amd64",
Force: false,
ClientRequestId: null,
SourceId: "source-1",
TriggerType: "manual");
// Act
var result = await service.SubmitScanAsync(request);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("allowlisted");
}
[Trait("Category", "Unit")]
[Fact]
public async Task SubmitBatchScanAsync_UsesDeterministicRequestId()
{
// Arrange
var configuration = BuildConfiguration("http://scanner.local");
var handler = new Mock<HttpMessageHandler>();
HttpRequestMessage? captured = null;
handler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((request, _) => captured = request)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"snapshot\":{\"id\":\"job-123\",\"status\":\"queued\"},\"created\":true}")
});
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(new HttpClient(handler.Object));
var service = new ScanJobEmitterService(
httpClientFactory.Object,
configuration,
NullLogger<ScanJobEmitterService>.Instance,
Options.Create(new ScannerHttpOptions
{
AllowedHosts = new List<string> { "scanner.local" }
}),
new QueueGuidProvider(new[] { Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") }));
var images = new[]
{
new DiscoveredImage("library/nginx", "latest", "sha256:deadbeef")
};
// Act
var result = await service.SubmitBatchScanAsync("source-1", images);
// Assert
result.Submitted.Should().Be(1);
captured.Should().NotBeNull();
var payload = await captured!.Content!.ReadAsStringAsync();
using var doc = JsonDocument.Parse(payload);
var clientRequestId = doc.RootElement.GetProperty("clientRequestId").GetString();
clientRequestId.Should().Be("registry-source-1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
}
private static IConfiguration BuildConfiguration(string scannerUrl)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SbomService:ScannerUrl"] = scannerUrl,
["SbomService:BatchScanSize"] = "1",
["SbomService:BatchScanDelayMs"] = "0"
})
.Build();
}
}

View File

@@ -0,0 +1,41 @@
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Tests;
internal sealed class FixedTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
private long _timestamp;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public void Advance(TimeSpan delta)
{
_utcNow = _utcNow.Add(delta);
_timestamp += delta.Ticks;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public override long GetTimestamp() => _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
}
internal sealed class QueueGuidProvider : IGuidProvider
{
private readonly Queue<Guid> _guids;
public QueueGuidProvider(IEnumerable<Guid> guids)
{
_guids = new Queue<Guid>(guids);
}
public Guid NewGuid()
{
return _guids.Count > 0 ? _guids.Dequeue() : Guid.Empty;
}
}