Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Validation.Tests/SbomValidationPipelineTests.cs

497 lines
17 KiB
C#

// <copyright file="SbomValidationPipelineTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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;
/// <summary>
/// Unit tests for <see cref="SbomValidationPipeline"/>.
/// Sprint: SPRINT_20260107_005_003 Task VG-005
/// </summary>
[Trait("Category", "Unit")]
public sealed class SbomValidationPipelineTests
{
private readonly Mock<ISbomValidator> _mockValidator;
private readonly TimeProvider _timeProvider;
public SbomValidationPipelineTests()
{
_mockValidator = new Mock<ISbomValidator>();
_timeProvider = TimeProvider.System;
}
private SbomValidationPipeline CreatePipeline(SbomValidationPipelineOptions? options = null)
{
return new SbomValidationPipeline(
_mockValidator.Object,
Options.Create(options ?? new SbomValidationPipelineOptions()),
NullLogger<SbomValidationPipeline>.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<AggregatedComponent>.Empty,
JsonBytes = Encoding.UTF8.GetBytes("{}"),
JsonSha256 = "abc123",
ContentHash = "abc123",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
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<AggregatedComponent>.Empty,
JsonBytes = Encoding.UTF8.GetBytes("{}"),
JsonSha256 = "xyz789",
ContentHash = "xyz789",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
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<LayerComponentFragment>.Empty,
Components = ImmutableArray<AggregatedComponent>.Empty,
ComponentMap = ImmutableDictionary<string, AggregatedComponent>.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<byte[]>(), It.IsAny<SbomFormat>(), It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ValidateAsync_WhenCycloneDxValid_ReturnsPassed()
{
// Arrange
var pipeline = CreatePipeline();
var result = CreateCompositionResult();
_mockValidator
.Setup(v => v.ValidateAsync(It.IsAny<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SbomValidationResult.Failure(
SbomFormat.CycloneDxJson,
"test-validator",
"1.0.0",
TimeSpan.FromMilliseconds(100),
diagnostics));
// Act & Assert
var ex = await Assert.ThrowsAsync<SbomValidationException>(
() => 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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()),
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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SbomValidationResult.Success(
SbomFormat.CycloneDxJson,
"test-validator",
"1.0.0",
TimeSpan.FromMilliseconds(100)));
_mockValidator
.Setup(v => v.ValidateAsync(It.IsAny<byte[]>(), SbomFormat.Spdx3JsonLd, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.Spdx3JsonLd, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()),
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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(
() => 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<byte[]>(), SbomFormat.CycloneDxJson, It.IsAny<SbomValidationOptions?>(), It.IsAny<CancellationToken>()))
.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);
}
}