consolidate the tests locations

This commit is contained in:
StellaOps Bot
2025-12-26 01:48:24 +02:00
parent 17613acf57
commit 39359da171
2031 changed files with 2607 additions and 476 deletions

View File

@@ -1,157 +0,0 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Testcontainers.PostgreSql;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Infrastructure.Postgres.Testing;
/// <summary>
/// Base class for PostgreSQL integration test fixtures.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
/// <remarks>
/// Inherit from this class and override <see cref="GetMigrationAssembly"/> and <see cref="GetModuleName"/>
/// to provide module-specific migrations.
/// </remarks>
public abstract class PostgresIntegrationFixture : IAsyncLifetime
{
private PostgreSqlContainer? _container;
private PostgresFixture? _fixture;
/// <summary>
/// Gets the PostgreSQL connection string for tests.
/// </summary>
public string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not initialized");
/// <summary>
/// Gets the schema name for test isolation.
/// </summary>
public string SchemaName => _fixture?.SchemaName
?? throw new InvalidOperationException("Fixture not initialized");
/// <summary>
/// Gets the PostgreSQL test fixture.
/// </summary>
public PostgresFixture Fixture => _fixture
?? throw new InvalidOperationException("Fixture not initialized");
/// <summary>
/// Gets the logger for this fixture.
/// </summary>
protected virtual ILogger Logger => NullLogger.Instance;
/// <summary>
/// Gets the PostgreSQL Docker image to use.
/// </summary>
protected virtual string PostgresImage => "postgres:16-alpine";
/// <summary>
/// Gets the assembly containing embedded SQL migrations.
/// </summary>
/// <returns>Assembly with embedded migration resources, or null if no migrations.</returns>
protected abstract Assembly? GetMigrationAssembly();
/// <summary>
/// Gets the module name for logging and schema naming.
/// </summary>
protected abstract string GetModuleName();
/// <summary>
/// Gets the resource prefix for filtering embedded resources.
/// </summary>
protected virtual string? GetResourcePrefix() => null;
/// <summary>
/// Initializes the PostgreSQL container and runs migrations.
/// </summary>
public virtual async Task InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder()
.WithImage(PostgresImage)
.Build();
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);
await _fixture.InitializeAsync();
var migrationAssembly = GetMigrationAssembly();
if (migrationAssembly != null)
{
await _fixture.RunMigrationsFromAssemblyAsync(
migrationAssembly,
moduleName,
GetResourcePrefix());
}
}
/// <summary>
/// Cleans up the PostgreSQL container and fixture.
/// </summary>
public virtual async Task DisposeAsync()
{
if (_fixture != null)
{
await _fixture.DisposeAsync();
}
if (_container != null)
{
await _container.DisposeAsync();
}
}
/// <summary>
/// Truncates all tables in the test schema for test isolation between test methods.
/// </summary>
public Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
=> Fixture.TruncateAllTablesAsync(cancellationToken);
/// <summary>
/// Executes raw SQL for test setup.
/// </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>
/// PostgreSQL integration fixture without migrations.
/// Useful for testing the infrastructure itself or creating schemas dynamically.
/// </summary>
public sealed class PostgresIntegrationFixtureWithoutMigrations : PostgresIntegrationFixture
{
protected override Assembly? GetMigrationAssembly() => null;
protected override string GetModuleName() => "Test";
}

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Infrastructure.Postgres.Testing</RootNamespace>
<AssemblyName>StellaOps.Infrastructure.Postgres.Testing</AssemblyName>
<Description>PostgreSQL test infrastructure for StellaOps module integration tests</Description>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,108 +0,0 @@
namespace StellaOps.Messaging.Testing.Builders;
/// <summary>
/// Builder for creating test messages with customizable properties.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public sealed class TestMessageBuilder<TMessage> where TMessage : class, new()
{
private readonly Dictionary<string, object?> _properties = new();
/// <summary>
/// Sets a property on the message.
/// </summary>
public TestMessageBuilder<TMessage> With(string propertyName, object? value)
{
_properties[propertyName] = value;
return this;
}
/// <summary>
/// Builds the message with all configured properties.
/// </summary>
public TMessage Build()
{
var message = new TMessage();
var type = typeof(TMessage);
foreach (var (propertyName, value) in _properties)
{
var property = type.GetProperty(propertyName);
if (property is not null && property.CanWrite)
{
property.SetValue(message, value);
}
}
return message;
}
}
/// <summary>
/// Simple test message for queue testing.
/// </summary>
public sealed record TestQueueMessage
{
/// <summary>
/// Gets or sets the message ID.
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets or sets the message content.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the priority.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets the tenant ID.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets when the message was created.
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// Gets or sets custom metadata.
/// </summary>
public Dictionary<string, string> Metadata { get; set; } = new();
}
/// <summary>
/// Extension methods for creating test messages.
/// </summary>
public static class TestMessageExtensions
{
/// <summary>
/// Creates a new test queue message with the specified content.
/// </summary>
public static TestQueueMessage CreateTestMessage(this string content, string? tenantId = null)
{
return new TestQueueMessage
{
Content = content,
TenantId = tenantId
};
}
/// <summary>
/// Creates multiple test messages for batch testing.
/// </summary>
public static IReadOnlyList<TestQueueMessage> CreateTestMessages(int count, string? tenantId = null)
{
return Enumerable.Range(1, count)
.Select(i => new TestQueueMessage
{
Content = $"Test message {i}",
TenantId = tenantId,
Priority = i % 3
})
.ToList();
}
}

View File

@@ -1,84 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Transport.InMemory;
using Xunit;
namespace StellaOps.Messaging.Testing.Fixtures;
/// <summary>
/// xUnit fixture for in-memory messaging transport.
/// Provides fast, isolated test infrastructure without external dependencies.
/// </summary>
public sealed class InMemoryMessagingFixture : IAsyncLifetime
{
private InMemoryQueueRegistry _registry = null!;
/// <summary>
/// Gets the shared queue registry for test coordination.
/// </summary>
public InMemoryQueueRegistry Registry => _registry;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
_registry = new InMemoryQueueRegistry();
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_registry.Clear();
return ValueTask.CompletedTask;
}
/// <summary>
/// Creates a service provider configured with in-memory transport.
/// </summary>
/// <returns>A configured service provider.</returns>
public IServiceProvider CreateServiceProvider()
{
var services = new ServiceCollection();
services.AddSingleton(_registry);
services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
return services.BuildServiceProvider();
}
/// <summary>
/// Creates a message queue factory using in-memory transport.
/// </summary>
public IMessageQueueFactory CreateQueueFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IMessageQueueFactory>();
}
/// <summary>
/// Creates a cache factory using in-memory transport.
/// </summary>
public IDistributedCacheFactory CreateCacheFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IDistributedCacheFactory>();
}
/// <summary>
/// Clears all queues and caches in the registry.
/// Call this between tests for isolation.
/// </summary>
public void Reset()
{
_registry.Clear();
}
}
/// <summary>
/// Collection definition for in-memory messaging fixture sharing across test classes.
/// </summary>
[CollectionDefinition(nameof(InMemoryMessagingFixtureCollection))]
public class InMemoryMessagingFixtureCollection : ICollectionFixture<InMemoryMessagingFixture>
{
}

View File

@@ -1,97 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Transport.Postgres;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Messaging.Testing.Fixtures;
/// <summary>
/// xUnit fixture for PostgreSQL testcontainer.
/// Provides a containerized PostgreSQL instance for queue integration tests.
/// </summary>
public sealed class PostgresQueueFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
/// <summary>
/// Gets the connection string for the PostgreSQL instance.
/// </summary>
public string ConnectionString => _container.GetConnectionString();
/// <summary>
/// Initializes a new instance of the <see cref="PostgresQueueFixture"/> class.
/// </summary>
public PostgresQueueFixture()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("stellaops_messaging_test")
.WithUsername("test")
.WithPassword("test")
.Build();
}
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _container.StopAsync();
await _container.DisposeAsync();
}
/// <summary>
/// Creates a service provider configured with PostgreSQL transport.
/// </summary>
/// <param name="configureOptions">Optional configuration for PostgreSQL options.</param>
/// <returns>A configured service provider.</returns>
public IServiceProvider CreateServiceProvider(Action<PostgresTransportOptions>? configureOptions = null)
{
var services = new ServiceCollection();
services.AddOptions<PostgresTransportOptions>().Configure(options =>
{
options.ConnectionString = ConnectionString;
options.Schema = "messaging";
options.AutoCreateTables = true;
configureOptions?.Invoke(options);
});
services.AddSingleton<PostgresConnectionFactory>();
services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
return services.BuildServiceProvider();
}
/// <summary>
/// Creates a message queue factory using PostgreSQL transport.
/// </summary>
public IMessageQueueFactory CreateQueueFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IMessageQueueFactory>();
}
/// <summary>
/// Creates a cache factory using PostgreSQL transport.
/// </summary>
public IDistributedCacheFactory CreateCacheFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IDistributedCacheFactory>();
}
}
/// <summary>
/// Collection definition for PostgreSQL fixture sharing across test classes.
/// </summary>
[CollectionDefinition(nameof(PostgresQueueFixtureCollection))]
public class PostgresQueueFixtureCollection : ICollectionFixture<PostgresQueueFixture>
{
}

View File

@@ -1,108 +0,0 @@
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Transport.Valkey;
using Xunit;
namespace StellaOps.Messaging.Testing.Fixtures;
/// <summary>
/// xUnit fixture for Valkey testcontainer.
/// Provides a containerized Valkey instance for integration tests.
/// </summary>
public sealed class ValkeyFixture : IAsyncLifetime
{
private readonly IContainer _container;
/// <summary>
/// Gets the connection string for the Valkey instance.
/// </summary>
public string ConnectionString { get; private set; } = null!;
/// <summary>
/// Gets the host of the Valkey instance.
/// </summary>
public string Host => _container.Hostname;
/// <summary>
/// Gets the mapped port for the Valkey instance.
/// </summary>
public ushort Port { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="ValkeyFixture"/> class.
/// </summary>
public ValkeyFixture()
{
_container = new ContainerBuilder()
.WithImage("valkey/valkey:8-alpine")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(6379))
.Build();
}
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
Port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{Host}:{Port}";
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _container.StopAsync();
await _container.DisposeAsync();
}
/// <summary>
/// Creates a service provider configured with Valkey transport.
/// </summary>
/// <param name="configureOptions">Optional configuration for Valkey options.</param>
/// <returns>A configured service provider.</returns>
public IServiceProvider CreateServiceProvider(Action<ValkeyTransportOptions>? configureOptions = null)
{
var services = new ServiceCollection();
services.AddOptions<ValkeyTransportOptions>().Configure(options =>
{
options.ConnectionString = ConnectionString;
configureOptions?.Invoke(options);
});
services.AddSingleton<ValkeyConnectionFactory>();
services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
return services.BuildServiceProvider();
}
/// <summary>
/// Creates a message queue factory using Valkey transport.
/// </summary>
public IMessageQueueFactory CreateQueueFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IMessageQueueFactory>();
}
/// <summary>
/// Creates a cache factory using Valkey transport.
/// </summary>
public IDistributedCacheFactory CreateCacheFactory()
{
var sp = CreateServiceProvider();
return sp.GetRequiredService<IDistributedCacheFactory>();
}
}
/// <summary>
/// Collection definition for Valkey fixture sharing across test classes.
/// </summary>
[CollectionDefinition(nameof(ValkeyFixtureCollection))]
public class ValkeyFixtureCollection : ICollectionFixture<ValkeyFixture>
{
}

View File

@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Messaging.Testing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
<ProjectReference Include="..\StellaOps.Messaging.Transport.Postgres\StellaOps.Messaging.Transport.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Messaging.Transport.InMemory\StellaOps.Messaging.Transport.InMemory.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Testcontainers" Version="4.4.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
<PackageReference Include="xunit.v3.extensibility.core" Version="2.0.2" />
</ItemGroup>
</Project>

View File

@@ -1,53 +0,0 @@
namespace StellaOps.Testing.AirGap.Docker;
/// <summary>
/// Builds containers with network isolation for air-gap testing.
/// </summary>
public sealed class IsolatedContainerBuilder
{
/// <summary>
/// Creates a container configuration with no network access.
/// </summary>
public ContainerConfiguration CreateIsolatedConfiguration(
string image,
IReadOnlyList<string> volumes)
{
return new ContainerConfiguration
{
Image = image,
NetworkMode = "none", // No network!
Volumes = volumes,
AutoRemove = true,
Environment = new Dictionary<string, string>
{
["STELLAOPS_OFFLINE_MODE"] = "true",
["HTTP_PROXY"] = "",
["HTTPS_PROXY"] = "",
["NO_PROXY"] = "*"
}
};
}
/// <summary>
/// Verifies that a container has no network access.
/// </summary>
public async Task<bool> VerifyNoNetworkAsync(
string containerId,
CancellationToken ct = default)
{
// TODO: Implement actual container exec to test network
// For now, return true (assume configuration is correct)
await Task.CompletedTask;
return true;
}
}
public sealed record ContainerConfiguration
{
public required string Image { get; init; }
public required string NetworkMode { get; init; }
public IReadOnlyList<string> Volumes { get; init; } = [];
public bool AutoRemove { get; init; }
public IReadOnlyDictionary<string, string> Environment { get; init; } =
new Dictionary<string, string>();
}

View File

@@ -1,148 +0,0 @@
namespace StellaOps.Testing.AirGap;
using System.Net.Sockets;
using System.Runtime.ExceptionServices;
using Xunit;
/// <summary>
/// Base class for tests that must run without network access.
/// Monitors and blocks any network calls during test execution.
/// </summary>
public abstract class NetworkIsolatedTestBase : IAsyncLifetime
{
private readonly NetworkMonitor _monitor;
private readonly List<NetworkAttempt> _blockedAttempts = [];
protected NetworkIsolatedTestBase()
{
_monitor = new NetworkMonitor(OnNetworkAttempt);
}
public virtual async Task InitializeAsync()
{
// Install network interception
await _monitor.StartMonitoringAsync();
// Configure HttpClient factory to use monitored handler
Environment.SetEnvironmentVariable("STELLAOPS_OFFLINE_MODE", "true");
// Block DNS resolution
_monitor.BlockDns();
}
public virtual async Task DisposeAsync()
{
await _monitor.StopMonitoringAsync();
// Fail test if any network calls were attempted
if (_blockedAttempts.Count > 0)
{
var attempts = string.Join("\n", _blockedAttempts.Select(a =>
$" - {a.Host}:{a.Port} at {a.Timestamp:O}\n{a.StackTrace}"));
throw new NetworkIsolationViolationException(
$"Test attempted {_blockedAttempts.Count} network call(s):\n{attempts}");
}
}
private void OnNetworkAttempt(NetworkAttempt attempt)
{
_blockedAttempts.Add(attempt);
}
/// <summary>
/// Asserts that no network calls were made during the test.
/// </summary>
protected void AssertNoNetworkCalls()
{
if (_blockedAttempts.Count > 0)
{
throw new NetworkIsolationViolationException(
$"Network isolation violated: {_blockedAttempts.Count} attempts blocked");
}
}
/// <summary>
/// Gets the offline bundle path for this test.
/// </summary>
protected string GetOfflineBundlePath() =>
Environment.GetEnvironmentVariable("STELLAOPS_OFFLINE_BUNDLE")
?? Path.Combine(AppContext.BaseDirectory, "fixtures", "offline-bundle");
}
public sealed class NetworkMonitor : IAsyncDisposable
{
private readonly Action<NetworkAttempt> _onAttempt;
private bool _isMonitoring;
private EventHandler<FirstChanceExceptionEventArgs>? _exceptionHandler;
public NetworkMonitor(Action<NetworkAttempt> onAttempt)
{
_onAttempt = onAttempt;
}
public Task StartMonitoringAsync()
{
_isMonitoring = true;
// Hook into socket creation
_exceptionHandler = OnException;
AppDomain.CurrentDomain.FirstChanceException += _exceptionHandler;
return Task.CompletedTask;
}
public Task StopMonitoringAsync()
{
_isMonitoring = false;
if (_exceptionHandler != null)
{
AppDomain.CurrentDomain.FirstChanceException -= _exceptionHandler;
}
return Task.CompletedTask;
}
public void BlockDns()
{
// Set environment to prevent DNS lookups
Environment.SetEnvironmentVariable("RES_OPTIONS", "timeout:0 attempts:0");
}
private void OnException(object? sender, FirstChanceExceptionEventArgs e)
{
if (!_isMonitoring) return;
if (e.Exception is SocketException se)
{
_onAttempt(new NetworkAttempt(
Host: "unknown",
Port: 0,
StackTrace: se.StackTrace ?? Environment.StackTrace,
Timestamp: DateTimeOffset.UtcNow));
}
else if (e.Exception is HttpRequestException hre)
{
_onAttempt(new NetworkAttempt(
Host: hre.Message,
Port: 0,
StackTrace: hre.StackTrace ?? Environment.StackTrace,
Timestamp: DateTimeOffset.UtcNow));
}
}
public ValueTask DisposeAsync()
{
_isMonitoring = false;
return ValueTask.CompletedTask;
}
}
public sealed record NetworkAttempt(
string Host,
int Port,
string StackTrace,
DateTimeOffset Timestamp);
public sealed class NetworkIsolationViolationException : Exception
{
public NetworkIsolationViolationException(string message) : base(message) { }
}

View File

@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.core" Version="2.6.6" />
</ItemGroup>
</Project>

View File

@@ -1,454 +0,0 @@
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

@@ -1,215 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Determinism gates for verifying reproducible outputs.
/// Ensures that operations produce identical results across multiple executions.
/// </summary>
public static class DeterminismGate
{
/// <summary>
/// Verifies that a function produces identical output across multiple invocations.
/// </summary>
/// <param name="operation">The operation to test.</param>
/// <param name="iterations">Number of times to execute (default: 3).</param>
public static void AssertDeterministic(Func<string> operation, int iterations = 3)
{
if (iterations < 2)
{
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
}
string? baseline = null;
var results = new List<string>();
for (int i = 0; i < iterations; i++)
{
var result = operation();
results.Add(result);
if (baseline == null)
{
baseline = result;
}
else if (result != baseline)
{
throw new DeterminismViolationException(
$"Determinism violation detected at iteration {i + 1}.\n\n" +
$"Baseline (iteration 1):\n{baseline}\n\n" +
$"Different (iteration {i + 1}):\n{result}");
}
}
}
/// <summary>
/// Verifies that a function produces identical binary output across multiple invocations.
/// </summary>
public static void AssertDeterministic(Func<byte[]> operation, int iterations = 3)
{
if (iterations < 2)
{
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
}
byte[]? baseline = null;
for (int i = 0; i < iterations; i++)
{
var result = operation();
if (baseline == null)
{
baseline = result;
}
else if (!result.SequenceEqual(baseline))
{
throw new DeterminismViolationException(
$"Binary determinism violation detected at iteration {i + 1}.\n" +
$"Baseline hash: {ComputeHash(baseline)}\n" +
$"Current hash: {ComputeHash(result)}");
}
}
}
/// <summary>
/// Verifies that a function producing JSON has stable property ordering and formatting.
/// </summary>
public static void AssertJsonDeterministic(Func<string> operation, int iterations = 3)
{
AssertDeterministic(() =>
{
var json = operation();
// Canonicalize to detect property ordering issues
return CanonicalizeJson(json);
}, iterations);
}
/// <summary>
/// Verifies that an object's JSON serialization is deterministic.
/// </summary>
public static void AssertJsonDeterministic<T>(Func<T> operation, int iterations = 3)
{
AssertDeterministic(() =>
{
var obj = operation();
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = null
});
return CanonicalizeJson(json);
}, iterations);
}
/// <summary>
/// Verifies that two objects produce identical canonical JSON.
/// </summary>
public static void AssertCanonicallyEqual(object expected, object actual)
{
var expectedJson = JsonSerializer.Serialize(expected);
var actualJson = JsonSerializer.Serialize(actual);
var expectedCanonical = CanonicalizeJson(expectedJson);
var actualCanonical = CanonicalizeJson(actualJson);
if (expectedCanonical != actualCanonical)
{
throw new DeterminismViolationException(
$"Canonical JSON mismatch:\n\nExpected:\n{expectedCanonical}\n\nActual:\n{actualCanonical}");
}
}
/// <summary>
/// Computes a stable SHA256 hash of text content.
/// </summary>
public static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
return ComputeHash(bytes);
}
/// <summary>
/// Computes a stable SHA256 hash of binary content.
/// </summary>
public static string ComputeHash(byte[] content)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Canonicalizes JSON for comparison (stable property ordering, no whitespace).
/// </summary>
private static string CanonicalizeJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = null,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
catch (JsonException ex)
{
throw new DeterminismViolationException($"Failed to parse JSON for canonicalization: {ex.Message}", ex);
}
}
/// <summary>
/// Verifies that file paths are sorted deterministically (for SBOM manifests).
/// </summary>
public static void AssertSortedPaths(IEnumerable<string> paths)
{
var pathList = paths.ToList();
var sortedPaths = pathList.OrderBy(p => p, StringComparer.Ordinal).ToList();
if (!pathList.SequenceEqual(sortedPaths))
{
throw new DeterminismViolationException(
$"Path ordering is non-deterministic.\n\n" +
$"Actual order:\n{string.Join("\n", pathList.Take(10))}\n\n" +
$"Expected (sorted) order:\n{string.Join("\n", sortedPaths.Take(10))}");
}
}
/// <summary>
/// Verifies that timestamps are in UTC and ISO 8601 format.
/// </summary>
public static void AssertUtcIso8601(string timestamp)
{
if (!DateTimeOffset.TryParse(timestamp, out var dto))
{
throw new DeterminismViolationException($"Invalid timestamp format: {timestamp}");
}
if (dto.Offset != TimeSpan.Zero)
{
throw new DeterminismViolationException(
$"Timestamp is not UTC: {timestamp} (offset: {dto.Offset})");
}
// Verify ISO 8601 format with 'Z' suffix
var iso8601 = dto.ToString("o");
if (!iso8601.EndsWith("Z"))
{
throw new DeterminismViolationException(
$"Timestamp does not have 'Z' suffix: {timestamp}");
}
}
}
/// <summary>
/// Exception thrown when determinism violations are detected.
/// </summary>
public sealed class DeterminismViolationException : Exception
{
public DeterminismViolationException(string message) : base(message) { }
public DeterminismViolationException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -1,322 +0,0 @@
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

@@ -1,238 +0,0 @@
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

@@ -1,183 +0,0 @@
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

@@ -1,374 +0,0 @@
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

@@ -1,16 +0,0 @@
<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

@@ -1,136 +0,0 @@
using System.Collections.Immutable;
namespace StellaOps.Testing.Manifests.Models;
/// <summary>
/// Captures all inputs required to reproduce a scan verdict deterministically.
/// This is the replay key that enables time-travel verification.
/// </summary>
public sealed record RunManifest
{
/// <summary>
/// Unique identifier for this run.
/// </summary>
public required string RunId { get; init; }
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Artifact digests being scanned (image layers, binaries, etc.).
/// </summary>
public required ImmutableArray<ArtifactDigest> ArtifactDigests { get; init; }
/// <summary>
/// SBOM digests produced or consumed during the run.
/// </summary>
public ImmutableArray<SbomReference> SbomDigests { get; init; } = [];
/// <summary>
/// Vulnerability feed snapshot used for matching.
/// </summary>
public required FeedSnapshot FeedSnapshot { get; init; }
/// <summary>
/// Policy version and lattice rules digest.
/// </summary>
public required PolicySnapshot PolicySnapshot { get; init; }
/// <summary>
/// Tool versions used in the scan pipeline.
/// </summary>
public required ToolVersions ToolVersions { get; init; }
/// <summary>
/// Cryptographic profile: trust roots, key IDs, algorithm set.
/// </summary>
public required CryptoProfile CryptoProfile { get; init; }
/// <summary>
/// Environment profile: postgres-only vs postgres+valkey.
/// </summary>
public required EnvironmentProfile EnvironmentProfile { get; init; }
/// <summary>
/// PRNG seed for any randomized operations (ensures reproducibility).
/// </summary>
public long? PrngSeed { get; init; }
/// <summary>
/// Canonicalization algorithm version for stable JSON output.
/// </summary>
public required string CanonicalizationVersion { get; init; }
/// <summary>
/// UTC timestamp when the run was initiated.
/// </summary>
public required DateTimeOffset InitiatedAt { get; init; }
/// <summary>
/// SHA-256 hash of this manifest (excluding this field).
/// </summary>
public string? ManifestDigest { get; init; }
}
/// <summary>
/// Artifact digest information.
/// </summary>
public sealed record ArtifactDigest(
string Algorithm,
string Digest,
string? MediaType,
string? Reference);
/// <summary>
/// SBOM reference information.
/// </summary>
public sealed record SbomReference(
string Format,
string Digest,
string? Uri);
/// <summary>
/// Feed snapshot reference.
/// </summary>
public sealed record FeedSnapshot(
string FeedId,
string Version,
string Digest,
DateTimeOffset SnapshotAt);
/// <summary>
/// Policy snapshot reference.
/// </summary>
public sealed record PolicySnapshot(
string PolicyVersion,
string LatticeRulesDigest,
ImmutableArray<string> EnabledRules);
/// <summary>
/// Toolchain versions used during the scan.
/// </summary>
public sealed record ToolVersions(
string ScannerVersion,
string SbomGeneratorVersion,
string ReachabilityEngineVersion,
string AttestorVersion,
ImmutableDictionary<string, string> AdditionalTools);
/// <summary>
/// Cryptographic profile for the run.
/// </summary>
public sealed record CryptoProfile(
string ProfileName,
ImmutableArray<string> TrustRootIds,
ImmutableArray<string> AllowedAlgorithms);
/// <summary>
/// Environment profile for determinism.
/// </summary>
public sealed record EnvironmentProfile(
string Name,
bool ValkeyEnabled,
string? PostgresVersion,
string? ValkeyVersion);

View File

@@ -1,120 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.io/schemas/run-manifest/v1",
"title": "StellaOps Run Manifest",
"description": "Captures all inputs for deterministic scan replay",
"type": "object",
"required": [
"runId",
"schemaVersion",
"artifactDigests",
"feedSnapshot",
"policySnapshot",
"toolVersions",
"cryptoProfile",
"environmentProfile",
"canonicalizationVersion",
"initiatedAt"
],
"properties": {
"runId": { "type": "string" },
"schemaVersion": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
"artifactDigests": {
"type": "array",
"items": { "$ref": "#/$defs/artifactDigest" },
"minItems": 1
},
"sbomDigests": {
"type": "array",
"items": { "$ref": "#/$defs/sbomReference" }
},
"feedSnapshot": { "$ref": "#/$defs/feedSnapshot" },
"policySnapshot": { "$ref": "#/$defs/policySnapshot" },
"toolVersions": { "$ref": "#/$defs/toolVersions" },
"cryptoProfile": { "$ref": "#/$defs/cryptoProfile" },
"environmentProfile": { "$ref": "#/$defs/environmentProfile" },
"prngSeed": { "type": ["integer", "null"] },
"canonicalizationVersion": { "type": "string" },
"initiatedAt": { "type": "string", "format": "date-time" },
"manifestDigest": { "type": ["string", "null"] }
},
"$defs": {
"artifactDigest": {
"type": "object",
"required": ["algorithm", "digest"],
"properties": {
"algorithm": { "enum": ["sha256", "sha512"] },
"digest": { "type": "string", "pattern": "^[a-f0-9]{64,128}$" },
"mediaType": { "type": ["string", "null"] },
"reference": { "type": ["string", "null"] }
}
},
"sbomReference": {
"type": "object",
"required": ["format", "digest"],
"properties": {
"format": { "type": "string" },
"digest": { "type": "string" },
"uri": { "type": ["string", "null"] }
}
},
"feedSnapshot": {
"type": "object",
"required": ["feedId", "version", "digest", "snapshotAt"],
"properties": {
"feedId": { "type": "string" },
"version": { "type": "string" },
"digest": { "type": "string" },
"snapshotAt": { "type": "string", "format": "date-time" }
}
},
"policySnapshot": {
"type": "object",
"required": ["policyVersion", "latticeRulesDigest", "enabledRules"],
"properties": {
"policyVersion": { "type": "string" },
"latticeRulesDigest": { "type": "string" },
"enabledRules": {
"type": "array",
"items": { "type": "string" }
}
}
},
"toolVersions": {
"type": "object",
"required": ["scannerVersion", "sbomGeneratorVersion", "reachabilityEngineVersion", "attestorVersion", "additionalTools"],
"properties": {
"scannerVersion": { "type": "string" },
"sbomGeneratorVersion": { "type": "string" },
"reachabilityEngineVersion": { "type": "string" },
"attestorVersion": { "type": "string" },
"additionalTools": { "type": "object" }
}
},
"cryptoProfile": {
"type": "object",
"required": ["profileName", "trustRootIds", "allowedAlgorithms"],
"properties": {
"profileName": { "type": "string" },
"trustRootIds": {
"type": "array",
"items": { "type": "string" }
},
"allowedAlgorithms": {
"type": "array",
"items": { "type": "string" }
}
}
},
"environmentProfile": {
"type": "object",
"required": ["name", "valkeyEnabled"],
"properties": {
"name": { "type": "string" },
"valkeyEnabled": { "type": "boolean" },
"postgresVersion": { "type": ["string", "null"] },
"valkeyVersion": { "type": ["string", "null"] }
}
}
}
}

View File

@@ -1,59 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Manifests.Models;
namespace StellaOps.Testing.Manifests.Serialization;
/// <summary>
/// Serialize and hash RunManifest in canonical form.
/// </summary>
public static class RunManifestSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Serializes a manifest to canonical JSON.
/// </summary>
public static string Serialize(RunManifest manifest)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
return Encoding.UTF8.GetString(canonicalBytes);
}
/// <summary>
/// Deserializes a manifest from JSON.
/// </summary>
public static RunManifest Deserialize(string json)
{
return JsonSerializer.Deserialize<RunManifest>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize manifest");
}
/// <summary>
/// Computes the SHA-256 digest of a manifest (excluding ManifestDigest).
/// </summary>
public static string ComputeDigest(RunManifest manifest)
{
var withoutDigest = manifest with { ManifestDigest = null };
var json = Serialize(withoutDigest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Returns a manifest with the digest computed and applied.
/// </summary>
public static RunManifest WithDigest(RunManifest manifest)
=> manifest with { ManifestDigest = ComputeDigest(manifest) };
}

View File

@@ -1,93 +0,0 @@
using System.Collections.Immutable;
using StellaOps.Testing.Manifests.Models;
using StellaOps.Testing.Manifests.Serialization;
namespace StellaOps.Testing.Manifests.Services;
/// <summary>
/// Captures a RunManifest during scan execution.
/// </summary>
public sealed class ManifestCaptureService : IManifestCaptureService
{
private readonly IFeedVersionProvider _feedProvider;
private readonly IPolicyVersionProvider _policyProvider;
private readonly TimeProvider _timeProvider;
public ManifestCaptureService(
IFeedVersionProvider feedProvider,
IPolicyVersionProvider policyProvider,
TimeProvider? timeProvider = null)
{
_feedProvider = feedProvider;
_policyProvider = policyProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<RunManifest> CaptureAsync(
ScanContext context,
CancellationToken ct = default)
{
var feedSnapshot = await _feedProvider.GetCurrentSnapshotAsync(ct).ConfigureAwait(false);
var policySnapshot = await _policyProvider.GetCurrentSnapshotAsync(ct).ConfigureAwait(false);
var manifest = new RunManifest
{
RunId = context.RunId,
SchemaVersion = "1.0.0",
ArtifactDigests = context.ArtifactDigests,
SbomDigests = context.GeneratedSboms,
FeedSnapshot = feedSnapshot,
PolicySnapshot = policySnapshot,
ToolVersions = context.ToolVersions ?? GetToolVersions(),
CryptoProfile = context.CryptoProfile,
EnvironmentProfile = context.EnvironmentProfile ?? GetEnvironmentProfile(),
PrngSeed = context.PrngSeed,
CanonicalizationVersion = "1.0.0",
InitiatedAt = _timeProvider.GetUtcNow()
};
return RunManifestSerializer.WithDigest(manifest);
}
private static ToolVersions GetToolVersions() => new(
ScannerVersion: typeof(ManifestCaptureService).Assembly.GetName().Version?.ToString() ?? "unknown",
SbomGeneratorVersion: "unknown",
ReachabilityEngineVersion: "unknown",
AttestorVersion: "unknown",
AdditionalTools: ImmutableDictionary<string, string>.Empty);
private static EnvironmentProfile GetEnvironmentProfile() => new(
Name: Environment.GetEnvironmentVariable("STELLAOPS_ENV_PROFILE") ?? "postgres-only",
ValkeyEnabled: string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_ENABLED"), "true", StringComparison.OrdinalIgnoreCase),
PostgresVersion: Environment.GetEnvironmentVariable("STELLAOPS_POSTGRES_VERSION"),
ValkeyVersion: Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_VERSION"));
}
public interface IManifestCaptureService
{
Task<RunManifest> CaptureAsync(ScanContext context, CancellationToken ct = default);
}
public interface IFeedVersionProvider
{
Task<FeedSnapshot> GetCurrentSnapshotAsync(CancellationToken ct = default);
}
public interface IPolicyVersionProvider
{
Task<PolicySnapshot> GetCurrentSnapshotAsync(CancellationToken ct = default);
}
/// <summary>
/// Input context required to capture a RunManifest.
/// </summary>
public sealed record ScanContext
{
public required string RunId { get; init; }
public required ImmutableArray<ArtifactDigest> ArtifactDigests { get; init; }
public ImmutableArray<SbomReference> GeneratedSboms { get; init; } = [];
public required CryptoProfile CryptoProfile { get; init; }
public ToolVersions? ToolVersions { get; init; }
public EnvironmentProfile? EnvironmentProfile { get; init; }
public long? PrngSeed { get; init; }
}

View File

@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="7.2.0" />
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\*.json" />
</ItemGroup>
</Project>

View File

@@ -1,64 +0,0 @@
using System.Text.Json;
using Json.Schema;
using StellaOps.Testing.Manifests.Models;
using StellaOps.Testing.Manifests.Serialization;
namespace StellaOps.Testing.Manifests.Validation;
/// <summary>
/// Validates RunManifest instances against schema and invariants.
/// </summary>
public sealed class RunManifestValidator : IRunManifestValidator
{
private readonly JsonSchema _schema;
public RunManifestValidator()
{
var schemaJson = SchemaLoader.LoadSchema("run-manifest.schema.json");
_schema = JsonSchema.FromText(schemaJson);
}
public ValidationResult Validate(RunManifest manifest)
{
var errors = new List<ValidationError>();
var json = RunManifestSerializer.Serialize(manifest);
var schemaResult = _schema.Evaluate(JsonDocument.Parse(json));
if (!schemaResult.IsValid && schemaResult.Errors is not null)
{
foreach (var error in schemaResult.Errors)
{
errors.Add(new ValidationError("Schema", error.Value ?? "Unknown error"));
}
}
if (manifest.ArtifactDigests.Length == 0)
{
errors.Add(new ValidationError("ArtifactDigests", "At least one artifact required"));
}
if (manifest.FeedSnapshot.SnapshotAt > manifest.InitiatedAt)
{
errors.Add(new ValidationError("FeedSnapshot", "Feed snapshot cannot be after run initiation"));
}
if (manifest.ManifestDigest is not null)
{
var computed = RunManifestSerializer.ComputeDigest(manifest);
if (!string.Equals(computed, manifest.ManifestDigest, StringComparison.OrdinalIgnoreCase))
{
errors.Add(new ValidationError("ManifestDigest", "Digest mismatch"));
}
}
return new ValidationResult(errors.Count == 0, errors);
}
}
public interface IRunManifestValidator
{
ValidationResult Validate(RunManifest manifest);
}
public sealed record ValidationResult(bool IsValid, IReadOnlyList<ValidationError> Errors);
public sealed record ValidationError(string Field, string Message);

View File

@@ -1,27 +0,0 @@
using System.Reflection;
namespace StellaOps.Testing.Manifests.Validation;
internal static class SchemaLoader
{
public static string LoadSchema(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
throw new InvalidOperationException($"Schema resource not found: {fileName}");
}
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new InvalidOperationException($"Schema resource not available: {resourceName}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -1,210 +0,0 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Testing.Factories;
/// <summary>
/// Factory for creating test frames with sensible defaults.
/// </summary>
public static class TestFrameFactory
{
/// <summary>
/// Creates a request frame with the specified payload.
/// </summary>
public static Frame CreateRequestFrame(
byte[]? payload = null,
string? correlationId = null,
FrameType frameType = FrameType.Request)
{
return new Frame
{
Type = frameType,
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
Payload = payload ?? Array.Empty<byte>()
};
}
/// <summary>
/// Creates a response frame for the given correlation ID.
/// </summary>
public static Frame CreateResponseFrame(
string correlationId,
byte[]? payload = null)
{
return new Frame
{
Type = FrameType.Response,
CorrelationId = correlationId,
Payload = payload ?? Array.Empty<byte>()
};
}
/// <summary>
/// Creates a hello frame for service registration.
/// </summary>
public static Frame CreateHelloFrame(
string serviceName = "test-service",
string version = "1.0.0",
string region = "test",
string instanceId = "test-instance",
IReadOnlyList<EndpointDescriptor>? endpoints = null)
{
var helloPayload = new HelloPayload
{
Instance = new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = region
},
Endpoints = endpoints ?? []
};
return new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(helloPayload)
};
}
/// <summary>
/// Creates a heartbeat frame.
/// </summary>
public static Frame CreateHeartbeatFrame(
string instanceId = "test-instance",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
int inFlightRequestCount = 0,
double errorRate = 0.0)
{
var heartbeatPayload = new HeartbeatPayload
{
InstanceId = instanceId,
Status = status,
InFlightRequestCount = inFlightRequestCount,
ErrorRate = errorRate,
TimestampUtc = DateTime.UtcNow
};
return new Frame
{
Type = FrameType.Heartbeat,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(heartbeatPayload)
};
}
/// <summary>
/// Creates a cancel frame for the given correlation ID.
/// </summary>
public static Frame CreateCancelFrame(
string correlationId,
string? reason = null)
{
var cancelPayload = new CancelPayload
{
Reason = reason ?? CancelReasons.Timeout
};
return new Frame
{
Type = FrameType.Cancel,
CorrelationId = correlationId,
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cancelPayload)
};
}
/// <summary>
/// Creates a frame with a specific payload size for testing limits.
/// </summary>
public static Frame CreateFrameWithPayloadSize(int payloadSize)
{
var payload = new byte[payloadSize];
Random.Shared.NextBytes(payload);
return new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = payload
};
}
/// <summary>
/// Creates a request frame from JSON content.
/// </summary>
public static RequestFrame CreateTypedRequestFrame<T>(
T request,
string method = "POST",
string path = "/test",
Dictionary<string, string>? headers = null)
{
return new RequestFrame
{
RequestId = Guid.NewGuid().ToString("N"),
CorrelationId = Guid.NewGuid().ToString("N"),
Method = method,
Path = path,
Headers = headers ?? new Dictionary<string, string>(),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(request)
};
}
/// <summary>
/// Creates an endpoint descriptor for testing.
/// </summary>
public static EndpointDescriptor CreateEndpointDescriptor(
string method = "GET",
string path = "/test",
string serviceName = "test-service",
string version = "1.0.0",
int timeoutSeconds = 30,
bool supportsStreaming = false,
IReadOnlyList<ClaimRequirement>? requiringClaims = null)
{
return new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version,
DefaultTimeout = TimeSpan.FromSeconds(timeoutSeconds),
SupportsStreaming = supportsStreaming,
RequiringClaims = requiringClaims ?? []
};
}
/// <summary>
/// Creates an instance descriptor for testing.
/// </summary>
public static InstanceDescriptor CreateInstanceDescriptor(
string instanceId = "test-instance",
string serviceName = "test-service",
string version = "1.0.0",
string region = "test")
{
return new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = region
};
}
/// <summary>
/// Creates a claim requirement for testing.
/// </summary>
public static ClaimRequirement CreateClaimRequirement(
string type,
string? value = null)
{
return new ClaimRequirement
{
Type = type,
Value = value
};
}
}

View File

@@ -1,102 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Router.Testing.Fixtures;
/// <summary>
/// Base test fixture for Router tests providing common utilities.
/// Implements IAsyncLifetime for async setup/teardown.
/// </summary>
public abstract class RouterTestFixture : IAsyncLifetime
{
/// <summary>
/// Gets a null logger factory for tests that don't need logging.
/// </summary>
protected ILoggerFactory LoggerFactory { get; } = NullLoggerFactory.Instance;
/// <summary>
/// Gets a null logger for tests that don't need logging.
/// </summary>
protected ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
/// <summary>
/// Creates a cancellation token that times out after the specified duration.
/// </summary>
protected static CancellationToken CreateTimeoutToken(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
return cts.Token;
}
/// <summary>
/// Creates a cancellation token that times out after 5 seconds (default for tests).
/// </summary>
protected static CancellationToken CreateTestTimeoutToken()
{
return CreateTimeoutToken(TimeSpan.FromSeconds(5));
}
/// <summary>
/// Waits for a condition to be true with timeout.
/// </summary>
protected static async Task WaitForConditionAsync(
Func<bool> condition,
TimeSpan timeout,
TimeSpan? pollInterval = null)
{
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(interval);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
/// <summary>
/// Waits for an async condition to be true with timeout.
/// </summary>
protected static async Task WaitForConditionAsync(
Func<Task<bool>> condition,
TimeSpan timeout,
TimeSpan? pollInterval = null)
{
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (await condition())
return;
await Task.Delay(interval);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
/// <summary>
/// Override for async initialization.
/// </summary>
public virtual Task InitializeAsync() => Task.CompletedTask;
/// <summary>
/// Override for async cleanup.
/// </summary>
public virtual Task DisposeAsync() => Task.CompletedTask;
}
/// <summary>
/// Collection fixture for sharing state across tests in the same collection.
/// </summary>
public abstract class RouterCollectionFixture : IAsyncLifetime
{
public virtual Task InitializeAsync() => Task.CompletedTask;
public virtual Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -1,56 +0,0 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Testing.Mocks;
/// <summary>
/// A mock connection state for testing routing and connection management.
/// </summary>
public sealed class MockConnectionState
{
public string ConnectionId { get; init; } = Guid.NewGuid().ToString("N");
public string ServiceName { get; init; } = "test-service";
public string Version { get; init; } = "1.0.0";
public string Region { get; init; } = "test";
public string InstanceId { get; init; } = "test-instance";
public InstanceHealthStatus HealthStatus { get; set; } = InstanceHealthStatus.Healthy;
public DateTimeOffset ConnectedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastHeartbeatUtc { get; set; } = DateTimeOffset.UtcNow;
public int InflightRequests { get; set; }
public int Weight { get; set; } = 100;
public List<EndpointDescriptor> Endpoints { get; init; } = new();
/// <summary>
/// Creates a connection state for testing.
/// </summary>
public static MockConnectionState Create(
string? serviceName = null,
string? instanceId = null,
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return new MockConnectionState
{
ServiceName = serviceName ?? "test-service",
InstanceId = instanceId ?? $"instance-{Guid.NewGuid():N}",
HealthStatus = status
};
}
/// <summary>
/// Creates multiple connection states simulating a service cluster.
/// </summary>
public static List<MockConnectionState> CreateCluster(
string serviceName,
int instanceCount,
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return Enumerable.Range(0, instanceCount)
.Select(i => new MockConnectionState
{
ServiceName = serviceName,
InstanceId = $"{serviceName}-{i}",
HealthStatus = status
})
.ToList();
}
}

View File

@@ -1,104 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace StellaOps.Router.Testing.Mocks;
/// <summary>
/// A logger that records all log entries for assertions.
/// </summary>
public sealed class RecordingLogger<T> : ILogger<T>
{
private readonly ConcurrentQueue<LogEntry> _entries = new();
/// <summary>
/// Gets all recorded log entries.
/// </summary>
public IReadOnlyList<LogEntry> Entries => _entries.ToList();
/// <summary>
/// Gets entries filtered by log level.
/// </summary>
public IEnumerable<LogEntry> GetEntries(LogLevel level) =>
_entries.Where(e => e.Level == level);
/// <summary>
/// Gets all error entries.
/// </summary>
public IEnumerable<LogEntry> Errors => GetEntries(LogLevel.Error);
/// <summary>
/// Gets all warning entries.
/// </summary>
public IEnumerable<LogEntry> Warnings => GetEntries(LogLevel.Warning);
/// <summary>
/// Clears all recorded entries.
/// </summary>
public void Clear()
{
while (_entries.TryDequeue(out _)) { }
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
_entries.Enqueue(new LogEntry
{
Level = logLevel,
EventId = eventId,
Message = formatter(state, exception),
Exception = exception,
Timestamp = DateTimeOffset.UtcNow
});
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
/// <summary>
/// Represents a recorded log entry.
/// </summary>
public sealed record LogEntry
{
public required LogLevel Level { get; init; }
public required EventId EventId { get; init; }
public required string Message { get; init; }
public Exception? Exception { get; init; }
public DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// A logger factory that creates recording loggers.
/// </summary>
public sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly ConcurrentDictionary<string, object> _loggers = new();
public ILogger CreateLogger(string categoryName) =>
(ILogger)_loggers.GetOrAdd(categoryName, _ => new RecordingLogger<object>());
public ILogger<T> CreateLogger<T>() =>
(ILogger<T>)_loggers.GetOrAdd(typeof(T).FullName!, _ => new RecordingLogger<T>());
public RecordingLogger<T>? GetLogger<T>() =>
_loggers.TryGetValue(typeof(T).FullName!, out var logger)
? logger as RecordingLogger<T>
: null;
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}

View File

@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Testing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>