// Copyright (c) StellaOps. Licensed under BUSL-1.1. // 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; using StellaOps.SbomService.Services; using Xunit; namespace StellaOps.SbomService.Tests; public class RegistrySourceServiceTests { private readonly Mock _sourceRepoMock; private readonly Mock _runRepoMock; private readonly RegistrySourceService _service; public RegistrySourceServiceTests() { _sourceRepoMock = new Mock(); _runRepoMock = new Mock(); var timeProvider = new FixedTimeProvider(FixedNow); var guidProvider = CreateGuidProvider(); var httpOptions = Options.Create(new RegistryHttpOptions { AllowedHosts = new List { "harbor.example.com", "registry.example.com", "test-registry.example.com" } }); var queryOptions = Options.Create(new RegistrySourceQueryOptions()); _service = new RegistrySourceService( _sourceRepoMock.Object, _runRepoMock.Object, NullLogger.Instance, timeProvider, guidProvider, httpOptions, queryOptions); } [Trait("Category", "Unit")] [Fact] public async Task CreateAsync_WithValidRequest_CreatesRegistrySource() { // Arrange var request = new CreateRegistrySourceRequest( Name: "Test Registry", Description: "Test description", Type: RegistrySourceType.Harbor, RegistryUrl: "https://harbor.example.com", AuthRefUri: "authref://vault/harbor#credentials", IntegrationId: null, RepoFilters: ["myorg/*"], TagFilters: null, TriggerMode: RegistryTriggerMode.Webhook, ScheduleCron: null, WebhookSecretRefUri: "authref://vault/harbor#webhook-secret", Tags: ["production"]); _sourceRepoMock .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); // Act var result = await _service.CreateAsync(request, "user@example.com", "tenant-1"); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Test Registry"); result.RegistryUrl.Should().Be("https://harbor.example.com"); result.Type.Should().Be(RegistrySourceType.Harbor); result.Status.Should().Be(RegistrySourceStatus.Pending); result.TriggerMode.Should().Be(RegistryTriggerMode.Webhook); result.RepoFilters.Should().Contain("myorg/*"); result.CreatedBy.Should().Be("user@example.com"); result.TenantId.Should().Be("tenant-1"); } [Trait("Category", "Unit")] [Fact] public async Task CreateAsync_TrimsTrailingSlashFromUrl() { // Arrange var request = new CreateRegistrySourceRequest( Name: "Test", Description: null, Type: RegistrySourceType.OciGeneric, RegistryUrl: "https://registry.example.com/", AuthRefUri: null, IntegrationId: null, RepoFilters: null, TagFilters: null, TriggerMode: RegistryTriggerMode.Manual, ScheduleCron: null, WebhookSecretRefUri: null, Tags: null); _sourceRepoMock .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); // Act var result = await _service.CreateAsync(request, null, "tenant-1"); // Assert result.RegistryUrl.Should().Be("https://registry.example.com"); } [Trait("Category", "Unit")] [Fact] public async Task GetByIdAsync_WithExistingId_ReturnsSource() { // Arrange var source = CreateTestSource(); _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); // Act var result = await _service.GetByIdAsync(source.Id, "tenant-1"); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(source.Id); } [Trait("Category", "Unit")] [Fact] public async Task GetByIdAsync_WithNonExistingId_ReturnsNull() { // Arrange var id = Guid.NewGuid(); _sourceRepoMock .Setup(r => r.GetByIdAsync(id, It.IsAny())) .ReturnsAsync((RegistrySource?)null); // Act var result = await _service.GetByIdAsync(id, "tenant-1"); // Assert result.Should().BeNull(); } [Trait("Category", "Unit")] [Fact] public async Task ListAsync_WithTypeFilter_ReturnsFilteredResults() { // Arrange var harborSources = new[] { CreateTestSource(type: RegistrySourceType.Harbor), CreateTestSource(type: RegistrySourceType.Harbor) }; _sourceRepoMock .Setup(r => r.GetAllAsync(It.Is(q => q.Type == RegistrySourceType.Harbor), It.IsAny())) .ReturnsAsync(harborSources); _sourceRepoMock .Setup(r => r.CountAsync(It.Is(q => q.Type == RegistrySourceType.Harbor), It.IsAny())) .ReturnsAsync(2); var request = new ListRegistrySourcesRequest(Type: RegistrySourceType.Harbor); // Act var result = await _service.ListAsync(request, "tenant-1"); // Assert result.Items.Should().HaveCount(2); result.Items.Should().OnlyContain(s => s.Type == RegistrySourceType.Harbor); } [Trait("Category", "Unit")] [Fact] public async Task UpdateAsync_WithExistingSource_UpdatesFields() { // Arrange var source = CreateTestSource(); _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); _sourceRepoMock .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); var request = new UpdateRegistrySourceRequest( Name: "Updated Name", Description: "Updated description", RegistryUrl: null, AuthRefUri: null, RepoFilters: null, TagFilters: null, TriggerMode: null, ScheduleCron: null, WebhookSecretRefUri: null, Status: null, Tags: null); // Act var result = await _service.UpdateAsync(source.Id, request, "updater@example.com", "tenant-1"); // Assert result.Should().NotBeNull(); result!.Name.Should().Be("Updated Name"); result.Description.Should().Be("Updated description"); result.UpdatedBy.Should().Be("updater@example.com"); } [Trait("Category", "Unit")] [Fact] public async Task UpdateAsync_WithNonExistingSource_ReturnsNull() { // Arrange var id = Guid.NewGuid(); _sourceRepoMock .Setup(r => r.GetByIdAsync(id, It.IsAny())) .ReturnsAsync((RegistrySource?)null); var request = new UpdateRegistrySourceRequest( Name: "Updated", Description: null, RegistryUrl: null, AuthRefUri: null, RepoFilters: null, TagFilters: null, TriggerMode: null, ScheduleCron: null, WebhookSecretRefUri: null, Status: null, Tags: null); // Act var result = await _service.UpdateAsync(id, request, "user", "tenant-1"); // Assert result.Should().BeNull(); } [Trait("Category", "Unit")] [Fact] public async Task DeleteAsync_WithExistingSource_DeletesFromRepository() { // Arrange var source = CreateTestSource(); _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); _sourceRepoMock .Setup(r => r.DeleteAsync(source.Id, It.IsAny())) .Returns(Task.CompletedTask); // Act var result = await _service.DeleteAsync(source.Id, "deleter@example.com", "tenant-1"); // Assert result.Should().BeTrue(); _sourceRepoMock.Verify(r => r.DeleteAsync(source.Id, It.IsAny()), Times.Once); } [Trait("Category", "Unit")] [Fact] public async Task TriggerAsync_WithActiveSource_CreatesRun() { // Arrange var source = CreateTestSource(); source.Status = RegistrySourceStatus.Active; _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); _runRepoMock .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .Returns((run, _) => Task.FromResult(run)); _sourceRepoMock .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); // Act var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com", "tenant-1"); // Assert result.Should().NotBeNull(); result.SourceId.Should().Be(source.Id); result.TriggerType.Should().Be("manual"); result.Status.Should().Be(RegistryRunStatus.Queued); } [Trait("Category", "Unit")] [Fact] public async Task PauseAsync_WithActiveSource_PausesSource() { // Arrange var source = CreateTestSource(); source.Status = RegistrySourceStatus.Active; _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); _sourceRepoMock .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); // Act var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com", "tenant-1"); // Assert result.Should().NotBeNull(); result!.Status.Should().Be(RegistrySourceStatus.Paused); } [Trait("Category", "Unit")] [Fact] public async Task ResumeAsync_WithPausedSource_ResumesSource() { // Arrange var source = CreateTestSource(); source.Status = RegistrySourceStatus.Paused; _sourceRepoMock .Setup(r => r.GetByIdAsync(source.Id, It.IsAny())) .ReturnsAsync(source); _sourceRepoMock .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) .Returns((s, _) => Task.FromResult(s)); // Act var result = await _service.ResumeAsync(source.Id, "admin@example.com", "tenant-1"); // Assert result.Should().NotBeNull(); result!.Status.Should().Be(RegistrySourceStatus.Active); } [Trait("Category", "Unit")] [Fact] public async Task GetRunHistoryAsync_ReturnsRunsForSource() { // Arrange var sourceId = Guid.NewGuid(); var source = new RegistrySource { Id = sourceId, Name = "Test Registry", Type = RegistrySourceType.Harbor, RegistryUrl = "https://test-registry.example.com", Status = RegistrySourceStatus.Active, TriggerMode = RegistryTriggerMode.Manual, TenantId = "tenant-1", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var runs = new[] { CreateTestRun(sourceId), CreateTestRun(sourceId), CreateTestRun(sourceId) }; _sourceRepoMock .Setup(r => r.GetByIdAsync(sourceId, It.IsAny())) .ReturnsAsync(source); _runRepoMock .Setup(r => r.GetBySourceIdAsync(sourceId, 50, It.IsAny())) .ReturnsAsync(runs); // Act var result = await _service.GetRunHistoryAsync(sourceId, 50, "tenant-1"); // Assert result.Should().HaveCount(3); result.Should().OnlyContain(r => r.SourceId == sourceId); } #region Helper Methods private static RegistrySource CreateTestSource(RegistrySourceType type = RegistrySourceType.Harbor) => new() { 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, CreatedAt = FixedNow, UpdatedAt = FixedNow, TenantId = "tenant-1" }; private static RegistrySourceRun CreateTestRun(Guid sourceId) => new() { Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), SourceId = sourceId, Status = RegistryRunStatus.Completed, TriggerType = "manual", 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 }