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 5590a99a1a
381 changed files with 21071 additions and 14678 deletions

View File

@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Canonical.Json;
@@ -18,6 +19,33 @@ namespace StellaOps.Canonical.Json;
/// </remarks>
public static class CanonJson
{
/// <summary>
/// Serializes an object to a canonical JSON string.
/// Object keys are recursively sorted using Ordinal comparison.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to serialize.</param>
/// <returns>Canonical JSON string.</returns>
public static string Serialize<T>(T obj)
{
var bytes = Canonicalize(obj);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Serializes an object to a canonical JSON string using custom serializer options.
/// Object keys are recursively sorted using Ordinal comparison.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to serialize.</param>
/// <param name="options">JSON serializer options to use for initial serialization.</param>
/// <returns>Canonical JSON string.</returns>
public static string Serialize<T>(T obj, JsonSerializerOptions options)
{
var bytes = Canonicalize(obj, options);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Canonicalizes an object to a deterministic byte array.
/// Object keys are recursively sorted using Ordinal comparison.

View File

@@ -53,7 +53,8 @@ public interface ICryptoProvider
/// <param name="algorithmId">Signing algorithm identifier (e.g., RS256, ES256).</param>
/// <param name="publicKeyBytes">Public key in SubjectPublicKeyInfo format (DER-encoded).</param>
/// <returns>Ephemeral signer instance (supports VerifyAsync only).</returns>
ICryptoSigner CreateEphemeralVerifier(string algorithmId, ReadOnlySpan<byte> publicKeyBytes);
ICryptoSigner CreateEphemeralVerifier(string algorithmId, ReadOnlySpan<byte> publicKeyBytes)
=> throw new NotSupportedException($"Provider '{Name}' does not support ephemeral verification.");
/// <summary>
/// Adds or replaces signing key material managed by this provider.

View File

@@ -67,7 +67,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHash(data, algorithmId));
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
@@ -99,7 +99,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
private static byte[] ComputeSha256(ReadOnlySpan<byte> data)
@@ -190,7 +190,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
}
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> Convert.ToHexString(ComputeHashForPurpose(data, purpose)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHashForPurpose(data, purpose));
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> Convert.ToBase64String(ComputeHashForPurpose(data, purpose));
@@ -207,7 +207,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashForPurposeAsync(stream, purpose, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
public string GetAlgorithmForPurpose(string purpose)

View File

@@ -61,7 +61,7 @@ public sealed class DefaultCryptoHmac : ICryptoHmac
}
public string ComputeHmacHexForPurpose(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, string purpose)
=> Convert.ToHexString(ComputeHmacForPurpose(key, data, purpose)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHmacForPurpose(key, data, purpose));
public string ComputeHmacBase64ForPurpose(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, string purpose)
=> Convert.ToBase64String(ComputeHmacForPurpose(key, data, purpose));
@@ -78,7 +78,7 @@ public sealed class DefaultCryptoHmac : ICryptoHmac
public async ValueTask<string> ComputeHmacHexForPurposeAsync(ReadOnlyMemory<byte> key, Stream stream, string purpose, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHmacForPurposeAsync(key, stream, purpose, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
#endregion

View File

@@ -0,0 +1,93 @@
namespace StellaOps.Cryptography.Digests;
/// <summary>
/// Shared helpers for working with SHA-256 digests in the canonical <c>sha256:&lt;hex&gt;</c> form.
/// </summary>
public static class Sha256Digest
{
public const string Prefix = "sha256:";
public const int HexLength = 64;
/// <summary>
/// Normalizes an input digest to the canonical <c>sha256:&lt;lower-hex&gt;</c> form.
/// </summary>
/// <param name="digest">Digest in either <c>sha256:&lt;hex&gt;</c> or bare-hex form.</param>
/// <param name="requirePrefix">If true, requires the <c>sha256:</c> prefix to be present.</param>
/// <param name="parameterName">Optional parameter name used in exception messages.</param>
/// <exception cref="ArgumentException">Thrown when the input is null/empty/whitespace.</exception>
/// <exception cref="FormatException">Thrown when the input is not a valid SHA-256 hex digest.</exception>
public static string Normalize(string digest, bool requirePrefix = false, string? parameterName = null)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest is required.", parameterName ?? nameof(digest));
}
var trimmed = digest.Trim();
string hex;
if (trimmed.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
hex = trimmed[Prefix.Length..];
}
else if (requirePrefix)
{
var name = string.IsNullOrWhiteSpace(parameterName) ? "Digest" : parameterName;
throw new FormatException($"{name} must start with '{Prefix}'.");
}
else if (trimmed.Contains(':', StringComparison.Ordinal))
{
throw new FormatException($"Unsupported digest algorithm in '{digest}'. Only sha256 is supported.");
}
else
{
hex = trimmed;
}
hex = hex.Trim();
if (hex.Length != HexLength || !IsHex(hex.AsSpan()))
{
var name = string.IsNullOrWhiteSpace(parameterName) ? "Digest" : parameterName;
throw new FormatException($"{name} must contain {HexLength} hexadecimal characters.");
}
return Prefix + hex.ToLowerInvariant();
}
/// <summary>
/// Normalizes a digest to the canonical form, returning null when the input is null/empty.
/// </summary>
public static string? NormalizeOrNull(string? digest, bool requirePrefix = false, string? parameterName = null)
=> string.IsNullOrWhiteSpace(digest) ? null : Normalize(digest, requirePrefix, parameterName);
/// <summary>
/// Extracts the lowercase hex value from a digest (with optional <c>sha256:</c> prefix).
/// </summary>
public static string ExtractHex(string digest, bool requirePrefix = false, string? parameterName = null)
=> Normalize(digest, requirePrefix, parameterName)[Prefix.Length..];
/// <summary>
/// Computes a canonical <c>sha256:&lt;hex&gt;</c> digest for the provided content using the StellaOps crypto stack.
/// </summary>
public static string Compute(ICryptoHash hash, ReadOnlySpan<byte> content)
{
ArgumentNullException.ThrowIfNull(hash);
return Prefix + hash.ComputeHashHex(content, HashAlgorithms.Sha256);
}
private static bool IsHex(ReadOnlySpan<char> value)
{
foreach (var c in value)
{
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))
{
continue;
}
return false;
}
return true;
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Testcontainers.PostgreSql;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Infrastructure.Postgres.Testing;
@@ -68,11 +69,33 @@ public abstract class PostgresIntegrationFixture : IAsyncLifetime
/// </summary>
public virtual async Task InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage(PostgresImage)
.Build();
try
{
_container = new PostgreSqlBuilder()
.WithImage(PostgresImage)
.Build();
await _container.StartAsync();
await _container.StartAsync();
}
catch (ArgumentException ex) when (ShouldSkipForMissingDocker(ex))
{
try
{
if (_container is not null)
{
await _container.DisposeAsync();
}
}
catch
{
// Ignore cleanup failures during skip.
}
_container = null;
throw SkipException.ForSkip(
$"Postgres integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
}
var moduleName = GetModuleName();
_fixture = PostgresFixtureFactory.Create(ConnectionString, moduleName, Logger);
@@ -115,6 +138,12 @@ public abstract class PostgresIntegrationFixture : IAsyncLifetime
/// </summary>
public Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default)
=> Fixture.ExecuteSqlAsync(sql, cancellationToken);
private static bool ShouldSkipForMissingDocker(ArgumentException exception)
{
return string.Equals(exception.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal)
|| exception.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>

View File

@@ -0,0 +1,130 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.TestKit.Assertions;
/// <summary>
/// Provides assertions for canonical JSON serialization and determinism testing.
/// </summary>
/// <remarks>
/// Canonical JSON ensures:
/// - Stable key ordering (alphabetical)
/// - Consistent number formatting
/// - No whitespace variations
/// - UTF-8 encoding
/// - Deterministic output (same input → same bytes)
/// </remarks>
public static class CanonicalJsonAssert
{
/// <summary>
/// Asserts that the canonical JSON serialization of the value produces the expected SHA-256 hash.
/// </summary>
/// <param name="value">The value to serialize.</param>
/// <param name="expectedSha256Hex">The expected SHA-256 hash (lowercase hex string).</param>
public static void HasExpectedHash<T>(T value, string expectedSha256Hex)
{
string actualHash = Canonical.Json.CanonJson.Hash(value);
Assert.Equal(expectedSha256Hex.ToLowerInvariant(), actualHash);
}
/// <summary>
/// Asserts that two values produce identical canonical JSON.
/// </summary>
public static void AreCanonicallyEqual<T>(T expected, T actual)
{
byte[] expectedBytes = Canonical.Json.CanonJson.Canonicalize(expected);
byte[] actualBytes = Canonical.Json.CanonJson.Canonicalize(actual);
Assert.Equal(expectedBytes, actualBytes);
}
/// <summary>
/// Asserts that serializing the value multiple times produces identical bytes (determinism check).
/// </summary>
public static void IsDeterministic<T>(T value, int iterations = 10)
{
byte[]? baseline = null;
for (int i = 0; i < iterations; i++)
{
byte[] current = Canonical.Json.CanonJson.Canonicalize(value);
if (baseline == null)
{
baseline = current;
}
else
{
Assert.Equal(baseline, current);
}
}
}
/// <summary>
/// Computes the SHA-256 hash of the canonical JSON and returns it as a lowercase hex string.
/// </summary>
public static string ComputeCanonicalHash<T>(T value)
{
return Canonical.Json.CanonJson.Hash(value);
}
/// <summary>
/// Asserts that the canonical JSON matches the expected string (useful for debugging).
/// </summary>
public static void MatchesJson<T>(T value, string expectedJson)
{
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
string actualJson = System.Text.Encoding.UTF8.GetString(canonicalBytes);
Assert.Equal(expectedJson, actualJson);
}
/// <summary>
/// Asserts that the JSON contains the expected key-value pair (deep search).
/// </summary>
public static void ContainsProperty<T>(T value, string propertyPath, object expectedValue)
{
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
using var doc = JsonDocument.Parse(canonicalBytes);
JsonElement? element = FindPropertyByPath(doc.RootElement, propertyPath);
Assert.NotNull(element);
// Compare values
string expectedJson = JsonSerializer.Serialize(expectedValue);
string actualJson = element.Value.GetRawText();
Assert.Equal(expectedJson, actualJson);
}
private static JsonElement? FindPropertyByPath(JsonElement root, string path)
{
var parts = path.Split('.');
var current = root;
foreach (var part in parts)
{
if (current.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!current.TryGetProperty(part, out var next))
{
return null;
}
current = next;
}
return current;
}
private static string ComputeSha256Hex(byte[] data)
{
byte[] hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,114 @@
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.TestKit.Assertions;
/// <summary>
/// Provides snapshot testing assertions for golden master testing.
/// Snapshots are stored in the test project's `Snapshots/` directory.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// [Fact]
/// public void TestSbomGeneration()
/// {
/// var sbom = GenerateSbom();
///
/// // Snapshot will be stored in Snapshots/TestSbomGeneration.json
/// SnapshotAssert.MatchesSnapshot(sbom, snapshotName: "TestSbomGeneration");
/// }
/// </code>
///
/// To update snapshots (e.g., after intentional changes), set environment variable:
/// UPDATE_SNAPSHOTS=1 dotnet test
/// </remarks>
public static class SnapshotAssert
{
private static readonly bool UpdateSnapshotsMode =
Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS") == "1";
/// <summary>
/// Asserts that the value matches the stored snapshot. If UPDATE_SNAPSHOTS=1, updates the snapshot.
/// </summary>
/// <param name="value">The value to snapshot (will be JSON-serialized).</param>
/// <param name="snapshotName">The snapshot name (filename without extension).</param>
/// <param name="snapshotsDirectory">Optional directory for snapshots (default: "Snapshots" in test project).</param>
public static void MatchesSnapshot<T>(T value, string snapshotName, string? snapshotsDirectory = null)
{
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
Directory.CreateDirectory(snapshotsDirectory);
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.json");
// Serialize to pretty JSON for readability
string actualJson = JsonSerializer.Serialize(value, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (UpdateSnapshotsMode)
{
// Update snapshot
File.WriteAllText(snapshotPath, actualJson, Encoding.UTF8);
return; // Don't assert in update mode
}
// Verify snapshot exists
Assert.True(File.Exists(snapshotPath),
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
// Compare with stored snapshot
string expectedJson = File.ReadAllText(snapshotPath, Encoding.UTF8);
Assert.Equal(expectedJson, actualJson);
}
/// <summary>
/// Asserts that the text matches the stored snapshot.
/// </summary>
public static void MatchesTextSnapshot(string value, string snapshotName, string? snapshotsDirectory = null)
{
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
Directory.CreateDirectory(snapshotsDirectory);
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.txt");
if (UpdateSnapshotsMode)
{
File.WriteAllText(snapshotPath, value, Encoding.UTF8);
return;
}
Assert.True(File.Exists(snapshotPath),
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
string expected = File.ReadAllText(snapshotPath, Encoding.UTF8);
Assert.Equal(expected, value);
}
/// <summary>
/// Asserts that binary data matches the stored snapshot.
/// </summary>
public static void MatchesBinarySnapshot(byte[] value, string snapshotName, string? snapshotsDirectory = null)
{
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
Directory.CreateDirectory(snapshotsDirectory);
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.bin");
if (UpdateSnapshotsMode)
{
File.WriteAllBytes(snapshotPath, value);
return;
}
Assert.True(File.Exists(snapshotPath),
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
byte[] expected = File.ReadAllBytes(snapshotPath);
Assert.Equal(expected, value);
}
}

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);
}

View File

@@ -0,0 +1,126 @@
namespace StellaOps.TestKit.Deterministic;
/// <summary>
/// Provides deterministic random number generation for testing.
/// Uses a fixed seed to ensure reproducible random sequences.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// var random = new DeterministicRandom(seed: 42);
/// var value1 = random.Next(); // Same value every time with seed 42
/// var value2 = random.NextDouble(); // Deterministic sequence
///
/// // For property-based testing with FsCheck
/// var gen = DeterministicRandom.CreateGen(seed: 42);
/// </code>
/// </remarks>
public sealed class DeterministicRandom
{
private readonly System.Random _random;
private readonly int _seed;
/// <summary>
/// Creates a new deterministic random number generator with the specified seed.
/// </summary>
/// <param name="seed">The seed value. Same seed always produces same sequence.</param>
public DeterministicRandom(int seed)
{
_seed = seed;
_random = new System.Random(seed);
}
/// <summary>
/// Gets the seed used for this random number generator.
/// </summary>
public int Seed => _seed;
/// <summary>
/// Returns a non-negative random integer.
/// </summary>
public int Next() => _random.Next();
/// <summary>
/// Returns a non-negative random integer less than the specified maximum.
/// </summary>
public int Next(int maxValue) => _random.Next(maxValue);
/// <summary>
/// Returns a random integer within the specified range.
/// </summary>
public int Next(int minValue, int maxValue) => _random.Next(minValue, maxValue);
/// <summary>
/// Returns a random floating-point number between 0.0 and 1.0.
/// </summary>
public double NextDouble() => _random.NextDouble();
/// <summary>
/// Fills the elements of the specified array with random bytes.
/// </summary>
public void NextBytes(byte[] buffer) => _random.NextBytes(buffer);
/// <summary>
/// Fills the elements of the specified span with random bytes.
/// </summary>
public void NextBytes(Span<byte> buffer) => _random.NextBytes(buffer);
/// <summary>
/// Creates a new deterministic Random instance with the specified seed.
/// Useful for integration with code that expects System.Random.
/// </summary>
public static System.Random CreateRandom(int seed) => new(seed);
/// <summary>
/// Generates a deterministic GUID based on the seed.
/// </summary>
public Guid NextGuid()
{
var bytes = new byte[16];
_random.NextBytes(bytes);
return new Guid(bytes);
}
/// <summary>
/// Generates a deterministic string of the specified length using alphanumeric characters.
/// </summary>
public string NextString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[_random.Next(chars.Length)];
}
return new string(result);
}
/// <summary>
/// Selects a random element from the specified array.
/// </summary>
public T NextElement<T>(T[] array)
{
if (array == null || array.Length == 0)
{
throw new ArgumentException("Array cannot be null or empty", nameof(array));
}
return array[_random.Next(array.Length)];
}
/// <summary>
/// Shuffles an array in-place using the Fisher-Yates algorithm (deterministic).
/// </summary>
public void Shuffle<T>(T[] array)
{
if (array == null)
{
throw new ArgumentNullException(nameof(array));
}
for (int i = array.Length - 1; i > 0; i--)
{
int j = _random.Next(i + 1);
(array[i], array[j]) = (array[j], array[i]);
}
}
}

View File

@@ -0,0 +1,108 @@
namespace StellaOps.TestKit.Deterministic;
/// <summary>
/// Provides deterministic time for testing. Replaces DateTime.UtcNow and DateTimeOffset.UtcNow
/// to ensure reproducible test results.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// using var deterministicTime = new DeterministicTime(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
/// // All calls to deterministicTime.UtcNow return the fixed time
/// var timestamp = deterministicTime.UtcNow; // Always 2026-01-15T10:30:00Z
///
/// // Advance time by a specific duration
/// deterministicTime.Advance(TimeSpan.FromHours(2));
/// var laterTimestamp = deterministicTime.UtcNow; // 2026-01-15T12:30:00Z
/// </code>
/// </remarks>
public sealed class DeterministicTime : IDisposable
{
private DateTime _currentUtc;
private readonly object _lock = new();
/// <summary>
/// Creates a new deterministic time provider starting at the specified UTC time.
/// </summary>
/// <param name="startUtc">The starting UTC time. Must have DateTimeKind.Utc.</param>
/// <exception cref="ArgumentException">Thrown if startUtc is not UTC.</exception>
public DeterministicTime(DateTime startUtc)
{
if (startUtc.Kind != DateTimeKind.Utc)
{
throw new ArgumentException("Start time must be UTC", nameof(startUtc));
}
_currentUtc = startUtc;
}
/// <summary>
/// Gets the current deterministic UTC time.
/// </summary>
public DateTime UtcNow
{
get
{
lock (_lock)
{
return _currentUtc;
}
}
}
/// <summary>
/// Gets the current deterministic UTC time as DateTimeOffset.
/// </summary>
public DateTimeOffset UtcNowOffset => new(_currentUtc, TimeSpan.Zero);
/// <summary>
/// Advances the deterministic time by the specified duration.
/// </summary>
/// <param name="duration">The duration to advance. Can be negative to go backwards.</param>
public void Advance(TimeSpan duration)
{
lock (_lock)
{
_currentUtc = _currentUtc.Add(duration);
}
}
/// <summary>
/// Sets the deterministic time to a specific UTC value.
/// </summary>
/// <param name="newUtc">The new UTC time. Must have DateTimeKind.Utc.</param>
/// <exception cref="ArgumentException">Thrown if newUtc is not UTC.</exception>
public void SetTo(DateTime newUtc)
{
if (newUtc.Kind != DateTimeKind.Utc)
{
throw new ArgumentException("Time must be UTC", nameof(newUtc));
}
lock (_lock)
{
_currentUtc = newUtc;
}
}
/// <summary>
/// Resets the deterministic time to the starting value.
/// </summary>
public void Reset(DateTime startUtc)
{
if (startUtc.Kind != DateTimeKind.Utc)
{
throw new ArgumentException("Start time must be UTC", nameof(startUtc));
}
lock (_lock)
{
_currentUtc = startUtc;
}
}
public void Dispose()
{
// Cleanup if needed
}
}

View File

@@ -0,0 +1,111 @@
using System.Net.Http.Headers;
using System.Text;
namespace StellaOps.TestKit.Extensions;
/// <summary>
/// Extension methods for HttpClient to support test scenarios.
/// </summary>
public static class HttpClientTestExtensions
{
/// <summary>
/// Sends a request without any authentication headers.
/// </summary>
public static async Task<HttpResponseMessage> SendWithoutAuthAsync(
this HttpClient client,
HttpMethod method,
string endpoint,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(method, endpoint);
request.Headers.Authorization = null; // Ensure no auth header
return await client.SendAsync(request, ct);
}
/// <summary>
/// Sends a request with an expired bearer token.
/// </summary>
public static async Task<HttpResponseMessage> SendWithExpiredTokenAsync(
this HttpClient client,
string endpoint,
string expiredToken,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
return await client.SendAsync(request, ct);
}
/// <summary>
/// Sends a request with a malformed content type (text/plain instead of application/json).
/// </summary>
public static async Task<HttpResponseMessage> SendWithMalformedContentTypeAsync(
this HttpClient client,
HttpMethod method,
string endpoint,
string body,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(method, endpoint)
{
Content = new StringContent(body, Encoding.UTF8, "text/plain")
};
return await client.SendAsync(request, ct);
}
/// <summary>
/// Sends a request with an oversized payload.
/// </summary>
public static async Task<HttpResponseMessage> SendOversizedPayloadAsync(
this HttpClient client,
string endpoint,
int sizeBytes,
CancellationToken ct = default)
{
var payload = new string('x', sizeBytes);
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return await client.SendAsync(request, ct);
}
/// <summary>
/// Sends a request with the wrong HTTP method (opposite of expected).
/// </summary>
public static async Task<HttpResponseMessage> SendWithWrongMethodAsync(
this HttpClient client,
string endpoint,
HttpMethod expectedMethod,
CancellationToken ct = default)
{
// If endpoint expects POST, send GET; if expects GET, send DELETE
var wrongMethod = expectedMethod == HttpMethod.Post ? HttpMethod.Get :
expectedMethod == HttpMethod.Get ? HttpMethod.Delete :
expectedMethod == HttpMethod.Put ? HttpMethod.Patch :
expectedMethod == HttpMethod.Delete ? HttpMethod.Post :
HttpMethod.Options;
var request = new HttpRequestMessage(wrongMethod, endpoint);
return await client.SendAsync(request, ct);
}
/// <summary>
/// Sends a request with a bearer token for a specific tenant.
/// </summary>
public static async Task<HttpResponseMessage> SendWithTokenAsync(
this HttpClient client,
HttpMethod method,
string endpoint,
string token,
HttpContent? content = null,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(method, endpoint)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await client.SendAsync(request, ct);
}
}

View File

@@ -0,0 +1,200 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Helpers for API contract testing using OpenAPI schema snapshots.
/// </summary>
public static class ContractTestHelper
{
/// <summary>
/// Fetches and validates the OpenAPI schema against a snapshot.
/// </summary>
public static async Task ValidateOpenApiSchemaAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string expectedSnapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (ShouldUpdateSnapshots())
{
await UpdateSnapshotAsync(expectedSnapshotPath, actualSchema);
return;
}
var expectedSchema = await File.ReadAllTextAsync(expectedSnapshotPath);
// Normalize both for comparison
var actualNormalized = NormalizeOpenApiSchema(actualSchema);
var expectedNormalized = NormalizeOpenApiSchema(expectedSchema);
actualNormalized.Should().Be(expectedNormalized,
"OpenAPI schema should match snapshot. Set STELLAOPS_UPDATE_FIXTURES=true to update.");
}
/// <summary>
/// Validates that the schema contains expected endpoints.
/// </summary>
public static async Task ValidateEndpointsExistAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
IEnumerable<string> expectedEndpoints,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var schemaJson = await response.Content.ReadAsStringAsync();
var schema = JsonDocument.Parse(schemaJson);
var paths = schema.RootElement.GetProperty("paths");
foreach (var endpoint in expectedEndpoints)
{
paths.TryGetProperty(endpoint, out _).Should().BeTrue(
$"Expected endpoint '{endpoint}' should exist in OpenAPI schema");
}
}
/// <summary>
/// Detects breaking changes between current schema and snapshot.
/// </summary>
public static async Task<SchemaBreakingChanges> DetectBreakingChangesAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string snapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (!File.Exists(snapshotPath))
{
return new SchemaBreakingChanges(new List<string> { "No previous snapshot exists" }, new List<string>());
}
var expectedSchema = await File.ReadAllTextAsync(snapshotPath);
return CompareSchemas(expectedSchema, actualSchema);
}
private static SchemaBreakingChanges CompareSchemas(string expected, string actual)
{
var breakingChanges = new List<string>();
var nonBreakingChanges = new List<string>();
try
{
var expectedDoc = JsonDocument.Parse(expected);
var actualDoc = JsonDocument.Parse(actual);
// Check for removed endpoints (breaking)
if (expectedDoc.RootElement.TryGetProperty("paths", out var expectedPaths) &&
actualDoc.RootElement.TryGetProperty("paths", out var actualPaths))
{
foreach (var path in expectedPaths.EnumerateObject())
{
if (!actualPaths.TryGetProperty(path.Name, out _))
{
breakingChanges.Add($"Endpoint removed: {path.Name}");
}
else
{
// Check for removed methods
foreach (var method in path.Value.EnumerateObject())
{
if (!actualPaths.GetProperty(path.Name).TryGetProperty(method.Name, out _))
{
breakingChanges.Add($"Method removed: {method.Name.ToUpper()} {path.Name}");
}
}
}
}
// Check for new endpoints (non-breaking)
foreach (var path in actualPaths.EnumerateObject())
{
if (!expectedPaths.TryGetProperty(path.Name, out _))
{
nonBreakingChanges.Add($"Endpoint added: {path.Name}");
}
}
}
// Check for removed schemas (breaking)
if (expectedDoc.RootElement.TryGetProperty("components", out var expectedComponents) &&
expectedComponents.TryGetProperty("schemas", out var expectedSchemas) &&
actualDoc.RootElement.TryGetProperty("components", out var actualComponents) &&
actualComponents.TryGetProperty("schemas", out var actualSchemas))
{
foreach (var schema in expectedSchemas.EnumerateObject())
{
if (!actualSchemas.TryGetProperty(schema.Name, out _))
{
breakingChanges.Add($"Schema removed: {schema.Name}");
}
}
}
}
catch (JsonException ex)
{
breakingChanges.Add($"Schema parse error: {ex.Message}");
}
return new SchemaBreakingChanges(breakingChanges, nonBreakingChanges);
}
private static string NormalizeOpenApiSchema(string schema)
{
try
{
var doc = JsonDocument.Parse(schema);
// Remove non-deterministic fields
return JsonSerializer.Serialize(doc, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch
{
return schema;
}
}
private static bool ShouldUpdateSnapshots()
{
return Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
}
private static async Task UpdateSnapshotAsync(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
// Pretty-print for readability
var doc = JsonDocument.Parse(content);
var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, pretty);
}
}
/// <summary>
/// Result of schema breaking change detection.
/// </summary>
public sealed record SchemaBreakingChanges(
IReadOnlyList<string> BreakingChanges,
IReadOnlyList<string> NonBreakingChanges)
{
public bool HasBreakingChanges => BreakingChanges.Count > 0;
}

View File

@@ -0,0 +1,152 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Provides an in-memory HTTP test server using WebApplicationFactory for contract testing.
/// </summary>
/// <typeparam name="TProgram">The entry point type of the web application (usually Program).</typeparam>
/// <remarks>
/// Usage:
/// <code>
/// public class ApiTests : IClassFixture&lt;HttpFixtureServer&lt;Program&gt;&gt;
/// {
/// private readonly HttpClient _client;
///
/// public ApiTests(HttpFixtureServer&lt;Program&gt; fixture)
/// {
/// _client = fixture.CreateClient();
/// }
///
/// [Fact]
/// public async Task GetHealth_ReturnsOk()
/// {
/// var response = await _client.GetAsync("/health");
/// response.EnsureSuccessStatusCode();
/// }
/// }
/// </code>
/// </remarks>
public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram>
where TProgram : class
{
private readonly Action<IServiceCollection>? _configureServices;
/// <summary>
/// Creates a new HTTP fixture server with optional service configuration.
/// </summary>
/// <param name="configureServices">Optional action to configure test services (e.g., replace dependencies with mocks).</param>
public HttpFixtureServer(Action<IServiceCollection>? configureServices = null)
{
_configureServices = configureServices;
}
/// <summary>
/// Configures the web host for testing (disables HTTPS redirection, applies custom services).
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Apply user-provided service configuration (e.g., mock dependencies)
_configureServices?.Invoke(services);
});
builder.UseEnvironment("Test");
}
/// <summary>
/// Creates an HttpClient configured to communicate with the test server.
/// </summary>
public new HttpClient CreateClient()
{
return base.CreateClient();
}
/// <summary>
/// Creates an HttpClient with custom configuration.
/// </summary>
public HttpClient CreateClient(Action<HttpClient> configure)
{
var client = CreateClient();
configure(client);
return client;
}
}
/// <summary>
/// Provides a stub HTTP message handler for hermetic HTTP tests without external dependencies.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// var handler = new HttpMessageHandlerStub()
/// .WhenRequest("https://api.example.com/data")
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
///
/// var httpClient = new HttpClient(handler);
/// var response = await httpClient.GetAsync("https://api.example.com/data");
/// // response.StatusCode == HttpStatusCode.OK
/// </code>
/// </remarks>
public sealed class HttpMessageHandlerStub : HttpMessageHandler
{
private readonly Dictionary<string, Func<HttpRequestMessage, Task<HttpResponseMessage>>> _handlers = new();
private Func<HttpRequestMessage, Task<HttpResponseMessage>>? _defaultHandler;
/// <summary>
/// Configures a response for a specific URL.
/// </summary>
public HttpMessageHandlerStub WhenRequest(string url, Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_handlers[url] = handler;
return this;
}
/// <summary>
/// Configures a simple response for a specific URL.
/// </summary>
public HttpMessageHandlerStub WhenRequest(string url, HttpStatusCode statusCode, string? content = null)
{
return WhenRequest(url, _ => Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = content != null ? new StringContent(content) : null
}));
}
/// <summary>
/// Configures a default handler for unmatched requests.
/// </summary>
public HttpMessageHandlerStub WhenAnyRequest(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_defaultHandler = handler;
return this;
}
/// <summary>
/// Sends the HTTP request through the stub handler.
/// </summary>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? string.Empty;
if (_handlers.TryGetValue(url, out var handler))
{
return await handler(request);
}
if (_defaultHandler != null)
{
return await _defaultHandler(request);
}
// Default: 404 Not Found for unmatched requests
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No stub configured for {url}")
};
}
}

View File

@@ -1,15 +1,38 @@
using System.Reflection;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Isolation modes for PostgreSQL test fixtures.
/// </summary>
public enum PostgresIsolationMode
{
/// <summary>Each test gets its own schema. Default, most isolated.</summary>
SchemaPerTest,
/// <summary>Truncate all tables between tests. Faster but shared schema.</summary>
Truncation,
/// <summary>Each test gets its own database. Maximum isolation, slowest.</summary>
DatabasePerTest
}
/// <summary>
/// Represents a migration source for PostgreSQL fixtures.
/// </summary>
public sealed record MigrationSource(string Module, string ScriptPath);
/// <summary>
/// Test fixture for PostgreSQL database using Testcontainers.
/// Provides an isolated PostgreSQL instance for integration tests.
/// Provides an isolated PostgreSQL instance for integration tests with
/// configurable isolation modes and migration support.
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private readonly List<MigrationSource> _migrations = new();
private int _schemaCounter;
private int _databaseCounter;
public PostgresFixture()
{
@@ -21,6 +44,11 @@ public sealed class PostgresFixture : IAsyncLifetime
.Build();
}
/// <summary>
/// Gets or sets the isolation mode for tests.
/// </summary>
public PostgresIsolationMode IsolationMode { get; set; } = PostgresIsolationMode.SchemaPerTest;
/// <summary>
/// Gets the connection string for the PostgreSQL container.
/// </summary>
@@ -51,6 +79,163 @@ public sealed class PostgresFixture : IAsyncLifetime
await _container.DisposeAsync();
}
/// <summary>
/// Registers migrations to be applied for a module.
/// </summary>
public void RegisterMigrations(string module, string scriptPath)
{
_migrations.Add(new MigrationSource(module, scriptPath));
}
/// <summary>
/// Creates a new test session with appropriate isolation.
/// </summary>
public async Task<PostgresTestSession> CreateSessionAsync(string? testName = null)
{
return IsolationMode switch
{
PostgresIsolationMode.SchemaPerTest => await CreateSchemaSessionAsync(testName),
PostgresIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
PostgresIsolationMode.Truncation => new PostgresTestSession(ConnectionString, "public", this),
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
};
}
/// <summary>
/// Creates a schema-isolated session for a test.
/// </summary>
public async Task<PostgresTestSession> CreateSchemaSessionAsync(string? testName = null)
{
var schemaName = $"test_{Interlocked.Increment(ref _schemaCounter):D4}_{testName ?? "anon"}";
await ExecuteSqlAsync($"CREATE SCHEMA IF NOT EXISTS \"{schemaName}\"");
// Apply migrations to the new schema
await ApplyMigrationsAsync(schemaName);
var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString)
{
SearchPath = schemaName
}.ToString();
return new PostgresTestSession(connectionString, schemaName, this);
}
/// <summary>
/// Creates a database-isolated session for a test.
/// </summary>
public async Task<PostgresTestSession> CreateDatabaseSessionAsync(string? testName = null)
{
var dbName = $"test_{Interlocked.Increment(ref _databaseCounter):D4}_{testName ?? "anon"}";
await CreateDatabaseAsync(dbName);
var connectionString = GetConnectionString(dbName);
// Apply migrations to the new database
await ApplyMigrationsToDatabaseAsync(connectionString);
return new PostgresTestSession(connectionString, "public", this, dbName);
}
/// <summary>
/// Truncates all user tables in the public schema.
/// </summary>
public async Task TruncateAllTablesAsync()
{
const string truncateSql = """
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public')
LOOP
EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
""";
await ExecuteSqlAsync(truncateSql);
}
/// <summary>
/// Applies all registered migrations to a schema.
/// </summary>
public async Task ApplyMigrationsAsync(string schemaName)
{
foreach (var migration in _migrations)
{
if (File.Exists(migration.ScriptPath))
{
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
await ExecuteSqlAsync(schemaQualifiedSql);
}
}
}
/// <summary>
/// Applies migrations from an assembly's embedded resources to a schema.
/// </summary>
/// <param name="assembly">Assembly containing embedded SQL migration resources.</param>
/// <param name="schemaName">Target schema name.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
public async Task ApplyMigrationsFromAssemblyAsync(
Assembly assembly,
string schemaName,
string? resourcePrefix = null)
{
ArgumentNullException.ThrowIfNull(assembly);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
var resourceNames = assembly.GetManifestResourceNames()
.Where(r => r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.Where(r => string.IsNullOrEmpty(resourcePrefix) || r.Contains(resourcePrefix))
.OrderBy(r => r)
.ToList();
foreach (var resourceName in resourceNames)
{
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null) continue;
using var reader = new StreamReader(stream);
var sql = await reader.ReadToEndAsync();
// Replace public schema with target schema
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
await ExecuteSqlAsync(schemaQualifiedSql);
}
}
/// <summary>
/// Applies migrations from an assembly's embedded resources using a marker type.
/// </summary>
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
/// <param name="schemaName">Target schema name.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
public Task ApplyMigrationsFromAssemblyAsync<TAssemblyMarker>(
string schemaName,
string? resourcePrefix = null)
=> ApplyMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, schemaName, resourcePrefix);
/// <summary>
/// Applies all registered migrations to a database.
/// </summary>
private async Task ApplyMigrationsToDatabaseAsync(string connectionString)
{
foreach (var migration in _migrations)
{
if (File.Exists(migration.ScriptPath))
{
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
await cmd.ExecuteNonQueryAsync();
}
}
}
/// <summary>
/// Executes a SQL command against the database.
/// </summary>
@@ -68,7 +253,7 @@ public sealed class PostgresFixture : IAsyncLifetime
/// </summary>
public async Task CreateDatabaseAsync(string databaseName)
{
var createDbSql = $"CREATE DATABASE {databaseName}";
var createDbSql = $"CREATE DATABASE \"{databaseName}\"";
await ExecuteSqlAsync(createDbSql);
}
@@ -77,10 +262,19 @@ public sealed class PostgresFixture : IAsyncLifetime
/// </summary>
public async Task DropDatabaseAsync(string databaseName)
{
var dropDbSql = $"DROP DATABASE IF EXISTS {databaseName}";
var dropDbSql = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
await ExecuteSqlAsync(dropDbSql);
}
/// <summary>
/// Drops a schema within the database.
/// </summary>
public async Task DropSchemaAsync(string schemaName)
{
var dropSchemaSql = $"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE";
await ExecuteSqlAsync(dropSchemaSql);
}
/// <summary>
/// Gets a connection string for a specific database in the container.
/// </summary>
@@ -94,6 +288,44 @@ public sealed class PostgresFixture : IAsyncLifetime
}
}
/// <summary>
/// Represents an isolated test session within PostgreSQL.
/// </summary>
public sealed class PostgresTestSession : IAsyncDisposable
{
private readonly PostgresFixture _fixture;
private readonly string? _databaseName;
public PostgresTestSession(string connectionString, string schema, PostgresFixture fixture, string? databaseName = null)
{
ConnectionString = connectionString;
Schema = schema;
_fixture = fixture;
_databaseName = databaseName;
}
/// <summary>Connection string for this session.</summary>
public string ConnectionString { get; }
/// <summary>Schema name for this session.</summary>
public string Schema { get; }
/// <summary>
/// Cleans up the session resources.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_databaseName != null)
{
await _fixture.DropDatabaseAsync(_databaseName);
}
else if (Schema != "public")
{
await _fixture.DropSchemaAsync(Schema);
}
}
}
/// <summary>
/// Collection fixture for PostgreSQL to share the container across multiple test classes.
/// </summary>

View File

@@ -1,56 +1,264 @@
using Testcontainers.Redis;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using StackExchange.Redis;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Test fixture for Valkey (Redis-compatible) using Testcontainers.
/// Provides an isolated Valkey instance for integration tests.
/// Isolation modes for Valkey/Redis test fixtures.
/// </summary>
public sealed class ValkeyFixture : IAsyncLifetime
public enum ValkeyIsolationMode
{
private readonly RedisContainer _container;
/// <summary>Each test gets its own database (0-15). Default, good isolation.</summary>
DatabasePerTest,
/// <summary>Flush the current database between tests. Faster but shared.</summary>
FlushDb,
/// <summary>Flush all databases between tests. Maximum cleanup.</summary>
FlushAll
}
public ValkeyFixture()
{
_container = new RedisBuilder()
.WithImage("valkey/valkey:8-alpine")
.Build();
}
/// <summary>
/// Provides a Testcontainers-based Valkey (Redis-compatible) instance for integration tests.
/// </summary>
/// <remarks>
/// Usage with xUnit:
/// <code>
/// public class MyTests : IClassFixture&lt;ValkeyFixture&gt;
/// {
/// private readonly ValkeyFixture _fixture;
///
/// public MyTests(ValkeyFixture fixture)
/// {
/// _fixture = fixture;
/// }
///
/// [Fact]
/// public async Task TestCache()
/// {
/// await using var session = await _fixture.CreateSessionAsync();
/// await session.Database.StringSetAsync("key", "value");
/// // ...
/// }
/// }
/// </code>
/// </remarks>
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
{
private IContainer? _container;
private ConnectionMultiplexer? _connection;
private bool _disposed;
private int _databaseCounter;
/// <summary>
/// Gets the connection string for the Valkey container.
/// Gets the Redis/Valkey connection string (format: "host:port").
/// </summary>
public string ConnectionString => _container.GetConnectionString();
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>
/// Gets the hostname of the Valkey container.
/// Gets the Redis/Valkey host.
/// </summary>
public string Host => _container.Hostname;
public string Host { get; private set; } = string.Empty;
/// <summary>
/// Gets the exposed port of the Valkey container.
/// Gets the Redis/Valkey port.
/// </summary>
public ushort Port => _container.GetMappedPublicPort(6379);
public int Port { get; private set; }
/// <summary>
/// Gets or sets the isolation mode for tests.
/// </summary>
public ValkeyIsolationMode IsolationMode { get; set; } = ValkeyIsolationMode.DatabasePerTest;
/// <summary>
/// Gets the underlying connection multiplexer.
/// </summary>
public ConnectionMultiplexer? Connection => _connection;
/// <summary>
/// Initializes the Valkey container asynchronously.
/// </summary>
public async Task InitializeAsync()
{
// Use official Redis image (Valkey is Redis-compatible)
// In production deployments, substitute with valkey/valkey image if needed
_container = new ContainerBuilder()
.WithImage("redis:7-alpine")
.WithPortBinding(6379, true) // Bind to random host port
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
.Build();
await _container.StartAsync();
Host = _container.Hostname;
Port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{Host}:{Port}";
_connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
}
/// <summary>
/// Creates a new test session with appropriate isolation.
/// </summary>
public async Task<ValkeyTestSession> CreateSessionAsync(string? testName = null)
{
if (_connection == null)
{
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
}
return IsolationMode switch
{
ValkeyIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
ValkeyIsolationMode.FlushDb => await CreateFlushDbSessionAsync(),
ValkeyIsolationMode.FlushAll => await CreateFlushAllSessionAsync(),
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
};
}
/// <summary>
/// Creates a database-isolated session (database 0-15).
/// </summary>
private async Task<ValkeyTestSession> CreateDatabaseSessionAsync(string? testName = null)
{
var dbIndex = Interlocked.Increment(ref _databaseCounter) % 16;
var db = _connection!.GetDatabase(dbIndex);
// Flush this specific database before use
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(dbIndex);
return new ValkeyTestSession(_connection, db, dbIndex, this, testName);
}
/// <summary>
/// Creates a session that flushes the current database.
/// </summary>
private async Task<ValkeyTestSession> CreateFlushDbSessionAsync()
{
var db = _connection!.GetDatabase(0);
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(0);
return new ValkeyTestSession(_connection, db, 0, this, null);
}
/// <summary>
/// Creates a session that flushes all databases.
/// </summary>
private async Task<ValkeyTestSession> CreateFlushAllSessionAsync()
{
var server = _connection!.GetServer(ConnectionString);
await server.FlushAllDatabasesAsync();
var db = _connection.GetDatabase(0);
return new ValkeyTestSession(_connection, db, 0, this, null);
}
/// <summary>
/// Flushes a specific database.
/// </summary>
public async Task FlushDatabaseAsync(int databaseIndex)
{
if (_connection == null) return;
var server = _connection.GetServer(ConnectionString);
await server.FlushDatabaseAsync(databaseIndex);
}
/// <summary>
/// Flushes all databases.
/// </summary>
public async Task FlushAllAsync()
{
if (_connection == null) return;
var server = _connection.GetServer(ConnectionString);
await server.FlushAllDatabasesAsync();
}
/// <summary>
/// Gets a database by index.
/// </summary>
public IDatabase GetDatabase(int dbIndex = 0)
{
if (_connection == null)
{
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
}
return _connection.GetDatabase(dbIndex);
}
/// <summary>
/// Disposes the Valkey container asynchronously.
/// </summary>
public async Task DisposeAsync()
{
await _container.DisposeAsync();
if (_connection != null)
{
await _connection.CloseAsync();
_connection.Dispose();
}
if (_container != null)
{
await _container.StopAsync();
await _container.DisposeAsync();
}
}
/// <summary>
/// Disposes the fixture.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().GetAwaiter().GetResult();
_disposed = true;
}
}
/// <summary>
/// Collection fixture for Valkey to share the container across multiple test classes.
/// Represents an isolated test session within Valkey/Redis.
/// </summary>
[CollectionDefinition("Valkey")]
public class ValkeyCollection : ICollectionFixture<ValkeyFixture>
public sealed class ValkeyTestSession : IAsyncDisposable
{
// This class has no code, and is never created. Its purpose is simply
// to be the place to apply [CollectionDefinition] and all the
// ICollectionFixture<> interfaces.
private readonly ValkeyFixture _fixture;
public ValkeyTestSession(
ConnectionMultiplexer connection,
IDatabase database,
int databaseIndex,
ValkeyFixture fixture,
string? testName)
{
Connection = connection;
Database = database;
DatabaseIndex = databaseIndex;
_fixture = fixture;
TestName = testName;
}
/// <summary>The underlying connection multiplexer.</summary>
public ConnectionMultiplexer Connection { get; }
/// <summary>The database for this session.</summary>
public IDatabase Database { get; }
/// <summary>The database index (0-15).</summary>
public int DatabaseIndex { get; }
/// <summary>Optional test name for debugging.</summary>
public string? TestName { get; }
/// <summary>
/// Cleans up the session resources.
/// </summary>
public async ValueTask DisposeAsync()
{
// Flush this database on cleanup
await _fixture.FlushDatabaseAsync(DatabaseIndex);
}
}

View File

@@ -0,0 +1,180 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Test fixture for ASP.NET web services using WebApplicationFactory.
/// Provides isolated service hosting with deterministic configuration.
/// </summary>
/// <typeparam name="TProgram">The program entry point (typically Program class).</typeparam>
public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsyncLifetime
where TProgram : class
{
private readonly Action<IServiceCollection>? _configureServices;
private readonly Action<IWebHostBuilder>? _configureWebHost;
public WebServiceFixture(
Action<IServiceCollection>? configureServices = null,
Action<IWebHostBuilder>? configureWebHost = null)
{
_configureServices = configureServices;
_configureWebHost = configureWebHost;
}
/// <summary>
/// Gets the environment name for tests. Defaults to "Testing".
/// </summary>
protected virtual string EnvironmentName => "Testing";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(EnvironmentName);
builder.ConfigureServices(services =>
{
// Add default test services
services.AddSingleton<TestRequestContext>();
// Apply custom configuration
_configureServices?.Invoke(services);
});
_configureWebHost?.Invoke(builder);
}
/// <summary>
/// Creates an HttpClient with optional authentication.
/// </summary>
public HttpClient CreateAuthenticatedClient(string? bearerToken = null)
{
var client = CreateClient();
if (bearerToken != null)
{
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearerToken);
}
return client;
}
/// <summary>
/// Creates an HttpClient with a specific tenant header.
/// </summary>
public HttpClient CreateTenantClient(string tenantId, string? bearerToken = null)
{
var client = CreateAuthenticatedClient(bearerToken);
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
return client;
}
public virtual Task InitializeAsync() => Task.CompletedTask;
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
}
/// <summary>
/// Provides test request context for tracking.
/// </summary>
public sealed class TestRequestContext
{
private readonly List<RequestRecord> _requests = new();
public void RecordRequest(string method, string path, int statusCode)
{
lock (_requests)
{
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
}
}
public IReadOnlyList<RequestRecord> GetRequests()
{
lock (_requests)
{
return _requests.ToList();
}
}
public sealed record RequestRecord(string Method, string Path, int StatusCode, DateTime Timestamp);
}
/// <summary>
/// Extension methods for web service testing.
/// </summary>
public static class WebServiceTestExtensions
{
/// <summary>
/// Sends a request with malformed content type header.
/// </summary>
public static async Task<HttpResponseMessage> SendWithMalformedContentTypeAsync(
this HttpClient client,
HttpMethod method,
string url,
string? body = null)
{
var request = new HttpRequestMessage(method, url);
if (body != null)
{
request.Content = new StringContent(body);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/malformed-type");
}
return await client.SendAsync(request);
}
/// <summary>
/// Sends a request with oversized payload.
/// </summary>
public static async Task<HttpResponseMessage> SendOversizedPayloadAsync(
this HttpClient client,
string url,
int sizeInBytes)
{
var payload = new string('x', sizeInBytes);
var content = new StringContent($"{{\"data\":\"{payload}\"}}");
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
return await client.PostAsync(url, content);
}
/// <summary>
/// Sends a request with wrong HTTP method.
/// </summary>
public static async Task<HttpResponseMessage> SendWithWrongMethodAsync(
this HttpClient client,
string url,
HttpMethod expectedMethod)
{
// If expected is POST, send GET; if expected is GET, send DELETE, etc.
var wrongMethod = expectedMethod == HttpMethod.Get ? HttpMethod.Delete : HttpMethod.Get;
return await client.SendAsync(new HttpRequestMessage(wrongMethod, url));
}
/// <summary>
/// Sends a request without authentication.
/// </summary>
public static async Task<HttpResponseMessage> SendWithoutAuthAsync(
this HttpClient client,
HttpMethod method,
string url)
{
// Remove any existing auth header
client.DefaultRequestHeaders.Authorization = null;
return await client.SendAsync(new HttpRequestMessage(method, url));
}
/// <summary>
/// Sends a request with expired token.
/// </summary>
public static async Task<HttpResponseMessage> SendWithExpiredTokenAsync(
this HttpClient client,
string url,
string expiredToken)
{
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken);
return await client.GetAsync(url);
}
}

View File

@@ -1,99 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TestKit.Json;
/// <summary>
/// Assertion helpers for canonical JSON comparison in tests.
/// Ensures deterministic serialization with sorted keys and normalized formatting.
/// </summary>
public static class CanonicalJsonAssert
{
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNameCaseInsensitive = false,
// Ensure deterministic property ordering
PropertyOrder = 0
};
/// <summary>
/// Asserts that two JSON strings are canonically equivalent.
/// </summary>
/// <param name="expected">The expected JSON.</param>
/// <param name="actual">The actual JSON.</param>
public static void Equal(string expected, string actual)
{
var expectedCanonical = Canonicalize(expected);
var actualCanonical = Canonicalize(actual);
if (expectedCanonical != actualCanonical)
{
throw new CanonicalJsonAssertException(
$"JSON mismatch:\nExpected (canonical):\n{expectedCanonical}\n\nActual (canonical):\n{actualCanonical}");
}
}
/// <summary>
/// Asserts that two objects produce canonically equivalent JSON when serialized.
/// </summary>
public static void EquivalentObjects<T>(T expected, T actual)
{
var expectedJson = JsonSerializer.Serialize(expected, CanonicalOptions);
var actualJson = JsonSerializer.Serialize(actual, CanonicalOptions);
Equal(expectedJson, actualJson);
}
/// <summary>
/// Canonicalizes a JSON string by parsing and re-serializing with deterministic formatting.
/// </summary>
public static string Canonicalize(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, CanonicalOptions);
}
catch (JsonException ex)
{
throw new CanonicalJsonAssertException($"Failed to parse JSON: {ex.Message}", ex);
}
}
/// <summary>
/// Computes a stable hash of canonical JSON for comparison.
/// </summary>
public static string ComputeHash(string json)
{
var canonical = Canonicalize(json);
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
/// <summary>
/// Asserts that JSON matches a specific hash (for regression testing).
/// </summary>
public static void MatchesHash(string expectedHash, string json)
{
var actualHash = ComputeHash(json);
if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
{
throw new CanonicalJsonAssertException(
$"JSON hash mismatch:\nExpected hash: {expectedHash}\nActual hash: {actualHash}\n\nJSON (canonical):\n{Canonicalize(json)}");
}
}
}
/// <summary>
/// Exception thrown when canonical JSON assertions fail.
/// </summary>
public sealed class CanonicalJsonAssertException : Exception
{
public CanonicalJsonAssertException(string message) : base(message) { }
public CanonicalJsonAssertException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,162 @@
using System.Diagnostics;
using OpenTelemetry;
using Xunit;
namespace StellaOps.TestKit.Observability;
/// <summary>
/// Captures OpenTelemetry traces and spans during test execution for assertion.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// using var capture = new OtelCapture();
///
/// // Execute code that emits traces
/// await MyService.DoWorkAsync();
///
/// // Assert traces were emitted
/// capture.AssertHasSpan("MyService.DoWork");
/// capture.AssertHasTag("user_id", "123");
/// capture.AssertSpanCount(expectedCount: 3);
/// </code>
/// </remarks>
public sealed class OtelCapture : IDisposable
{
private readonly List<Activity> _capturedActivities = new();
private readonly ActivityListener _listener;
private bool _disposed;
/// <summary>
/// Creates a new OTel capture and starts listening for activities.
/// </summary>
/// <param name="activitySourceName">Optional activity source name filter. If null, captures all activities.</param>
public OtelCapture(string? activitySourceName = null)
{
_listener = new ActivityListener
{
ShouldListenTo = source => activitySourceName == null || source.Name == activitySourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity =>
{
lock (_capturedActivities)
{
_capturedActivities.Add(activity);
}
}
};
ActivitySource.AddActivityListener(_listener);
}
/// <summary>
/// Gets all captured activities (spans).
/// </summary>
public IReadOnlyList<Activity> CapturedActivities
{
get
{
lock (_capturedActivities)
{
return _capturedActivities.ToList();
}
}
}
/// <summary>
/// Asserts that a span with the specified name was captured.
/// </summary>
public void AssertHasSpan(string spanName)
{
lock (_capturedActivities)
{
Assert.Contains(_capturedActivities, a => a.DisplayName == spanName || a.OperationName == spanName);
}
}
/// <summary>
/// Asserts that at least one span has the specified tag (attribute).
/// </summary>
public void AssertHasTag(string tagKey, string expectedValue)
{
lock (_capturedActivities)
{
var found = _capturedActivities.Any(a =>
a.Tags.Any(tag => tag.Key == tagKey && tag.Value == expectedValue));
Assert.True(found, $"No span found with tag {tagKey}={expectedValue}");
}
}
/// <summary>
/// Asserts that exactly the specified number of spans were captured.
/// </summary>
public void AssertSpanCount(int expectedCount)
{
lock (_capturedActivities)
{
Assert.Equal(expectedCount, _capturedActivities.Count);
}
}
/// <summary>
/// Asserts that a span with the specified name has the expected tag.
/// </summary>
public void AssertSpanHasTag(string spanName, string tagKey, string expectedValue)
{
lock (_capturedActivities)
{
var span = _capturedActivities.FirstOrDefault(a =>
a.DisplayName == spanName || a.OperationName == spanName);
Assert.NotNull(span);
var tag = span.Tags.FirstOrDefault(t => t.Key == tagKey);
Assert.True(tag.Key != null, $"Tag '{tagKey}' not found in span '{spanName}'");
Assert.Equal(expectedValue, tag.Value);
}
}
/// <summary>
/// Asserts that spans form a valid parent-child hierarchy.
/// </summary>
public void AssertHierarchy(string parentSpanName, string childSpanName)
{
lock (_capturedActivities)
{
var parent = _capturedActivities.FirstOrDefault(a =>
a.DisplayName == parentSpanName || a.OperationName == parentSpanName);
var child = _capturedActivities.FirstOrDefault(a =>
a.DisplayName == childSpanName || a.OperationName == childSpanName);
Assert.NotNull(parent);
Assert.NotNull(child);
Assert.Equal(parent.SpanId, child.ParentSpanId);
}
}
/// <summary>
/// Clears all captured activities.
/// </summary>
public void Clear()
{
lock (_capturedActivities)
{
_capturedActivities.Clear();
}
}
/// <summary>
/// Disposes the capture and stops listening for activities.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_listener?.Dispose();
_disposed = true;
}
}

View File

@@ -1,174 +1,28 @@
# StellaOps.TestKit
Test infrastructure and fixtures for StellaOps projects. Provides deterministic time/random, canonical JSON assertions, snapshot testing, database fixtures, and OpenTelemetry capture.
Testing infrastructure for StellaOps - deterministic helpers, fixtures, and assertions.
## Features
## Quick Start
### Deterministic Time
```csharp
using StellaOps.TestKit.Time;
// Create a clock at a fixed time
var clock = new DeterministicClock();
var now = clock.UtcNow; // 2025-01-01T00:00:00Z
// Advance time
clock.Advance(TimeSpan.FromMinutes(5));
// Or use helpers
var clock2 = DeterministicClockExtensions.AtTestEpoch();
var clock3 = DeterministicClockExtensions.At("2025-06-15T10:30:00Z");
```
### Deterministic Random
```csharp
using StellaOps.TestKit.Random;
// Create deterministic RNG with standard test seed (42)
var rng = DeterministicRandomExtensions.WithTestSeed();
// Generate reproducible values
var number = rng.Next(1, 100);
var text = rng.NextString(10);
var item = rng.PickOne(new[] { "a", "b", "c" });
```
### Canonical JSON Assertions
```csharp
using StellaOps.TestKit.Json;
// Assert JSON equality (ignores formatting)
CanonicalJsonAssert.Equal(expectedJson, actualJson);
// Assert object equivalence
CanonicalJsonAssert.EquivalentObjects(expectedObj, actualObj);
// Hash-based regression testing
var hash = CanonicalJsonAssert.ComputeHash(json);
CanonicalJsonAssert.MatchesHash("abc123...", json);
using var time = new DeterministicTime(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
var timestamp = time.UtcNow; // Always 2026-01-15T10:30:00Z
```
### Snapshot Testing
```csharp
using StellaOps.TestKit.Snapshots;
public class MyTests
{
[Fact]
public void TestOutput()
{
var output = GenerateSomeOutput();
// Compare against __snapshots__/test_output.txt
var snapshotPath = SnapshotHelper.GetSnapshotPath("test_output");
SnapshotHelper.VerifySnapshot(output, snapshotPath);
}
[Fact]
public void TestJsonOutput()
{
var obj = new { Name = "test", Value = 42 };
// Compare JSON serialization
var snapshotPath = SnapshotHelper.GetSnapshotPath("test_json", ".json");
SnapshotHelper.VerifyJsonSnapshot(obj, snapshotPath);
}
}
// Update snapshots: set environment variable UPDATE_SNAPSHOTS=1
SnapshotAssert.MatchesSnapshot(sbom, "TestSbom");
// Update: UPDATE_SNAPSHOTS=1 dotnet test
```
### PostgreSQL Fixture
### PostgreSQL Integration
```csharp
using StellaOps.TestKit.Fixtures;
using Xunit;
[Collection("Postgres")]
public class DatabaseTests
public class Tests : IClassFixture<PostgresFixture>
{
private readonly PostgresFixture _postgres;
public DatabaseTests(PostgresFixture postgres)
{
_postgres = postgres;
}
[Fact]
public async Task TestQuery()
{
// Use connection string
await using var conn = new Npgsql.NpgsqlConnection(_postgres.ConnectionString);
await conn.OpenAsync();
// Execute SQL
await _postgres.ExecuteSqlAsync("CREATE TABLE test (id INT)");
// Create additional databases
await _postgres.CreateDatabaseAsync("otherdb");
}
public async Task TestDb() { /* use _fixture.ConnectionString */ }
}
```
### Valkey/Redis Fixture
```csharp
using StellaOps.TestKit.Fixtures;
using Xunit;
[Collection("Valkey")]
public class CacheTests
{
private readonly ValkeyFixture _valkey;
public CacheTests(ValkeyFixture valkey)
{
_valkey = valkey;
}
[Fact]
public void TestCache()
{
var connectionString = _valkey.ConnectionString;
// Use with your Redis/Valkey client
}
}
```
### OpenTelemetry Capture
```csharp
using StellaOps.TestKit.Telemetry;
[Fact]
public void TestTracing()
{
using var otel = new OTelCapture("my-service");
// Code that emits traces
using (var activity = otel.ActivitySource.StartActivity("operation"))
{
activity?.SetTag("key", "value");
}
// Assert traces
otel.AssertActivityExists("operation");
otel.AssertActivityHasTag("operation", "key", "value");
// Get summary for debugging
Console.WriteLine(otel.GetTraceSummary());
}
```
## Usage in Tests
Add to your test project:
```xml
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
```
## Design Principles
- **Determinism**: All utilities produce reproducible results
- **Offline-first**: No network dependencies (uses Testcontainers for local infrastructure)
- **Minimal dependencies**: Only essential packages
- **xUnit-friendly**: Works seamlessly with xUnit fixtures and collections
See full documentation in this README.

View File

@@ -1,107 +0,0 @@
namespace StellaOps.TestKit.Random;
/// <summary>
/// Deterministic random number generator for testing with reproducible sequences.
/// </summary>
public sealed class DeterministicRandom
{
private readonly System.Random _rng;
private readonly int _seed;
/// <summary>
/// Creates a new deterministic random number generator with the specified seed.
/// </summary>
/// <param name="seed">The seed value. If null, uses 42 (standard test seed).</param>
public DeterministicRandom(int? seed = null)
{
_seed = seed ?? 42;
_rng = new System.Random(_seed);
}
/// <summary>
/// Gets the seed used for this random number generator.
/// </summary>
public int Seed => _seed;
/// <summary>
/// Returns a non-negative random integer.
/// </summary>
public int Next() => _rng.Next();
/// <summary>
/// Returns a non-negative random integer less than the specified maximum.
/// </summary>
public int Next(int maxValue) => _rng.Next(maxValue);
/// <summary>
/// Returns a random integer within the specified range.
/// </summary>
public int Next(int minValue, int maxValue) => _rng.Next(minValue, maxValue);
/// <summary>
/// Returns a random double between 0.0 and 1.0.
/// </summary>
public double NextDouble() => _rng.NextDouble();
/// <summary>
/// Fills the specified byte array with random bytes.
/// </summary>
public void NextBytes(byte[] buffer) => _rng.NextBytes(buffer);
/// <summary>
/// Fills the specified span with random bytes.
/// </summary>
public void NextBytes(Span<byte> buffer) => _rng.NextBytes(buffer);
/// <summary>
/// Returns a random boolean value.
/// </summary>
public bool NextBool() => _rng.Next(2) == 1;
/// <summary>
/// Returns a random string of the specified length using alphanumeric characters.
/// </summary>
public string NextString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[_rng.Next(chars.Length)];
}
return new string(result);
}
/// <summary>
/// Selects a random element from the specified collection.
/// </summary>
public T PickOne<T>(IReadOnlyList<T> items)
{
if (items.Count == 0)
{
throw new ArgumentException("Cannot pick from empty collection", nameof(items));
}
return items[_rng.Next(items.Count)];
}
}
/// <summary>
/// Extensions for working with deterministic random generators in tests.
/// </summary>
public static class DeterministicRandomExtensions
{
/// <summary>
/// Standard test seed value.
/// </summary>
public const int TestSeed = 42;
/// <summary>
/// Creates a deterministic random generator with the standard test seed.
/// </summary>
public static DeterministicRandom WithTestSeed() => new(TestSeed);
/// <summary>
/// Creates a deterministic random generator with a specific seed.
/// </summary>
public static DeterministicRandom WithSeed(int seed) => new(seed);
}

View File

@@ -1,114 +0,0 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace StellaOps.TestKit.Snapshots;
/// <summary>
/// Helper for snapshot testing - comparing test output against golden files.
/// </summary>
public static class SnapshotHelper
{
private static readonly JsonSerializerOptions DefaultOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Verifies that actual content matches a snapshot file.
/// </summary>
/// <param name="actual">The actual content to verify.</param>
/// <param name="snapshotPath">Path to the snapshot file.</param>
/// <param name="updateSnapshots">If true, updates the snapshot file instead of comparing. Use for regenerating snapshots.</param>
public static void VerifySnapshot(string actual, string snapshotPath, bool updateSnapshots = false)
{
var normalizedActual = NormalizeLineEndings(actual);
if (updateSnapshots)
{
// Update mode: write the snapshot
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
File.WriteAllText(snapshotPath, normalizedActual, Encoding.UTF8);
return;
}
// Verify mode: compare against existing snapshot
if (!File.Exists(snapshotPath))
{
throw new SnapshotMismatchException(
$"Snapshot file not found: {snapshotPath}\n\nTo create it, run with updateSnapshots=true or set environment variable UPDATE_SNAPSHOTS=1");
}
var expected = File.ReadAllText(snapshotPath, Encoding.UTF8);
var normalizedExpected = NormalizeLineEndings(expected);
if (normalizedActual != normalizedExpected)
{
throw new SnapshotMismatchException(
$"Snapshot mismatch for {Path.GetFileName(snapshotPath)}:\n\nExpected:\n{normalizedExpected}\n\nActual:\n{normalizedActual}");
}
}
/// <summary>
/// Verifies that an object's JSON serialization matches a snapshot file.
/// </summary>
public static void VerifyJsonSnapshot<T>(T value, string snapshotPath, bool updateSnapshots = false, JsonSerializerOptions? options = null)
{
var json = JsonSerializer.Serialize(value, options ?? DefaultOptions);
VerifySnapshot(json, snapshotPath, updateSnapshots);
}
/// <summary>
/// Gets the snapshot directory for the calling test class.
/// </summary>
/// <param name="testFilePath">Automatically populated by compiler.</param>
/// <returns>Path to the __snapshots__ directory next to the test file.</returns>
public static string GetSnapshotDirectory([CallerFilePath] string testFilePath = "")
{
var testDir = Path.GetDirectoryName(testFilePath)!;
return Path.Combine(testDir, "__snapshots__");
}
/// <summary>
/// Gets the full path for a snapshot file.
/// </summary>
/// <param name="snapshotName">Name of the snapshot file (without extension).</param>
/// <param name="extension">File extension (default: .txt).</param>
/// <param name="testFilePath">Automatically populated by compiler.</param>
public static string GetSnapshotPath(
string snapshotName,
string extension = ".txt",
[CallerFilePath] string testFilePath = "")
{
var snapshotDir = GetSnapshotDirectory(testFilePath);
var fileName = $"{snapshotName}{extension}";
return Path.Combine(snapshotDir, fileName);
}
/// <summary>
/// Normalizes line endings to LF for cross-platform consistency.
/// </summary>
private static string NormalizeLineEndings(string content)
{
return content.Replace("\r\n", "\n").Replace("\r", "\n");
}
/// <summary>
/// Checks if snapshot update mode is enabled via environment variable.
/// </summary>
public static bool IsUpdateMode()
{
var updateEnv = Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS");
return string.Equals(updateEnv, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(updateEnv, "true", StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Exception thrown when snapshot verification fails.
/// </summary>
public sealed class SnapshotMismatchException : Exception
{
public SnapshotMismatchException(string message) : base(message) { }
}

View File

@@ -1,30 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>Testing infrastructure and utilities for StellaOps</Description>
</PropertyGroup>
<PropertyGroup>
<AssemblyName>StellaOps.TestKit</AssemblyName>
<RootNamespace>StellaOps.TestKit</RootNamespace>
<Description>Test infrastructure and fixtures for StellaOps projects - deterministic time/random, canonical JSON, snapshots, and database fixtures</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.abstractions" Version="2.0.3" />
<PackageReference Include="xunit.extensibility.core" Version="2.9.2" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageReference Include="Testcontainers.Redis" Version="4.1.0" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="OpenTelemetry" Version="1.10.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.10.0" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" Version="1.10.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FsCheck" Version="2.16.6" />
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
<PackageReference Include="Testcontainers" Version="3.10.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageReference Include="Npgsql" Version="8.0.5" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="OpenTelemetry" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,150 +0,0 @@
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
namespace StellaOps.TestKit.Telemetry;
/// <summary>
/// Captures OpenTelemetry traces in-memory for testing.
/// </summary>
public sealed class OTelCapture : IDisposable
{
private readonly TracerProvider _tracerProvider;
private readonly InMemoryExporter _exporter;
private readonly ActivitySource _activitySource;
public OTelCapture(string serviceName = "test-service")
{
_exporter = new InMemoryExporter();
_activitySource = new ActivitySource(serviceName);
_tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
.AddSource(serviceName)
.AddInMemoryExporter(_exporter)
.Build()!;
}
/// <summary>
/// Gets all captured activities (spans).
/// </summary>
public IReadOnlyList<Activity> Activities => _exporter.Activities;
/// <summary>
/// Gets the activity source for creating spans in tests.
/// </summary>
public ActivitySource ActivitySource => _activitySource;
/// <summary>
/// Clears all captured activities.
/// </summary>
public void Clear()
{
_exporter.Activities.Clear();
}
/// <summary>
/// Finds activities by operation name.
/// </summary>
public IEnumerable<Activity> FindByOperationName(string operationName)
{
return Activities.Where(a => a.OperationName == operationName);
}
/// <summary>
/// Finds activities by tag value.
/// </summary>
public IEnumerable<Activity> FindByTag(string tagKey, string tagValue)
{
return Activities.Where(a => a.Tags.Any(t => t.Key == tagKey && t.Value == tagValue));
}
/// <summary>
/// Asserts that at least one activity with the specified operation name exists.
/// </summary>
public void AssertActivityExists(string operationName)
{
if (!Activities.Any(a => a.OperationName == operationName))
{
var availableOps = string.Join(", ", Activities.Select(a => a.OperationName).Distinct());
throw new OTelAssertException(
$"No activity found with operation name '{operationName}'. Available operations: {availableOps}");
}
}
/// <summary>
/// Asserts that an activity has a specific tag.
/// </summary>
public void AssertActivityHasTag(string operationName, string tagKey, string expectedValue)
{
var activities = FindByOperationName(operationName).ToList();
if (activities.Count == 0)
{
throw new OTelAssertException($"No activity found with operation name '{operationName}'");
}
var activity = activities.First();
var tag = activity.Tags.FirstOrDefault(t => t.Key == tagKey);
if (tag.Key == null)
{
throw new OTelAssertException($"Activity '{operationName}' does not have tag '{tagKey}'");
}
if (tag.Value != expectedValue)
{
throw new OTelAssertException(
$"Tag '{tagKey}' on activity '{operationName}' has value '{tag.Value}' but expected '{expectedValue}'");
}
}
/// <summary>
/// Gets a summary of captured traces for debugging.
/// </summary>
public string GetTraceSummary()
{
if (Activities.Count == 0)
{
return "No traces captured";
}
var summary = new System.Text.StringBuilder();
summary.AppendLine($"Captured {Activities.Count} activities:");
foreach (var activity in Activities)
{
summary.AppendLine($" - {activity.OperationName} ({activity.Duration.TotalMilliseconds:F2}ms)");
foreach (var tag in activity.Tags)
{
summary.AppendLine($" {tag.Key} = {tag.Value}");
}
}
return summary.ToString();
}
public void Dispose()
{
_tracerProvider?.Dispose();
_activitySource?.Dispose();
}
}
/// <summary>
/// In-memory exporter for OpenTelemetry activities.
/// </summary>
internal sealed class InMemoryExporter
{
public List<Activity> Activities { get; } = new();
public void Export(Activity activity)
{
Activities.Add(activity);
}
}
/// <summary>
/// Exception thrown when OTel assertions fail.
/// </summary>
public sealed class OTelAssertException : Exception
{
public OTelAssertException(string message) : base(message) { }
}

View File

@@ -0,0 +1,221 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for Valkey/Redis cache tests.
/// Inherit from this class to verify cache operations work correctly.
/// </summary>
/// <typeparam name="TEntity">The entity type being cached.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class CacheIdempotencyTests<TEntity, TKey> : IClassFixture<ValkeyFixture>
where TEntity : class
where TKey : notnull
{
protected readonly ValkeyFixture Fixture;
protected CacheIdempotencyTests(ValkeyFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = ValkeyIsolationMode.DatabasePerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key);
/// <summary>
/// Converts a key to its Redis key string.
/// </summary>
protected abstract string ToRedisKey(TKey key);
/// <summary>
/// Sets the entity in cache.
/// </summary>
protected abstract Task SetAsync(ValkeyTestSession session, TKey key, TEntity entity, TimeSpan? expiry = null, CancellationToken ct = default);
/// <summary>
/// Gets the entity from cache.
/// </summary>
protected abstract Task<TEntity?> GetAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Deletes the entity from cache.
/// </summary>
protected abstract Task<bool> DeleteAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Checks if key exists in cache.
/// </summary>
protected abstract Task<bool> ExistsAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Serializes entity to a deterministic string representation.
/// </summary>
protected abstract string SerializeEntity(TEntity entity);
[Fact]
public async Task Set_Same_Key_Multiple_Times_Is_Idempotent()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Set_Same_Key_Multiple_Times_Is_Idempotent));
var key = GenerateKey(1);
var entity = CreateTestEntity(key);
// Act
await SetAsync(session, key, entity);
await SetAsync(session, key, entity);
await SetAsync(session, key, entity);
// Assert
var result = await GetAsync(session, key);
result.Should().NotBeNull();
SerializeEntity(result!).Should().Be(SerializeEntity(entity));
}
[Fact]
public async Task Get_NonExistent_Key_Returns_Null()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Get_NonExistent_Key_Returns_Null));
var key = GenerateKey(999);
// Act
var result = await GetAsync(session, key);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task Delete_Removes_Key()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Delete_Removes_Key));
var key = GenerateKey(2);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var deleted = await DeleteAsync(session, key);
// Assert
deleted.Should().BeTrue();
var exists = await ExistsAsync(session, key);
exists.Should().BeFalse();
}
[Fact]
public async Task Delete_NonExistent_Key_Returns_False()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Delete_NonExistent_Key_Returns_False));
var key = GenerateKey(888);
// Act
var deleted = await DeleteAsync(session, key);
// Assert
deleted.Should().BeFalse();
}
[Fact]
public async Task Set_With_Expiry_Key_Expires()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Set_With_Expiry_Key_Expires));
var key = GenerateKey(3);
var entity = CreateTestEntity(key);
// Act
await SetAsync(session, key, entity, TimeSpan.FromMilliseconds(100));
var beforeExpiry = await GetAsync(session, key);
await Task.Delay(200);
var afterExpiry = await GetAsync(session, key);
// Assert
beforeExpiry.Should().NotBeNull();
afterExpiry.Should().BeNull("key should have expired");
}
[Fact]
public async Task Concurrent_Sets_Same_Key_Last_Write_Wins()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Sets_Same_Key_Last_Write_Wins));
var key = GenerateKey(4);
// Act - Fire multiple concurrent sets
var tasks = Enumerable.Range(1, 10)
.Select(i => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
}));
await Task.WhenAll(tasks);
// Assert - Key should exist with some valid value
var result = await GetAsync(session, key);
result.Should().NotBeNull("one of the concurrent writes should succeed");
}
[Fact]
public async Task Get_Returns_Same_Value_Multiple_Times()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Get_Returns_Same_Value_Multiple_Times));
var key = GenerateKey(5);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var results = new List<string>();
for (int i = 0; i < 5; i++)
{
var result = await GetAsync(session, key);
results.Add(SerializeEntity(result!));
}
// Assert
results.Distinct().Should().HaveCount(1, "repeated gets should return identical values");
}
[Fact]
public async Task Exists_Returns_True_When_Key_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Exists_Returns_True_When_Key_Exists));
var key = GenerateKey(6);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var exists = await ExistsAsync(session, key);
// Assert
exists.Should().BeTrue();
}
[Fact]
public async Task Exists_Returns_False_When_Key_Not_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Exists_Returns_False_When_Key_Not_Exists));
var key = GenerateKey(777);
// Act
var exists = await ExistsAsync(session, key);
// Assert
exists.Should().BeFalse();
}
}

View File

@@ -0,0 +1,257 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for query determinism tests.
/// Inherit from this class to verify that queries produce deterministic results.
/// </summary>
/// <typeparam name="TEntity">The entity type being queried.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected QueryDeterminismTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key, int sortValue = 0);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves all entities sorted by the primary ordering.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> GetAllSortedAsync(PostgresTestSession session, CancellationToken ct = default);
/// <summary>
/// Retrieves entities matching a filter, sorted.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> QueryFilteredAsync(PostgresTestSession session, Func<TEntity, bool> filter, CancellationToken ct = default);
/// <summary>
/// Retrieves entities with pagination.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> GetPagedAsync(PostgresTestSession session, int skip, int take, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Gets the sort value from an entity for ordering verification.
/// </summary>
protected abstract int GetSortValue(TEntity entity);
/// <summary>
/// Serializes entity to a deterministic string representation.
/// </summary>
protected abstract string SerializeEntity(TEntity entity);
[Fact]
public async Task GetAll_Returns_Same_Order_Every_Time()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(GetAll_Returns_Same_Order_Every_Time));
var entities = Enumerable.Range(1, 20)
.Select(i => CreateTestEntity(GenerateKey(i), i))
.ToList();
// Insert in random order
var random = new Random(42); // Fixed seed for determinism
foreach (var entity in entities.OrderBy(_ => random.Next()))
{
await InsertAsync(session, entity);
}
// Act
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 5; i++)
{
results.Add(await GetAllSortedAsync(session));
}
// Assert
var firstResult = results[0].Select(SerializeEntity).ToList();
foreach (var result in results.Skip(1))
{
var serialized = result.Select(SerializeEntity).ToList();
serialized.Should().BeEquivalentTo(firstResult, options => options.WithStrictOrdering(),
"query should return same order every time");
}
}
[Fact]
public async Task GetAll_Is_Sorted_Correctly()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(GetAll_Is_Sorted_Correctly));
var entities = Enumerable.Range(1, 10)
.Select(i => CreateTestEntity(GenerateKey(i), i * 10))
.ToList();
// Insert in reverse order
foreach (var entity in entities.AsEnumerable().Reverse())
{
await InsertAsync(session, entity);
}
// Act
var result = await GetAllSortedAsync(session);
// Assert
var sortValues = result.Select(GetSortValue).ToList();
sortValues.Should().BeInAscendingOrder("results should be sorted by sort value");
}
[Fact]
public async Task Filtered_Query_Returns_Deterministic_Results()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Filtered_Query_Returns_Deterministic_Results));
for (int i = 1; i <= 30; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i));
}
// Act
Func<TEntity, bool> filter = e => GetSortValue(e) % 2 == 0; // Even values
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 3; i++)
{
results.Add(await QueryFilteredAsync(session, filter));
}
// Assert
var firstSerialized = results[0].Select(SerializeEntity).ToList();
foreach (var result in results.Skip(1))
{
var serialized = result.Select(SerializeEntity).ToList();
serialized.Should().BeEquivalentTo(firstSerialized, options => options.WithStrictOrdering());
}
}
[Fact]
public async Task Pagination_Returns_Consistent_Pages()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Pagination_Returns_Consistent_Pages));
for (int i = 1; i <= 50; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i));
}
// Act
var page1A = await GetPagedAsync(session, 0, 10);
var page1B = await GetPagedAsync(session, 0, 10);
var page2A = await GetPagedAsync(session, 10, 10);
var page2B = await GetPagedAsync(session, 10, 10);
// Assert
page1A.Select(SerializeEntity).Should().BeEquivalentTo(
page1B.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"same page should return same results");
page2A.Select(SerializeEntity).Should().BeEquivalentTo(
page2B.Select(SerializeEntity),
options => options.WithStrictOrdering());
// Pages should not overlap
var page1Keys = page1A.Select(GetSortValue).ToHashSet();
var page2Keys = page2A.Select(GetSortValue).ToHashSet();
page1Keys.Intersect(page2Keys).Should().BeEmpty("pages should not overlap");
}
[Fact]
public async Task Query_After_Insert_Returns_Updated_Results_Deterministically()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Query_After_Insert_Returns_Updated_Results_Deterministically));
for (int i = 1; i <= 10; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i * 10));
}
// Get baseline
var baseline = await GetAllSortedAsync(session);
baseline.Should().HaveCount(10);
// Act - Insert more
for (int i = 11; i <= 15; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i * 10));
}
var after1 = await GetAllSortedAsync(session);
var after2 = await GetAllSortedAsync(session);
// Assert
after1.Should().HaveCount(15);
after1.Select(SerializeEntity).Should().BeEquivalentTo(
after2.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"queries after insert should be consistent");
}
[Fact]
public async Task Empty_Query_Returns_Empty_Deterministically()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Empty_Query_Returns_Empty_Deterministically));
// Don't insert anything
// Act
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 3; i++)
{
results.Add(await GetAllSortedAsync(session));
}
// Assert
foreach (var result in results)
{
result.Should().BeEmpty("empty table should return empty results");
}
}
[Fact]
public async Task Large_Result_Set_Maintains_Deterministic_Order()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Large_Result_Set_Maintains_Deterministic_Order));
var random = new Random(12345);
var entities = Enumerable.Range(1, 100)
.Select(i => CreateTestEntity(GenerateKey(i), random.Next(1, 1000)))
.ToList();
foreach (var entity in entities)
{
await InsertAsync(session, entity);
}
// Act
var result1 = await GetAllSortedAsync(session);
var result2 = await GetAllSortedAsync(session);
// Assert
result1.Select(SerializeEntity).Should().BeEquivalentTo(
result2.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"large result sets should maintain deterministic order");
}
}

View File

@@ -0,0 +1,222 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for storage concurrency tests.
/// Inherit from this class to verify that storage operations handle concurrency correctly.
/// </summary>
/// <typeparam name="TEntity">The entity type being stored.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected StorageConcurrencyTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key, int version = 1);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Updates the entity in storage.
/// </summary>
protected abstract Task<TEntity> UpdateAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves the entity from storage by key.
/// </summary>
protected abstract Task<TEntity?> GetByKeyAsync(PostgresTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Gets the version/timestamp from an entity for optimistic concurrency.
/// </summary>
protected abstract int GetVersion(TEntity entity);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Default concurrency level for tests.
/// </summary>
protected virtual int DefaultConcurrency => 10;
[Fact]
public async Task Concurrent_Inserts_Different_Keys_Should_All_Succeed()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Inserts_Different_Keys_Should_All_Succeed));
var entities = Enumerable.Range(1, DefaultConcurrency)
.Select(i => CreateTestEntity(GenerateKey(i)))
.ToList();
// Act
var tasks = entities.Select(e => Task.Run(async () => await InsertAsync(session, e)));
await Task.WhenAll(tasks);
// Assert
foreach (var entity in entities)
{
var key = GenerateKey(entities.IndexOf(entity) + 1);
var retrieved = await GetByKeyAsync(session, key);
retrieved.Should().NotBeNull();
}
}
[Fact]
public async Task Concurrent_Updates_Same_Key_Should_Not_Lose_Updates()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Updates_Same_Key_Should_Not_Lose_Updates));
var key = GenerateKey(100);
var initial = CreateTestEntity(key, 0);
await InsertAsync(session, initial);
// Act
var successCount = 0;
var tasks = Enumerable.Range(1, DefaultConcurrency)
.Select(i => Task.Run(async () =>
{
try
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
Interlocked.Increment(ref successCount);
}
catch
{
// Some updates may fail due to optimistic concurrency
}
}));
await Task.WhenAll(tasks);
// Assert
successCount.Should().BeGreaterThan(0, "at least some updates should succeed");
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull();
}
[Fact]
public async Task Read_During_Write_Should_Return_Consistent_Data()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Read_During_Write_Should_Return_Consistent_Data));
var key = GenerateKey(200);
var initial = CreateTestEntity(key, 1);
await InsertAsync(session, initial);
// Act
var readResults = new List<TEntity?>();
var readTask = Task.Run(async () =>
{
for (int i = 0; i < 20; i++)
{
var result = await GetByKeyAsync(session, key);
lock (readResults)
{
readResults.Add(result);
}
await Task.Delay(10);
}
});
var writeTask = Task.Run(async () =>
{
for (int i = 2; i <= 10; i++)
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
await Task.Delay(15);
}
});
await Task.WhenAll(readTask, writeTask);
// Assert
readResults.Should().NotBeEmpty();
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
}
[Fact]
public async Task Parallel_Operations_Should_Maintain_Data_Integrity()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Parallel_Operations_Should_Maintain_Data_Integrity));
var keys = Enumerable.Range(1, 5).Select(GenerateKey).ToList();
// Insert initial entities
foreach (var key in keys)
{
await InsertAsync(session, CreateTestEntity(key, 1));
}
// Act
var operations = new List<Task>();
for (int round = 0; round < 3; round++)
{
foreach (var key in keys)
{
operations.Add(Task.Run(async () =>
{
// Read
var entity = await GetByKeyAsync(session, key);
if (entity != null)
{
// Update
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
await UpdateAsync(session, updated);
}
}));
}
}
await Task.WhenAll(operations);
// Assert
foreach (var key in keys)
{
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull("entity should exist after parallel operations");
}
}
[Fact]
public async Task High_Concurrency_Batch_Insert_Should_Complete()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(High_Concurrency_Batch_Insert_Should_Complete));
var entityCount = DefaultConcurrency * 10;
var entities = Enumerable.Range(1, entityCount)
.Select(i => CreateTestEntity(GenerateKey(i + 1000)))
.ToList();
// Act
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = DefaultConcurrency };
await Parallel.ForEachAsync(entities, parallelOptions, async (entity, ct) =>
{
await InsertAsync(session, entity, ct);
});
// Assert
// All inserts should complete without deadlock or timeout
var sample = await GetByKeyAsync(session, GenerateKey(1001));
sample.Should().NotBeNull();
}
}

View File

@@ -0,0 +1,151 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for storage idempotency tests.
/// Inherit from this class to verify that storage operations are idempotent.
/// </summary>
/// <typeparam name="TEntity">The entity type being stored.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class StorageIdempotencyTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected StorageIdempotencyTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key);
/// <summary>
/// Gets the key from an entity.
/// </summary>
protected abstract TKey GetKey(TEntity entity);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Upserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> UpsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves the entity from storage by key.
/// </summary>
protected abstract Task<TEntity?> GetByKeyAsync(PostgresTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Counts all entities in storage.
/// </summary>
protected abstract Task<int> CountAsync(PostgresTestSession session, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
[Fact]
public async Task Insert_SameEntity_Twice_Should_Be_Idempotent()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Insert_SameEntity_Twice_Should_Be_Idempotent));
var key = GenerateKey(1);
var entity = CreateTestEntity(key);
// Act
var first = await InsertAsync(session, entity);
var second = await UpsertAsync(session, entity);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "idempotent insert should not create duplicates");
}
[Fact]
public async Task Upsert_Creates_When_Not_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Upsert_Creates_When_Not_Exists));
var key = GenerateKey(2);
var entity = CreateTestEntity(key);
// Act
var result = await UpsertAsync(session, entity);
// Assert
var retrieved = await GetByKeyAsync(session, key);
retrieved.Should().NotBeNull();
}
[Fact]
public async Task Upsert_Updates_When_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Upsert_Updates_When_Exists));
var key = GenerateKey(3);
var entity = CreateTestEntity(key);
// Act
await InsertAsync(session, entity);
var modified = CreateTestEntity(key);
var result = await UpsertAsync(session, modified);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "upsert should update existing, not create duplicate");
}
[Fact]
public async Task Multiple_Upserts_Same_Key_Produces_Single_Record()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Multiple_Upserts_Same_Key_Produces_Single_Record));
var key = GenerateKey(4);
// Act
for (int i = 0; i < 5; i++)
{
var entity = CreateTestEntity(key);
await UpsertAsync(session, entity);
}
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "repeated upserts should not create duplicates");
}
[Fact]
public async Task Concurrent_Upserts_Same_Key_Should_Not_Fail()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Upserts_Same_Key_Should_Not_Fail));
var key = GenerateKey(5);
// Act
var tasks = Enumerable.Range(0, 10)
.Select(_ => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await UpsertAsync(session, entity);
}));
await Task.WhenAll(tasks);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "concurrent upserts should resolve to single record");
}
}

View File

@@ -0,0 +1,325 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.TestKit.Extensions;
using StellaOps.TestKit.Observability;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for web service contract tests.
/// Provides OpenAPI schema validation and standard test patterns.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceContractTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceContractTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
/// <summary>
/// Gets the path to the OpenAPI schema snapshot.
/// </summary>
protected abstract string OpenApiSnapshotPath { get; }
/// <summary>
/// Gets the Swagger endpoint path.
/// </summary>
protected virtual string SwaggerEndpoint => "/swagger/v1/swagger.json";
/// <summary>
/// Gets the expected endpoints that must exist.
/// </summary>
protected abstract IEnumerable<string> RequiredEndpoints { get; }
/// <summary>
/// Gets the endpoints requiring authentication.
/// </summary>
protected abstract IEnumerable<string> AuthenticatedEndpoints { get; }
[Fact]
public virtual async Task OpenApiSchema_MatchesSnapshot()
{
await Fixtures.ContractTestHelper.ValidateOpenApiSchemaAsync(
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
}
[Fact]
public virtual async Task OpenApiSchema_ContainsRequiredEndpoints()
{
await Fixtures.ContractTestHelper.ValidateEndpointsExistAsync(
Factory, RequiredEndpoints, SwaggerEndpoint);
}
[Fact]
public virtual async Task OpenApiSchema_HasNoBreakingChanges()
{
var changes = await Fixtures.ContractTestHelper.DetectBreakingChangesAsync(
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
changes.HasBreakingChanges.Should().BeFalse(
$"Breaking changes detected: {string.Join(", ", changes.BreakingChanges)}");
}
public void Dispose()
{
if (_disposed) return;
OtelCapture.Dispose();
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service negative tests.
/// Tests malformed requests, oversized payloads, wrong methods, etc.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceNegativeTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
private bool _disposed;
protected WebServiceNegativeTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
}
/// <summary>
/// Gets test cases for malformed content type (endpoint, expected status).
/// </summary>
protected abstract IEnumerable<(string Endpoint, HttpStatusCode ExpectedStatus)> MalformedContentTypeTestCases { get; }
/// <summary>
/// Gets test cases for oversized payloads.
/// </summary>
protected abstract IEnumerable<(string Endpoint, int PayloadSizeBytes)> OversizedPayloadTestCases { get; }
/// <summary>
/// Gets test cases for method mismatch.
/// </summary>
protected abstract IEnumerable<(string Endpoint, HttpMethod ExpectedMethod)> MethodMismatchTestCases { get; }
[Fact]
public virtual async Task MalformedContentType_Returns415()
{
foreach (var (endpoint, expectedStatus) in MalformedContentTypeTestCases)
{
var response = await Client.SendWithMalformedContentTypeAsync(
HttpMethod.Post, endpoint, "{}");
response.StatusCode.Should().Be(expectedStatus,
$"endpoint {endpoint} should return {expectedStatus} for malformed content type");
}
}
[Fact]
public virtual async Task OversizedPayload_Returns413()
{
foreach (var (endpoint, sizeBytes) in OversizedPayloadTestCases)
{
var response = await Client.SendOversizedPayloadAsync(endpoint, sizeBytes);
response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge,
$"endpoint {endpoint} should return 413 for oversized payload ({sizeBytes} bytes)");
}
}
[Fact]
public virtual async Task WrongHttpMethod_Returns405()
{
foreach (var (endpoint, expectedMethod) in MethodMismatchTestCases)
{
var response = await Client.SendWithWrongMethodAsync(endpoint, expectedMethod);
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
$"endpoint {endpoint} should return 405 when called with wrong method");
}
}
public void Dispose()
{
if (_disposed) return;
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service auth/authz tests.
/// Tests deny-by-default, token expiry, tenant isolation.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceAuthTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
private bool _disposed;
protected WebServiceAuthTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
}
/// <summary>
/// Gets endpoints that require authentication.
/// </summary>
protected abstract IEnumerable<string> ProtectedEndpoints { get; }
/// <summary>
/// Generates a valid token for the given tenant.
/// </summary>
protected abstract string GenerateValidToken(string tenantId);
/// <summary>
/// Generates an expired token.
/// </summary>
protected abstract string GenerateExpiredToken();
/// <summary>
/// Generates a token for a different tenant (for isolation tests).
/// </summary>
protected abstract string GenerateOtherTenantToken(string otherTenantId);
[Fact]
public virtual async Task ProtectedEndpoints_WithoutAuth_Returns401()
{
using var client = Factory.CreateClient();
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.SendWithoutAuthAsync(HttpMethod.Get, endpoint);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should require authentication");
}
}
[Fact]
public virtual async Task ProtectedEndpoints_WithExpiredToken_Returns401()
{
using var client = Factory.CreateClient();
var expiredToken = GenerateExpiredToken();
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.SendWithExpiredTokenAsync(endpoint, expiredToken);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should reject expired tokens");
}
}
[Fact]
public virtual async Task ProtectedEndpoints_WithValidToken_ReturnsSuccess()
{
using var client = Factory.CreateClient();
var validToken = GenerateValidToken("test-tenant");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", validToken);
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.GetAsync(endpoint);
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should accept valid tokens");
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service OTel trace tests.
/// Validates that traces are emitted with required attributes.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceOtelTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceOtelTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
/// <summary>
/// Gets endpoints and their expected span names.
/// </summary>
protected abstract IEnumerable<(string Endpoint, string ExpectedSpanName)> TracedEndpoints { get; }
/// <summary>
/// Gets required trace attributes for all spans.
/// </summary>
protected abstract IEnumerable<string> RequiredTraceAttributes { get; }
[Fact]
public virtual async Task Endpoints_EmitTraces()
{
foreach (var (endpoint, expectedSpan) in TracedEndpoints)
{
var capture = new OtelCapture();
var response = await Client.GetAsync(endpoint);
capture.AssertHasSpan(expectedSpan);
capture.Dispose();
}
}
[Fact]
public virtual async Task Traces_ContainRequiredAttributes()
{
foreach (var (endpoint, _) in TracedEndpoints)
{
var capture = new OtelCapture();
await Client.GetAsync(endpoint);
foreach (var attr in RequiredTraceAttributes)
{
capture.CapturedActivities.Should().Contain(a =>
a.Tags.Any(t => t.Key == attr),
$"trace for {endpoint} should have attribute '{attr}'");
}
capture.Dispose();
}
}
public void Dispose()
{
if (_disposed) return;
OtelCapture.Dispose();
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,63 @@
namespace StellaOps.TestKit;
/// <summary>
/// Standardized test trait categories for organizing and filtering tests in CI pipelines.
/// </summary>
/// <remarks>
/// Usage with xUnit:
/// <code>
/// [Fact, Trait("Category", TestCategories.Unit)]
/// public void TestBusinessLogic() { }
///
/// [Fact, Trait("Category", TestCategories.Integration)]
/// public async Task TestDatabaseAccess() { }
/// </code>
///
/// Filter by category during test runs:
/// <code>
/// dotnet test --filter "Category=Unit"
/// dotnet test --filter "Category!=Live"
/// </code>
/// </remarks>
public static class TestCategories
{
/// <summary>
/// Unit tests: Fast, in-memory, no external dependencies.
/// </summary>
public const string Unit = "Unit";
/// <summary>
/// Property-based tests: FsCheck/generative testing for invariants.
/// </summary>
public const string Property = "Property";
/// <summary>
/// Snapshot tests: Golden master regression testing.
/// </summary>
public const string Snapshot = "Snapshot";
/// <summary>
/// Integration tests: Testcontainers, PostgreSQL, Valkey, etc.
/// </summary>
public const string Integration = "Integration";
/// <summary>
/// Contract tests: API/WebService contract verification.
/// </summary>
public const string Contract = "Contract";
/// <summary>
/// Security tests: Cryptographic validation, vulnerability scanning.
/// </summary>
public const string Security = "Security";
/// <summary>
/// Performance tests: Benchmarking, load testing.
/// </summary>
public const string Performance = "Performance";
/// <summary>
/// Live tests: Require external services (e.g., Rekor, NuGet feeds). Disabled by default in CI.
/// </summary>
public const string Live = "Live";
}

View File

@@ -1,70 +0,0 @@
namespace StellaOps.TestKit.Time;
/// <summary>
/// Deterministic clock for testing that returns a fixed time.
/// </summary>
public sealed class DeterministicClock
{
private DateTimeOffset _currentTime;
/// <summary>
/// Creates a new deterministic clock with the specified initial time.
/// </summary>
/// <param name="initialTime">The initial time. If null, uses 2025-01-01T00:00:00Z.</param>
public DeterministicClock(DateTimeOffset? initialTime = null)
{
_currentTime = initialTime ?? new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
/// <summary>
/// Gets the current time.
/// </summary>
public DateTimeOffset UtcNow => _currentTime;
/// <summary>
/// Advances the clock by the specified duration.
/// </summary>
/// <param name="duration">The duration to advance.</param>
public void Advance(TimeSpan duration)
{
_currentTime = _currentTime.Add(duration);
}
/// <summary>
/// Sets the clock to a specific time.
/// </summary>
/// <param name="time">The time to set.</param>
public void SetTime(DateTimeOffset time)
{
_currentTime = time;
}
/// <summary>
/// Resets the clock to the initial time.
/// </summary>
public void Reset()
{
_currentTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}
/// <summary>
/// Extensions for working with deterministic clocks in tests.
/// </summary>
public static class DeterministicClockExtensions
{
/// <summary>
/// Standard test epoch: 2025-01-01T00:00:00Z
/// </summary>
public static readonly DateTimeOffset TestEpoch = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Creates a clock at the standard test epoch.
/// </summary>
public static DeterministicClock AtTestEpoch() => new(TestEpoch);
/// <summary>
/// Creates a clock at a specific ISO 8601 timestamp.
/// </summary>
public static DeterministicClock At(string iso8601) => new(DateTimeOffset.Parse(iso8601));
}

View File

@@ -1,21 +0,0 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace StellaOps.TestKit.Traits;
/// <summary>
/// Trait discoverer for Lane attribute.
/// </summary>
public sealed class LaneTraitDiscoverer : ITraitDiscoverer
{
public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
{
var lane = traitAttribute.GetNamedArgument<string>(nameof(LaneAttribute.Lane))
?? traitAttribute.GetConstructorArguments().FirstOrDefault()?.ToString();
if (!string.IsNullOrEmpty(lane))
{
yield return new KeyValuePair<string, string>("Lane", lane);
}
}
}

View File

@@ -1,144 +0,0 @@
using Xunit.Sdk;
namespace StellaOps.TestKit.Traits;
/// <summary>
/// Base attribute for test traits that categorize tests by lane and type.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public abstract class TestTraitAttributeBase : Attribute, ITraitAttribute
{
protected TestTraitAttributeBase(string traitName, string value)
{
TraitName = traitName;
Value = value;
}
public string TraitName { get; }
public string Value { get; }
}
/// <summary>
/// Marks a test as belonging to a specific test lane.
/// Lanes: Unit, Contract, Integration, Security, Performance, Live
/// </summary>
[TraitDiscoverer("StellaOps.TestKit.Traits.LaneTraitDiscoverer", "StellaOps.TestKit")]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
public sealed class LaneAttribute : Attribute, ITraitAttribute
{
public LaneAttribute(string lane)
{
Lane = lane ?? throw new ArgumentNullException(nameof(lane));
}
public string Lane { get; }
}
/// <summary>
/// Marks a test with a specific test type trait.
/// Common types: unit, property, snapshot, determinism, integration_postgres, contract, authz, etc.
/// </summary>
[TraitDiscoverer("StellaOps.TestKit.Traits.TestTypeTraitDiscoverer", "StellaOps.TestKit")]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public sealed class TestTypeAttribute : Attribute, ITraitAttribute
{
public TestTypeAttribute(string testType)
{
TestType = testType ?? throw new ArgumentNullException(nameof(testType));
}
public string TestType { get; }
}
// Lane-specific convenience attributes
/// <summary>
/// Marks a test as a Unit test.
/// </summary>
public sealed class UnitTestAttribute : LaneAttribute
{
public UnitTestAttribute() : base("Unit") { }
}
/// <summary>
/// Marks a test as a Contract test.
/// </summary>
public sealed class ContractTestAttribute : LaneAttribute
{
public ContractTestAttribute() : base("Contract") { }
}
/// <summary>
/// Marks a test as an Integration test.
/// </summary>
public sealed class IntegrationTestAttribute : LaneAttribute
{
public IntegrationTestAttribute() : base("Integration") { }
}
/// <summary>
/// Marks a test as a Security test.
/// </summary>
public sealed class SecurityTestAttribute : LaneAttribute
{
public SecurityTestAttribute() : base("Security") { }
}
/// <summary>
/// Marks a test as a Performance test.
/// </summary>
public sealed class PerformanceTestAttribute : LaneAttribute
{
public PerformanceTestAttribute() : base("Performance") { }
}
/// <summary>
/// Marks a test as a Live test (requires external connectivity).
/// These tests should be opt-in only and never PR-gating.
/// </summary>
public sealed class LiveTestAttribute : LaneAttribute
{
public LiveTestAttribute() : base("Live") { }
}
// Test type-specific convenience attributes
/// <summary>
/// Marks a test as testing determinism.
/// </summary>
public sealed class DeterminismTestAttribute : TestTypeAttribute
{
public DeterminismTestAttribute() : base("determinism") { }
}
/// <summary>
/// Marks a test as a snapshot test.
/// </summary>
public sealed class SnapshotTestAttribute : TestTypeAttribute
{
public SnapshotTestAttribute() : base("snapshot") { }
}
/// <summary>
/// Marks a test as a property-based test.
/// </summary>
public sealed class PropertyTestAttribute : TestTypeAttribute
{
public PropertyTestAttribute() : base("property") { }
}
/// <summary>
/// Marks a test as an authorization test.
/// </summary>
public sealed class AuthzTestAttribute : TestTypeAttribute
{
public AuthzTestAttribute() : base("authz") { }
}
/// <summary>
/// Marks a test as testing OpenTelemetry traces.
/// </summary>
public sealed class OTelTestAttribute : TestTypeAttribute
{
public OTelTestAttribute() : base("otel") { }
}

View File

@@ -1,21 +0,0 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace StellaOps.TestKit.Traits;
/// <summary>
/// Trait discoverer for TestType attribute.
/// </summary>
public sealed class TestTypeTraitDiscoverer : ITraitDiscoverer
{
public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
{
var testType = traitAttribute.GetNamedArgument<string>(nameof(TestTypeAttribute.TestType))
?? traitAttribute.GetConstructorArguments().FirstOrDefault()?.ToString();
if (!string.IsNullOrEmpty(testType))
{
yield return new KeyValuePair<string, string>("TestType", testType);
}
}
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Stores and retrieves determinism baselines for artifact comparison.
/// Baselines are SHA-256 hashes of canonical artifact representations used to detect drift.
/// </summary>
public sealed class DeterminismBaselineStore
{
private readonly string _baselineDirectory;
private readonly ConcurrentDictionary<string, DeterminismBaseline> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Creates a baseline store with the specified directory.
/// </summary>
/// <param name="baselineDirectory">Directory path for storing baselines.</param>
public DeterminismBaselineStore(string baselineDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baselineDirectory);
_baselineDirectory = baselineDirectory;
}
/// <summary>
/// Creates a baseline store using the default baseline directory.
/// Default: tests/baselines/determinism relative to repository root.
/// </summary>
/// <param name="repositoryRoot">Repository root directory.</param>
/// <returns>Configured baseline store.</returns>
public static DeterminismBaselineStore CreateDefault(string repositoryRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot);
var baselineDir = Path.Combine(repositoryRoot, "tests", "baselines", "determinism");
return new DeterminismBaselineStore(baselineDir);
}
/// <summary>
/// Stores a baseline for an artifact.
/// </summary>
/// <param name="artifactType">Type of artifact (e.g., "sbom", "vex", "policy-verdict").</param>
/// <param name="artifactName">Name of the artifact (e.g., "alpine-3.18-spdx").</param>
/// <param name="baseline">The baseline to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task StoreBaselineAsync(
string artifactType,
string artifactName,
DeterminismBaseline baseline,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
ArgumentNullException.ThrowIfNull(baseline);
var key = GetBaselineKey(artifactType, artifactName);
var filePath = GetBaselineFilePath(artifactType, artifactName);
// Ensure directory exists
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Serialize and write
var json = JsonSerializer.Serialize(baseline, JsonOptions);
await File.WriteAllTextAsync(filePath, json, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
// Update cache
_cache[key] = baseline;
}
/// <summary>
/// Retrieves a baseline for an artifact.
/// </summary>
/// <param name="artifactType">Type of artifact.</param>
/// <param name="artifactName">Name of the artifact.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The baseline if found, null otherwise.</returns>
public async Task<DeterminismBaseline?> GetBaselineAsync(
string artifactType,
string artifactName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
var key = GetBaselineKey(artifactType, artifactName);
// Check cache first
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
// Load from file
var filePath = GetBaselineFilePath(artifactType, artifactName);
if (!File.Exists(filePath))
{
return null;
}
var json = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
var baseline = JsonSerializer.Deserialize<DeterminismBaseline>(json, JsonOptions);
if (baseline is not null)
{
_cache[key] = baseline;
}
return baseline;
}
/// <summary>
/// Compares an artifact against its stored baseline.
/// </summary>
/// <param name="artifactType">Type of artifact.</param>
/// <param name="artifactName">Name of the artifact.</param>
/// <param name="currentHash">Current SHA-256 hash of the artifact.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Comparison result indicating match, drift, or missing baseline.</returns>
public async Task<BaselineComparisonResult> CompareAsync(
string artifactType,
string artifactName,
string currentHash,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
ArgumentException.ThrowIfNullOrWhiteSpace(currentHash);
var baseline = await GetBaselineAsync(artifactType, artifactName, cancellationToken).ConfigureAwait(false);
if (baseline is null)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Missing,
CurrentHash = currentHash,
BaselineHash = null,
Message = $"No baseline found for {artifactType}/{artifactName}. Run with UPDATE_BASELINES=true to create."
};
}
var isMatch = string.Equals(baseline.CanonicalHash, currentHash, StringComparison.OrdinalIgnoreCase);
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = isMatch ? BaselineStatus.Match : BaselineStatus.Drift,
CurrentHash = currentHash,
BaselineHash = baseline.CanonicalHash,
BaselineVersion = baseline.Version,
Message = isMatch
? $"Artifact {artifactType}/{artifactName} matches baseline."
: $"DRIFT DETECTED: {artifactType}/{artifactName} hash changed from {baseline.CanonicalHash} to {currentHash}."
};
}
/// <summary>
/// Lists all baselines in the store.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of baseline entries.</returns>
public async Task<IReadOnlyList<BaselineEntry>> ListBaselinesAsync(
CancellationToken cancellationToken = default)
{
var entries = new List<BaselineEntry>();
if (!Directory.Exists(_baselineDirectory))
{
return entries;
}
var files = Directory.GetFiles(_baselineDirectory, "*.baseline.json", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var json = await File.ReadAllTextAsync(file, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
var baseline = JsonSerializer.Deserialize<DeterminismBaseline>(json, JsonOptions);
if (baseline is not null)
{
var relativePath = Path.GetRelativePath(_baselineDirectory, file);
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
entries.Add(new BaselineEntry
{
ArtifactType = parts.Length > 1 ? parts[0] : "unknown",
ArtifactName = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(file)),
CanonicalHash = baseline.CanonicalHash,
Version = baseline.Version,
UpdatedAt = baseline.UpdatedAt,
FilePath = file
});
}
}
catch
{
// Skip invalid baseline files
}
}
return entries.OrderBy(e => e.ArtifactType).ThenBy(e => e.ArtifactName).ToList();
}
/// <summary>
/// Creates a baseline from an artifact.
/// </summary>
/// <param name="artifactBytes">The artifact bytes to hash.</param>
/// <param name="version">Version identifier for this baseline.</param>
/// <param name="metadata">Optional metadata about the baseline.</param>
/// <returns>Created baseline.</returns>
public static DeterminismBaseline CreateBaseline(
ReadOnlySpan<byte> artifactBytes,
string version,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var hash = CanonJson.Sha256Hex(artifactBytes);
return new DeterminismBaseline
{
CanonicalHash = hash,
Algorithm = "SHA-256",
Version = version,
UpdatedAt = DateTimeOffset.UtcNow,
Metadata = metadata
};
}
/// <summary>
/// Creates a baseline from a JSON artifact with canonical serialization.
/// </summary>
/// <typeparam name="T">The artifact type.</typeparam>
/// <param name="artifact">The artifact to serialize and hash.</param>
/// <param name="version">Version identifier for this baseline.</param>
/// <param name="metadata">Optional metadata about the baseline.</param>
/// <returns>Created baseline.</returns>
public static DeterminismBaseline CreateBaselineFromJson<T>(
T artifact,
string version,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentNullException.ThrowIfNull(artifact);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var canonicalBytes = CanonJson.Canonicalize(artifact);
var hash = CanonJson.Sha256Hex(canonicalBytes);
return new DeterminismBaseline
{
CanonicalHash = hash,
Algorithm = "SHA-256",
Version = version,
UpdatedAt = DateTimeOffset.UtcNow,
Metadata = metadata
};
}
/// <summary>
/// Gets the baseline directory path.
/// </summary>
public string BaselineDirectory => _baselineDirectory;
private string GetBaselineFilePath(string artifactType, string artifactName)
{
var safeType = SanitizePathComponent(artifactType);
var safeName = SanitizePathComponent(artifactName);
return Path.Combine(_baselineDirectory, safeType, $"{safeName}.baseline.json");
}
private static string GetBaselineKey(string artifactType, string artifactName)
{
return $"{artifactType}/{artifactName}".ToLowerInvariant();
}
private static string SanitizePathComponent(string component)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new StringBuilder(component.Length);
foreach (var c in component)
{
sanitized.Append(invalid.Contains(c) ? '_' : c);
}
return sanitized.ToString();
}
}
/// <summary>
/// A stored baseline for determinism comparison.
/// </summary>
public sealed record DeterminismBaseline
{
/// <summary>
/// SHA-256 hash of the canonical artifact representation (hex-encoded).
/// </summary>
[JsonPropertyName("canonicalHash")]
public required string CanonicalHash { get; init; }
/// <summary>
/// Hash algorithm used (always "SHA-256").
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Version identifier for this baseline (e.g., "1.0.0", git SHA, or timestamp).
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// UTC timestamp when this baseline was created or updated.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Optional metadata about the baseline.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Result of comparing an artifact against its baseline.
/// </summary>
public sealed record BaselineComparisonResult
{
/// <summary>
/// Type of artifact compared.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact compared.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Comparison status.
/// </summary>
[JsonPropertyName("status")]
public required BaselineStatus Status { get; init; }
/// <summary>
/// Current hash of the artifact.
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
/// <summary>
/// Baseline hash (null if missing).
/// </summary>
[JsonPropertyName("baselineHash")]
public string? BaselineHash { get; init; }
/// <summary>
/// Baseline version (null if missing).
/// </summary>
[JsonPropertyName("baselineVersion")]
public string? BaselineVersion { get; init; }
/// <summary>
/// Human-readable message describing the result.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Status of a baseline comparison.
/// </summary>
public enum BaselineStatus
{
/// <summary>
/// Artifact matches baseline hash.
/// </summary>
Match,
/// <summary>
/// Artifact hash differs from baseline (drift detected).
/// </summary>
Drift,
/// <summary>
/// No baseline exists for this artifact.
/// </summary>
Missing
}
/// <summary>
/// Entry in the baseline registry.
/// </summary>
public sealed record BaselineEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Canonical hash of the baseline.
/// </summary>
[JsonPropertyName("canonicalHash")]
public required string CanonicalHash { get; init; }
/// <summary>
/// Version identifier.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// When baseline was last updated.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// File path of the baseline.
/// </summary>
[JsonPropertyName("filePath")]
public required string FilePath { get; init; }
}

View File

@@ -2,7 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.TestKit.Determinism;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Determinism gates for verifying reproducible outputs.

View File

@@ -0,0 +1,322 @@
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Determinism manifest tracking artifact reproducibility with canonical bytes hash,
/// version stamps, and toolchain information.
/// </summary>
public sealed record DeterminismManifest
{
/// <summary>
/// Version of this manifest schema (currently "1.0").
/// </summary>
[JsonPropertyName("schemaVersion")]
public required string SchemaVersion { get; init; }
/// <summary>
/// Artifact being tracked for determinism.
/// </summary>
[JsonPropertyName("artifact")]
public required ArtifactInfo Artifact { get; init; }
/// <summary>
/// Hash of the canonical representation of the artifact.
/// </summary>
[JsonPropertyName("canonicalHash")]
public required CanonicalHashInfo CanonicalHash { get; init; }
/// <summary>
/// Version stamps of all inputs used to generate the artifact.
/// </summary>
[JsonPropertyName("inputs")]
public InputStamps? Inputs { get; init; }
/// <summary>
/// Toolchain version information.
/// </summary>
[JsonPropertyName("toolchain")]
public required ToolchainInfo Toolchain { get; init; }
/// <summary>
/// UTC timestamp when artifact was generated (ISO 8601).
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Reproducibility metadata.
/// </summary>
[JsonPropertyName("reproducibility")]
public ReproducibilityMetadata? Reproducibility { get; init; }
/// <summary>
/// Verification instructions for reproducing the artifact.
/// </summary>
[JsonPropertyName("verification")]
public VerificationInfo? Verification { get; init; }
/// <summary>
/// Optional cryptographic signatures of this manifest.
/// </summary>
[JsonPropertyName("signatures")]
public IReadOnlyList<SignatureInfo>? Signatures { get; init; }
}
/// <summary>
/// Artifact being tracked for determinism.
/// </summary>
public sealed record ArtifactInfo
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Artifact identifier or name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Artifact version or timestamp.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Artifact format (e.g., 'SPDX 3.0.1', 'CycloneDX 1.6', 'OpenVEX').
/// </summary>
[JsonPropertyName("format")]
public string? Format { get; init; }
/// <summary>
/// Additional artifact-specific metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object?>? Metadata { get; init; }
}
/// <summary>
/// Hash of the canonical representation of the artifact.
/// </summary>
public sealed record CanonicalHashInfo
{
/// <summary>
/// Hash algorithm used (SHA-256, SHA-384, SHA-512).
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Hex-encoded hash value.
/// </summary>
[JsonPropertyName("value")]
public required string Value { get; init; }
/// <summary>
/// Encoding of the hash value (hex or base64).
/// </summary>
[JsonPropertyName("encoding")]
public required string Encoding { get; init; }
}
/// <summary>
/// Version stamps of all inputs used to generate the artifact.
/// </summary>
public sealed record InputStamps
{
/// <summary>
/// SHA-256 hash of the vulnerability feed snapshot used.
/// </summary>
[JsonPropertyName("feedSnapshotHash")]
public string? FeedSnapshotHash { get; init; }
/// <summary>
/// SHA-256 hash of the policy manifest used.
/// </summary>
[JsonPropertyName("policyManifestHash")]
public string? PolicyManifestHash { get; init; }
/// <summary>
/// Git commit SHA or source code hash.
/// </summary>
[JsonPropertyName("sourceCodeHash")]
public string? SourceCodeHash { get; init; }
/// <summary>
/// Hash of dependency lockfile (e.g., package-lock.json, Cargo.lock).
/// </summary>
[JsonPropertyName("dependencyLockfileHash")]
public string? DependencyLockfileHash { get; init; }
/// <summary>
/// Container base image digest (sha256:...).
/// </summary>
[JsonPropertyName("baseImageDigest")]
public string? BaseImageDigest { get; init; }
/// <summary>
/// Hashes of all VEX documents used as input.
/// </summary>
[JsonPropertyName("vexDocumentHashes")]
public IReadOnlyList<string>? VexDocumentHashes { get; init; }
/// <summary>
/// Custom input hashes specific to artifact type.
/// </summary>
[JsonPropertyName("custom")]
public IReadOnlyDictionary<string, string>? Custom { get; init; }
}
/// <summary>
/// Toolchain version information.
/// </summary>
public sealed record ToolchainInfo
{
/// <summary>
/// Runtime platform (e.g., '.NET 10.0', 'Node.js 20.0').
/// </summary>
[JsonPropertyName("platform")]
public required string Platform { get; init; }
/// <summary>
/// Toolchain component versions.
/// </summary>
[JsonPropertyName("components")]
public required IReadOnlyList<ComponentInfo> Components { get; init; }
/// <summary>
/// Compiler information if applicable.
/// </summary>
[JsonPropertyName("compiler")]
public CompilerInfo? Compiler { get; init; }
}
/// <summary>
/// Toolchain component version.
/// </summary>
public sealed record ComponentInfo
{
/// <summary>
/// Component name (e.g., 'StellaOps.Scanner', 'CycloneDX Generator').
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Semantic version or git SHA.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Optional: SHA-256 hash of the component binary.
/// </summary>
[JsonPropertyName("hash")]
public string? Hash { get; init; }
}
/// <summary>
/// Compiler information.
/// </summary>
public sealed record CompilerInfo
{
/// <summary>
/// Compiler name (e.g., 'Roslyn', 'rustc').
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Compiler version.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
}
/// <summary>
/// Reproducibility metadata.
/// </summary>
public sealed record ReproducibilityMetadata
{
/// <summary>
/// Deterministic random seed if used.
/// </summary>
[JsonPropertyName("deterministicSeed")]
public int? DeterministicSeed { get; init; }
/// <summary>
/// Whether system clock was fixed during generation.
/// </summary>
[JsonPropertyName("clockFixed")]
public bool? ClockFixed { get; init; }
/// <summary>
/// Ordering guarantee for collections in output.
/// </summary>
[JsonPropertyName("orderingGuarantee")]
public string? OrderingGuarantee { get; init; }
/// <summary>
/// Normalization rules applied (e.g., 'UTF-8', 'LF line endings', 'no whitespace').
/// </summary>
[JsonPropertyName("normalizationRules")]
public IReadOnlyList<string>? NormalizationRules { get; init; }
}
/// <summary>
/// Verification instructions for reproducing the artifact.
/// </summary>
public sealed record VerificationInfo
{
/// <summary>
/// Command to regenerate the artifact.
/// </summary>
[JsonPropertyName("command")]
public string? Command { get; init; }
/// <summary>
/// Expected SHA-256 hash after reproduction.
/// </summary>
[JsonPropertyName("expectedHash")]
public string? ExpectedHash { get; init; }
/// <summary>
/// Baseline manifest file path for regression testing.
/// </summary>
[JsonPropertyName("baseline")]
public string? Baseline { get; init; }
}
/// <summary>
/// Cryptographic signature of the manifest.
/// </summary>
public sealed record SignatureInfo
{
/// <summary>
/// Signature algorithm (e.g., 'ES256', 'RS256').
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Key identifier used for signing.
/// </summary>
[JsonPropertyName("keyId")]
public required string KeyId { get; init; }
/// <summary>
/// Base64-encoded signature.
/// </summary>
[JsonPropertyName("signature")]
public required string Signature { get; init; }
/// <summary>
/// UTC timestamp when signature was created.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
}

View File

@@ -0,0 +1,238 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Reader for determinism manifest files with validation.
/// </summary>
public sealed class DeterminismManifestReader
{
private static readonly JsonSerializerOptions DefaultOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Deserializes a determinism manifest from JSON bytes.
/// </summary>
/// <param name="jsonBytes">UTF-8 encoded JSON bytes.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest FromBytes(ReadOnlySpan<byte> jsonBytes)
{
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(jsonBytes, DefaultOptions);
if (manifest is null)
{
throw new JsonException("Failed to deserialize determinism manifest: result was null.");
}
ValidateManifest(manifest);
return manifest;
}
/// <summary>
/// Deserializes a determinism manifest from a JSON string.
/// </summary>
/// <param name="json">JSON string.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest FromString(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
var bytes = Encoding.UTF8.GetBytes(json);
return FromBytes(bytes);
}
/// <summary>
/// Reads a determinism manifest from a file.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="FileNotFoundException">If file does not exist.</exception>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static async Task<DeterminismManifest> ReadFromFileAsync(
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Determinism manifest file not found: {filePath}");
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return FromBytes(bytes);
}
/// <summary>
/// Reads a determinism manifest from a file synchronously.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="FileNotFoundException">If file does not exist.</exception>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest ReadFromFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Determinism manifest file not found: {filePath}");
}
var bytes = File.ReadAllBytes(filePath);
return FromBytes(bytes);
}
/// <summary>
/// Tries to read a determinism manifest from a file, returning null if the file doesn't exist.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Deserialized manifest or null if file doesn't exist.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static async Task<DeterminismManifest?> TryReadFromFileAsync(
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
return null;
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return FromBytes(bytes);
}
/// <summary>
/// Validates a determinism manifest.
/// </summary>
/// <param name="manifest">The manifest to validate.</param>
/// <exception cref="InvalidOperationException">If validation fails.</exception>
private static void ValidateManifest(DeterminismManifest manifest)
{
// Validate schema version
if (string.IsNullOrWhiteSpace(manifest.SchemaVersion))
{
throw new InvalidOperationException("Determinism manifest schemaVersion is required.");
}
if (manifest.SchemaVersion != "1.0")
{
throw new InvalidOperationException($"Unsupported schema version: {manifest.SchemaVersion}. Expected '1.0'.");
}
// Validate artifact
if (manifest.Artifact is null)
{
throw new InvalidOperationException("Determinism manifest artifact is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Type))
{
throw new InvalidOperationException("Artifact type is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Name))
{
throw new InvalidOperationException("Artifact name is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Version))
{
throw new InvalidOperationException("Artifact version is required.");
}
// Validate canonical hash
if (manifest.CanonicalHash is null)
{
throw new InvalidOperationException("Determinism manifest canonicalHash is required.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Algorithm))
{
throw new InvalidOperationException("CanonicalHash algorithm is required.");
}
if (!IsSupportedHashAlgorithm(manifest.CanonicalHash.Algorithm))
{
throw new InvalidOperationException($"Unsupported hash algorithm: {manifest.CanonicalHash.Algorithm}. Supported: SHA-256, SHA-384, SHA-512.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Value))
{
throw new InvalidOperationException("CanonicalHash value is required.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Encoding))
{
throw new InvalidOperationException("CanonicalHash encoding is required.");
}
if (manifest.CanonicalHash.Encoding != "hex" && manifest.CanonicalHash.Encoding != "base64")
{
throw new InvalidOperationException($"Unsupported hash encoding: {manifest.CanonicalHash.Encoding}. Supported: hex, base64.");
}
// Validate toolchain
if (manifest.Toolchain is null)
{
throw new InvalidOperationException("Determinism manifest toolchain is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Toolchain.Platform))
{
throw new InvalidOperationException("Toolchain platform is required.");
}
if (manifest.Toolchain.Components is null || manifest.Toolchain.Components.Count == 0)
{
throw new InvalidOperationException("Toolchain components are required (at least one component).");
}
foreach (var component in manifest.Toolchain.Components)
{
if (string.IsNullOrWhiteSpace(component.Name))
{
throw new InvalidOperationException("Toolchain component name is required.");
}
if (string.IsNullOrWhiteSpace(component.Version))
{
throw new InvalidOperationException("Toolchain component version is required.");
}
}
// Validate generatedAt
if (manifest.GeneratedAt == default)
{
throw new InvalidOperationException("Determinism manifest generatedAt is required.");
}
}
private static bool IsSupportedHashAlgorithm(string algorithm)
{
return algorithm switch
{
"SHA-256" => true,
"SHA-384" => true,
"SHA-512" => true,
_ => false
};
}
}

View File

@@ -0,0 +1,183 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Writer for determinism manifest files with canonical JSON serialization.
/// </summary>
public sealed class DeterminismManifestWriter
{
private static readonly JsonSerializerOptions DefaultOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Serializes a determinism manifest to canonical JSON bytes.
/// Uses StellaOps.Canonical.Json for deterministic output.
/// </summary>
/// <param name="manifest">The manifest to serialize.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] ToCanonicalBytes(DeterminismManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
// Validate schema version
if (manifest.SchemaVersion != "1.0")
{
throw new InvalidOperationException($"Unsupported schema version: {manifest.SchemaVersion}. Expected '1.0'.");
}
// Canonicalize using CanonJson for deterministic output
return CanonJson.Canonicalize(manifest, DefaultOptions);
}
/// <summary>
/// Serializes a determinism manifest to a canonical JSON string.
/// </summary>
/// <param name="manifest">The manifest to serialize.</param>
/// <returns>UTF-8 encoded canonical JSON string.</returns>
public static string ToCanonicalString(DeterminismManifest manifest)
{
var bytes = ToCanonicalBytes(manifest);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Writes a determinism manifest to a file with canonical JSON serialization.
/// </summary>
/// <param name="manifest">The manifest to write.</param>
/// <param name="filePath">File path to write to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteToFileAsync(
DeterminismManifest manifest,
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var bytes = ToCanonicalBytes(manifest);
await File.WriteAllBytesAsync(filePath, bytes, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Writes a determinism manifest to a file synchronously.
/// </summary>
/// <param name="manifest">The manifest to write.</param>
/// <param name="filePath">File path to write to.</param>
public static void WriteToFile(DeterminismManifest manifest, string filePath)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var bytes = ToCanonicalBytes(manifest);
File.WriteAllBytes(filePath, bytes);
}
/// <summary>
/// Computes the SHA-256 hash of the canonical representation of a manifest.
/// </summary>
/// <param name="manifest">The manifest to hash.</param>
/// <returns>64-character lowercase hex string.</returns>
public static string ComputeCanonicalHash(DeterminismManifest manifest)
{
var bytes = ToCanonicalBytes(manifest);
return CanonJson.Sha256Hex(bytes);
}
/// <summary>
/// Creates a determinism manifest for an artifact with computed canonical hash.
/// </summary>
/// <param name="artifactBytes">The artifact bytes to hash.</param>
/// <param name="artifactInfo">Artifact metadata.</param>
/// <param name="toolchain">Toolchain information.</param>
/// <param name="inputs">Optional input stamps.</param>
/// <param name="reproducibility">Optional reproducibility metadata.</param>
/// <param name="verification">Optional verification info.</param>
/// <returns>Determinism manifest with computed canonical hash.</returns>
public static DeterminismManifest CreateManifest(
ReadOnlySpan<byte> artifactBytes,
ArtifactInfo artifactInfo,
ToolchainInfo toolchain,
InputStamps? inputs = null,
ReproducibilityMetadata? reproducibility = null,
VerificationInfo? verification = null)
{
ArgumentNullException.ThrowIfNull(artifactInfo);
ArgumentNullException.ThrowIfNull(toolchain);
var canonicalHash = CanonJson.Sha256Hex(artifactBytes);
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = artifactInfo,
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = canonicalHash,
Encoding = "hex"
},
Inputs = inputs,
Toolchain = toolchain,
GeneratedAt = DateTimeOffset.UtcNow,
Reproducibility = reproducibility,
Verification = verification,
Signatures = null
};
}
/// <summary>
/// Creates a determinism manifest for a JSON artifact (SBOM, VEX, policy verdict, etc.)
/// with canonical JSON serialization before hashing.
/// </summary>
/// <typeparam name="T">The artifact type.</typeparam>
/// <param name="artifact">The artifact to serialize and hash.</param>
/// <param name="artifactInfo">Artifact metadata.</param>
/// <param name="toolchain">Toolchain information.</param>
/// <param name="inputs">Optional input stamps.</param>
/// <param name="reproducibility">Optional reproducibility metadata.</param>
/// <param name="verification">Optional verification info.</param>
/// <returns>Determinism manifest with computed canonical hash.</returns>
public static DeterminismManifest CreateManifestForJsonArtifact<T>(
T artifact,
ArtifactInfo artifactInfo,
ToolchainInfo toolchain,
InputStamps? inputs = null,
ReproducibilityMetadata? reproducibility = null,
VerificationInfo? verification = null)
{
ArgumentNullException.ThrowIfNull(artifact);
ArgumentNullException.ThrowIfNull(artifactInfo);
ArgumentNullException.ThrowIfNull(toolchain);
// Canonicalize the artifact using CanonJson for deterministic serialization
var canonicalBytes = CanonJson.Canonicalize(artifact);
var canonicalHash = CanonJson.Sha256Hex(canonicalBytes);
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = artifactInfo,
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = canonicalHash,
Encoding = "hex"
},
Inputs = inputs,
Toolchain = toolchain,
GeneratedAt = DateTimeOffset.UtcNow,
Reproducibility = reproducibility,
Verification = verification,
Signatures = null
};
}
}

View File

@@ -0,0 +1,374 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Summary of determinism validation results for CI artifact output.
/// This is the "determinism.json" file emitted by CI workflows.
/// </summary>
public sealed record DeterminismSummary
{
/// <summary>
/// Schema version for this summary format.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// UTC timestamp when this summary was generated.
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Git commit SHA or other source identifier.
/// </summary>
[JsonPropertyName("sourceRef")]
public string? SourceRef { get; init; }
/// <summary>
/// CI run identifier (e.g., GitHub Actions run ID).
/// </summary>
[JsonPropertyName("ciRunId")]
public string? CiRunId { get; init; }
/// <summary>
/// Overall status of the determinism check.
/// </summary>
[JsonPropertyName("status")]
public required DeterminismCheckStatus Status { get; init; }
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("statistics")]
public required DeterminismStatistics Statistics { get; init; }
/// <summary>
/// Individual artifact comparison results.
/// </summary>
[JsonPropertyName("results")]
public required IReadOnlyList<BaselineComparisonResult> Results { get; init; }
/// <summary>
/// Artifacts with detected drift (subset of results for quick access).
/// </summary>
[JsonPropertyName("drift")]
public IReadOnlyList<DriftEntry>? Drift { get; init; }
/// <summary>
/// Artifacts missing baselines (subset of results for quick access).
/// </summary>
[JsonPropertyName("missing")]
public IReadOnlyList<MissingEntry>? Missing { get; init; }
}
/// <summary>
/// Overall status of determinism check.
/// </summary>
public enum DeterminismCheckStatus
{
/// <summary>
/// All artifacts match their baselines.
/// </summary>
Pass,
/// <summary>
/// One or more artifacts have drifted from their baselines.
/// </summary>
Fail,
/// <summary>
/// New artifacts detected without baselines (warning, not failure by default).
/// </summary>
Warning
}
/// <summary>
/// Summary statistics for determinism check.
/// </summary>
public sealed record DeterminismStatistics
{
/// <summary>
/// Total number of artifacts checked.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Number of artifacts matching their baselines.
/// </summary>
[JsonPropertyName("matched")]
public required int Matched { get; init; }
/// <summary>
/// Number of artifacts with detected drift.
/// </summary>
[JsonPropertyName("drifted")]
public required int Drifted { get; init; }
/// <summary>
/// Number of artifacts missing baselines.
/// </summary>
[JsonPropertyName("missing")]
public required int Missing { get; init; }
}
/// <summary>
/// Entry for an artifact that has drifted from its baseline.
/// </summary>
public sealed record DriftEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Previous baseline hash.
/// </summary>
[JsonPropertyName("baselineHash")]
public required string BaselineHash { get; init; }
/// <summary>
/// Current computed hash.
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
}
/// <summary>
/// Entry for an artifact missing a baseline.
/// </summary>
public sealed record MissingEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Current computed hash (to be used as baseline).
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
}
/// <summary>
/// Builder for creating determinism summaries from comparison results.
/// </summary>
public sealed class DeterminismSummaryBuilder
{
private readonly List<BaselineComparisonResult> _results = new();
private string? _sourceRef;
private string? _ciRunId;
private bool _failOnMissing;
/// <summary>
/// Sets the source reference (git commit SHA).
/// </summary>
public DeterminismSummaryBuilder WithSourceRef(string sourceRef)
{
_sourceRef = sourceRef;
return this;
}
/// <summary>
/// Sets the CI run identifier.
/// </summary>
public DeterminismSummaryBuilder WithCiRunId(string ciRunId)
{
_ciRunId = ciRunId;
return this;
}
/// <summary>
/// Configures whether missing baselines should cause failure.
/// </summary>
public DeterminismSummaryBuilder FailOnMissingBaselines(bool fail = true)
{
_failOnMissing = fail;
return this;
}
/// <summary>
/// Adds a comparison result.
/// </summary>
public DeterminismSummaryBuilder AddResult(BaselineComparisonResult result)
{
ArgumentNullException.ThrowIfNull(result);
_results.Add(result);
return this;
}
/// <summary>
/// Adds multiple comparison results.
/// </summary>
public DeterminismSummaryBuilder AddResults(IEnumerable<BaselineComparisonResult> results)
{
ArgumentNullException.ThrowIfNull(results);
_results.AddRange(results);
return this;
}
/// <summary>
/// Builds the determinism summary.
/// </summary>
public DeterminismSummary Build()
{
var matched = _results.Count(r => r.Status == BaselineStatus.Match);
var drifted = _results.Count(r => r.Status == BaselineStatus.Drift);
var missing = _results.Count(r => r.Status == BaselineStatus.Missing);
var status = DetermineStatus(drifted, missing);
var drift = _results
.Where(r => r.Status == BaselineStatus.Drift)
.Select(r => new DriftEntry
{
ArtifactType = r.ArtifactType,
ArtifactName = r.ArtifactName,
BaselineHash = r.BaselineHash!,
CurrentHash = r.CurrentHash
})
.ToList();
var missingEntries = _results
.Where(r => r.Status == BaselineStatus.Missing)
.Select(r => new MissingEntry
{
ArtifactType = r.ArtifactType,
ArtifactName = r.ArtifactName,
CurrentHash = r.CurrentHash
})
.ToList();
return new DeterminismSummary
{
GeneratedAt = DateTimeOffset.UtcNow,
SourceRef = _sourceRef,
CiRunId = _ciRunId,
Status = status,
Statistics = new DeterminismStatistics
{
Total = _results.Count,
Matched = matched,
Drifted = drifted,
Missing = missing
},
Results = _results.ToList(),
Drift = drift.Count > 0 ? drift : null,
Missing = missingEntries.Count > 0 ? missingEntries : null
};
}
private DeterminismCheckStatus DetermineStatus(int drifted, int missing)
{
if (drifted > 0)
{
return DeterminismCheckStatus.Fail;
}
if (missing > 0 && _failOnMissing)
{
return DeterminismCheckStatus.Fail;
}
if (missing > 0)
{
return DeterminismCheckStatus.Warning;
}
return DeterminismCheckStatus.Pass;
}
}
/// <summary>
/// Writer for determinism summary files.
/// </summary>
public static class DeterminismSummaryWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Writes a determinism summary to a file.
/// </summary>
/// <param name="summary">The summary to write.</param>
/// <param name="filePath">Output file path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteToFileAsync(
DeterminismSummary summary,
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(summary);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(summary, JsonOptions);
await File.WriteAllTextAsync(filePath, json, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Serializes a determinism summary to JSON string.
/// </summary>
/// <param name="summary">The summary to serialize.</param>
/// <returns>JSON string.</returns>
public static string ToJson(DeterminismSummary summary)
{
ArgumentNullException.ThrowIfNull(summary);
return JsonSerializer.Serialize(summary, JsonOptions);
}
/// <summary>
/// Writes hash files (sha256.txt) for each artifact in the summary.
/// </summary>
/// <param name="summary">The summary containing artifacts.</param>
/// <param name="outputDirectory">Directory to write hash files.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteHashFilesAsync(
DeterminismSummary summary,
string outputDirectory,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(summary);
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
Directory.CreateDirectory(outputDirectory);
foreach (var result in summary.Results)
{
var hashFileName = $"{result.ArtifactType}_{result.ArtifactName}.sha256.txt";
var hashFilePath = Path.Combine(outputDirectory, hashFileName);
var content = $"{result.CurrentHash} {result.ArtifactType}/{result.ArtifactName}";
await File.WriteAllTextAsync(hashFilePath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>true</IsPackable>
<Description>Determinism manifest writer/reader for reproducible artifact tracking</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -20,7 +20,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = SHA256.HashData(Sample);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha256);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -29,7 +29,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = SHA512.HashData(Sample);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha512);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -38,7 +38,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = ComputeGostDigest(use256: true);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_256);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -47,7 +47,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = ComputeGostDigest(use256: false);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_512);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -60,6 +60,25 @@ public sealed class DefaultCryptoHashTests
Assert.Equal(Convert.ToHexString(bufferDigest), Convert.ToHexString(streamDigest));
}
[Fact]
public void ComputeHashHex_Sha256_MatchesBclLowerHex()
{
var hash = CryptoHashFactory.CreateDefault();
var expected = Convert.ToHexStringLower(SHA256.HashData(Sample));
var actual = hash.ComputeHashHex(Sample, HashAlgorithms.Sha256);
Assert.Equal(expected, actual);
}
[Fact]
public async Task ComputeHashHexAsync_Sha256_MatchesBclLowerHex()
{
var hash = CryptoHashFactory.CreateDefault();
var expected = Convert.ToHexStringLower(SHA256.HashData(Sample));
await using var stream = new MemoryStream(Sample);
var actual = await hash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256);
Assert.Equal(expected, actual);
}
private static byte[] ComputeGostDigest(bool use256)
{
Org.BouncyCastle.Crypto.IDigest digest = use256

View File

@@ -0,0 +1,34 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class DefaultCryptoHmacTests
{
private static readonly byte[] Sample = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog");
private static readonly byte[] Key = Encoding.UTF8.GetBytes("test-key");
[Fact]
public void ComputeHmacHexForPurpose_WebhookInterop_MatchesBclLowerHex()
{
var hmac = DefaultCryptoHmac.CreateForTests();
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Key, Sample));
var actual = hmac.ComputeHmacHexForPurpose(Key, Sample, HmacPurpose.WebhookInterop);
Assert.Equal(expected, actual);
}
[Fact]
public async Task ComputeHmacHexForPurposeAsync_WebhookInterop_MatchesBclLowerHex()
{
var hmac = DefaultCryptoHmac.CreateForTests();
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Key, Sample));
await using var stream = new MemoryStream(Sample);
var actual = await hmac.ComputeHmacHexForPurposeAsync(Key, stream, HmacPurpose.WebhookInterop);
Assert.Equal(expected, actual);
}
}

View File

@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Digests;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class Sha256DigestTests
{
[Fact]
public void Normalize_AllowsBareHex_WhenPrefixNotRequired()
{
var hex = new string('a', Sha256Digest.HexLength);
Assert.Equal($"sha256:{hex}", Sha256Digest.Normalize(hex));
}
[Fact]
public void Normalize_NormalizesPrefixAndHexToLower()
{
var hexUpper = new string('A', Sha256Digest.HexLength);
Assert.Equal(
$"sha256:{new string('a', Sha256Digest.HexLength)}",
Sha256Digest.Normalize($"SHA256:{hexUpper}"));
}
[Fact]
public void Normalize_RequiresPrefix_WhenConfigured()
{
var hex = new string('a', Sha256Digest.HexLength);
var ex = Assert.Throws<FormatException>(() => Sha256Digest.Normalize(hex, requirePrefix: true, parameterName: "sbomDigest"));
Assert.Contains("sbomDigest", ex.Message, StringComparison.Ordinal);
Assert.Contains("sha256:", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void ExtractHex_ReturnsLowercaseHex()
{
var hexUpper = new string('A', Sha256Digest.HexLength);
Assert.Equal(new string('a', Sha256Digest.HexLength), Sha256Digest.ExtractHex($"sha256:{hexUpper}"));
}
[Fact]
public void Compute_UsesCryptoHashStack()
{
var hash = CryptoHashFactory.CreateDefault();
var content = Encoding.UTF8.GetBytes("hello");
var expectedHex = Convert.ToHexStringLower(SHA256.HashData(content));
Assert.Equal($"sha256:{expectedHex}", Sha256Digest.Compute(hash, content));
}
}

View File

@@ -16,6 +16,10 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,495 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.TestKit.Determinism;
using Xunit;
namespace StellaOps.TestKit.Tests;
public sealed class DeterminismManifestTests
{
[Fact]
public void ToCanonicalBytes_WithValidManifest_ProducesDeterministicOutput()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var bytes1 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
var bytes2 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
bytes1.Should().Equal(bytes2, "Same manifest should produce identical canonical bytes");
}
[Fact]
public void ToCanonicalString_WithValidManifest_ProducesDeterministicString()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var json1 = DeterminismManifestWriter.ToCanonicalString(manifest);
var json2 = DeterminismManifestWriter.ToCanonicalString(manifest);
// Assert
json1.Should().Be(json2, "Same manifest should produce identical canonical JSON string");
json1.Should().NotContain("\n", "Canonical JSON should have no newlines");
json1.Should().NotContain(" ", "Canonical JSON should have no indentation");
}
[Fact]
public void WriteToFile_AndReadFromFile_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
DeterminismManifestWriter.WriteToFile(manifest, tempFile);
// Act - Read
var readManifest = DeterminismManifestReader.ReadFromFile(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task WriteToFileAsync_AndReadFromFileAsync_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
await DeterminismManifestWriter.WriteToFileAsync(manifest, tempFile);
// Act - Read
var readManifest = await DeterminismManifestReader.ReadFromFileAsync(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public void FromBytes_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
var deserialized = DeterminismManifestReader.FromBytes(bytes);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromString_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
// Act
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromBytes_WithInvalidSchemaVersion_ThrowsInvalidOperationException()
{
// Arrange
var manifest = CreateSampleManifest() with { SchemaVersion = "2.0" };
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
Action act = () => DeterminismManifestReader.FromBytes(bytes);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*schema version*2.0*");
}
[Fact]
public void TryReadFromFileAsync_WithNonExistentFile_ReturnsNull()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
var result = DeterminismManifestReader.TryReadFromFileAsync(nonExistentPath).GetAwaiter().GetResult();
// Assert
result.Should().BeNull();
}
[Fact]
public void ReadFromFile_WithNonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
Action act = () => DeterminismManifestReader.ReadFromFile(nonExistentPath);
// Assert
act.Should().Throw<FileNotFoundException>();
}
[Fact]
public void ComputeCanonicalHash_ProducesDeterministicHash()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var hash1 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
var hash2 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
// Assert
hash1.Should().Be(hash2, "Same manifest should produce same hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$", "Hash should be 64-character hex string");
}
[Fact]
public void CreateManifest_WithValidInputs_CreatesManifestWithCorrectHash()
{
// Arrange
var artifactBytes = "Test artifact content"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
manifest.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Verify hash is correct
var expectedHash = CanonJson.Sha256Hex(artifactBytes);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifestForJsonArtifact_WithValidInputs_CreatesManifestWithCanonicalHash()
{
// Arrange
var artifact = new { Name = "test", Value = 123, Items = new[] { "a", "b", "c" } };
var artifactInfo = new ArtifactInfo
{
Type = "verdict",
Name = "test-verdict",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Policy.Engine", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifestForJsonArtifact(
artifact,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
// Verify hash is correct (should use canonical JSON)
var expectedHash = CanonJson.Hash(artifact);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifest_WithInputStamps_IncludesInputStamps()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var inputs = new InputStamps
{
FeedSnapshotHash = "abc123",
PolicyManifestHash = "def456",
SourceCodeHash = "789abc"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
inputs: inputs);
// Assert
manifest.Inputs.Should().NotBeNull();
manifest.Inputs!.FeedSnapshotHash.Should().Be("abc123");
manifest.Inputs.PolicyManifestHash.Should().Be("def456");
manifest.Inputs.SourceCodeHash.Should().Be("789abc");
}
[Fact]
public void CreateManifest_WithReproducibilityMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 42,
ClockFixed = true,
OrderingGuarantee = "sorted",
NormalizationRules = new[] { "UTF-8", "LF line endings", "sorted JSON keys" }
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
reproducibility: reproducibility);
// Assert
manifest.Reproducibility.Should().NotBeNull();
manifest.Reproducibility!.DeterministicSeed.Should().Be(42);
manifest.Reproducibility.ClockFixed.Should().BeTrue();
manifest.Reproducibility.OrderingGuarantee.Should().Be("sorted");
manifest.Reproducibility.NormalizationRules.Should().ContainInOrder("UTF-8", "LF line endings", "sorted JSON keys");
}
[Fact]
public void CreateManifest_WithVerificationInfo_IncludesVerification()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var verification = new VerificationInfo
{
Command = "dotnet run --project Scanner -- scan container alpine:3.18",
ExpectedHash = "abc123def456",
Baseline = "tests/baselines/sbom-alpine-3.18.determinism.json"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
verification: verification);
// Assert
manifest.Verification.Should().NotBeNull();
manifest.Verification!.Command.Should().Be("dotnet run --project Scanner -- scan container alpine:3.18");
manifest.Verification.ExpectedHash.Should().Be("abc123def456");
manifest.Verification.Baseline.Should().Be("tests/baselines/sbom-alpine-3.18.determinism.json");
}
[Fact]
public void ManifestSerialization_WithComplexMetadata_PreservesAllFields()
{
// Arrange
var manifest = CreateComplexManifest();
// Act
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
private static DeterminismManifest CreateSampleManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "abc123def456789012345678901234567890123456789012345678901234",
Encoding = "hex"
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo
{
Name = "StellaOps.Scanner",
Version = "1.0.0",
Hash = "def456abc789012345678901234567890123456789012345678901234567"
}
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 17, 45, 0, TimeSpan.Zero)
};
}
private static DeterminismManifest CreateComplexManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-bundle",
Version = "2.0.0",
Format = "DSSE Envelope",
Metadata = new Dictionary<string, object?>
{
["predicateType"] = "https://in-toto.io/attestation/v1",
["subject"] = "pkg:docker/alpine@3.18"
}
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
Encoding = "hex"
},
Inputs = new InputStamps
{
FeedSnapshotHash = "feed123abc",
PolicyManifestHash = "policy456def",
SourceCodeHash = "git789ghi",
VexDocumentHashes = new[] { "vex123", "vex456" },
Custom = new Dictionary<string, string>
{
["baselineVersion"] = "1.0.0",
["environment"] = "production"
}
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Attestor", Version = "2.0.0", Hash = "hash123" },
new ComponentInfo { Name = "StellaOps.Signer", Version = "2.1.0" }
},
Compiler = new CompilerInfo
{
Name = "Roslyn",
Version = "4.8.0"
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 18, 0, 0, TimeSpan.Zero),
Reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 12345,
ClockFixed = true,
OrderingGuarantee = "stable",
NormalizationRules = new[] { "UTF-8", "LF line endings", "no trailing whitespace" }
},
Verification = new VerificationInfo
{
Command = "dotnet test --verify-determinism",
ExpectedHash = "abc123def456",
Baseline = "baselines/test-bundle.json"
},
Signatures = new[]
{
new SignatureInfo
{
Algorithm = "ES256",
KeyId = "signing-key-1",
Signature = "base64encodedSig==",
Timestamp = new DateTimeOffset(2025, 12, 23, 18, 0, 30, TimeSpan.Zero)
}
}
};
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
<ProjectReference Include="..\..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,306 @@
// -----------------------------------------------------------------------------
// DeterminismBaselineStoreTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T9 - Determinism Baseline Storage
// Description: Tests for baseline storage and comparison functionality
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismBaselineStoreTests : IDisposable
{
private readonly string _testDirectory;
private readonly DeterminismBaselineStore _store;
public DeterminismBaselineStoreTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"determinism-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_store = new DeterminismBaselineStore(_testDirectory);
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
#region CreateBaseline Tests
[Fact]
public void CreateBaseline_WithValidInput_ReturnsCorrectHash()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var version = "1.0.0";
// Act
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, version);
// Assert
baseline.Should().NotBeNull();
baseline.CanonicalHash.Should().MatchRegex("^[0-9a-f]{64}$");
baseline.Algorithm.Should().Be("SHA-256");
baseline.Version.Should().Be("1.0.0");
baseline.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void CreateBaseline_WithSameInput_ProducesSameHash()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
// Act
var baseline1 = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
// Assert
baseline1.CanonicalHash.Should().Be(baseline2.CanonicalHash);
}
[Fact]
public void CreateBaseline_WithDifferentInput_ProducesDifferentHash()
{
// Arrange
var artifactBytes1 = Encoding.UTF8.GetBytes("{\"test\":\"data1\"}");
var artifactBytes2 = Encoding.UTF8.GetBytes("{\"test\":\"data2\"}");
// Act
var baseline1 = DeterminismBaselineStore.CreateBaseline(artifactBytes1, "1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(artifactBytes2, "1.0.0");
// Assert
baseline1.CanonicalHash.Should().NotBe(baseline2.CanonicalHash);
}
[Fact]
public void CreateBaseline_WithMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var metadata = new Dictionary<string, string>
{
["format"] = "CycloneDX 1.6",
["source"] = "scanner-test"
};
// Act
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0", metadata);
// Assert
baseline.Metadata.Should().NotBeNull();
baseline.Metadata.Should().ContainKey("format").WhoseValue.Should().Be("CycloneDX 1.6");
baseline.Metadata.Should().ContainKey("source").WhoseValue.Should().Be("scanner-test");
}
#endregion
#region Store and Retrieve Tests
[Fact]
public async Task StoreBaseline_AndRetrieve_RoundTripsCorrectly()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"component\":\"test\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "2.0.0");
// Act
await _store.StoreBaselineAsync("sbom", "test-artifact", baseline);
var retrieved = await _store.GetBaselineAsync("sbom", "test-artifact");
// Assert
retrieved.Should().NotBeNull();
retrieved!.CanonicalHash.Should().Be(baseline.CanonicalHash);
retrieved.Version.Should().Be("2.0.0");
retrieved.Algorithm.Should().Be("SHA-256");
}
[Fact]
public async Task GetBaseline_WhenNotExists_ReturnsNull()
{
// Act
var result = await _store.GetBaselineAsync("sbom", "nonexistent");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task StoreBaseline_CreatesCorrectDirectoryStructure()
{
// Arrange
var baseline = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("test"),
"1.0.0");
// Act
await _store.StoreBaselineAsync("vex", "openevex-document", baseline);
// Assert
var expectedPath = Path.Combine(_testDirectory, "vex", "openevex-document.baseline.json");
File.Exists(expectedPath).Should().BeTrue();
}
[Fact]
public async Task StoreBaseline_OverwritesExistingBaseline()
{
// Arrange
var baseline1 = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("original"),
"1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("updated"),
"2.0.0");
// Act
await _store.StoreBaselineAsync("sbom", "artifact", baseline1);
await _store.StoreBaselineAsync("sbom", "artifact", baseline2);
var retrieved = await _store.GetBaselineAsync("sbom", "artifact");
// Assert
retrieved.Should().NotBeNull();
retrieved!.CanonicalHash.Should().Be(baseline2.CanonicalHash);
retrieved.Version.Should().Be("2.0.0");
}
#endregion
#region Compare Tests
[Fact]
public async Task Compare_WhenMatches_ReturnsMatchStatus()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
await _store.StoreBaselineAsync("sbom", "test", baseline);
// Act
var result = await _store.CompareAsync("sbom", "test", baseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Match);
result.CurrentHash.Should().Be(baseline.CanonicalHash);
result.BaselineHash.Should().Be(baseline.CanonicalHash);
result.Message.Should().Contain("matches baseline");
}
[Fact]
public async Task Compare_WhenDrifted_ReturnsDriftStatus()
{
// Arrange
var originalBytes = Encoding.UTF8.GetBytes("{\"test\":\"original\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(originalBytes, "1.0.0");
await _store.StoreBaselineAsync("sbom", "test", baseline);
var newBytes = Encoding.UTF8.GetBytes("{\"test\":\"changed\"}");
var newBaseline = DeterminismBaselineStore.CreateBaseline(newBytes, "1.0.0");
// Act
var result = await _store.CompareAsync("sbom", "test", newBaseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Drift);
result.CurrentHash.Should().Be(newBaseline.CanonicalHash);
result.BaselineHash.Should().Be(baseline.CanonicalHash);
result.Message.Should().Contain("DRIFT DETECTED");
}
[Fact]
public async Task Compare_WhenMissing_ReturnsMissingStatus()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
// Act
var result = await _store.CompareAsync("sbom", "nonexistent", baseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Missing);
result.CurrentHash.Should().Be(baseline.CanonicalHash);
result.BaselineHash.Should().BeNull();
result.Message.Should().Contain("No baseline found");
}
#endregion
#region ListBaselines Tests
[Fact]
public async Task ListBaselines_WhenEmpty_ReturnsEmptyList()
{
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines.Should().BeEmpty();
}
[Fact]
public async Task ListBaselines_ReturnsAllStoredBaselines()
{
// Arrange
await _store.StoreBaselineAsync("sbom", "artifact1",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("1"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "artifact2",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("2"), "1.0.0"));
await _store.StoreBaselineAsync("vex", "document1",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("3"), "1.0.0"));
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines.Should().HaveCount(3);
baselines.Should().Contain(e => e.ArtifactType == "sbom" && e.ArtifactName == "artifact1");
baselines.Should().Contain(e => e.ArtifactType == "sbom" && e.ArtifactName == "artifact2");
baselines.Should().Contain(e => e.ArtifactType == "vex" && e.ArtifactName == "document1");
}
[Fact]
public async Task ListBaselines_ReturnsOrderedResults()
{
// Arrange
await _store.StoreBaselineAsync("vex", "z-document",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("1"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "a-artifact",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("2"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "b-artifact",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("3"), "1.0.0"));
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines[0].ArtifactType.Should().Be("sbom");
baselines[0].ArtifactName.Should().Be("a-artifact");
baselines[1].ArtifactType.Should().Be("sbom");
baselines[1].ArtifactName.Should().Be("b-artifact");
baselines[2].ArtifactType.Should().Be("vex");
}
#endregion
#region CreateDefault Tests
[Fact]
public void CreateDefault_CreatesStoreWithCorrectPath()
{
// Act
var store = DeterminismBaselineStore.CreateDefault(_testDirectory);
// Assert
store.BaselineDirectory.Should().Be(Path.Combine(_testDirectory, "tests", "baselines", "determinism"));
}
#endregion
}

View File

@@ -0,0 +1,501 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismManifestTests
{
[Fact]
public void ToCanonicalBytes_WithValidManifest_ProducesDeterministicOutput()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var bytes1 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
var bytes2 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
bytes1.Should().Equal(bytes2, "Same manifest should produce identical canonical bytes");
}
[Fact]
public void ToCanonicalString_WithValidManifest_ProducesDeterministicString()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var json1 = DeterminismManifestWriter.ToCanonicalString(manifest);
var json2 = DeterminismManifestWriter.ToCanonicalString(manifest);
// Assert
json1.Should().Be(json2, "Same manifest should produce identical canonical JSON string");
json1.Should().NotContain("\n", "Canonical JSON should have no newlines");
json1.Should().NotContain(" ", "Canonical JSON should have no indentation");
}
[Fact]
public void WriteToFile_AndReadFromFile_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
DeterminismManifestWriter.WriteToFile(manifest, tempFile);
// Act - Read
var readManifest = DeterminismManifestReader.ReadFromFile(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task WriteToFileAsync_AndReadFromFileAsync_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
await DeterminismManifestWriter.WriteToFileAsync(manifest, tempFile);
// Act - Read
var readManifest = await DeterminismManifestReader.ReadFromFileAsync(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public void FromBytes_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
var deserialized = DeterminismManifestReader.FromBytes(bytes);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromString_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
// Act
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void ToCanonicalBytes_WithInvalidSchemaVersion_ThrowsInvalidOperationException()
{
// Arrange
var manifest = CreateSampleManifest() with { SchemaVersion = "2.0" };
// Act
Action act = () => DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*schema version*2.0*");
}
[Fact]
public void TryReadFromFileAsync_WithNonExistentFile_ReturnsNull()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
var result = DeterminismManifestReader.TryReadFromFileAsync(nonExistentPath).GetAwaiter().GetResult();
// Assert
result.Should().BeNull();
}
[Fact]
public void ReadFromFile_WithNonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
Action act = () => DeterminismManifestReader.ReadFromFile(nonExistentPath);
// Assert
act.Should().Throw<FileNotFoundException>();
}
[Fact]
public void ComputeCanonicalHash_ProducesDeterministicHash()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var hash1 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
var hash2 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
// Assert
hash1.Should().Be(hash2, "Same manifest should produce same hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$", "Hash should be 64-character hex string");
}
[Fact]
public void CreateManifest_WithValidInputs_CreatesManifestWithCorrectHash()
{
// Arrange
var artifactBytes = "Test artifact content"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
manifest.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Verify hash is correct
var expectedHash = CanonJson.Sha256Hex(artifactBytes);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifestForJsonArtifact_WithValidInputs_CreatesManifestWithCanonicalHash()
{
// Arrange
var artifact = new { Name = "test", Value = 123, Items = new[] { "a", "b", "c" } };
var artifactInfo = new ArtifactInfo
{
Type = "verdict",
Name = "test-verdict",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Policy.Engine", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifestForJsonArtifact(
artifact,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
// Verify hash is correct (should use canonical JSON)
var expectedHash = CanonJson.Hash(artifact);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifest_WithInputStamps_IncludesInputStamps()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var inputs = new InputStamps
{
FeedSnapshotHash = "abc123",
PolicyManifestHash = "def456",
SourceCodeHash = "789abc"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
inputs: inputs);
// Assert
manifest.Inputs.Should().NotBeNull();
manifest.Inputs!.FeedSnapshotHash.Should().Be("abc123");
manifest.Inputs.PolicyManifestHash.Should().Be("def456");
manifest.Inputs.SourceCodeHash.Should().Be("789abc");
}
[Fact]
public void CreateManifest_WithReproducibilityMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 42,
ClockFixed = true,
OrderingGuarantee = "sorted",
NormalizationRules = new[] { "UTF-8", "LF line endings", "sorted JSON keys" }
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
reproducibility: reproducibility);
// Assert
manifest.Reproducibility.Should().NotBeNull();
manifest.Reproducibility!.DeterministicSeed.Should().Be(42);
manifest.Reproducibility.ClockFixed.Should().BeTrue();
manifest.Reproducibility.OrderingGuarantee.Should().Be("sorted");
manifest.Reproducibility.NormalizationRules.Should().ContainInOrder("UTF-8", "LF line endings", "sorted JSON keys");
}
[Fact]
public void CreateManifest_WithVerificationInfo_IncludesVerification()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var verification = new VerificationInfo
{
Command = "dotnet run --project Scanner -- scan container alpine:3.18",
ExpectedHash = "abc123def456",
Baseline = "tests/baselines/sbom-alpine-3.18.determinism.json"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
verification: verification);
// Assert
manifest.Verification.Should().NotBeNull();
manifest.Verification!.Command.Should().Be("dotnet run --project Scanner -- scan container alpine:3.18");
manifest.Verification.ExpectedHash.Should().Be("abc123def456");
manifest.Verification.Baseline.Should().Be("tests/baselines/sbom-alpine-3.18.determinism.json");
}
[Fact]
public void ManifestSerialization_WithComplexMetadata_PreservesAllFields()
{
// Arrange
var manifest = CreateComplexManifest();
// Act
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
var deserialized = DeterminismManifestReader.FromString(json);
// Assert - Use custom comparison to handle JsonElement values in metadata
deserialized.Should().BeEquivalentTo(manifest, options => options
.Excluding(m => m.Artifact.Metadata));
// Verify metadata separately (JSON deserialization converts values to JsonElement)
deserialized.Artifact.Metadata.Should().NotBeNull();
deserialized.Artifact.Metadata.Should().HaveCount(2);
deserialized.Artifact.Metadata.Should().ContainKey("predicateType");
deserialized.Artifact.Metadata.Should().ContainKey("subject");
}
private static DeterminismManifest CreateSampleManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "abc123def456789012345678901234567890123456789012345678901234",
Encoding = "hex"
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo
{
Name = "StellaOps.Scanner",
Version = "1.0.0",
Hash = "def456abc789012345678901234567890123456789012345678901234567"
}
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 17, 45, 0, TimeSpan.Zero)
};
}
private static DeterminismManifest CreateComplexManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-bundle",
Version = "2.0.0",
Format = "DSSE Envelope",
Metadata = new Dictionary<string, object?>
{
["predicateType"] = "https://in-toto.io/attestation/v1",
["subject"] = "pkg:docker/alpine@3.18"
}
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
Encoding = "hex"
},
Inputs = new InputStamps
{
FeedSnapshotHash = "feed123abc",
PolicyManifestHash = "policy456def",
SourceCodeHash = "git789ghi",
VexDocumentHashes = new[] { "vex123", "vex456" },
Custom = new Dictionary<string, string>
{
["baselineVersion"] = "1.0.0",
["environment"] = "production"
}
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Attestor", Version = "2.0.0", Hash = "hash123" },
new ComponentInfo { Name = "StellaOps.Signer", Version = "2.1.0" }
},
Compiler = new CompilerInfo
{
Name = "Roslyn",
Version = "4.8.0"
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 18, 0, 0, TimeSpan.Zero),
Reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 12345,
ClockFixed = true,
OrderingGuarantee = "stable",
NormalizationRules = new[] { "UTF-8", "LF line endings", "no trailing whitespace" }
},
Verification = new VerificationInfo
{
Command = "dotnet test --verify-determinism",
ExpectedHash = "abc123def456",
Baseline = "baselines/test-bundle.json"
},
Signatures = new[]
{
new SignatureInfo
{
Algorithm = "ES256",
KeyId = "signing-key-1",
Signature = "base64encodedSig==",
Timestamp = new DateTimeOffset(2025, 12, 23, 18, 0, 30, TimeSpan.Zero)
}
}
};
}
}

View File

@@ -0,0 +1,338 @@
// -----------------------------------------------------------------------------
// DeterminismSummaryTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T9 - Determinism Baseline Storage
// Description: Tests for determinism summary and CI artifact generation
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismSummaryTests : IDisposable
{
private readonly string _testDirectory;
public DeterminismSummaryTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"summary-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
#region DeterminismSummaryBuilder Tests
[Fact]
public void Build_WithNoResults_ReturnsPassStatus()
{
// Act
var summary = new DeterminismSummaryBuilder().Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Pass);
summary.Statistics.Total.Should().Be(0);
summary.Statistics.Matched.Should().Be(0);
summary.Statistics.Drifted.Should().Be(0);
summary.Statistics.Missing.Should().Be(0);
}
[Fact]
public void Build_WithAllMatching_ReturnsPassStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMatchResult("sbom", "artifact2"))
.AddResult(CreateMatchResult("vex", "document1"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Pass);
summary.Statistics.Total.Should().Be(3);
summary.Statistics.Matched.Should().Be(3);
summary.Statistics.Drifted.Should().Be(0);
summary.Statistics.Missing.Should().Be(0);
summary.Drift.Should().BeNull();
summary.Missing.Should().BeNull();
}
[Fact]
public void Build_WithDrift_ReturnsFailStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateDriftResult("sbom", "artifact2"))
.AddResult(CreateMatchResult("vex", "document1"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
summary.Statistics.Total.Should().Be(3);
summary.Statistics.Matched.Should().Be(2);
summary.Statistics.Drifted.Should().Be(1);
summary.Statistics.Missing.Should().Be(0);
summary.Drift.Should().HaveCount(1);
summary.Drift![0].ArtifactName.Should().Be("artifact2");
}
[Fact]
public void Build_WithMissing_ReturnsWarningStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Warning);
summary.Statistics.Total.Should().Be(2);
summary.Statistics.Matched.Should().Be(1);
summary.Statistics.Missing.Should().Be(1);
summary.Missing.Should().HaveCount(1);
summary.Missing![0].ArtifactName.Should().Be("artifact2");
}
[Fact]
public void Build_WithMissing_AndFailOnMissing_ReturnsFailStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.FailOnMissingBaselines()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
}
[Fact]
public void Build_DriftTakesPrecedenceOverMissing()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateDriftResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
summary.Statistics.Drifted.Should().Be(1);
summary.Statistics.Missing.Should().Be(1);
}
[Fact]
public void Build_WithSourceRef_IncludesSourceRef()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.WithSourceRef("abc123def456")
.AddResult(CreateMatchResult("sbom", "artifact"));
// Act
var summary = builder.Build();
// Assert
summary.SourceRef.Should().Be("abc123def456");
}
[Fact]
public void Build_WithCiRunId_IncludesCiRunId()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.WithCiRunId("run-12345")
.AddResult(CreateMatchResult("sbom", "artifact"));
// Act
var summary = builder.Build();
// Assert
summary.CiRunId.Should().Be("run-12345");
}
[Fact]
public void Build_SetsGeneratedAtToUtcNow()
{
// Arrange
var before = DateTimeOffset.UtcNow;
var builder = new DeterminismSummaryBuilder();
// Act
var summary = builder.Build();
var after = DateTimeOffset.UtcNow;
// Assert
summary.GeneratedAt.Should().BeOnOrAfter(before);
summary.GeneratedAt.Should().BeOnOrBefore(after);
}
#endregion
#region DeterminismSummaryWriter Tests
[Fact]
public async Task WriteToFileAsync_CreatesValidJsonFile()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.WithSourceRef("test-sha")
.AddResult(CreateMatchResult("sbom", "test-artifact"))
.Build();
var filePath = Path.Combine(_testDirectory, "determinism.json");
// Act
await DeterminismSummaryWriter.WriteToFileAsync(summary, filePath);
// Assert
File.Exists(filePath).Should().BeTrue();
var content = await File.ReadAllTextAsync(filePath);
content.Should().Contain("\"schemaVersion\": \"1.0\"");
content.Should().Contain("\"sourceRef\": \"test-sha\"");
content.Should().Contain("\"status\": \"pass\"");
}
[Fact]
public async Task WriteToFileAsync_CreatesDirectoryIfNeeded()
{
// Arrange
var summary = new DeterminismSummaryBuilder().Build();
var filePath = Path.Combine(_testDirectory, "nested", "dir", "determinism.json");
// Act
await DeterminismSummaryWriter.WriteToFileAsync(summary, filePath);
// Assert
File.Exists(filePath).Should().BeTrue();
}
[Fact]
public void ToJson_ReturnsValidJson()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact"))
.AddResult(CreateDriftResult("vex", "document"))
.Build();
// Act
var json = DeterminismSummaryWriter.ToJson(summary);
// Assert
json.Should().Contain("\"status\": \"fail\"");
json.Should().Contain("\"total\": 2");
json.Should().Contain("\"matched\": 1");
json.Should().Contain("\"drifted\": 1");
}
[Fact]
public async Task WriteHashFilesAsync_CreatesHashFilesForAllArtifacts()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMatchResult("vex", "document1"))
.Build();
var hashDir = Path.Combine(_testDirectory, "hashes");
// Act
await DeterminismSummaryWriter.WriteHashFilesAsync(summary, hashDir);
// Assert
File.Exists(Path.Combine(hashDir, "sbom_artifact1.sha256.txt")).Should().BeTrue();
File.Exists(Path.Combine(hashDir, "vex_document1.sha256.txt")).Should().BeTrue();
}
[Fact]
public async Task WriteHashFilesAsync_HashFileContainsCorrectFormat()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(new BaselineComparisonResult
{
ArtifactType = "sbom",
ArtifactName = "test",
Status = BaselineStatus.Match,
CurrentHash = "abc123def456",
Message = "Test"
})
.Build();
var hashDir = Path.Combine(_testDirectory, "hashes");
// Act
await DeterminismSummaryWriter.WriteHashFilesAsync(summary, hashDir);
// Assert
var content = await File.ReadAllTextAsync(Path.Combine(hashDir, "sbom_test.sha256.txt"));
content.Should().Be("abc123def456 sbom/test");
}
#endregion
#region Helper Methods
private static BaselineComparisonResult CreateMatchResult(string artifactType, string artifactName)
{
var hash = $"hash-{artifactType}-{artifactName}";
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Match,
CurrentHash = hash,
BaselineHash = hash,
Message = $"Artifact {artifactType}/{artifactName} matches baseline."
};
}
private static BaselineComparisonResult CreateDriftResult(string artifactType, string artifactName)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Drift,
CurrentHash = $"new-hash-{artifactType}-{artifactName}",
BaselineHash = $"old-hash-{artifactType}-{artifactName}",
Message = $"DRIFT DETECTED: {artifactType}/{artifactName}"
};
}
private static BaselineComparisonResult CreateMissingResult(string artifactType, string artifactName)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Missing,
CurrentHash = $"hash-{artifactType}-{artifactName}",
Message = $"No baseline found for {artifactType}/{artifactName}"
};
}
#endregion
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Testing.Determinism\StellaOps.Testing.Determinism.csproj" />
</ItemGroup>
</Project>