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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
193
src/__Libraries/StellaOps.TestKit/Connectors/FixtureUpdater.cs
Normal file
193
src/__Libraries/StellaOps.TestKit/Connectors/FixtureUpdater.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user