audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -14,7 +14,7 @@ namespace StellaOps.TestKit.Assertions;
/// - Consistent number formatting
/// - No whitespace variations
/// - UTF-8 encoding
/// - Deterministic output (same input same bytes)
/// - Deterministic output (same input -> same bytes)
/// </remarks>
public static class CanonicalJsonAssert
{

View File

@@ -1,5 +1,6 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.TestKit.Connectors;
@@ -9,8 +10,11 @@ namespace StellaOps.TestKit.Connectors;
/// </summary>
public sealed class ConnectorHttpFixture : IDisposable
{
private const string ClientName = "ConnectorHttpFixture";
private readonly Dictionary<string, HttpResponseEntry> _responses = new();
private readonly List<HttpRequestMessage> _capturedRequests = new();
private ServiceProvider? _serviceProvider;
private IHttpClientFactory? _httpClientFactory;
private bool _disposed;
/// <summary>
@@ -23,7 +27,7 @@ public sealed class ConnectorHttpFixture : IDisposable
/// </summary>
public HttpClient CreateClient()
{
return new HttpClient(new CannedMessageHandler(this));
return GetClientFactory().CreateClient(ClientName);
}
/// <summary>
@@ -163,9 +167,28 @@ public sealed class ConnectorHttpFixture : IDisposable
if (_disposed) return;
_responses.Clear();
_capturedRequests.Clear();
_serviceProvider?.Dispose();
_serviceProvider = null;
_httpClientFactory = null;
_disposed = true;
}
private IHttpClientFactory GetClientFactory()
{
if (_httpClientFactory != null)
{
return _httpClientFactory;
}
var services = new ServiceCollection();
services.AddHttpClient(ClientName)
.ConfigurePrimaryHttpMessageHandler(() => new CannedMessageHandler(this));
_serviceProvider = services.BuildServiceProvider();
_httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
return _httpClientFactory;
}
private sealed record HttpResponseEntry(
HttpStatusCode StatusCode = HttpStatusCode.OK,
string ContentType = "application/json",

View File

@@ -1,8 +1,8 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TestKit.Connectors;
/// <summary>
@@ -34,19 +34,31 @@ namespace StellaOps.TestKit.Connectors;
/// </remarks>
public abstract class ConnectorLiveSchemaTestBase : IAsyncLifetime
{
private const string LiveClientName = "ConnectorLiveSchema";
private static readonly Lazy<IServiceProvider> LiveServices = new(() =>
{
var services = new ServiceCollection();
services.AddHttpClient(LiveClientName)
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
return services.BuildServiceProvider();
});
private readonly HttpClient _httpClient;
private readonly FixtureUpdater _fixtureUpdater;
private readonly List<FixtureDriftReport> _driftReports = new();
protected ConnectorLiveSchemaTestBase()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_httpClient = LiveHttpClientFactory.CreateClient(LiveClientName);
_fixtureUpdater = new FixtureUpdater(FixturesDirectory, _httpClient);
}
private static IHttpClientFactory LiveHttpClientFactory =>
LiveServices.Value.GetRequiredService<IHttpClientFactory>();
/// <summary>
/// Gets the base directory for test fixtures (relative to test assembly).
/// </summary>

View File

@@ -12,10 +12,10 @@ public sealed class FixtureUpdater
private readonly string _fixturesDirectory;
private readonly bool _enabled;
public FixtureUpdater(string fixturesDirectory, HttpClient? httpClient = null)
public FixtureUpdater(string fixturesDirectory, HttpClient httpClient)
{
_fixturesDirectory = fixturesDirectory;
_httpClient = httpClient ?? new HttpClient();
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_enabled = Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
}

View File

@@ -87,7 +87,11 @@ public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram
/// .WhenRequest("https://api.example.com/data")
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
///
/// var httpClient = new HttpClient(handler);
/// var services = new ServiceCollection();
/// services.AddHttpClient("stub")
/// .ConfigurePrimaryHttpMessageHandler(() => handler);
/// using var provider = services.BuildServiceProvider();
/// var httpClient = provider.GetRequiredService&lt;IHttpClientFactory&gt;().CreateClient("stub");
/// var response = await httpClient.GetAsync("https://api.example.com/data");
/// // response.StatusCode == HttpStatusCode.OK
/// </code>

View File

@@ -43,11 +43,10 @@ public enum ValkeyIsolationMode
/// }
/// </code>
/// </remarks>
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
public sealed class ValkeyFixture : IAsyncLifetime
{
private IContainer? _container;
private ConnectionMultiplexer? _connection;
private bool _disposed;
private int _databaseCounter;
/// <summary>
@@ -206,19 +205,6 @@ public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
}
}
/// <summary>
/// Disposes the fixture.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().GetAwaiter().GetResult();
_disposed = true;
}
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Xunit;
@@ -38,6 +39,7 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
builder.ConfigureServices(services =>
{
// Add default test services
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<TestRequestContext>();
// Apply custom configuration
@@ -81,12 +83,18 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
public sealed class TestRequestContext
{
private readonly List<RequestRecord> _requests = new();
private readonly TimeProvider _timeProvider;
public TestRequestContext(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public void RecordRequest(string method, string path, int statusCode)
{
lock (_requests)
{
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
_requests.Add(new RequestRecord(method, path, statusCode, _timeProvider.GetUtcNow().UtcDateTime));
}
}

View File

@@ -155,11 +155,7 @@ public abstract class CacheIdempotencyTests<TEntity, TKey> : IClassFixture<Valke
// Act - Fire multiple concurrent sets
var tasks = Enumerable.Range(1, 10)
.Select(i => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
}));
.Select(_ => SetAsync(session, key, CreateTestEntity(key)));
await Task.WhenAll(tasks);

View File

@@ -7,6 +7,7 @@
// -----------------------------------------------------------------------------
using System.Diagnostics;
using StellaOps.TestKit.Deterministic;
namespace StellaOps.TestKit.Templates;
@@ -17,13 +18,13 @@ namespace StellaOps.TestKit.Templates;
/// <remarks>
/// Common sources of test flakiness and their solutions:
///
/// 1. **DateTime.Now/UtcNow** Use injected TimeProvider or DeterministicTime
/// 2. **Random without seed** Use DeterministicRandom with fixed seed
/// 3. **Task.Delay for timing** Use polling with configurable timeout or fake timers
/// 4. **External service calls** Use HttpFixtureServer or mocks
/// 5. **Ordering assumptions** Ensure explicit ORDER BY or use sorted assertions
/// 6. **Parallel test interference** Use test isolation (schema-per-test, unique IDs)
/// 7. **Environment dependencies** Use TestContainers with fixed versions
/// 1. **DateTime.Now/UtcNow** -> Use injected TimeProvider or DeterministicTime
/// 2. **Random without seed** -> Use DeterministicRandom with fixed seed
/// 3. **Task.Delay for timing** -> Use polling with configurable timeout or fake timers
/// 4. **External service calls** -> Use HttpFixtureServer or mocks
/// 5. **Ordering assumptions** -> Ensure explicit ORDER BY or use sorted assertions
/// 6. **Parallel test interference** -> Use test isolation (schema-per-test, unique IDs)
/// 7. **Environment dependencies** -> Use TestContainers with fixed versions
/// </remarks>
public static class FlakyToDeterministicPattern
{
@@ -32,7 +33,7 @@ public static class FlakyToDeterministicPattern
// FLAKY: Uses system clock - different results on each run
// public void Flaky_DateTimeNow()
// {
// var record = new AuditRecord { CreatedAt = DateTime.UtcNow };
// var record = new AuditRecord { CreatedAt = GetSystemUtcNow() };
// Assert.True(record.CreatedAt.Hour == 12); // Fails at any other hour
// }
@@ -59,7 +60,7 @@ public static class FlakyToDeterministicPattern
// FLAKY: Different random sequence each run
// public void Flaky_Random()
// {
// var random = new Random();
// var random = CreateUnseededRandom();
// var value = random.Next(1, 100);
// Assert.Equal(42, value); // Almost never passes
// }
@@ -70,7 +71,7 @@ public static class FlakyToDeterministicPattern
public static int Deterministic_SeededRandom(int seed = 12345)
{
// Same seed always produces same sequence
var random = new Random(seed);
var random = new DeterministicRandom(seed);
return random.Next(1, 100); // Always returns same value for same seed
}
@@ -129,9 +130,9 @@ public static class FlakyToDeterministicPattern
#region Pattern 4: Replace External HTTP with Fixture Server
// FLAKY: Depends on external service availability
// public async Task Flaky_ExternalHttp()
// public async Task Flaky_ExternalHttp(IHttpClientFactory httpClientFactory)
// {
// var client = new HttpClient();
// var client = httpClientFactory.CreateClient("live");
// var response = await client.GetAsync("https://api.example.com/data");
// Assert.True(response.IsSuccessStatusCode);
// }
@@ -207,12 +208,14 @@ public static class FlakyToDeterministicPattern
// }
/// <summary>
/// Deterministic version with unique identifiers.
/// Deterministic version with seeded identifiers.
/// </summary>
public static string GenerateTestId(string testName)
public static string GenerateTestId(string testName, DeterministicRandom random)
{
// Each test gets unique ID based on test name + timestamp
return $"{testName}-{Guid.NewGuid():N}";
ArgumentNullException.ThrowIfNull(testName);
ArgumentNullException.ThrowIfNull(random);
// Each test gets deterministic ID based on test name + seeded random
return $"{testName}-{random.NextGuid():N}";
}
/// <summary>

View File

@@ -1,3 +1,4 @@
using StellaOps.TestKit.Deterministic;
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
@@ -72,7 +73,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
.ToList();
// Insert in random order
var random = new Random(42); // Fixed seed for determinism
var random = new DeterministicRandom(42);
foreach (var entity in entities.OrderBy(_ => random.Next()))
{
await InsertAsync(session, entity);
@@ -234,7 +235,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Large_Result_Set_Maintains_Deterministic_Order));
var random = new Random(12345);
var random = new DeterministicRandom(12345);
var entities = Enumerable.Range(1, 100)
.Select(i => CreateTestEntity(GenerateKey(i), random.Next(1, 1000)))
.ToList();

View File

@@ -67,7 +67,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
.ToList();
// Act
var tasks = entities.Select(e => Task.Run(async () => await InsertAsync(session, e)));
var tasks = entities.Select(e => InsertAsync(session, e));
await Task.WhenAll(tasks);
// Assert
@@ -91,19 +91,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
// Act
var successCount = 0;
var tasks = Enumerable.Range(1, DefaultConcurrency)
.Select(i => Task.Run(async () =>
{
try
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
Interlocked.Increment(ref successCount);
}
catch
{
// Some updates may fail due to optimistic concurrency
}
}));
.Select(UpdateSafelyAsync);
await Task.WhenAll(tasks);
@@ -111,6 +99,20 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
successCount.Should().BeGreaterThan(0, "at least some updates should succeed");
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull();
async Task UpdateSafelyAsync(int i)
{
try
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
Interlocked.Increment(ref successCount);
}
catch
{
// Some updates may fail due to optimistic concurrency
}
}
}
[Fact]
@@ -124,7 +126,16 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
// Act
var readResults = new List<TEntity?>();
var readTask = Task.Run(async () =>
var readTask = ReadLoopAsync();
var writeTask = WriteLoopAsync();
await Task.WhenAll(readTask, writeTask);
// Assert
readResults.Should().NotBeEmpty();
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
async Task ReadLoopAsync()
{
for (int i = 0; i < 20; i++)
{
@@ -135,9 +146,9 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
}
await Task.Delay(10);
}
});
}
var writeTask = Task.Run(async () =>
async Task WriteLoopAsync()
{
for (int i = 2; i <= 10; i++)
{
@@ -145,13 +156,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
await UpdateAsync(session, entity);
await Task.Delay(15);
}
});
await Task.WhenAll(readTask, writeTask);
// Assert
readResults.Should().NotBeEmpty();
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
}
}
[Fact]
@@ -173,17 +178,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
{
foreach (var key in keys)
{
operations.Add(Task.Run(async () =>
{
// Read
var entity = await GetByKeyAsync(session, key);
if (entity != null)
{
// Update
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
await UpdateAsync(session, updated);
}
}));
operations.Add(RunOperationAsync(key));
}
}
@@ -195,6 +190,18 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull("entity should exist after parallel operations");
}
async Task RunOperationAsync(TKey key)
{
// Read
var entity = await GetByKeyAsync(session, key);
if (entity != null)
{
// Update
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
await UpdateAsync(session, updated);
}
}
}
[Fact]

View File

@@ -136,11 +136,7 @@ public abstract class StorageIdempotencyTests<TEntity, TKey> : IClassFixture<Pos
// Act
var tasks = Enumerable.Range(0, 10)
.Select(_ => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await UpsertAsync(session, entity);
}));
.Select(_ => UpsertAsync(session, CreateTestEntity(key)));
await Task.WhenAll(tasks);