497 lines
17 KiB
C#
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);
|
|
}
|
|
}
|