// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using System.Collections.Immutable; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Emit.Composition; using StellaOps.Scanner.Validation; using Xunit; namespace StellaOps.Scanner.Validation.Tests; /// /// Unit tests for . /// Sprint: SPRINT_20260107_005_003 Task VG-005 /// [Trait("Category", "Unit")] public sealed class SbomValidationPipelineTests { private readonly Mock _mockValidator; private readonly TimeProvider _timeProvider; public SbomValidationPipelineTests() { _mockValidator = new Mock(); _timeProvider = TimeProvider.System; } private SbomValidationPipeline CreatePipeline(SbomValidationPipelineOptions? options = null) { return new SbomValidationPipeline( _mockValidator.Object, Options.Create(options ?? new SbomValidationPipelineOptions()), NullLogger.Instance, _timeProvider); } private static SbomCompositionResult CreateCompositionResult( bool includeUsage = false, bool includeSpdx = false) { var cdxInventory = new CycloneDxArtifact { View = SbomView.Inventory, SerialNumber = "urn:uuid:test-1", GeneratedAt = DateTimeOffset.UtcNow, Components = ImmutableArray.Empty, JsonBytes = Encoding.UTF8.GetBytes("{}"), JsonSha256 = "abc123", ContentHash = "abc123", JsonMediaType = "application/vnd.cyclonedx+json", ProtobufBytes = Array.Empty(), ProtobufSha256 = "def456", ProtobufMediaType = "application/vnd.cyclonedx+protobuf", }; var cdxUsage = includeUsage ? new CycloneDxArtifact { View = SbomView.Usage, SerialNumber = "urn:uuid:test-2", GeneratedAt = DateTimeOffset.UtcNow, Components = ImmutableArray.Empty, JsonBytes = Encoding.UTF8.GetBytes("{}"), JsonSha256 = "xyz789", ContentHash = "xyz789", JsonMediaType = "application/vnd.cyclonedx+json", ProtobufBytes = Array.Empty(), ProtobufSha256 = "uvw012", ProtobufMediaType = "application/vnd.cyclonedx+protobuf", } : null; var spdxInventory = includeSpdx ? new SpdxArtifact { View = SbomView.Inventory, GeneratedAt = DateTimeOffset.UtcNow, JsonBytes = Encoding.UTF8.GetBytes("{}"), JsonSha256 = "spdx123", ContentHash = "spdx123", JsonMediaType = "application/spdx+json", } : null; return new SbomCompositionResult { Inventory = cdxInventory, Usage = cdxUsage, SpdxInventory = spdxInventory, Graph = new ComponentGraph { Layers = ImmutableArray.Empty, Components = ImmutableArray.Empty, ComponentMap = ImmutableDictionary.Empty, }, CompositionRecipeJson = Encoding.UTF8.GetBytes("{}"), CompositionRecipeSha256 = "recipe123", }; } [Fact] public async Task ValidateAsync_WhenDisabled_ReturnsSkipped() { // Arrange var options = new SbomValidationPipelineOptions { Enabled = false }; var pipeline = CreatePipeline(options); var result = CreateCompositionResult(); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.True(validationResult.WasSkipped); _mockValidator.Verify( v => v.ValidateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ValidateAsync_WhenCycloneDxValid_ReturnsPassed() { // Arrange var pipeline = CreatePipeline(); var result = CreateCompositionResult(); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100))); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.False(validationResult.WasSkipped); Assert.NotNull(validationResult.CycloneDxInventoryResult); Assert.True(validationResult.CycloneDxInventoryResult.IsValid); } [Fact] public async Task ValidateAsync_WhenCycloneDxInvalid_ReturnsFailed() { // Arrange var options = new SbomValidationPipelineOptions { FailOnError = false }; var pipeline = CreatePipeline(options); var result = CreateCompositionResult(); var diagnostics = new[] { new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "CDX001", Message = "Invalid component" } }; _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Failure( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100), diagnostics)); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.False(validationResult.IsValid); Assert.NotNull(validationResult.CycloneDxInventoryResult); Assert.False(validationResult.CycloneDxInventoryResult.IsValid); Assert.Equal(1, validationResult.TotalErrorCount); } [Fact] public async Task ValidateAsync_WhenFailOnErrorAndInvalid_ThrowsException() { // Arrange var options = new SbomValidationPipelineOptions { FailOnError = true }; var pipeline = CreatePipeline(options); var result = CreateCompositionResult(); var diagnostics = new[] { new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "CDX001", Message = "Invalid component" } }; _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Failure( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100), diagnostics)); // Act & Assert var ex = await Assert.ThrowsAsync( () => pipeline.ValidateAsync(result)); Assert.Contains("1 error", ex.Message); Assert.NotNull(ex.Result); } [Fact] public async Task ValidateAsync_WithUsageSbom_ValidatesBothSboms() { // Arrange var pipeline = CreatePipeline(); var result = CreateCompositionResult(includeUsage: true); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100))); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.NotNull(validationResult.CycloneDxInventoryResult); Assert.NotNull(validationResult.CycloneDxUsageResult); _mockValidator.Verify( v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny()), Times.Exactly(2)); } [Fact] public async Task ValidateAsync_WithSpdxSbom_ValidatesSpdx() { // Arrange var pipeline = CreatePipeline(); var result = CreateCompositionResult(includeSpdx: true); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100))); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.Spdx3JsonLd, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.Spdx3JsonLd, "spdx-validator", "1.0.0", TimeSpan.FromMilliseconds(100))); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.NotNull(validationResult.CycloneDxInventoryResult); Assert.NotNull(validationResult.SpdxInventoryResult); } [Fact] public async Task ValidateAsync_WhenValidateCycloneDxDisabled_SkipsCycloneDx() { // Arrange var options = new SbomValidationPipelineOptions { ValidateCycloneDx = false, ValidateSpdx = true }; var pipeline = CreatePipeline(options); var result = CreateCompositionResult(includeSpdx: true); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.Spdx3JsonLd, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.Spdx3JsonLd, "spdx-validator", "1.0.0", TimeSpan.FromMilliseconds(100))); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.Null(validationResult.CycloneDxInventoryResult); Assert.NotNull(validationResult.SpdxInventoryResult); _mockValidator.Verify( v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ValidateAsync_WhenValidatorThrows_ReturnsFailedResult() { // Arrange var options = new SbomValidationPipelineOptions { FailOnError = false }; var pipeline = CreatePipeline(options); var result = CreateCompositionResult(); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Validator binary not found")); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.False(validationResult.IsValid); Assert.NotNull(validationResult.CycloneDxInventoryResult); Assert.False(validationResult.CycloneDxInventoryResult.IsValid); Assert.Contains("not found", validationResult.CycloneDxInventoryResult.Diagnostics[0].Message); } [Fact] public async Task ValidateAsync_WhenCancelled_ThrowsOperationCanceled() { // Arrange var pipeline = CreatePipeline(); var result = CreateCompositionResult(); var cts = new CancellationTokenSource(); cts.Cancel(); _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ThrowsAsync(new OperationCanceledException()); // Act & Assert await Assert.ThrowsAsync( () => pipeline.ValidateAsync(result, cts.Token)); } [Fact] public async Task ValidateAsync_WithWarnings_ReturnsValidWithWarnings() { // Arrange var pipeline = CreatePipeline(); var result = CreateCompositionResult(); var diagnostics = new[] { new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Warning, Code = "CDX-WARN-001", Message = "Component missing description" } }; _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), SbomFormat.CycloneDxJson, It.IsAny(), It.IsAny())) .ReturnsAsync(SbomValidationResult.Success( SbomFormat.CycloneDxJson, "test-validator", "1.0.0", TimeSpan.FromMilliseconds(100), diagnostics)); // Act var validationResult = await pipeline.ValidateAsync(result); // Assert Assert.True(validationResult.IsValid); Assert.Equal(0, validationResult.TotalErrorCount); Assert.Equal(1, validationResult.TotalWarningCount); } [Fact] public void SbomValidationPipelineResult_Success_CreatesValidResult() { // Act var result = SbomValidationPipelineResult.Success(); // Assert Assert.True(result.IsValid); Assert.False(result.WasSkipped); Assert.Equal(0, result.TotalErrorCount); } [Fact] public void SbomValidationPipelineResult_Failure_CreatesInvalidResult() { // Act var result = SbomValidationPipelineResult.Failure(); // Assert Assert.False(result.IsValid); Assert.False(result.WasSkipped); } [Fact] public void SbomValidationPipelineResult_Skipped_CreatesSkippedResult() { // Act var result = SbomValidationPipelineResult.Skipped(); // Assert Assert.True(result.IsValid); Assert.True(result.WasSkipped); } [Fact] public void LayerValidationResult_IsValid_ReturnsTrueWhenBothValid() { // Arrange var cdxResult = SbomValidationResult.Success( SbomFormat.CycloneDxJson, "cdx", "1.0.0", TimeSpan.Zero); var spdxResult = SbomValidationResult.Success( SbomFormat.Spdx3JsonLd, "spdx", "1.0.0", TimeSpan.Zero); var layerResult = new LayerValidationResult { LayerId = "sha256:abc123", CycloneDxResult = cdxResult, SpdxResult = spdxResult }; // Assert Assert.True(layerResult.IsValid); } [Fact] public void LayerValidationResult_IsValid_ReturnsFalseWhenAnyInvalid() { // Arrange var cdxResult = SbomValidationResult.Failure( SbomFormat.CycloneDxJson, "cdx", "1.0.0", TimeSpan.Zero, [new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "E1", Message = "Error" }]); var layerResult = new LayerValidationResult { LayerId = "sha256:abc123", CycloneDxResult = cdxResult, SpdxResult = null }; // Assert Assert.False(layerResult.IsValid); } [Fact] public void SbomValidationException_StoresResult() { // Arrange var pipelineResult = SbomValidationPipelineResult.Failure(); // Act var ex = new SbomValidationException("Test error", pipelineResult); // Assert Assert.Equal("Test error", ex.Message); Assert.Same(pipelineResult, ex.Result); } [Fact] public void SbomValidationPipelineOptions_HasCorrectDefaults() { // Act var options = new SbomValidationPipelineOptions(); // Assert Assert.True(options.Enabled); Assert.True(options.FailOnError); Assert.True(options.ValidateCycloneDx); Assert.True(options.ValidateSpdx); Assert.Equal(TimeSpan.FromSeconds(60), options.ValidationTimeout); } }