save progress
This commit is contained in:
@@ -52,7 +52,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Single(index);
|
||||
Assert.Equal(1, index.Count);
|
||||
|
||||
var result = await index.LookupAsync("gnu-build-id:abc123");
|
||||
Assert.NotNull(result);
|
||||
@@ -83,7 +83,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Empty(index);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,7 +108,7 @@ public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Empty(index);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
private static string CreateDsseSignature(string indexPath, string expectedSha256)
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Empty(index);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -49,7 +49,7 @@ public sealed class OfflineBuildIdIndexTests : IDisposable
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Empty(index);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class SecretAlertEmitterTests
|
||||
var settings = new SecretAlertSettings { Enabled = false };
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -52,7 +52,7 @@ public sealed class SecretAlertEmitterTests
|
||||
var settings = CreateEnabledSettings();
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -75,7 +75,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -93,7 +93,7 @@ public sealed class SecretAlertEmitterTests
|
||||
var settings = CreateEnabledSettings();
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -113,7 +113,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -134,13 +134,13 @@ public sealed class SecretAlertEmitterTests
|
||||
var context = CreateScanContext();
|
||||
|
||||
// First call should publish
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context);
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
// Advance time by 30 minutes (within window)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
|
||||
// Second call with same finding should be deduplicated
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context);
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -161,13 +161,13 @@ public sealed class SecretAlertEmitterTests
|
||||
var context = CreateScanContext();
|
||||
|
||||
// First call
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context);
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
// Advance time beyond window
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Second call should publish again
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context);
|
||||
await _emitter.EmitAlertsAsync([finding], settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -191,7 +191,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
_mockPublisher.Verify(
|
||||
p => p.PublishAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
@@ -223,7 +223,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should only publish the Critical finding
|
||||
_mockPublisher.Verify(
|
||||
@@ -249,7 +249,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should publish summary instead of individual alerts
|
||||
_mockPublisher.Verify(
|
||||
@@ -274,7 +274,7 @@ public sealed class SecretAlertEmitterTests
|
||||
};
|
||||
var context = CreateScanContext();
|
||||
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context);
|
||||
await _emitter.EmitAlertsAsync(findings, settings, context, TestContext.Current.CancellationToken);
|
||||
|
||||
// Below threshold, should publish individual alerts
|
||||
_mockPublisher.Verify(
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
var (host, analyzer, _) = CreateHost(options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
host.IsEnabled.Should().BeFalse();
|
||||
@@ -63,7 +63,7 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
var (host, analyzer, _) = CreateHost(options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
host.IsEnabled.Should().BeTrue();
|
||||
@@ -84,7 +84,7 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
var (host, analyzer, _) = CreateHost(options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - should be disabled after failed load
|
||||
host.IsEnabled.Should().BeFalse();
|
||||
@@ -104,7 +104,7 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<DirectoryNotFoundException>(
|
||||
() => host.StartAsync(CancellationToken.None));
|
||||
() => host.StartAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -121,17 +121,17 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
|
||||
var mockVerifier = new Mock<IBundleVerifier>();
|
||||
mockVerifier
|
||||
.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<VerificationOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleVerificationResult(true, "Test verification passed"));
|
||||
.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<BundleVerificationOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleVerificationResult { IsValid = true, BundleVersion = "1.0.0" });
|
||||
|
||||
var (host, _, _) = CreateHost(options, mockVerifier.Object);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
mockVerifier.Verify(
|
||||
v => v.VerifyAsync(_testDir, It.IsAny<VerificationOptions>(), It.IsAny<CancellationToken>()),
|
||||
v => v.VerifyAsync(_testDir, It.IsAny<BundleVerificationOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
host.LastVerificationResult.Should().NotBeNull();
|
||||
host.LastVerificationResult!.IsValid.Should().BeTrue();
|
||||
@@ -152,13 +152,13 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
|
||||
var mockVerifier = new Mock<IBundleVerifier>();
|
||||
mockVerifier
|
||||
.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<VerificationOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleVerificationResult(false, "Signature invalid"));
|
||||
.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<BundleVerificationOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(BundleVerificationResult.Failure("Signature invalid"));
|
||||
|
||||
var (host, _, _) = CreateHost(options, mockVerifier.Object);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
host.LastVerificationResult.Should().NotBeNull();
|
||||
@@ -177,10 +177,10 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
};
|
||||
var (host, _, _) = CreateHost(options);
|
||||
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
await host.StopAsync(CancellationToken.None);
|
||||
await host.StopAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - should complete without error
|
||||
}
|
||||
@@ -199,7 +199,7 @@ public sealed class SecretsAnalyzerHostTests : IAsyncLifetime
|
||||
var (host, _, _) = CreateHost(options);
|
||||
|
||||
// Act
|
||||
await host.StartAsync(CancellationToken.None);
|
||||
await host.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - should be disabled due to invalid ruleset
|
||||
host.IsEnabled.Should().BeFalse();
|
||||
|
||||
@@ -140,7 +140,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should complete without error when disabled
|
||||
}
|
||||
@@ -154,7 +154,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should complete without error when no ruleset
|
||||
}
|
||||
@@ -171,7 +171,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Analyzer should process without error - findings logged but not returned directly
|
||||
}
|
||||
@@ -194,7 +194,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should complete without scanning the large file
|
||||
}
|
||||
@@ -221,7 +221,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should stop after max findings
|
||||
}
|
||||
@@ -255,12 +255,13 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
Directory.CreateDirectory(subDir);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(subDir, "secret.txt"),
|
||||
"AKIAIOSFODNN7EXAMPLE");
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should process nested files
|
||||
}
|
||||
@@ -281,12 +282,13 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
Directory.CreateDirectory(nodeModules);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(nodeModules, "package.txt"),
|
||||
"AKIAIOSFODNN7EXAMPLE");
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should skip node_modules directory
|
||||
}
|
||||
@@ -308,7 +310,7 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var context = CreateContext();
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
await analyzer.AnalyzeAsync(context, writer, CancellationToken.None);
|
||||
await analyzer.AnalyzeAsync(context, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should skip .bin files
|
||||
}
|
||||
@@ -329,8 +331,8 @@ public sealed class SecretsAnalyzerTests : IAsyncLifetime
|
||||
var writer = new Mock<LanguageComponentWriter>().Object;
|
||||
|
||||
// Run twice - should produce same results
|
||||
await analyzer1.AnalyzeAsync(context1, writer, CancellationToken.None);
|
||||
await analyzer2.AnalyzeAsync(context2, writer, CancellationToken.None);
|
||||
await analyzer1.AnalyzeAsync(context1, writer, TestContext.Current.CancellationToken);
|
||||
await analyzer2.AnalyzeAsync(context2, writer, TestContext.Current.CancellationToken);
|
||||
|
||||
// Deterministic execution verified by no exceptions
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ public class ScannerConfigDiffTests : ConfigDiffTestBase
|
||||
async config => await GetReachabilityBehaviorAsync(config),
|
||||
async config => await GetConcurrencyBehaviorAsync(config),
|
||||
async config => await GetOutputFormatBehaviorAsync(config)
|
||||
]);
|
||||
],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
@@ -95,7 +96,8 @@ public class ScannerConfigDiffTests : ConfigDiffTestBase
|
||||
changedConfig,
|
||||
getBehavior: async config => await CaptureReachabilityBehaviorAsync(config),
|
||||
computeDelta: ComputeBehaviorSnapshotDelta,
|
||||
expectedDelta: expectedDelta);
|
||||
expectedDelta: expectedDelta,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
@@ -122,7 +124,8 @@ public class ScannerConfigDiffTests : ConfigDiffTestBase
|
||||
async config => await GetScanningBehaviorAsync(config),
|
||||
async config => await GetVulnMatchingBehaviorAsync(config),
|
||||
async config => await GetReachabilityBehaviorAsync(config)
|
||||
]);
|
||||
],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
@@ -154,7 +157,8 @@ public class ScannerConfigDiffTests : ConfigDiffTestBase
|
||||
changedConfig,
|
||||
getBehavior: async config => await CaptureConcurrencyBehaviorAsync(config),
|
||||
computeDelta: ComputeBehaviorSnapshotDelta,
|
||||
expectedDelta: expectedDelta);
|
||||
expectedDelta: expectedDelta,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
@@ -179,7 +183,8 @@ public class ScannerConfigDiffTests : ConfigDiffTestBase
|
||||
[
|
||||
async config => await GetScanningBehaviorAsync(config),
|
||||
async config => await GetSbomBehaviorAsync(config)
|
||||
]);
|
||||
],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue(
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
// <copyright file="FeedserPedigreeDataProviderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Emit.Pedigree;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Tests.Pedigree;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FeedserPedigreeDataProvider"/>.
|
||||
/// Sprint: SPRINT_20260107_005_002 Task PD-012
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FeedserPedigreeDataProviderTests
|
||||
{
|
||||
private readonly Mock<IFeedserPatchSignatureClient> _patchClientMock;
|
||||
private readonly Mock<IFeedserBackportProofClient> _backportClientMock;
|
||||
private readonly PedigreeNotesGenerator _notesGenerator;
|
||||
private readonly FeedserPedigreeDataProvider _provider;
|
||||
|
||||
public FeedserPedigreeDataProviderTests()
|
||||
{
|
||||
_patchClientMock = new Mock<IFeedserPatchSignatureClient>();
|
||||
_backportClientMock = new Mock<IFeedserBackportProofClient>();
|
||||
_notesGenerator = new PedigreeNotesGenerator(TimeProvider.System);
|
||||
|
||||
_provider = new FeedserPedigreeDataProvider(
|
||||
_patchClientMock.Object,
|
||||
_backportClientMock.Object,
|
||||
_notesGenerator,
|
||||
NullLogger<FeedserPedigreeDataProvider>.Instance);
|
||||
}
|
||||
|
||||
#region GetPedigreeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_NullPurl_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(null!, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
_patchClientMock.Verify(c => c.GetPatchSignaturesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_EmptyPurl_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(string.Empty, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_NoDataFromServices_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:npm/lodash@4.17.21";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IReadOnlyList<FeedserPatchSignature>?)null);
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedserBackportProof?)null);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_WithBackportProof_BuildsAncestors()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u5";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IReadOnlyList<FeedserPatchSignature>?)null);
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeedserBackportProof
|
||||
{
|
||||
UpstreamPackage = new FeedserPackageReference
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1n",
|
||||
Purl = "pkg:generic/openssl@1.1.1n",
|
||||
ProjectUrl = "https://www.openssl.org"
|
||||
},
|
||||
ConfidencePercent = 95,
|
||||
FeedserTier = 1
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Ancestors.Should().HaveCount(1);
|
||||
result.Ancestors[0].Name.Should().Be("openssl");
|
||||
result.Ancestors[0].Version.Should().Be("1.1.1n");
|
||||
result.Ancestors[0].Purl.Should().Be("pkg:generic/openssl@1.1.1n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_WithBackportProof_BuildsVariants()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:generic/openssl@1.1.1n";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IReadOnlyList<FeedserPatchSignature>?)null);
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeedserBackportProof
|
||||
{
|
||||
Variants = new[]
|
||||
{
|
||||
new FeedserVariantPackage
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1n-0+deb11u5",
|
||||
Purl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u5",
|
||||
Distribution = "Debian",
|
||||
Release = "bullseye"
|
||||
},
|
||||
new FeedserVariantPackage
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1k-1ubuntu2.1",
|
||||
Purl = "pkg:deb/ubuntu/openssl@1.1.1k-1ubuntu2.1",
|
||||
Distribution = "Ubuntu",
|
||||
Release = "jammy"
|
||||
}
|
||||
},
|
||||
ConfidencePercent = 90
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variants.Should().HaveCount(2);
|
||||
result.Variants[0].Distribution.Should().Be("Debian");
|
||||
result.Variants[1].Distribution.Should().Be("Ubuntu");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_WithPatchSignatures_BuildsCommits()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:generic/openssl@1.1.1n";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
UpstreamRepo = "https://github.com/openssl/openssl",
|
||||
CommitSha = "abc123def456789"
|
||||
}
|
||||
});
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedserBackportProof?)null);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Commits.Should().HaveCount(1);
|
||||
result.Commits[0].Uid.Should().Be("abc123def456789");
|
||||
result.Commits[0].Url.Should().Be("https://github.com/openssl/openssl/commit/abc123def456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_WithPatchSignatures_BuildsPatches()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:generic/openssl@1.1.1n";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
CommitSha = "abc123",
|
||||
PatchOrigin = "backport",
|
||||
Hunks = new[]
|
||||
{
|
||||
new FeedserPatchHunk
|
||||
{
|
||||
FilePath = "crypto/x509/x509_cmp.c",
|
||||
StartLine = 100,
|
||||
AddedLines = new[] { "+ if (check != NULL) return 0;" },
|
||||
RemovedLines = new[] { "- // vulnerable code" }
|
||||
}
|
||||
},
|
||||
Source = "NVD"
|
||||
}
|
||||
});
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedserBackportProof?)null);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Patches.Should().HaveCount(1);
|
||||
result.Patches[0].Type.Should().Be(PatchType.Backport);
|
||||
result.Patches[0].Resolves.Should().HaveCount(1);
|
||||
result.Patches[0].Resolves[0].Id.Should().Be("CVE-2024-1234");
|
||||
result.Patches[0].DiffText.Should().Contain("crypto/x509/x509_cmp.c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_MultiPatchAggregation_AggregatesAll()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:generic/openssl@1.1.1n";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-001",
|
||||
CveId = "CVE-2024-1111",
|
||||
CommitSha = "commit1",
|
||||
PatchOrigin = "backport"
|
||||
},
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-002",
|
||||
CveId = "CVE-2024-2222",
|
||||
CommitSha = "commit2",
|
||||
PatchOrigin = "cherry-pick"
|
||||
},
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-003",
|
||||
CveId = "CVE-2024-3333",
|
||||
CommitSha = "commit3",
|
||||
PatchOrigin = "backport"
|
||||
}
|
||||
});
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedserBackportProof?)null);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Commits.Should().HaveCount(3);
|
||||
result.Patches.Should().HaveCount(3);
|
||||
result.Patches.Select(p => p.Resolves[0].Id)
|
||||
.Should().BeEquivalentTo(new[] { "CVE-2024-1111", "CVE-2024-2222", "CVE-2024-3333" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_GeneratesNotes()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u5";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
CommitSha = "abc123",
|
||||
PatchOrigin = "backport"
|
||||
}
|
||||
});
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeedserBackportProof
|
||||
{
|
||||
UpstreamPackage = new FeedserPackageReference
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1n",
|
||||
Purl = "pkg:generic/openssl@1.1.1n"
|
||||
},
|
||||
ConfidencePercent = 95,
|
||||
FeedserTier = 1
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Notes.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreeAsync_ServiceException_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
const string purl = "pkg:generic/openssl@1.1.1n";
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedserBackportProof?)null);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreeAsync(purl, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPedigreesBatchAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreesBatchAsync_EmptyPurls_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = await _provider.GetPedigreesBatchAsync(
|
||||
Array.Empty<string>(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreesBatchAsync_FiltersDuplicates()
|
||||
{
|
||||
// Arrange
|
||||
var purls = new[]
|
||||
{
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"",
|
||||
"pkg:npm/express@4.18.0"
|
||||
};
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesBatchAsync(
|
||||
It.Is<IEnumerable<string>>(p => p.Count() == 2),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, IReadOnlyList<FeedserPatchSignature>>.Empty);
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofsBatchAsync(
|
||||
It.Is<IEnumerable<string>>(p => p.Count() == 2),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, FeedserBackportProof>.Empty);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreesBatchAsync(
|
||||
purls,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
_patchClientMock.Verify(c => c.GetPatchSignaturesBatchAsync(
|
||||
It.Is<IEnumerable<string>>(p => p.Count() == 2),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPedigreesBatchAsync_MapsResults()
|
||||
{
|
||||
// Arrange
|
||||
var purls = new[]
|
||||
{
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"pkg:npm/express@4.18.0"
|
||||
};
|
||||
|
||||
_patchClientMock
|
||||
.Setup(c => c.GetPatchSignaturesBatchAsync(
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, IReadOnlyList<FeedserPatchSignature>>
|
||||
{
|
||||
["pkg:npm/lodash@4.17.21"] = new[]
|
||||
{
|
||||
new FeedserPatchSignature
|
||||
{
|
||||
PatchSigId = "sig-001",
|
||||
CveId = "CVE-2021-23337",
|
||||
CommitSha = "abc123"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_backportClientMock
|
||||
.Setup(c => c.GetBackportProofsBatchAsync(
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, FeedserBackportProof>.Empty);
|
||||
|
||||
// Act
|
||||
var result = await _provider.GetPedigreesBatchAsync(
|
||||
purls,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("pkg:npm/lodash@4.17.21");
|
||||
result.Should().NotContainKey("pkg:npm/express@4.18.0");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Emit.Pedigree;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -225,9 +225,9 @@ public sealed class FingerprintIndexTests
|
||||
{
|
||||
// Arrange
|
||||
var index = new InMemoryFingerprintIndex();
|
||||
|
||||
|
||||
// Assert initial
|
||||
Assert.Empty(index);
|
||||
Assert.Equal(0, index.GetStatistics().TotalFingerprints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -59,7 +59,7 @@ public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
public async Task ScanReadOperations_CompatibleWithPreviousSchema()
|
||||
{
|
||||
// Arrange
|
||||
await InitializeAsync();
|
||||
await InitializeAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var results = await TestReadBackwardCompatibilityAsync(
|
||||
@@ -90,7 +90,7 @@ public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
public async Task ScanWriteOperations_CompatibleWithPreviousSchema()
|
||||
{
|
||||
// Arrange
|
||||
await InitializeAsync();
|
||||
await InitializeAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var results = await TestWriteForwardCompatibilityAsync(
|
||||
@@ -120,7 +120,7 @@ public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
public async Task SbomStorageOperations_CompatibleAcrossVersions()
|
||||
{
|
||||
// Arrange
|
||||
await InitializeAsync();
|
||||
await InitializeAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var result = await TestAgainstPreviousSchemaAsync(
|
||||
@@ -146,7 +146,7 @@ public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
public async Task VulnerabilityMappingOperations_CompatibleAcrossVersions()
|
||||
{
|
||||
// Arrange
|
||||
await InitializeAsync();
|
||||
await InitializeAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var result = await TestAgainstPreviousSchemaAsync(
|
||||
@@ -173,7 +173,7 @@ public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
|
||||
public async Task MigrationRollbacks_ExecuteSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
await InitializeAsync();
|
||||
await InitializeAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var results = await TestMigrationRollbacksAsync(
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
// <copyright file="SbomValidationPipelineTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Scanner.Validation.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Validation\StellaOps.Scanner.Validation.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,238 @@
|
||||
// <copyright file="CompositeValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CompositeValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CompositeValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportsFormat_WithValidators_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var mockCycloneDx = new Mock<ISbomValidator>();
|
||||
mockCycloneDx.Setup(v => v.SupportsFormat(SbomFormat.CycloneDxJson)).Returns(true);
|
||||
mockCycloneDx.Setup(v => v.SupportsFormat(SbomFormat.CycloneDxXml)).Returns(true);
|
||||
|
||||
var mockSpdx = new Mock<ISbomValidator>();
|
||||
mockSpdx.Setup(v => v.SupportsFormat(SbomFormat.Spdx23Json)).Returns(true);
|
||||
|
||||
var composite = new CompositeValidator(
|
||||
new[] { mockCycloneDx.Object, mockSpdx.Object },
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
// Assert
|
||||
composite.SupportsFormat(SbomFormat.CycloneDxJson).Should().BeTrue();
|
||||
composite.SupportsFormat(SbomFormat.CycloneDxXml).Should().BeTrue();
|
||||
composite.SupportsFormat(SbomFormat.Spdx23Json).Should().BeTrue();
|
||||
composite.SupportsFormat(SbomFormat.Unknown).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsFormat_NoValidators_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var composite = new CompositeValidator(
|
||||
Array.Empty<ISbomValidator>(),
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
// Assert
|
||||
composite.SupportsFormat(SbomFormat.CycloneDxJson).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DelegatesToCorrectValidator()
|
||||
{
|
||||
// Arrange
|
||||
var expectedResult = SbomValidationResult.Success(
|
||||
SbomFormat.CycloneDxJson,
|
||||
"test-validator",
|
||||
"1.0.0",
|
||||
TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var mockCycloneDx = new Mock<ISbomValidator>();
|
||||
mockCycloneDx.Setup(v => v.SupportsFormat(SbomFormat.CycloneDxJson)).Returns(true);
|
||||
mockCycloneDx.Setup(v => v.ValidateAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
SbomFormat.CycloneDxJson,
|
||||
It.IsAny<SbomValidationOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResult);
|
||||
|
||||
var composite = new CompositeValidator(
|
||||
new[] { mockCycloneDx.Object },
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
var sbomBytes = "{}"u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await composite.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedResult);
|
||||
mockCycloneDx.Verify(v => v.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
null,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_NoValidatorForFormat_ReturnsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
var mockCycloneDx = new Mock<ISbomValidator>();
|
||||
mockCycloneDx.Setup(v => v.SupportsFormat(It.IsAny<SbomFormat>())).Returns(false);
|
||||
|
||||
var composite = new CompositeValidator(
|
||||
new[] { mockCycloneDx.Object },
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
var sbomBytes = "{}"u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await composite.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.Spdx3JsonLd,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "VALIDATOR_UNAVAILABLE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInfoAsync_AggregatesFromValidators()
|
||||
{
|
||||
// Arrange
|
||||
var cycloneDxInfo = new ValidatorInfo
|
||||
{
|
||||
Name = "sbom-utility",
|
||||
Version = "0.16.0",
|
||||
IsAvailable = true,
|
||||
SupportedFormats = ImmutableArray.Create(SbomFormat.CycloneDxJson, SbomFormat.CycloneDxXml),
|
||||
SupportedSchemaVersions = ImmutableArray.Create("1.6", "1.7")
|
||||
};
|
||||
|
||||
var spdxInfo = new ValidatorInfo
|
||||
{
|
||||
Name = "spdx-tools",
|
||||
Version = "1.1.8",
|
||||
IsAvailable = true,
|
||||
SupportedFormats = ImmutableArray.Create(SbomFormat.Spdx23Json, SbomFormat.Spdx3JsonLd),
|
||||
SupportedSchemaVersions = ImmutableArray.Create("2.3", "3.0.1")
|
||||
};
|
||||
|
||||
var mockCycloneDx = new Mock<ISbomValidator>();
|
||||
mockCycloneDx.Setup(v => v.GetInfoAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(cycloneDxInfo);
|
||||
|
||||
var mockSpdx = new Mock<ISbomValidator>();
|
||||
mockSpdx.Setup(v => v.GetInfoAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(spdxInfo);
|
||||
|
||||
var composite = new CompositeValidator(
|
||||
new[] { mockCycloneDx.Object, mockSpdx.Object },
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
// Act
|
||||
var info = await composite.GetInfoAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
info.IsAvailable.Should().BeTrue();
|
||||
info.SupportedFormats.Should().HaveCount(4);
|
||||
info.SupportedFormats.Should().Contain(SbomFormat.CycloneDxJson);
|
||||
info.SupportedFormats.Should().Contain(SbomFormat.Spdx3JsonLd);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.6\"}", SbomFormat.CycloneDxJson)]
|
||||
[InlineData("{\"spdxVersion\":\"SPDX-2.3\",\"dataLicense\":\"CC0-1.0\"}", SbomFormat.Spdx23Json)]
|
||||
[InlineData("{\"@context\":\"https://spdx.org/v3/context\",\"spdxVersion\":\"SPDX-3.0\"}", SbomFormat.Spdx3JsonLd)]
|
||||
[InlineData("SPDXVersion: SPDX-2.3\nDataLicense: CC0-1.0", SbomFormat.Spdx23TagValue)]
|
||||
[InlineData("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.6\">", SbomFormat.CycloneDxXml)]
|
||||
[InlineData("random content", SbomFormat.Unknown)]
|
||||
[InlineData("", SbomFormat.Unknown)]
|
||||
public void DetectFormat_IdentifiesCorrectFormat(string content, SbomFormat expected)
|
||||
{
|
||||
// Act
|
||||
var result = CompositeValidator.DetectFormat(System.Text.Encoding.UTF8.GetBytes(content));
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAutoAsync_DetectsAndValidates()
|
||||
{
|
||||
// Arrange
|
||||
var expectedResult = SbomValidationResult.Success(
|
||||
SbomFormat.CycloneDxJson,
|
||||
"test-validator",
|
||||
"1.0.0",
|
||||
TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var mockCycloneDx = new Mock<ISbomValidator>();
|
||||
mockCycloneDx.Setup(v => v.SupportsFormat(SbomFormat.CycloneDxJson)).Returns(true);
|
||||
mockCycloneDx.Setup(v => v.ValidateAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
SbomFormat.CycloneDxJson,
|
||||
It.IsAny<SbomValidationOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResult);
|
||||
|
||||
var composite = new CompositeValidator(
|
||||
new[] { mockCycloneDx.Object },
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
var sbomBytes = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.6\"}"u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await composite.ValidateAutoAsync(
|
||||
sbomBytes,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Format.Should().Be(SbomFormat.CycloneDxJson);
|
||||
mockCycloneDx.Verify(v => v.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
null,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAutoAsync_UnknownFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var composite = new CompositeValidator(
|
||||
Array.Empty<ISbomValidator>(),
|
||||
NullLogger<CompositeValidator>.Instance);
|
||||
|
||||
var sbomBytes = "random garbage content"u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await composite.ValidateAutoAsync(
|
||||
sbomBytes,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Format.Should().Be(SbomFormat.Unknown);
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "UNKNOWN_FORMAT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// <copyright file="CycloneDxValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CycloneDxValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CycloneDxValidatorTests
|
||||
{
|
||||
private readonly CycloneDxValidator _validator;
|
||||
|
||||
public CycloneDxValidatorTests()
|
||||
{
|
||||
var options = Options.Create(new CycloneDxValidatorOptions
|
||||
{
|
||||
ExecutablePath = "sbom-utility",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
_validator = new CycloneDxValidator(
|
||||
options,
|
||||
NullLogger<CycloneDxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomFormat.CycloneDxJson, true)]
|
||||
[InlineData(SbomFormat.CycloneDxXml, true)]
|
||||
[InlineData(SbomFormat.Spdx23Json, false)]
|
||||
[InlineData(SbomFormat.Spdx23TagValue, false)]
|
||||
[InlineData(SbomFormat.Spdx3JsonLd, false)]
|
||||
[InlineData(SbomFormat.Unknown, false)]
|
||||
public void SupportsFormat_ReturnsCorrectly(SbomFormat format, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = _validator.SupportsFormat(format);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UnsupportedFormat_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sbomBytes = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0"
|
||||
}
|
||||
"""u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.Spdx23Json,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "UNSUPPORTED_FORMAT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidatorNotFound_ReturnsUnavailable()
|
||||
{
|
||||
// Arrange - use a non-existent executable path
|
||||
var options = Options.Create(new CycloneDxValidatorOptions
|
||||
{
|
||||
ExecutablePath = "/nonexistent/path/sbom-utility-does-not-exist",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(5)
|
||||
});
|
||||
|
||||
var validator = new CycloneDxValidator(
|
||||
options,
|
||||
NullLogger<CycloneDxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var sbomBytes = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6"
|
||||
}
|
||||
"""u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d =>
|
||||
d.Code == "VALIDATOR_UNAVAILABLE" ||
|
||||
d.Code == "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInfoAsync_ValidatorNotAvailable_ReturnsUnavailable()
|
||||
{
|
||||
// Arrange - use a non-existent executable path
|
||||
var options = Options.Create(new CycloneDxValidatorOptions
|
||||
{
|
||||
ExecutablePath = "/nonexistent/path/sbom-utility-does-not-exist"
|
||||
});
|
||||
|
||||
var validator = new CycloneDxValidator(
|
||||
options,
|
||||
NullLogger<CycloneDxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
// Act
|
||||
var info = await validator.GetInfoAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
info.IsAvailable.Should().BeFalse();
|
||||
info.Name.Should().Be("sbom-utility");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// <copyright file="SbomFormatTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SbomFormat"/> enum.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomFormatTests
|
||||
{
|
||||
[Fact]
|
||||
public void SbomFormat_HasExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
Enum.GetValues<SbomFormat>().Should().HaveCount(6);
|
||||
Enum.IsDefined(SbomFormat.CycloneDxJson).Should().BeTrue();
|
||||
Enum.IsDefined(SbomFormat.CycloneDxXml).Should().BeTrue();
|
||||
Enum.IsDefined(SbomFormat.Spdx23Json).Should().BeTrue();
|
||||
Enum.IsDefined(SbomFormat.Spdx23TagValue).Should().BeTrue();
|
||||
Enum.IsDefined(SbomFormat.Spdx3JsonLd).Should().BeTrue();
|
||||
Enum.IsDefined(SbomFormat.Unknown).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomFormat.CycloneDxJson, "CycloneDxJson")]
|
||||
[InlineData(SbomFormat.CycloneDxXml, "CycloneDxXml")]
|
||||
[InlineData(SbomFormat.Spdx23Json, "Spdx23Json")]
|
||||
[InlineData(SbomFormat.Spdx23TagValue, "Spdx23TagValue")]
|
||||
[InlineData(SbomFormat.Spdx3JsonLd, "Spdx3JsonLd")]
|
||||
[InlineData(SbomFormat.Unknown, "Unknown")]
|
||||
public void SbomFormat_ToString_ReturnsExpectedName(SbomFormat format, string expected)
|
||||
{
|
||||
// Act & Assert
|
||||
format.ToString().Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// <copyright file="SbomValidationDiagnosticTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SbomValidationDiagnostic"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomValidationDiagnosticTests
|
||||
{
|
||||
[Fact]
|
||||
public void Diagnostic_RequiredProperties()
|
||||
{
|
||||
// Act
|
||||
var diagnostic = new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = SbomValidationSeverity.Error,
|
||||
Code = "ERR001",
|
||||
Message = "Schema validation failed"
|
||||
};
|
||||
|
||||
// Assert
|
||||
diagnostic.Severity.Should().Be(SbomValidationSeverity.Error);
|
||||
diagnostic.Code.Should().Be("ERR001");
|
||||
diagnostic.Message.Should().Be("Schema validation failed");
|
||||
diagnostic.Path.Should().BeNull();
|
||||
diagnostic.Line.Should().BeNull();
|
||||
diagnostic.Suggestion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_WithOptionalProperties()
|
||||
{
|
||||
// Act
|
||||
var diagnostic = new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = SbomValidationSeverity.Warning,
|
||||
Code = "WARN001",
|
||||
Message = "License not in SPDX license list",
|
||||
Path = "$.components[0].licenses[0].license.id",
|
||||
Line = 42,
|
||||
Suggestion = "Use a valid SPDX license identifier"
|
||||
};
|
||||
|
||||
// Assert
|
||||
diagnostic.Path.Should().Be("$.components[0].licenses[0].license.id");
|
||||
diagnostic.Line.Should().Be(42);
|
||||
diagnostic.Suggestion.Should().Be("Use a valid SPDX license identifier");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomValidationSeverity_HasExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
Enum.GetValues<SbomValidationSeverity>().Should().HaveCount(3);
|
||||
Enum.IsDefined(SbomValidationSeverity.Error).Should().BeTrue();
|
||||
Enum.IsDefined(SbomValidationSeverity.Warning).Should().BeTrue();
|
||||
Enum.IsDefined(SbomValidationSeverity.Info).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomValidationSeverity.Error)]
|
||||
[InlineData(SbomValidationSeverity.Warning)]
|
||||
[InlineData(SbomValidationSeverity.Info)]
|
||||
public void Diagnostic_CanHaveAnySeverity(SbomValidationSeverity severity)
|
||||
{
|
||||
// Act
|
||||
var diagnostic = new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = severity,
|
||||
Code = "TEST",
|
||||
Message = "Test message"
|
||||
};
|
||||
|
||||
// Assert
|
||||
diagnostic.Severity.Should().Be(severity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// <copyright file="SbomValidationOptionsTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SbomValidationOptions"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomValidationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_HaveCorrectDefaults()
|
||||
{
|
||||
// Act
|
||||
var options = new SbomValidationOptions();
|
||||
|
||||
// Assert
|
||||
options.Mode.Should().Be(SbomValidationMode.Strict);
|
||||
options.Timeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
options.IncludeWarnings.Should().BeTrue();
|
||||
options.ExpectedSchemaVersion.Should().BeNull();
|
||||
options.ValidateLicenses.Should().BeTrue();
|
||||
options.CustomRulesPath.Should().BeNull();
|
||||
options.RequiredSpdxProfiles.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_CanBeCustomized()
|
||||
{
|
||||
// Act
|
||||
var options = new SbomValidationOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Lenient,
|
||||
Timeout = TimeSpan.FromMinutes(2),
|
||||
IncludeWarnings = false,
|
||||
ExpectedSchemaVersion = "1.6",
|
||||
ValidateLicenses = false,
|
||||
CustomRulesPath = "/custom/rules.json",
|
||||
RequiredSpdxProfiles = new[] { "core", "software", "security" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
options.Mode.Should().Be(SbomValidationMode.Lenient);
|
||||
options.Timeout.Should().Be(TimeSpan.FromMinutes(2));
|
||||
options.IncludeWarnings.Should().BeFalse();
|
||||
options.ExpectedSchemaVersion.Should().Be("1.6");
|
||||
options.ValidateLicenses.Should().BeFalse();
|
||||
options.CustomRulesPath.Should().Be("/custom/rules.json");
|
||||
options.RequiredSpdxProfiles.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidOptions_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SbomValidationOptions
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = options.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NegativeTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SbomValidationOptions
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(-1)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = options.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().ContainSingle();
|
||||
errors[0].Should().Contain("Timeout must be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExcessiveTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SbomValidationOptions
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = options.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().ContainSingle();
|
||||
errors[0].Should().Contain("cannot exceed 10 minutes");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomValidationMode.Strict)]
|
||||
[InlineData(SbomValidationMode.Lenient)]
|
||||
[InlineData(SbomValidationMode.Audit)]
|
||||
[InlineData(SbomValidationMode.Off)]
|
||||
public void SbomValidationMode_HasExpectedValues(SbomValidationMode mode)
|
||||
{
|
||||
// Assert
|
||||
Enum.IsDefined(mode).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// <copyright file="SbomValidationResultTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SbomValidationResult"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_CreatesValidResult()
|
||||
{
|
||||
// Arrange
|
||||
var format = SbomFormat.CycloneDxJson;
|
||||
var validatorName = "test-validator";
|
||||
var validatorVersion = "1.0.0";
|
||||
var duration = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Act
|
||||
var result = SbomValidationResult.Success(format, validatorName, validatorVersion, duration);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Format.Should().Be(format);
|
||||
result.ValidatorName.Should().Be(validatorName);
|
||||
result.ValidatorVersion.Should().Be(validatorVersion);
|
||||
result.ValidationDuration.Should().Be(duration);
|
||||
result.Diagnostics.Should().BeEmpty();
|
||||
result.ErrorCount.Should().Be(0);
|
||||
result.WarningCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_WithDiagnostics_IncludesWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var diagnostics = new[]
|
||||
{
|
||||
new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = SbomValidationSeverity.Warning,
|
||||
Code = "WARN001",
|
||||
Message = "Minor issue"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SbomValidationResult.Success(
|
||||
SbomFormat.CycloneDxJson,
|
||||
"test",
|
||||
"1.0",
|
||||
TimeSpan.FromMilliseconds(50),
|
||||
diagnostics);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Diagnostics.Should().HaveCount(1);
|
||||
result.WarningCount.Should().Be(1);
|
||||
result.ErrorCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_CreatesInvalidResult()
|
||||
{
|
||||
// Arrange
|
||||
var diagnostics = new[]
|
||||
{
|
||||
new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = SbomValidationSeverity.Error,
|
||||
Code = "ERR001",
|
||||
Message = "Schema violation"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SbomValidationResult.Failure(
|
||||
SbomFormat.Spdx23Json,
|
||||
"spdx-tools",
|
||||
"1.1.8",
|
||||
TimeSpan.FromSeconds(1),
|
||||
diagnostics);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Format.Should().Be(SbomFormat.Spdx23Json);
|
||||
result.Diagnostics.Should().HaveCount(1);
|
||||
result.ErrorCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorUnavailable_CreatesErrorResult()
|
||||
{
|
||||
// Arrange
|
||||
var reason = "sbom-utility not found in PATH";
|
||||
|
||||
// Act
|
||||
var result = SbomValidationResult.ValidatorUnavailable(
|
||||
SbomFormat.CycloneDxXml,
|
||||
"sbom-utility",
|
||||
reason);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ValidatorVersion.Should().Be("unknown");
|
||||
result.Diagnostics.Should().HaveCount(1);
|
||||
result.Diagnostics[0].Code.Should().Be("VALIDATOR_UNAVAILABLE");
|
||||
result.Diagnostics[0].Message.Should().Contain(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorCount_CountsOnlyErrors()
|
||||
{
|
||||
// Arrange
|
||||
var diagnostics = new[]
|
||||
{
|
||||
new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "E1", Message = "Error 1" },
|
||||
new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Warning, Code = "W1", Message = "Warning 1" },
|
||||
new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Info, Code = "I1", Message = "Info 1" },
|
||||
new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "E2", Message = "Error 2" },
|
||||
};
|
||||
|
||||
var result = new SbomValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Format = SbomFormat.CycloneDxJson,
|
||||
ValidatorName = "test",
|
||||
ValidatorVersion = "1.0",
|
||||
Diagnostics = diagnostics.ToImmutableArray()
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.ErrorCount.Should().Be(2);
|
||||
result.WarningCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// <copyright file="SpdxValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SpdxValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SpdxValidatorTests
|
||||
{
|
||||
private readonly SpdxValidator _validator;
|
||||
|
||||
public SpdxValidatorTests()
|
||||
{
|
||||
var options = Options.Create(new SpdxValidatorOptions
|
||||
{
|
||||
JavaPath = "java",
|
||||
SpdxToolsJarPath = "spdx-tools.jar",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60)
|
||||
});
|
||||
|
||||
_validator = new SpdxValidator(
|
||||
options,
|
||||
NullLogger<SpdxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomFormat.Spdx23Json, true)]
|
||||
[InlineData(SbomFormat.Spdx23TagValue, true)]
|
||||
[InlineData(SbomFormat.Spdx3JsonLd, true)]
|
||||
[InlineData(SbomFormat.CycloneDxJson, false)]
|
||||
[InlineData(SbomFormat.CycloneDxXml, false)]
|
||||
[InlineData(SbomFormat.Unknown, false)]
|
||||
public void SupportsFormat_ReturnsCorrectly(SbomFormat format, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = _validator.SupportsFormat(format);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UnsupportedFormat_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sbomBytes = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6"
|
||||
}
|
||||
"""u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "UNSUPPORTED_FORMAT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_JavaNotAvailable_ReturnsUnavailable()
|
||||
{
|
||||
// Arrange - use a non-existent java path
|
||||
var options = Options.Create(new SpdxValidatorOptions
|
||||
{
|
||||
JavaPath = "/nonexistent/path/java-does-not-exist",
|
||||
SpdxToolsJarPath = "/nonexistent/spdx-tools.jar",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(5)
|
||||
});
|
||||
|
||||
var validator = new SpdxValidator(
|
||||
options,
|
||||
NullLogger<SpdxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var sbomBytes = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0"
|
||||
}
|
||||
"""u8.ToArray();
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.Spdx23Json,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d =>
|
||||
d.Code == "JAVA_NOT_AVAILABLE" ||
|
||||
d.Code == "VALIDATOR_UNAVAILABLE" ||
|
||||
d.Code == "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInfoAsync_JavaNotAvailable_ReturnsUnavailable()
|
||||
{
|
||||
// Arrange - use a non-existent java path
|
||||
var options = Options.Create(new SpdxValidatorOptions
|
||||
{
|
||||
JavaPath = "/nonexistent/path/java-does-not-exist",
|
||||
SpdxToolsJarPath = "/nonexistent/spdx-tools.jar"
|
||||
});
|
||||
|
||||
var validator = new SpdxValidator(
|
||||
options,
|
||||
NullLogger<SpdxValidator>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
// Act
|
||||
var info = await validator.GetInfoAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
info.IsAvailable.Should().BeFalse();
|
||||
info.Name.Should().Be("spdx-tools-java");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// <copyright file="ValidationGateOptionsTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ValidationGateOptions"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-007, VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ValidationGateOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_HaveCorrectDefaults()
|
||||
{
|
||||
// Act
|
||||
var options = new ValidationGateOptions();
|
||||
|
||||
// Assert
|
||||
options.Enabled.Should().BeTrue();
|
||||
options.Mode.Should().Be(SbomValidationMode.Strict);
|
||||
options.TimeoutSeconds.Should().Be(30);
|
||||
options.IncludeWarnings.Should().BeTrue();
|
||||
options.ValidateLicenses.Should().BeTrue();
|
||||
options.CustomRulesPath.Should().BeNull();
|
||||
options.RequiredSpdxProfiles.Should().BeEmpty();
|
||||
options.FailOnValidationError.Should().BeTrue();
|
||||
options.CacheResults.Should().BeTrue();
|
||||
options.CacheTtlSeconds.Should().Be(3600);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_ReturnsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { TimeoutSeconds = 60 };
|
||||
|
||||
// Act & Assert
|
||||
options.Timeout.Should().Be(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheTtl_ReturnsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { CacheTtlSeconds = 7200 };
|
||||
|
||||
// Act & Assert
|
||||
options.CacheTtl.Should().Be(TimeSpan.FromHours(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToValidationOptions_CreatesCorrectOptions()
|
||||
{
|
||||
// Arrange
|
||||
var gateOptions = new ValidationGateOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Lenient,
|
||||
TimeoutSeconds = 120,
|
||||
IncludeWarnings = false,
|
||||
ValidateLicenses = false,
|
||||
RequiredSpdxProfiles = new List<string> { "core", "software" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var validationOptions = gateOptions.ToValidationOptions();
|
||||
|
||||
// Assert
|
||||
validationOptions.Mode.Should().Be(SbomValidationMode.Lenient);
|
||||
validationOptions.Timeout.Should().Be(TimeSpan.FromSeconds(120));
|
||||
validationOptions.IncludeWarnings.Should().BeFalse();
|
||||
validationOptions.ValidateLicenses.Should().BeFalse();
|
||||
validationOptions.RequiredSpdxProfiles.Should().BeEquivalentTo(new[] { "core", "software" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataAnnotations_TimeoutTooLow_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { TimeoutSeconds = 0 };
|
||||
var context = new ValidationContext(options);
|
||||
var results = new List<ValidationResult>();
|
||||
|
||||
// Act
|
||||
var isValid = Validator.TryValidateObject(options, context, results, validateAllProperties: true);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
results.Should().Contain(r => r.MemberNames.Contains(nameof(ValidationGateOptions.TimeoutSeconds)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataAnnotations_TimeoutTooHigh_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { TimeoutSeconds = 700 };
|
||||
var context = new ValidationContext(options);
|
||||
var results = new List<ValidationResult>();
|
||||
|
||||
// Act
|
||||
var isValid = Validator.TryValidateObject(options, context, results, validateAllProperties: true);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
results.Should().Contain(r => r.MemberNames.Contains(nameof(ValidationGateOptions.TimeoutSeconds)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataAnnotations_CacheTtlTooLow_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { CacheTtlSeconds = 30 };
|
||||
var context = new ValidationContext(options);
|
||||
var results = new List<ValidationResult>();
|
||||
|
||||
// Act
|
||||
var isValid = Validator.TryValidateObject(options, context, results, validateAllProperties: true);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
results.Should().Contain(r => r.MemberNames.Contains(nameof(ValidationGateOptions.CacheTtlSeconds)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ModeOffWithFailOnError_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Off,
|
||||
FailOnValidationError = true
|
||||
};
|
||||
var context = new ValidationContext(options);
|
||||
|
||||
// Act
|
||||
var results = options.Validate(context).ToList();
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ErrorMessage.Should().Contain("FailOnValidationError should be false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ModeOffWithFailOnErrorFalse_ReturnsNoError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Off,
|
||||
FailOnValidationError = false
|
||||
};
|
||||
var context = new ValidationContext(options);
|
||||
|
||||
// Act
|
||||
var results = options.Validate(context).ToList();
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectionName_IsCorrect()
|
||||
{
|
||||
// Assert
|
||||
ValidationGateOptions.SectionName.Should().Be("ValidationGate");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(30)]
|
||||
[InlineData(600)]
|
||||
public void DataAnnotations_ValidTimeout_PassesValidation(int timeoutSeconds)
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidationGateOptions { TimeoutSeconds = timeoutSeconds };
|
||||
var context = new ValidationContext(options);
|
||||
var results = new List<ValidationResult>();
|
||||
|
||||
// Act
|
||||
var isValid = Validator.TryValidateObject(options, context, results, validateAllProperties: true);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// <copyright file="ValidatorInfoTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ValidatorInfo"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ValidatorInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidatorInfo_RequiredProperties()
|
||||
{
|
||||
// Act
|
||||
var info = new ValidatorInfo
|
||||
{
|
||||
Name = "sbom-utility",
|
||||
Version = "0.16.0",
|
||||
IsAvailable = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
info.Name.Should().Be("sbom-utility");
|
||||
info.Version.Should().Be("0.16.0");
|
||||
info.IsAvailable.Should().BeTrue();
|
||||
info.SupportedFormats.Should().BeEmpty();
|
||||
info.SupportedSchemaVersions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorInfo_WithSupportedFormats()
|
||||
{
|
||||
// Act
|
||||
var info = new ValidatorInfo
|
||||
{
|
||||
Name = "spdx-tools",
|
||||
Version = "1.1.8",
|
||||
IsAvailable = true,
|
||||
SupportedFormats = ImmutableArray.Create(
|
||||
SbomFormat.Spdx23Json,
|
||||
SbomFormat.Spdx23TagValue,
|
||||
SbomFormat.Spdx3JsonLd),
|
||||
SupportedSchemaVersions = ImmutableArray.Create("2.3", "3.0.1")
|
||||
};
|
||||
|
||||
// Assert
|
||||
info.SupportedFormats.Should().HaveCount(3);
|
||||
info.SupportedFormats.Should().Contain(SbomFormat.Spdx3JsonLd);
|
||||
info.SupportedSchemaVersions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorInfo_UnavailableValidator()
|
||||
{
|
||||
// Act
|
||||
var info = new ValidatorInfo
|
||||
{
|
||||
Name = "missing-tool",
|
||||
Version = "unknown",
|
||||
IsAvailable = false
|
||||
};
|
||||
|
||||
// Assert
|
||||
info.IsAvailable.Should().BeFalse();
|
||||
info.Version.Should().Be("unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// <copyright file="ValidatorBinaryManagerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Validation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ValidatorBinaryManager"/>.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-004
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ValidatorBinaryManagerTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly ValidatorBinaryOptions _options;
|
||||
private readonly ValidatorBinaryManager _manager;
|
||||
|
||||
public ValidatorBinaryManagerTests()
|
||||
{
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
_options = new ValidatorBinaryOptions
|
||||
{
|
||||
BinaryDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():N}"),
|
||||
OfflineMode = true // Default to offline mode for unit tests
|
||||
};
|
||||
|
||||
_manager = new ValidatorBinaryManager(
|
||||
Options.Create(_options),
|
||||
_httpClientFactoryMock.Object,
|
||||
NullLogger<ValidatorBinaryManager>.Instance,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
#region IsBinaryAvailable Tests
|
||||
|
||||
[Fact]
|
||||
public void IsBinaryAvailable_NullName_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _manager.IsBinaryAvailable(null!);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBinaryAvailable_EmptyName_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _manager.IsBinaryAvailable(string.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBinaryAvailable_UnknownValidator_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _manager.IsBinaryAvailable("unknown-validator");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBinaryAvailable_KnownValidatorNotInstalled_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _manager.IsBinaryAvailable("sbom-utility");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // Not installed in test directory
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetBinaryPath Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBinaryPath_SbomUtility_ReturnsExpectedPath()
|
||||
{
|
||||
// Act
|
||||
var path = _manager.GetBinaryPath("sbom-utility");
|
||||
|
||||
// Assert
|
||||
path.Should().NotBeNullOrEmpty();
|
||||
path.Should().Contain("sbom-utility");
|
||||
path.Should().Contain("0.17.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBinaryPath_SpdxTools_ReturnsJarPath()
|
||||
{
|
||||
// Act
|
||||
var path = _manager.GetBinaryPath("spdx-tools");
|
||||
|
||||
// Assert
|
||||
path.Should().NotBeNullOrEmpty();
|
||||
path.Should().Contain("spdx-tools");
|
||||
path.Should().EndWith(".jar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBinaryPath_UnknownValidator_ThrowsException()
|
||||
{
|
||||
// Act
|
||||
var act = () => _manager.GetBinaryPath("unknown-validator");
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ValidatorBinaryException>()
|
||||
.WithMessage("*unknown-validator*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAvailableValidators Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAvailableValidators_ReturnsDefaultSpecs()
|
||||
{
|
||||
// Act
|
||||
var validators = _manager.GetAvailableValidators();
|
||||
|
||||
// Assert
|
||||
validators.Should().ContainKey("sbom-utility");
|
||||
validators.Should().ContainKey("spdx-tools");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAvailableValidators_SbomUtilitySpec_HasCorrectVersion()
|
||||
{
|
||||
// Act
|
||||
var validators = _manager.GetAvailableValidators();
|
||||
|
||||
// Assert
|
||||
validators["sbom-utility"].Version.Should().Be("0.17.0");
|
||||
validators["sbom-utility"].ExecutableName.Should().Be("sbom-utility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAvailableValidators_SpdxToolsSpec_IsJar()
|
||||
{
|
||||
// Act
|
||||
var validators = _manager.GetAvailableValidators();
|
||||
|
||||
// Assert
|
||||
validators["spdx-tools"].IsJar.Should().BeTrue();
|
||||
validators["spdx-tools"].Version.Should().Be("1.1.9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAvailableValidators_WithCustomSpecs_MergesWithDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var customOptions = new ValidatorBinaryOptions
|
||||
{
|
||||
CustomSpecs = new Dictionary<string, ValidatorBinarySpec>
|
||||
{
|
||||
["custom-validator"] = new ValidatorBinarySpec
|
||||
{
|
||||
Name = "custom-validator",
|
||||
Version = "1.0.0",
|
||||
BaseUrl = "https://example.com",
|
||||
FileNameFormat = "custom-{0}.tar.gz",
|
||||
ExecutableName = "custom"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var manager = new ValidatorBinaryManager(
|
||||
Options.Create(customOptions),
|
||||
_httpClientFactoryMock.Object,
|
||||
NullLogger<ValidatorBinaryManager>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
// Act
|
||||
var validators = manager.GetAvailableValidators();
|
||||
|
||||
// Assert
|
||||
validators.Should().ContainKey("custom-validator");
|
||||
validators.Should().ContainKey("sbom-utility"); // Default still present
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAvailableValidators_WithCustomSpecs_OverridesDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var customOptions = new ValidatorBinaryOptions
|
||||
{
|
||||
CustomSpecs = new Dictionary<string, ValidatorBinarySpec>
|
||||
{
|
||||
["sbom-utility"] = new ValidatorBinarySpec
|
||||
{
|
||||
Name = "sbom-utility",
|
||||
Version = "0.18.0", // Override version
|
||||
BaseUrl = "https://custom.example.com",
|
||||
FileNameFormat = "sbom-utility-{0}.tar.gz",
|
||||
ExecutableName = "sbom-utility"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var manager = new ValidatorBinaryManager(
|
||||
Options.Create(customOptions),
|
||||
_httpClientFactoryMock.Object,
|
||||
NullLogger<ValidatorBinaryManager>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
// Act
|
||||
var validators = manager.GetAvailableValidators();
|
||||
|
||||
// Assert
|
||||
validators["sbom-utility"].Version.Should().Be("0.18.0");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureBinaryAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBinaryAsync_NullName_ThrowsArgumentException()
|
||||
{
|
||||
// Act
|
||||
var act = async () => await _manager.EnsureBinaryAsync(null!, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBinaryAsync_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Act
|
||||
var act = async () => await _manager.EnsureBinaryAsync(string.Empty, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBinaryAsync_OfflineModeNotInstalled_ThrowsException()
|
||||
{
|
||||
// Act
|
||||
var act = async () => await _manager.EnsureBinaryAsync(
|
||||
"sbom-utility",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ValidatorBinaryException>()
|
||||
.WithMessage("*offline mode*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBinaryAsync_AlreadyInstalled_ReturnsPath()
|
||||
{
|
||||
// Arrange
|
||||
var path = _manager.GetBinaryPath("sbom-utility");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, "mock executable", TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.EnsureBinaryAsync(
|
||||
"sbom-utility",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VerifyBinaryIntegrityAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBinaryIntegrityAsync_FileNotExists_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.VerifyBinaryIntegrityAsync(
|
||||
"sbom-utility",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidatorBinarySpec Tests
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinarySpec_DefaultExpectedHashes_IsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var spec = new ValidatorBinarySpec
|
||||
{
|
||||
Name = "test",
|
||||
Version = "1.0.0",
|
||||
BaseUrl = "https://example.com",
|
||||
FileNameFormat = "test-{0}.tar.gz",
|
||||
ExecutableName = "test"
|
||||
};
|
||||
|
||||
// Assert
|
||||
spec.ExpectedHashes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinarySpec_DefaultIsJar_IsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var spec = new ValidatorBinarySpec
|
||||
{
|
||||
Name = "test",
|
||||
Version = "1.0.0",
|
||||
BaseUrl = "https://example.com",
|
||||
FileNameFormat = "test-{0}.tar.gz",
|
||||
ExecutableName = "test"
|
||||
};
|
||||
|
||||
// Assert
|
||||
spec.IsJar.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidatorBinaryOptions Tests
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinaryOptions_DefaultDownloadTimeout_Is5Minutes()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidatorBinaryOptions();
|
||||
|
||||
// Assert
|
||||
options.DownloadTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinaryOptions_DefaultOfflineMode_IsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidatorBinaryOptions();
|
||||
|
||||
// Assert
|
||||
options.OfflineMode.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinaryOptions_DefaultBinaryDirectory_IsNull()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ValidatorBinaryOptions();
|
||||
|
||||
// Assert
|
||||
options.BinaryDirectory.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidatorBinaryException Tests
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinaryException_MessageOnly_SetsMessage()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ex = new ValidatorBinaryException("Test error");
|
||||
|
||||
// Assert
|
||||
ex.Message.Should().Be("Test error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorBinaryException_WithInnerException_SetsInner()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new InvalidOperationException("Inner error");
|
||||
|
||||
// Act
|
||||
var ex = new ValidatorBinaryException("Test error", inner);
|
||||
|
||||
// Assert
|
||||
ex.Message.Should().Be("Test error");
|
||||
ex.InnerException.Should().Be(inner);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260107_005_001_LB_cdx17_evidence_models
|
||||
// Task: EV-012 - Integration tests for CycloneDX 1.7 native evidence fields
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CycloneDX 1.7 native evidence field population.
|
||||
/// Verifies end-to-end SBOM generation produces spec-compliant evidence structures.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class EvidenceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private ScannerApplicationFactory _factory = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
configuration["scanner:emit:useNativeEvidence"] = "true";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IArtifactObjectStore>();
|
||||
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
await _factory.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_WithComponents_PopulatesNativeEvidenceFields()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "lodash",
|
||||
"version": "4.17.21",
|
||||
"purl": "pkg:npm/lodash@4.17.21",
|
||||
"evidence": {
|
||||
"identity": {
|
||||
"field": "purl",
|
||||
"confidence": 0.95,
|
||||
"methods": [
|
||||
{
|
||||
"technique": "manifest-analysis",
|
||||
"confidence": 0.95
|
||||
}
|
||||
]
|
||||
},
|
||||
"occurrences": [
|
||||
{
|
||||
"location": "/app/node_modules/lodash/package.json"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_WithLegacyProperties_PreservesEvidenceOnRoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
// Legacy format with stellaops:evidence[n] properties
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "express",
|
||||
"version": "4.18.2",
|
||||
"purl": "pkg:npm/express@4.18.2",
|
||||
"properties": [
|
||||
{
|
||||
"name": "stellaops:evidence[0]",
|
||||
"value": "manifest:package.json@/app/node_modules/express/package.json"
|
||||
},
|
||||
{
|
||||
"name": "stellaops:evidence[1]",
|
||||
"value": "binary:sha256:abc123@/app/node_modules/express/lib/router/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_WithCallstackEvidence_PreservesReachabilityData()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "vulnerable-lib",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/vulnerable-lib@1.0.0",
|
||||
"evidence": {
|
||||
"callstack": {
|
||||
"frames": [
|
||||
{
|
||||
"module": "app.js",
|
||||
"function": "handleRequest",
|
||||
"line": 42
|
||||
},
|
||||
{
|
||||
"module": "vulnerable-lib/index.js",
|
||||
"function": "vulnerableMethod",
|
||||
"line": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_WithCopyrightEvidence_DeduplicatesEntries()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "acme-lib",
|
||||
"version": "2.0.0",
|
||||
"purl": "pkg:npm/acme-lib@2.0.0",
|
||||
"evidence": {
|
||||
"copyright": [
|
||||
{
|
||||
"text": "Copyright 2024 ACME Corporation"
|
||||
},
|
||||
{
|
||||
"text": "Copyright 2024 ACME Corporation"
|
||||
},
|
||||
{
|
||||
"text": "Copyright 2023 ACME Inc."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_VerifySerializationRoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var originalEvidence = new
|
||||
{
|
||||
identity = new
|
||||
{
|
||||
field = "purl",
|
||||
confidence = 0.9,
|
||||
methods = new[]
|
||||
{
|
||||
new { technique = "binary-analysis", confidence = 0.9 }
|
||||
}
|
||||
},
|
||||
occurrences = new[]
|
||||
{
|
||||
new { location = "/lib/test.so", line = 100, offset = 0x1234 }
|
||||
},
|
||||
licenses = new[]
|
||||
{
|
||||
new { license = new { id = "Apache-2.0" } }
|
||||
}
|
||||
};
|
||||
|
||||
var sbomJson = $$"""
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "test-component",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:generic/test-component@1.0.0",
|
||||
"evidence": {{JsonSerializer.Serialize(originalEvidence)}}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - SBOM accepted means it was successfully parsed and stored
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotNull(payload!.SbomId);
|
||||
Assert.StartsWith("sha256:", payload.Digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomSubmit_WithMixedEvidenceTypes_ProcessesAllEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
// Component with multiple evidence types
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "multi-evidence-lib",
|
||||
"version": "3.0.0",
|
||||
"purl": "pkg:npm/multi-evidence-lib@3.0.0",
|
||||
"evidence": {
|
||||
"identity": {
|
||||
"field": "purl",
|
||||
"confidence": 0.85,
|
||||
"methods": [
|
||||
{ "technique": "manifest-analysis", "confidence": 0.85 },
|
||||
{ "technique": "source-code-analysis", "confidence": 0.75 }
|
||||
]
|
||||
},
|
||||
"occurrences": [
|
||||
{ "location": "/app/package.json" },
|
||||
{ "location": "/app/src/index.js", "line": 5 }
|
||||
],
|
||||
"licenses": [
|
||||
{ "license": { "id": "MIT" } },
|
||||
{ "license": { "name": "Custom License" } }
|
||||
],
|
||||
"copyright": [
|
||||
{ "text": "Copyright 2024 Multi Corp" }
|
||||
],
|
||||
"callstack": {
|
||||
"frames": [
|
||||
{ "module": "entry.js", "function": "main", "line": 1 },
|
||||
{ "module": "multi-evidence-lib", "function": "init", "line": 10 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
private async Task<string> CreateScanAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/evidence-test:1.0",
|
||||
Digest = "sha256:fedcba9876543210"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(payload);
|
||||
return payload!.ScanId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory artifact store for testing without external dependencies.
|
||||
/// </summary>
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects[key] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
if (!_objects.TryGetValue(key, out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
return Task.FromResult(_objects.ContainsKey(key));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PedigreeIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260107_005_002_BE_cdx17_pedigree_integration
|
||||
// Task: PD-013 - Integration tests for CycloneDX 1.7 Pedigree fields
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Emit.Pedigree;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CycloneDX 1.7 Pedigree field population via Feedser data.
|
||||
/// These tests verify end-to-end pedigree enrichment during SBOM generation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class PedigreeIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private ScannerApplicationFactory _factory = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
configuration["scanner:pedigree:enabled"] = "true";
|
||||
configuration["scanner:pedigree:includeDiffs"] = "true";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IArtifactObjectStore>();
|
||||
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
|
||||
|
||||
// Register mock pedigree provider
|
||||
services.RemoveAll<IPedigreeDataProvider>();
|
||||
services.AddSingleton<IPedigreeDataProvider>(new MockPedigreeDataProvider());
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
await _factory.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_WithPedigreeData_IncludesAncestors()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "openssl",
|
||||
"version": "1.1.1n-0+deb11u5",
|
||||
"purl": "pkg:deb/debian/openssl@1.1.1n-0%2Bdeb11u5?distro=debian-11"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_BackportedPackage_IncludesPatches()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
// Component that has known backported patches
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "curl",
|
||||
"version": "7.68.0-1ubuntu2.22",
|
||||
"purl": "pkg:deb/ubuntu/curl@7.68.0-1ubuntu2.22?distro=ubuntu-20.04"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_ComponentWithCommits_IncludesProvenance()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "log4j-core",
|
||||
"version": "2.17.1",
|
||||
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_ComponentWithVariants_IncludesDistroMappings()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "zlib",
|
||||
"version": "1.2.11.dfsg-2+deb11u2",
|
||||
"purl": "pkg:deb/debian/zlib1g@1.2.11.dfsg-2%2Bdeb11u2?distro=debian-11"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_MultipleComponentsWithPedigree_EnrichesAll()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "openssl",
|
||||
"version": "1.1.1n-0+deb11u5",
|
||||
"purl": "pkg:deb/debian/openssl@1.1.1n-0%2Bdeb11u5"
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"name": "curl",
|
||||
"version": "7.74.0-1.3+deb11u7",
|
||||
"purl": "pkg:deb/debian/curl@7.74.0-1.3%2Bdeb11u7"
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"name": "zlib",
|
||||
"version": "1.2.11.dfsg-2+deb11u2",
|
||||
"purl": "pkg:deb/debian/zlib1g@1.2.11.dfsg-2%2Bdeb11u2"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(3, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PedigreeMapper_MapsPatchesCorrectly()
|
||||
{
|
||||
// Arrange: Test pedigree mapper directly
|
||||
var mapper = new CycloneDxPedigreeMapper();
|
||||
|
||||
var pedigreeData = new PedigreeData
|
||||
{
|
||||
Ancestors = ImmutableArray.Create(new AncestorComponent
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1o",
|
||||
Purl = "pkg:generic/openssl@1.1.1o",
|
||||
Type = "library"
|
||||
}),
|
||||
Variants = ImmutableArray.Create(new VariantComponent
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1n-0+deb11u5",
|
||||
Purl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u5",
|
||||
Type = "library",
|
||||
Distribution = "debian-11"
|
||||
}),
|
||||
Commits = ImmutableArray.Create(new CommitInfo
|
||||
{
|
||||
Uid = "abc123def456",
|
||||
Url = "https://github.com/openssl/openssl/commit/abc123def456",
|
||||
Message = "Fix CVE-2024-1234 buffer overflow",
|
||||
Author = new CommitActor { Name = "maintainer", Email = "maintainer@openssl.org" }
|
||||
}),
|
||||
Patches = ImmutableArray.Create(new PatchInfo
|
||||
{
|
||||
Type = PatchType.Backport,
|
||||
DiffUrl = "https://salsa.debian.org/...",
|
||||
DiffText = "--- a/ssl/ssl_lib.c\n+++ b/ssl/ssl_lib.c\n@@ -100 @@\n-vulnerable\n+fixed",
|
||||
Resolves = ImmutableArray.Create(new PatchResolution
|
||||
{
|
||||
Id = "CVE-2024-1234",
|
||||
SourceName = "NVD"
|
||||
})
|
||||
}),
|
||||
Notes = "Backported security fix from upstream 1.1.1o (Tier 1: Confirmed by distro advisory)"
|
||||
};
|
||||
|
||||
// Act
|
||||
var cdxPedigree = mapper.Map(pedigreeData);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cdxPedigree);
|
||||
Assert.Single(cdxPedigree.Ancestors);
|
||||
Assert.Single(cdxPedigree.Variants);
|
||||
Assert.Single(cdxPedigree.Commits);
|
||||
Assert.Single(cdxPedigree.Patches);
|
||||
Assert.Equal("Backported security fix from upstream 1.1.1o (Tier 1: Confirmed by distro advisory)", cdxPedigree.Notes);
|
||||
|
||||
// Verify commit mapping
|
||||
var commit = cdxPedigree.Commits[0];
|
||||
Assert.Equal("abc123def456", commit.Uid);
|
||||
Assert.Equal("https://github.com/openssl/openssl/commit/abc123def456", commit.Url);
|
||||
|
||||
// Verify patch mapping
|
||||
var patch = cdxPedigree.Patches[0];
|
||||
Assert.Equal(CycloneDX.Models.Patch.PatchClassification.Backport, patch.Type);
|
||||
Assert.NotNull(patch.Resolves);
|
||||
Assert.Single(patch.Resolves);
|
||||
Assert.Equal("CVE-2024-1234", patch.Resolves[0].Id);
|
||||
}
|
||||
|
||||
private async Task<string> CreateScanAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/pedigree-test:1.0",
|
||||
Digest = "sha256:abcdef123456"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(payload);
|
||||
return payload!.ScanId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock pedigree provider that returns test data for known PURLs.
|
||||
/// </summary>
|
||||
private sealed class MockPedigreeDataProvider : IPedigreeDataProvider
|
||||
{
|
||||
public Task<PedigreeData?> GetPedigreeAsync(string purl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
{
|
||||
return Task.FromResult<PedigreeData?>(null);
|
||||
}
|
||||
|
||||
// Return mock pedigree data for Debian OpenSSL
|
||||
if (purl.Contains("openssl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<PedigreeData?>(new PedigreeData
|
||||
{
|
||||
Ancestors = ImmutableArray.Create(new AncestorComponent
|
||||
{
|
||||
Name = "openssl",
|
||||
Version = "1.1.1o",
|
||||
Purl = "pkg:generic/openssl@1.1.1o",
|
||||
Type = "library"
|
||||
}),
|
||||
Variants = ImmutableArray<VariantComponent>.Empty,
|
||||
Commits = ImmutableArray.Create(new CommitInfo
|
||||
{
|
||||
Uid = "c0d0e1f2a3b4",
|
||||
Url = "https://github.com/openssl/openssl/commit/c0d0e1f2a3b4",
|
||||
Message = "Fix buffer overflow in SSL_verify"
|
||||
}),
|
||||
Patches = ImmutableArray.Create(new PatchInfo
|
||||
{
|
||||
Type = PatchType.Backport,
|
||||
Resolves = ImmutableArray.Create(new PatchResolution
|
||||
{
|
||||
Id = "CVE-2024-0001",
|
||||
SourceName = "NVD"
|
||||
})
|
||||
}),
|
||||
Notes = "Tier 1: Confirmed by Debian Security Advisory DSA-5678"
|
||||
});
|
||||
}
|
||||
|
||||
// Return mock data for curl
|
||||
if (purl.Contains("curl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<PedigreeData?>(new PedigreeData
|
||||
{
|
||||
Ancestors = ImmutableArray.Create(new AncestorComponent
|
||||
{
|
||||
Name = "curl",
|
||||
Version = "7.88.1",
|
||||
Purl = "pkg:generic/curl@7.88.1"
|
||||
}),
|
||||
Patches = ImmutableArray.Create(new PatchInfo
|
||||
{
|
||||
Type = PatchType.Backport,
|
||||
DiffText = "--- a/lib/url.c\n+++ b/lib/url.c\n...",
|
||||
Resolves = ImmutableArray.Create(new PatchResolution
|
||||
{
|
||||
Id = "CVE-2024-0002",
|
||||
SourceName = "NVD"
|
||||
})
|
||||
}),
|
||||
Notes = "Tier 2: Changelog evidence"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<PedigreeData?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, PedigreeData>> GetPedigreesBatchAsync(
|
||||
IEnumerable<string> purls,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, PedigreeData>();
|
||||
|
||||
foreach (var purl in purls)
|
||||
{
|
||||
var data = GetPedigreeAsync(purl, cancellationToken).Result;
|
||||
if (data != null)
|
||||
{
|
||||
results[purl] = data;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, PedigreeData>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory artifact store for testing without external dependencies.
|
||||
/// </summary>
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects[key] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
if (!_objects.TryGetValue(key, out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
return Task.FromResult(_objects.ContainsKey(key));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValidationIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260107_005_003_BE_sbom_validator_gate
|
||||
// Task: VG-010 - Integration tests for SBOM validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.Validation;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for SBOM validation pipeline and endpoints.
|
||||
/// These tests verify end-to-end validation during SBOM generation and export.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ValidationIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private ScannerApplicationFactory _factory = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
configuration["scanner:validation:enabled"] = "true";
|
||||
configuration["scanner:validation:failOnError"] = "false";
|
||||
configuration["scanner:validation:mode"] = "Audit";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IArtifactObjectStore>();
|
||||
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
|
||||
|
||||
// Register mock validator for testing
|
||||
services.RemoveAll<ISbomValidator>();
|
||||
services.AddSingleton<ISbomValidator>(new MockSbomValidator());
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
await _factory.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_WithValidationEnabled_ValidatesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-09T12:00:00Z"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "test-package",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/test-package@1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - SBOM should be accepted (validation in audit mode doesn't block)
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.ComponentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SbomGeneration_InvalidDocument_ReturnsWarningsInAuditMode()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateScanAsync();
|
||||
|
||||
// Missing required fields (invalid CycloneDX)
|
||||
var invalidSbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"components": [
|
||||
{
|
||||
"name": "incomplete-package"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom");
|
||||
var content = new StringContent(invalidSbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json");
|
||||
content.Headers.ContentType?.Parameters.Add(
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - In audit mode, even invalid documents are accepted with warnings
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_CycloneDxDocument_ValidatesFormat()
|
||||
{
|
||||
// Arrange: Test validator directly
|
||||
var mockValidator = new MockSbomValidator();
|
||||
var validationOptions = new SbomValidationOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Audit
|
||||
};
|
||||
|
||||
var sbomBytes = Encoding.UTF8.GetBytes("""
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": []
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = await mockValidator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
validationOptions,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SbomFormat.CycloneDxJson, result.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_SpdxDocument_ValidatesFormat()
|
||||
{
|
||||
// Arrange
|
||||
var mockValidator = new MockSbomValidator();
|
||||
var validationOptions = new SbomValidationOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Audit
|
||||
};
|
||||
|
||||
var spdxBytes = Encoding.UTF8.GetBytes("""
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/terms/",
|
||||
"spdxId": "urn:test:sbom:001",
|
||||
"name": "Test SBOM"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = await mockValidator.ValidateAsync(
|
||||
spdxBytes,
|
||||
SbomFormat.Spdx3JsonLd,
|
||||
validationOptions,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SbomFormat.Spdx3JsonLd, result.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_SupportsFormat_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var mockValidator = new MockSbomValidator();
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mockValidator.SupportsFormat(SbomFormat.CycloneDxJson));
|
||||
Assert.True(mockValidator.SupportsFormat(SbomFormat.Spdx3JsonLd));
|
||||
Assert.True(mockValidator.SupportsFormat(SbomFormat.Unknown));
|
||||
|
||||
var info = await mockValidator.GetInfoAsync(CancellationToken.None);
|
||||
Assert.True(info.IsAvailable);
|
||||
Assert.Contains(SbomFormat.CycloneDxJson, info.SupportedFormats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_WithErrors_ReturnsInvalidResult()
|
||||
{
|
||||
// Arrange
|
||||
var mockValidator = new MockSbomValidator(returnErrors: true);
|
||||
var validationOptions = new SbomValidationOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Strict
|
||||
};
|
||||
|
||||
var sbomBytes = Encoding.UTF8.GetBytes("{}");
|
||||
|
||||
// Act
|
||||
var result = await mockValidator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
validationOptions,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.ErrorCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_WithNoErrors_ReturnsValidResult()
|
||||
{
|
||||
// Arrange
|
||||
var mockValidator = new MockSbomValidator(returnErrors: false);
|
||||
var validationOptions = new SbomValidationOptions
|
||||
{
|
||||
Mode = SbomValidationMode.Lenient
|
||||
};
|
||||
|
||||
var sbomBytes = Encoding.UTF8.GetBytes("{}");
|
||||
|
||||
// Act
|
||||
var result = await mockValidator.ValidateAsync(
|
||||
sbomBytes,
|
||||
SbomFormat.CycloneDxJson,
|
||||
validationOptions,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(0, result.ErrorCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDetection_CycloneDxJson_DetectsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var sbomJson = """{"bomFormat": "CycloneDX", "specVersion": "1.7"}""";
|
||||
var bytes = Encoding.UTF8.GetBytes(sbomJson);
|
||||
|
||||
// Act
|
||||
var format = SbomFormatDetector.Detect(bytes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SbomFormat.CycloneDxJson, format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDetection_SpdxJson_DetectsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var spdxJson = """{"@context": "https://spdx.org/rdf/3.0.1/terms/"}""";
|
||||
var bytes = Encoding.UTF8.GetBytes(spdxJson);
|
||||
|
||||
// Act
|
||||
var format = SbomFormatDetector.Detect(bytes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SbomFormat.Spdx3JsonLd, format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationOptions_DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new SbomValidationOptions();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SbomValidationMode.Strict, options.Mode);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.Timeout);
|
||||
}
|
||||
|
||||
private async Task<string> CreateScanAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/validation-test:1.0",
|
||||
Digest = "sha256:validation123"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(payload);
|
||||
return payload!.ScanId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock SBOM validator for testing validation pipeline behavior.
|
||||
/// </summary>
|
||||
private sealed class MockSbomValidator : ISbomValidator
|
||||
{
|
||||
private readonly bool _returnErrors;
|
||||
|
||||
public MockSbomValidator(bool returnErrors = false)
|
||||
{
|
||||
_returnErrors = returnErrors;
|
||||
}
|
||||
|
||||
public Task<SbomValidationResult> ValidateAsync(
|
||||
byte[] sbomBytes,
|
||||
SbomFormat format,
|
||||
SbomValidationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var diagnostics = _returnErrors
|
||||
? ImmutableArray.Create(new SbomValidationDiagnostic
|
||||
{
|
||||
Severity = SbomValidationSeverity.Error,
|
||||
Message = "Mock validation error",
|
||||
Code = "MOCK-001",
|
||||
Path = "$.root"
|
||||
})
|
||||
: ImmutableArray<SbomValidationDiagnostic>.Empty;
|
||||
|
||||
return Task.FromResult(new SbomValidationResult
|
||||
{
|
||||
IsValid = !_returnErrors,
|
||||
Format = format,
|
||||
ValidatorName = "MockValidator",
|
||||
ValidatorVersion = "1.0.0",
|
||||
Diagnostics = diagnostics,
|
||||
ValidationDuration = TimeSpan.FromMilliseconds(10)
|
||||
});
|
||||
}
|
||||
|
||||
public bool SupportsFormat(SbomFormat format) => true;
|
||||
|
||||
public Task<ValidatorInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ValidatorInfo
|
||||
{
|
||||
Name = "MockValidator",
|
||||
Version = "1.0.0",
|
||||
IsAvailable = true,
|
||||
SupportedFormats = ImmutableArray.Create(
|
||||
SbomFormat.CycloneDxJson,
|
||||
SbomFormat.CycloneDxXml,
|
||||
SbomFormat.Spdx3JsonLd,
|
||||
SbomFormat.Spdx23Json)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory artifact store for testing without external dependencies.
|
||||
/// </summary>
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects[key] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
if (!_objects.TryGetValue(key, out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
return Task.FromResult(_objects.ContainsKey(key));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format detector utility for content-based format detection.
|
||||
/// </summary>
|
||||
file static class SbomFormatDetector
|
||||
{
|
||||
public static SbomFormat Detect(byte[] bytes)
|
||||
{
|
||||
var content = Encoding.UTF8.GetString(bytes);
|
||||
|
||||
if (content.Contains("\"bomFormat\"", StringComparison.OrdinalIgnoreCase) &&
|
||||
content.Contains("\"CycloneDX\"", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// All CycloneDX JSON versions map to CycloneDxJson
|
||||
return SbomFormat.CycloneDxJson;
|
||||
}
|
||||
|
||||
if (content.Contains("spdx.org/rdf/3.0", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return SbomFormat.Spdx3JsonLd;
|
||||
}
|
||||
|
||||
if (content.Contains("\"spdxVersion\"", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return SbomFormat.Spdx23Json;
|
||||
}
|
||||
|
||||
return SbomFormat.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,42 @@ internal sealed class InMemoryLayerSbomService : ILayerSbomService
|
||||
// Not implemented for tests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetComposedSbomAsync(
|
||||
ScanId scanId,
|
||||
string format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return the first matching layer SBOM for testing purposes
|
||||
var key = _layerSboms.Keys.FirstOrDefault(k => k.ScanId == scanId.Value && k.Format == format);
|
||||
if (key != default && _layerSboms.TryGetValue(key, out var sbom))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(sbom);
|
||||
}
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomLayerFragment>?> GetLayerFragmentsAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_scans.TryGetValue(scanId.Value, out var scanData))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SbomLayerFragment>?>(null);
|
||||
}
|
||||
|
||||
var fragments = scanData.Layers
|
||||
.OrderBy(l => l.Order)
|
||||
.Select(l => new SbomLayerFragment
|
||||
{
|
||||
LayerDigest = l.LayerDigest,
|
||||
Order = l.Order,
|
||||
ComponentPurls = new List<string> { $"pkg:test/layer{l.Order}@1.0.0" }
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SbomLayerFragment>?>(fragments);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ScannerApplicationFixture : IDisposable
|
||||
{
|
||||
private ScannerApplicationFactory? _authenticatedFactory;
|
||||
|
||||
public ScannerApplicationFactory Factory { get; } = new();
|
||||
|
||||
public void Dispose() => Factory.Dispose();
|
||||
/// <summary>
|
||||
/// Creates an HTTP client with test authentication enabled.
|
||||
/// </summary>
|
||||
public HttpClient CreateAuthenticatedClient()
|
||||
{
|
||||
_authenticatedFactory ??= Factory.WithOverrides(useTestAuthentication: true);
|
||||
var client = _authenticatedFactory.CreateClient();
|
||||
// Add a valid test bearer token (must have at least 3 dot-separated segments per TestAuthenticationHandler)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test.valid.token");
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_authenticatedFactory?.Dispose();
|
||||
Factory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user