save progress
This commit is contained in:
@@ -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