Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -0,0 +1,194 @@
using System.Net;
using System.Text;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Provides HTTP canning/mocking capabilities for connector tests.
/// Use this for fixture-based testing of external data source connectors.
/// </summary>
public sealed class ConnectorHttpFixture : IDisposable
{
private readonly Dictionary<string, HttpResponseEntry> _responses = new();
private readonly List<HttpRequestMessage> _capturedRequests = new();
private bool _disposed;
/// <summary>
/// Gets the list of all captured requests for verification.
/// </summary>
public IReadOnlyList<HttpRequestMessage> CapturedRequests => _capturedRequests;
/// <summary>
/// Creates the HttpClient configured with canned responses.
/// </summary>
public HttpClient CreateClient()
{
return new HttpClient(new CannedMessageHandler(this));
}
/// <summary>
/// Creates the HttpMessageHandler for DI scenarios.
/// </summary>
public HttpMessageHandler CreateHandler()
{
return new CannedMessageHandler(this);
}
/// <summary>
/// Adds a JSON response for a URL pattern.
/// </summary>
public void AddJsonResponse(string urlPattern, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
{
_responses[urlPattern] = new HttpResponseEntry(
statusCode,
"application/json",
Encoding.UTF8.GetBytes(json));
}
/// <summary>
/// Adds a JSON response from a fixture file.
/// </summary>
public void AddJsonResponseFromFile(string urlPattern, string fixturePath, HttpStatusCode statusCode = HttpStatusCode.OK)
{
var json = File.ReadAllText(fixturePath);
AddJsonResponse(urlPattern, json, statusCode);
}
/// <summary>
/// Adds a raw bytes response for a URL pattern.
/// </summary>
public void AddBinaryResponse(string urlPattern, byte[] content, string contentType, HttpStatusCode statusCode = HttpStatusCode.OK)
{
_responses[urlPattern] = new HttpResponseEntry(statusCode, contentType, content);
}
/// <summary>
/// Adds a gzipped JSON response for testing decompression.
/// </summary>
public void AddGzipJsonResponse(string urlPattern, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
{
using var output = new MemoryStream();
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionMode.Compress))
{
var bytes = Encoding.UTF8.GetBytes(json);
gzip.Write(bytes, 0, bytes.Length);
}
_responses[urlPattern] = new HttpResponseEntry(statusCode, "application/json", output.ToArray(), "gzip");
}
/// <summary>
/// Adds an error response for a URL pattern.
/// </summary>
public void AddErrorResponse(string urlPattern, HttpStatusCode statusCode, string? errorBody = null)
{
_responses[urlPattern] = new HttpResponseEntry(
statusCode,
"application/json",
errorBody != null ? Encoding.UTF8.GetBytes(errorBody) : Array.Empty<byte>());
}
/// <summary>
/// Adds a timeout/exception for a URL pattern.
/// </summary>
public void AddTimeout(string urlPattern)
{
_responses[urlPattern] = new HttpResponseEntry(IsTimeout: true);
}
/// <summary>
/// Clears all canned responses and captured requests.
/// </summary>
public void Reset()
{
_responses.Clear();
_capturedRequests.Clear();
}
internal HttpResponseMessage? GetResponse(HttpRequestMessage request)
{
_capturedRequests.Add(request);
var url = request.RequestUri?.ToString() ?? "";
foreach (var (pattern, entry) in _responses)
{
if (MatchesPattern(url, pattern))
{
if (entry.IsTimeout)
{
throw new TaskCanceledException("Request timed out (simulated)");
}
var response = new HttpResponseMessage(entry.StatusCode)
{
Content = new ByteArrayContent(entry.Content)
};
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(entry.ContentType);
if (entry.ContentEncoding != null)
{
response.Content.Headers.ContentEncoding.Add(entry.ContentEncoding);
}
return response;
}
}
// Return 404 for unmatched URLs
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No canned response for: {url}")
};
}
private static bool MatchesPattern(string url, string pattern)
{
// Exact match
if (url == pattern) return true;
// Wildcard support: pattern ends with *
if (pattern.EndsWith('*') && url.StartsWith(pattern[..^1])) return true;
// Contains support: pattern is surrounded by *
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
{
var inner = pattern[1..^1];
return url.Contains(inner);
}
return false;
}
public void Dispose()
{
if (_disposed) return;
_responses.Clear();
_capturedRequests.Clear();
_disposed = true;
}
private sealed record HttpResponseEntry(
HttpStatusCode StatusCode = HttpStatusCode.OK,
string ContentType = "application/json",
byte[]? Content = null,
string? ContentEncoding = null,
bool IsTimeout = false)
{
public byte[] Content { get; } = Content ?? Array.Empty<byte>();
}
private sealed class CannedMessageHandler : HttpMessageHandler
{
private readonly ConnectorHttpFixture _fixture;
public CannedMessageHandler(ConnectorHttpFixture fixture)
{
_fixture = fixture;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = _fixture.GetResponse(request);
return Task.FromResult(response ?? new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
}

View File

@@ -0,0 +1,265 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using Xunit;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Base class for connector resilience tests.
/// Tests handling of partial/bad input and deterministic failure classification.
/// </summary>
public abstract class ConnectorResilienceTestBase : IDisposable
{
protected readonly ConnectorHttpFixture HttpFixture;
private bool _disposed;
protected ConnectorResilienceTestBase()
{
HttpFixture = new ConnectorHttpFixture();
}
/// <summary>
/// Gets the base directory for test fixtures.
/// </summary>
protected abstract string FixturesDirectory { get; }
/// <summary>
/// Attempts to parse JSON and returns whether it succeeded.
/// </summary>
protected abstract (bool Success, string? ErrorCategory) TryParse(string json);
/// <summary>
/// Attempts to fetch from URL and returns whether it succeeded.
/// </summary>
protected abstract Task<(bool Success, string? ErrorCategory)> TryFetchAsync(string url, CancellationToken ct = default);
/// <summary>
/// Reads a fixture file.
/// </summary>
protected string ReadFixture(string fileName)
{
var path = Path.Combine(FixturesDirectory, fileName);
return File.ReadAllText(path);
}
[Fact]
public void MissingRequiredFields_ProducesDeterministicErrorCategory()
{
// This test should be overridden per connector to test specific required fields
var invalidJson = "{}";
var results = new List<string?>();
for (int i = 0; i < 3; i++)
{
var (success, errorCategory) = TryParse(invalidJson);
results.Add(errorCategory);
}
results.Distinct().Should().HaveCount(1,
"error category should be deterministic for same input");
}
[Fact]
public void MalformedJson_ProducesDeterministicErrorCategory()
{
var malformedJson = "{ invalid json }";
var results = new List<string?>();
for (int i = 0; i < 3; i++)
{
var (success, errorCategory) = TryParse(malformedJson);
success.Should().BeFalse("malformed JSON should fail to parse");
results.Add(errorCategory);
}
results.Distinct().Should().HaveCount(1,
"error category should be deterministic for malformed JSON");
}
[Fact]
public void EmptyInput_ProducesDeterministicErrorCategory()
{
var emptyJson = "";
var results = new List<string?>();
for (int i = 0; i < 3; i++)
{
var (success, errorCategory) = TryParse(emptyJson);
results.Add(errorCategory);
}
results.Distinct().Should().HaveCount(1,
"error category should be deterministic for empty input");
}
[Fact]
public void NullInput_ProducesDeterministicErrorCategory()
{
var (success, errorCategory) = TryParse(null!);
success.Should().BeFalse("null input should fail to parse");
errorCategory.Should().NotBeNullOrEmpty("should have error category");
}
[Fact]
public async Task HttpError_ProducesDeterministicErrorCategory()
{
HttpFixture.AddErrorResponse("https://test.example.com/*", System.Net.HttpStatusCode.InternalServerError);
var results = new List<string?>();
for (int i = 0; i < 3; i++)
{
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
success.Should().BeFalse("HTTP 500 should fail");
results.Add(errorCategory);
}
results.Distinct().Should().HaveCount(1,
"error category should be deterministic for HTTP errors");
}
[Fact]
public async Task HttpNotFound_ProducesDeterministicErrorCategory()
{
HttpFixture.AddErrorResponse("https://test.example.com/*", System.Net.HttpStatusCode.NotFound);
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
success.Should().BeFalse("HTTP 404 should fail");
errorCategory.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Timeout_ProducesDeterministicErrorCategory()
{
HttpFixture.AddTimeout("https://test.example.com/*");
var results = new List<string?>();
for (int i = 0; i < 3; i++)
{
try
{
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
success.Should().BeFalse("timeout should fail");
results.Add(errorCategory);
}
catch (TaskCanceledException)
{
results.Add("timeout");
}
}
results.Distinct().Should().HaveCount(1,
"error category should be deterministic for timeouts");
}
public void Dispose()
{
if (_disposed) return;
HttpFixture.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for connector security tests.
/// Tests URL allowlist, redirect handling, max payload size, decompression bombs.
/// </summary>
public abstract class ConnectorSecurityTestBase : IDisposable
{
protected readonly ConnectorHttpFixture HttpFixture;
private bool _disposed;
protected ConnectorSecurityTestBase()
{
HttpFixture = new ConnectorHttpFixture();
}
/// <summary>
/// Attempts to fetch from URL and returns whether it was allowed.
/// </summary>
protected abstract Task<bool> IsUrlAllowedAsync(string url, CancellationToken ct = default);
/// <summary>
/// Gets the maximum allowed payload size in bytes.
/// </summary>
protected abstract long MaxPayloadSizeBytes { get; }
/// <summary>
/// Gets the list of allowed URL patterns/domains.
/// </summary>
protected abstract IReadOnlyList<string> AllowedUrlPatterns { get; }
[Fact]
public async Task AllowlistedUrl_IsAccepted()
{
foreach (var pattern in AllowedUrlPatterns)
{
var url = pattern.Replace("*", "test");
HttpFixture.AddJsonResponse(url, "{}");
var allowed = await IsUrlAllowedAsync(url);
allowed.Should().BeTrue($"URL '{url}' should be allowed");
}
}
[Fact]
public async Task NonAllowlistedUrl_IsRejected()
{
var disallowedUrls = new[]
{
"https://evil.example.com/api",
"http://malicious.test/data",
"file:///etc/passwd",
"data:text/html,<script>alert(1)</script>"
};
foreach (var url in disallowedUrls)
{
HttpFixture.AddJsonResponse(url, "{}");
var allowed = await IsUrlAllowedAsync(url);
allowed.Should().BeFalse($"URL '{url}' should be rejected");
}
}
[Fact]
public async Task OversizedPayload_IsRejected()
{
// Create payload larger than max
var largePayload = new string('x', (int)MaxPayloadSizeBytes + 1000);
HttpFixture.AddJsonResponse("https://test.example.com/*", $"{{\"data\":\"{largePayload}\"}}");
Func<Task> act = async () => await IsUrlAllowedAsync("https://test.example.com/api");
// Should either return false or throw
// Implementation-specific behavior
}
[Fact]
public async Task DecompressionBomb_IsRejected()
{
// Create a small gzipped payload that expands to large size
// This is a simplified test - real decompression bombs are more sophisticated
var smallCompressed = "{}"; // In reality, this would be crafted maliciously
HttpFixture.AddGzipJsonResponse("https://test.example.com/*", smallCompressed);
// The connector should detect and reject decompression bombs
// Implementation varies by connector
}
[Fact]
public async Task HttpsRedirectToHttp_IsRejected()
{
// Test that HTTPS -> HTTP downgrades are rejected
// This requires redirect handling implementation
}
public void Dispose()
{
if (_disposed) return;
HttpFixture.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,205 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using Xunit;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Base class for connector parser tests.
/// Inherit from this class to implement fixture-based parser testing.
/// </summary>
/// <typeparam name="TRawModel">The raw upstream model type.</typeparam>
/// <typeparam name="TNormalizedModel">The normalized internal model type.</typeparam>
public abstract class ConnectorParserTestBase<TRawModel, TNormalizedModel> : IDisposable
where TRawModel : class
where TNormalizedModel : class
{
protected readonly ConnectorHttpFixture HttpFixture;
private bool _disposed;
protected ConnectorParserTestBase()
{
HttpFixture = new ConnectorHttpFixture();
}
/// <summary>
/// Gets the base directory for test fixtures.
/// </summary>
protected abstract string FixturesDirectory { get; }
/// <summary>
/// Gets the directory for expected snapshots.
/// </summary>
protected virtual string ExpectedDirectory => Path.Combine(FixturesDirectory, "..", "Expected");
/// <summary>
/// Deserializes raw upstream JSON to the raw model.
/// </summary>
protected abstract TRawModel DeserializeRaw(string json);
/// <summary>
/// Parses the raw model into the normalized model.
/// </summary>
protected abstract TNormalizedModel Parse(TRawModel raw);
/// <summary>
/// Deserializes the normalized model from JSON snapshot.
/// </summary>
protected abstract TNormalizedModel DeserializeNormalized(string json);
/// <summary>
/// Serializes the normalized model to canonical JSON for comparison.
/// </summary>
protected virtual string SerializeToCanonical(TNormalizedModel model)
{
return CanonJson.Serialize(model);
}
/// <summary>
/// Reads a fixture file from the fixtures directory.
/// </summary>
protected string ReadFixture(string fileName)
{
var path = Path.Combine(FixturesDirectory, fileName);
return File.ReadAllText(path);
}
/// <summary>
/// Reads an expected snapshot file.
/// </summary>
protected string ReadExpected(string fileName)
{
var path = Path.Combine(ExpectedDirectory, fileName);
return File.ReadAllText(path);
}
/// <summary>
/// Verifies that a fixture parses to the expected canonical output.
/// </summary>
protected void VerifyParseSnapshot(string fixtureFile, string expectedFile)
{
// Arrange
var rawJson = ReadFixture(fixtureFile);
var expectedJson = ReadExpected(expectedFile);
var raw = DeserializeRaw(rawJson);
// Act
var normalized = Parse(raw);
var actualJson = SerializeToCanonical(normalized);
// Assert
actualJson.Should().Be(expectedJson,
$"fixture '{fixtureFile}' should parse to expected '{expectedFile}'");
}
/// <summary>
/// Verifies that parsing produces deterministic output.
/// </summary>
protected void VerifyDeterministicParse(string fixtureFile)
{
// Arrange
var rawJson = ReadFixture(fixtureFile);
// Act
var results = new List<string>();
for (int i = 0; i < 3; i++)
{
var raw = DeserializeRaw(rawJson);
var normalized = Parse(raw);
results.Add(SerializeToCanonical(normalized));
}
// Assert
results.Distinct().Should().HaveCount(1,
$"parsing '{fixtureFile}' multiple times should produce identical output");
}
/// <summary>
/// Updates or creates an expected snapshot file.
/// Use with STELLAOPS_UPDATE_FIXTURES=true environment variable.
/// </summary>
protected void UpdateSnapshot(string fixtureFile, string expectedFile)
{
if (Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") != "true")
{
throw new InvalidOperationException(
"Set STELLAOPS_UPDATE_FIXTURES=true to update snapshots");
}
var rawJson = ReadFixture(fixtureFile);
var raw = DeserializeRaw(rawJson);
var normalized = Parse(raw);
var canonicalJson = SerializeToCanonical(normalized);
var expectedPath = Path.Combine(ExpectedDirectory, expectedFile);
Directory.CreateDirectory(Path.GetDirectoryName(expectedPath)!);
File.WriteAllText(expectedPath, canonicalJson);
}
public void Dispose()
{
if (_disposed) return;
HttpFixture.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for connector fetch + parse integration tests.
/// </summary>
/// <typeparam name="TConnector">The connector type.</typeparam>
/// <typeparam name="TNormalizedModel">The normalized output type.</typeparam>
public abstract class ConnectorFetchTestBase<TConnector, TNormalizedModel> : IDisposable
where TConnector : class
where TNormalizedModel : class
{
protected readonly ConnectorHttpFixture HttpFixture;
private bool _disposed;
protected ConnectorFetchTestBase()
{
HttpFixture = new ConnectorHttpFixture();
}
/// <summary>
/// Gets the base directory for test fixtures.
/// </summary>
protected abstract string FixturesDirectory { get; }
/// <summary>
/// Creates the connector instance configured with the HTTP fixture.
/// </summary>
protected abstract TConnector CreateConnector();
/// <summary>
/// Executes the connector fetch operation.
/// </summary>
protected abstract Task<IReadOnlyList<TNormalizedModel>> FetchAsync(TConnector connector, CancellationToken ct = default);
/// <summary>
/// Reads a fixture file.
/// </summary>
protected string ReadFixture(string fileName)
{
var path = Path.Combine(FixturesDirectory, fileName);
return File.ReadAllText(path);
}
/// <summary>
/// Sets up a canned response from a fixture file.
/// </summary>
protected void SetupFixtureResponse(string urlPattern, string fixtureFile)
{
var json = ReadFixture(fixtureFile);
HttpFixture.AddJsonResponse(urlPattern, json);
}
public void Dispose()
{
if (_disposed) return;
HttpFixture.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,193 @@
using System.Text.Json;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Utility for updating test fixtures from live sources.
/// Enabled via STELLAOPS_UPDATE_FIXTURES=true environment variable.
/// </summary>
public sealed class FixtureUpdater
{
private readonly HttpClient _httpClient;
private readonly string _fixturesDirectory;
private readonly bool _enabled;
public FixtureUpdater(string fixturesDirectory, HttpClient? httpClient = null)
{
_fixturesDirectory = fixturesDirectory;
_httpClient = httpClient ?? new HttpClient();
_enabled = Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
}
/// <summary>
/// Returns true if fixture updating is enabled.
/// </summary>
public bool IsEnabled => _enabled;
/// <summary>
/// Fetches and saves a fixture from a live URL.
/// Only runs when STELLAOPS_UPDATE_FIXTURES=true.
/// </summary>
public async Task UpdateFixtureFromUrlAsync(
string url,
string fixtureName,
CancellationToken ct = default)
{
if (!_enabled)
{
return;
}
var response = await _httpClient.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(ct);
await SaveFixtureAsync(fixtureName, content, ct);
}
/// <summary>
/// Fetches JSON and saves as pretty-printed fixture.
/// </summary>
public async Task UpdateJsonFixtureFromUrlAsync(
string url,
string fixtureName,
CancellationToken ct = default)
{
if (!_enabled)
{
return;
}
var response = await _httpClient.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
// Pretty-print for readability
var doc = JsonDocument.Parse(json);
var prettyJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
await SaveFixtureAsync(fixtureName, prettyJson, ct);
}
/// <summary>
/// Saves content to a fixture file.
/// </summary>
public async Task SaveFixtureAsync(
string fixtureName,
string content,
CancellationToken ct = default)
{
if (!_enabled)
{
return;
}
var path = Path.Combine(_fixturesDirectory, fixtureName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, content, ct);
}
/// <summary>
/// Saves a canonical JSON snapshot.
/// </summary>
public async Task SaveExpectedSnapshotAsync<T>(
T model,
string snapshotName,
string? expectedDirectory = null,
CancellationToken ct = default)
{
if (!_enabled)
{
return;
}
var canonical = StellaOps.Canonical.Json.CanonJson.Serialize(model);
var directory = expectedDirectory ?? Path.Combine(_fixturesDirectory, "..", "Expected");
var path = Path.Combine(directory, snapshotName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, canonical, ct);
}
/// <summary>
/// Compares current live data with existing fixture and reports drift.
/// </summary>
public async Task<FixtureDriftReport> CheckDriftAsync(
string url,
string fixtureName,
CancellationToken ct = default)
{
var fixturePath = Path.Combine(_fixturesDirectory, fixtureName);
if (!File.Exists(fixturePath))
{
return new FixtureDriftReport(fixtureName, true, "Fixture file does not exist");
}
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
return new FixtureDriftReport(fixtureName, false, $"Failed to fetch: {response.StatusCode}");
}
var liveContent = await response.Content.ReadAsStringAsync(ct);
var fixtureContent = await File.ReadAllTextAsync(fixturePath, ct);
// Try to normalize JSON for comparison
try
{
var liveDoc = JsonDocument.Parse(liveContent);
var fixtureDoc = JsonDocument.Parse(fixtureContent);
var liveNormalized = JsonSerializer.Serialize(liveDoc);
var fixtureNormalized = JsonSerializer.Serialize(fixtureDoc);
if (liveNormalized != fixtureNormalized)
{
return new FixtureDriftReport(fixtureName, true, "JSON content differs", liveContent);
}
return new FixtureDriftReport(fixtureName, false, "No drift detected");
}
catch (JsonException)
{
// Non-JSON content, compare raw
if (liveContent != fixtureContent)
{
return new FixtureDriftReport(fixtureName, true, "Content differs", liveContent);
}
return new FixtureDriftReport(fixtureName, false, "No drift detected");
}
}
}
/// <summary>
/// Report of schema/content drift between live source and fixture.
/// </summary>
public sealed record FixtureDriftReport(
string FixtureName,
bool HasDrift,
string Message,
string? LiveContent = null);
/// <summary>
/// Configuration for fixture update operations.
/// </summary>
public sealed class FixtureUpdateConfig
{
/// <summary>
/// Mapping of fixture names to live URLs.
/// </summary>
public Dictionary<string, string> FixtureUrls { get; init; } = new();
/// <summary>
/// Headers to include in live requests.
/// </summary>
public Dictionary<string, string> RequestHeaders { get; init; } = new();
/// <summary>
/// Timeout for live requests.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}