// 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 _sourceRepoMock; private readonly Mock _runRepoMock; private readonly Mock _sourceServiceMock; private readonly Mock _clockMock; private readonly RegistryWebhookService _service; public RegistryWebhookServiceTests() { _sourceRepoMock = new Mock(); _runRepoMock = new Mock(); _sourceServiceMock = new Mock(); _clockMock = new Mock(); _clockMock.Setup(c => c.UtcNow).Returns(DateTimeOffset.Parse("2025-12-29T12:00:00Z")); _service = new RegistryWebhookService( _sourceRepoMock.Object, _runRepoMock.Object, _sourceServiceMock.Object, NullLogger.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())) .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())) .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())) .ReturnsAsync(source); var expectedRun = CreateTestRun(source.Id); _sourceServiceMock .Setup(s => s.TriggerAsync( source.Id, "webhook", It.IsAny(), It.IsAny(), It.IsAny())) .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 }