Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistryDiscoveryService and ScanJobEmitterService
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class RegistryDiscoveryServiceTests
|
||||
{
|
||||
private readonly Mock<IRegistrySourceRepository> _sourceRepoMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly RegistryDiscoveryService _service;
|
||||
|
||||
public RegistryDiscoveryServiceTests()
|
||||
{
|
||||
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
|
||||
var httpClient = new HttpClient(_httpHandlerMock.Object)
|
||||
{
|
||||
BaseAddress = new Uri("https://test-registry.example.com")
|
||||
};
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
_service = new RegistryDiscoveryService(
|
||||
_sourceRepoMock.Object,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RegistryDiscoveryService>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithInvalidSourceId_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var invalidSourceId = "not-a-guid";
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverRepositoriesAsync(invalidSourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid source ID");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithUnknownSource_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverRepositoriesAsync(sourceId.ToString());
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Source not found");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithValidSource_ReturnsRepositories()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
var catalogResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
repositories = new[] { "library/nginx", "library/redis", "app/backend" }
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, catalogResponse);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Repositories.Should().HaveCount(3);
|
||||
result.Repositories.Should().Contain("library/nginx");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithRepositoryDenylist_ExcludesMatches()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
source.RepositoryDenylist = ["*/test*"];
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
var catalogResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
repositories = new[] { "library/nginx", "library/test-app", "app/backend" }
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, catalogResponse);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
// Note: exact filtering depends on implementation
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverTagsAsync_WithInvalidSourceId_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var invalidSourceId = "not-a-guid";
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverTagsAsync(invalidSourceId, "library/nginx");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid source ID");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverTagsAsync_WithValidRepository_ReturnsTags()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
var tagsResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
name = "library/nginx",
|
||||
tags = new[] { "latest", "1.25.0", "1.24.0" }
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, tagsResponse);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverTagsAsync(source.Id.ToString(), "library/nginx");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Repository.Should().Be("library/nginx");
|
||||
result.Tags.Should().HaveCountGreaterThan(0);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverImagesAsync_WithInvalidSourceId_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var invalidSourceId = "not-a-guid";
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverImagesAsync(invalidSourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static RegistrySource CreateTestSource() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Registry",
|
||||
Type = RegistrySourceType.Harbor,
|
||||
RegistryUrl = "https://test-registry.example.com",
|
||||
AuthRefUri = "authref://vault/registry#credentials",
|
||||
Status = RegistrySourceStatus.Active
|
||||
};
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistrySourceService
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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<IRegistrySourceRepository> _sourceRepoMock;
|
||||
private readonly Mock<IRegistrySourceRunRepository> _runRepoMock;
|
||||
private readonly RegistrySourceService _service;
|
||||
|
||||
public RegistrySourceServiceTests()
|
||||
{
|
||||
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
|
||||
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
|
||||
|
||||
_service = new RegistrySourceService(
|
||||
_sourceRepoMock.Object,
|
||||
_runRepoMock.Object,
|
||||
NullLogger<RegistrySourceService>.Instance);
|
||||
}
|
||||
|
||||
[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<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((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<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request, null, null);
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(source.Id);
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(id);
|
||||
|
||||
// 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<RegistrySourceQuery>(q => q.Type == RegistrySourceType.Harbor), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(harborSources);
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.CountAsync(It.Is<RegistrySourceQuery>(q => q.Type == RegistrySourceType.Harbor), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(2);
|
||||
|
||||
var request = new ListRegistrySourcesRequest(Type: RegistrySourceType.Harbor);
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync(request, null);
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((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");
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.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");
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.DeleteAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(source.Id, "deleter@example.com");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
_sourceRepoMock.Verify(r => r.DeleteAsync(source.Id, It.IsAny<CancellationToken>()), 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
_runRepoMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<RegistrySourceRun>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySourceRun, CancellationToken>((run, _) => Task.FromResult(run));
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com");
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com");
|
||||
|
||||
// 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<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.ResumeAsync(source.Id, "admin@example.com");
|
||||
|
||||
// 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 runs = new[]
|
||||
{
|
||||
CreateTestRun(sourceId),
|
||||
CreateTestRun(sourceId),
|
||||
CreateTestRun(sourceId)
|
||||
};
|
||||
|
||||
_runRepoMock
|
||||
.Setup(r => r.GetBySourceIdAsync(sourceId, 50, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(runs);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetRunHistoryAsync(sourceId, 50);
|
||||
|
||||
// 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.NewGuid(),
|
||||
Name = "Test Registry",
|
||||
Type = type,
|
||||
RegistryUrl = "https://test-registry.example.com",
|
||||
Status = RegistrySourceStatus.Pending,
|
||||
TriggerMode = RegistryTriggerMode.Manual
|
||||
};
|
||||
|
||||
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
Status = RegistryRunStatus.Completed,
|
||||
TriggerType = "manual",
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistryWebhookService
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class RegistryWebhookServiceTests
|
||||
{
|
||||
private readonly Mock<IRegistrySourceRepository> _sourceRepoMock;
|
||||
private readonly Mock<IRegistrySourceRunRepository> _runRepoMock;
|
||||
private readonly Mock<IRegistrySourceService> _sourceServiceMock;
|
||||
private readonly Mock<IClock> _clockMock;
|
||||
private readonly RegistryWebhookService _service;
|
||||
|
||||
public RegistryWebhookServiceTests()
|
||||
{
|
||||
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
|
||||
_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"));
|
||||
|
||||
_service = new RegistryWebhookService(
|
||||
_sourceRepoMock.Object,
|
||||
_runRepoMock.Object,
|
||||
_sourceServiceMock.Object,
|
||||
NullLogger<RegistryWebhookService>.Instance,
|
||||
_clockMock.Object);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ProcessWebhookAsync_WithInvalidSourceId_ReturnsFailure()
|
||||
{
|
||||
// Arrange - invalid GUID format
|
||||
var invalidSourceId = "not-a-guid";
|
||||
|
||||
// Act
|
||||
var result = await _service.ProcessWebhookAsync(
|
||||
invalidSourceId, "harbor", "{}", null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("Invalid source ID");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ProcessWebhookAsync_WithUnknownSource_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.ProcessWebhookAsync(
|
||||
sourceId.ToString(), "harbor", "{}", null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("Source not found");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ProcessWebhookAsync_WithInactiveSource_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
source.Status = RegistrySourceStatus.Paused;
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
// Act
|
||||
var result = await _service.ProcessWebhookAsync(
|
||||
source.Id.ToString(), "harbor", "{}", null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("not active");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ProcessWebhookAsync_WithValidHarborPushEvent_TriggersRun()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
var harborPayload = CreateHarborPushPayload("library/nginx", "latest");
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
var expectedRun = CreateTestRun(source.Id);
|
||||
_sourceServiceMock
|
||||
.Setup(s => s.TriggerAsync(
|
||||
source.Id,
|
||||
"webhook",
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedRun);
|
||||
|
||||
// Act
|
||||
var result = await _service.ProcessWebhookAsync(
|
||||
source.Id.ToString(), "harbor", harborPayload, null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Message.Should().Contain("Scan triggered");
|
||||
result.TriggeredRunId.Should().Be(expectedRun.Id.ToString());
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ValidateSignature_WithNoSecret_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
var result = _service.ValidateSignature("{}", null, null, "harbor");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ValidateSignature_WithSecretButNoSignature_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _service.ValidateSignature("{}", null, "secret123", "harbor");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void ValidateSignature_WithValidHarborSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var payload = "{}";
|
||||
var secret = "secret123";
|
||||
|
||||
// Calculate expected signature (HMAC-SHA256 with sha256= prefix in hex format)
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
var signature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateSignature(payload, signature, secret, "harbor");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void ValidateSignature_WithInvalidSignature_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _service.ValidateSignature("{}", "invalid-signature", "secret123", "harbor");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static RegistrySource CreateTestSource() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Harbor",
|
||||
Type = RegistrySourceType.Harbor,
|
||||
RegistryUrl = "https://harbor.example.com",
|
||||
Status = RegistrySourceStatus.Active,
|
||||
TriggerMode = RegistryTriggerMode.Webhook
|
||||
};
|
||||
|
||||
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
Status = RegistryRunStatus.Running,
|
||||
TriggerType = "webhook",
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static string CreateHarborPushPayload(string repository, string tag) => JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "PUSH_ARTIFACT",
|
||||
occur_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
@operator = "admin",
|
||||
event_data = new
|
||||
{
|
||||
resources = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
resource_url = $"harbor.example.com/{repository}:{tag}",
|
||||
digest = "sha256:abc123def456",
|
||||
tag
|
||||
}
|
||||
},
|
||||
repository = new
|
||||
{
|
||||
name = repository,
|
||||
repo_full_name = repository,
|
||||
@namespace = repository.Contains('/') ? repository.Split('/')[0] : repository
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -17,4 +21,4 @@
|
||||
<ProjectReference Include="../StellaOps.SbomService/StellaOps.SbomService.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user