diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d62a227f1..598be309d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Generate certificates run: | mkdir -p certs @@ -89,7 +89,7 @@ jobs: sudo chown -R $USER:$USER certs sudo chmod -R 755 certs - name: Upload certificates - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: certs path: certs @@ -123,21 +123,10 @@ jobs: run: | dotnet build --configuration ${{ matrix.configuration }} --framework ${{ matrix.framework }} src/EventStore.Client - name: Download certificates - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: name: certs path: certs - - name: Import certificates (Linux) - if: runner.os == 'Linux' - shell: bash - run: | - sudo cp certs/ca/ca.crt /usr/local/share/ca-certificates/eventstore_ca.crt - sudo update-ca-certificates - - name: Import certificates (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - Import-Certificate -FilePath "certs\ca\ca.crt" -CertStoreLocation "Cert:\LocalMachine\Root" - name: Run Tests (Linux) if: runner.os == 'Linux' shell: bash @@ -191,7 +180,7 @@ jobs: /p:RepositoryUrl=https://github.com/EventStore/EventStore-Client-Dotnet \ /p:RepositoryType=git - name: Publish Artifacts - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: path: packages name: nuget-packages diff --git a/gencert.ps1 b/gencert.ps1 index 74fc80d59..4055131d2 100644 --- a/gencert.ps1 +++ b/gencert.ps1 @@ -21,5 +21,3 @@ docker run --rm --volume .\certs:/tmp docker.eventstore.com/eventstore-utils/es- # Set permissions recursively for the directory icacls .\certs /grant:r "$($env:UserName):(OI)(CI)F" - -Import-Certificate -FilePath ".\certs\ca\ca.crt" -CertStoreLocation Cert:\CurrentUser\Root diff --git a/src/EventStore.Client/ChannelFactory.cs b/src/EventStore.Client/ChannelFactory.cs index 9e9e6fee7..0dc28ee8e 100644 --- a/src/EventStore.Client/ChannelFactory.cs +++ b/src/EventStore.Client/ChannelFactory.cs @@ -5,7 +5,7 @@ using TChannel = Grpc.Net.Client.GrpcChannel; namespace EventStore.Client { - + internal static class ChannelFactory { private const int MaxReceiveMessageLength = 17 * 1024 * 1024; @@ -38,14 +38,8 @@ public static TChannel CreateChannel(EventStoreClientSettings settings, EndPoint #if NET48 static HttpMessageHandler CreateHandler(EventStoreClientSettings settings) { - if (settings.CreateHttpMessageHandler != null) { + if (settings.CreateHttpMessageHandler is not null) return settings.CreateHttpMessageHandler.Invoke(); - } - - var certificate = settings.ConnectivitySettings.ClientCertificate ?? - settings.ConnectivitySettings.TlsCaFile; - - var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; var handler = new WinHttpHandler { TcpKeepAliveEnabled = true, @@ -56,42 +50,53 @@ static HttpMessageHandler CreateHandler(EventStoreClientSettings settings) { if (settings.ConnectivitySettings.Insecure) return handler; - if (configureClientCert) { - handler.ClientCertificates.Add(certificate!); - } + if (settings.ConnectivitySettings.ClientCertificate is not null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); - if (!settings.ConnectivitySettings.TlsVerifyCert) { - handler.ServerCertificateValidationCallback = delegate { return true; }; - } + handler.ServerCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (chain is null) return false; + + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(certificate); + }, + _ => null + }; return handler; } #else static HttpMessageHandler CreateHandler(EventStoreClientSettings settings) { - if (settings.CreateHttpMessageHandler != null) { + if (settings.CreateHttpMessageHandler is not null) return settings.CreateHttpMessageHandler.Invoke(); - } - - var certificate = settings.ConnectivitySettings.ClientCertificate ?? - settings.ConnectivitySettings.TlsCaFile; - - var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; var handler = new SocketsHttpHandler { KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval, KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout, - EnableMultipleHttp2Connections = true, + EnableMultipleHttp2Connections = true }; - if (settings.ConnectivitySettings.Insecure) return handler; + if (settings.ConnectivitySettings.Insecure) + return handler; - if (configureClientCert) { - handler.SslOptions.ClientCertificates = new X509CertificateCollection { certificate! }; + if (settings.ConnectivitySettings.ClientCertificate is not null) { + handler.SslOptions.ClientCertificates = new X509CertificateCollection { + settings.ConnectivitySettings.ClientCertificate + }; } - if (!settings.ConnectivitySettings.TlsVerifyCert) { - handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; - } + handler.SslOptions.RemoteCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is not X509Certificate2 peerCertificate || chain is null) return false; + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(peerCertificate); + }, + _ => null + }; return handler; } diff --git a/src/EventStore.Client/EventStore.Client.csproj b/src/EventStore.Client/EventStore.Client.csproj index 321f89c8c..47a116602 100644 --- a/src/EventStore.Client/EventStore.Client.csproj +++ b/src/EventStore.Client/EventStore.Client.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs index b6d6ee60a..48eb84956 100644 --- a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs +++ b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs @@ -235,10 +235,8 @@ private static EventStoreClientSettings CreateSettings( #if NET48 HttpMessageHandler CreateDefaultHandler() { - var certificate = settings.ConnectivitySettings.ClientCertificate ?? - settings.ConnectivitySettings.TlsCaFile; - - var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; + if (settings.CreateHttpMessageHandler is not null) + return settings.CreateHttpMessageHandler.Invoke(); var handler = new WinHttpHandler { TcpKeepAliveEnabled = true, @@ -249,38 +247,50 @@ HttpMessageHandler CreateDefaultHandler() { if (settings.ConnectivitySettings.Insecure) return handler; - if (configureClientCert) { - handler.ClientCertificates.Add(certificate!); - } + if (settings.ConnectivitySettings.ClientCertificate is not null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); - if (!settings.ConnectivitySettings.TlsVerifyCert) { - handler.ServerCertificateValidationCallback = delegate { return true; }; - } + handler.ServerCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (chain is null) return false; + + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(certificate); + }, + _ => null + }; return handler; } #else HttpMessageHandler CreateDefaultHandler() { - var certificate = settings.ConnectivitySettings.ClientCertificate ?? - settings.ConnectivitySettings.TlsCaFile; - - var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; - var handler = new SocketsHttpHandler { KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval, KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout, - EnableMultipleHttp2Connections = true, + EnableMultipleHttp2Connections = true }; - if (settings.ConnectivitySettings.Insecure) return handler; + if (settings.ConnectivitySettings.Insecure) + return handler; - if (configureClientCert) { - handler.SslOptions.ClientCertificates = [certificate!]; + if (settings.ConnectivitySettings.ClientCertificate is not null) { + handler.SslOptions.ClientCertificates = new X509CertificateCollection { + settings.ConnectivitySettings.ClientCertificate + }; } - if (!settings.ConnectivitySettings.TlsVerifyCert) { - handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; - } + handler.SslOptions.RemoteCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is not X509Certificate2 peerCertificate || chain is null) return false; + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(peerCertificate); + }, + _ => null + }; return handler; } diff --git a/src/EventStore.Client/HttpFallback.cs b/src/EventStore.Client/HttpFallback.cs index 44a72cf7e..3e5420e9a 100644 --- a/src/EventStore.Client/HttpFallback.cs +++ b/src/EventStore.Client/HttpFallback.cs @@ -21,14 +21,27 @@ internal HttpFallback(EventStoreClientSettings settings) { if (!settings.ConnectivitySettings.Insecure) { handler.ClientCertificateOptions = ClientCertificateOption.Manual; - if (settings.ConnectivitySettings.TlsCaFile != null) - handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile); - - if (settings.ConnectivitySettings.ClientCertificate != null) + if (settings.ConnectivitySettings.ClientCertificate is not null) handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); - if (!settings.ConnectivitySettings.TlsVerifyCert) - handler.ServerCertificateCustomValidationCallback = delegate { return true; }; + handler.ServerCertificateCustomValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is null || chain is null) return false; + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + +#if NET48 + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); +#else + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); +#endif + + return chain.Build(certificate); + }, + _ => null + }; } _httpClient = new HttpClient(handler); @@ -45,9 +58,9 @@ internal async Task HttpGetAsync(string path, ChannelInfo channelInfo, Tim UserCredentials? userCredentials, Action onNotFound, CancellationToken cancellationToken) { var request = CreateRequest(path, HttpMethod.Get, channelInfo, userCredentials); - + var httpResult = await HttpSendAsync(request, onNotFound, deadline, cancellationToken).ConfigureAwait(false); - + #if NET var json = await httpResult.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); #else @@ -66,7 +79,7 @@ internal async Task HttpPostAsync(string path, string query, ChannelInfo channel UserCredentials? userCredentials, Action onNotFound, CancellationToken cancellationToken) { var request = CreateRequest(path, query, HttpMethod.Post, channelInfo, userCredentials); - + await HttpSendAsync(request, onNotFound, deadline, cancellationToken).ConfigureAwait(false); } @@ -74,18 +87,18 @@ private async Task HttpSendAsync(HttpRequestMessage request TimeSpan? deadline, CancellationToken cancellationToken) { if (!deadline.HasValue) { - return await HttpSendAsync(request, onNotFound, cancellationToken).ConfigureAwait(false); + return await HttpSendAsync(request, onNotFound, cancellationToken).ConfigureAwait(false); } - + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(deadline.Value); - + return await HttpSendAsync(request, onNotFound, cts.Token).ConfigureAwait(false); } - + async Task HttpSendAsync(HttpRequestMessage request, Action onNotFound, CancellationToken cancellationToken) { - + var httpResult = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (httpResult.IsSuccessStatusCode) { return httpResult; @@ -107,7 +120,7 @@ private HttpRequestMessage CreateRequest(string path, HttpMethod method, Channel private HttpRequestMessage CreateRequest(string path, string query, HttpMethod method, ChannelInfo channelInfo, UserCredentials? credentials) { - + var uriBuilder = new UriBuilder($"{_addressScheme}://{channelInfo.Channel.Target}") { Path = path, Query = query @@ -119,7 +132,7 @@ private HttpRequestMessage CreateRequest(string path, string query, HttpMethod m if (credentials != null) { httpRequest.Headers.Add(Constants.Headers.Authorization, credentials.ToString()); } - + return httpRequest; } diff --git a/test/EventStore.Client.Plugins.Tests/ClientCertificate.cs b/test/EventStore.Client.Plugins.Tests/ClientCertificate.cs deleted file mode 100644 index b680dbb56..000000000 --- a/test/EventStore.Client.Plugins.Tests/ClientCertificate.cs +++ /dev/null @@ -1,89 +0,0 @@ -namespace EventStore.Client.Plugins.Tests; - -[Trait("Category", "Target:Plugins")] -[Trait("Category", "Type:UserCertificate")] -public class ClientCertificate(ITestOutputHelper output, EventStoreFixture fixture) - : EventStoreTests(output, fixture) { - public static IEnumerable TlsCertPaths => - new List { - new object[] { Path.Combine("certs", "ca", "ca.crt") }, - new object[] { Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "ca", "ca.crt") } - }; - - public static IEnumerable AdminClientCertPaths => - new List { - new object[] { - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "user-admin", "user-admin.crt"), - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "user-admin", "user-admin.key") - }, - new object[] { - Path.Combine("certs", "user-admin", "user-admin.crt"), - Path.Combine("certs", "user-admin", "user-admin.key") - } - }; - - public static IEnumerable BadClientCertPaths => - new List { - new object[] { - Path.Combine("certs", "user-invalid", "user-invalid.crt"), - Path.Combine("certs", "user-invalid", "user-invalid.key") - }, - new object[] { - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "user-invalid", "user-invalid.crt"), - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "user-invalid", "user-invalid.key") - } - }; - - [Theory] - [MemberData(nameof(TlsCertPaths))] - async Task append_with_different_tls_cert_path(string certificateFilePath) { - await AppendWithCertificate($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={certificateFilePath}"); - } - - [Theory] - [MemberData(nameof(AdminClientCertPaths))] - async Task append_with_admin_client_certificate(string userCertFile, string userKeyFile) { - await AppendWithCertificate($"esdb://localhost:2113/?tls=true&tlsVerifyCert=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}"); - } - - [Theory] - [MemberData(nameof(BadClientCertPaths))] - async Task append_with_bad_client_certificate(string userCertFile, string userKeyFile) { - await AssertAppendFailsWithCertificate( - $"esdb://localhost:2113/?tls=true&tlsVerifyCert=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}", - typeof(NotAuthenticatedException) - ); - } - - [Theory] - [MemberData(nameof(BadClientCertPaths))] - async Task user_credentials_takes_precedence_over_client_certificates(string userCertFile, string userKeyFile) { - await AppendWithCertificate($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}"); - } - - async Task AppendWithCertificate(string connectionString) { - var settings = EventStoreClientSettings.Create(connectionString); - - await using var client = new EventStoreClient(settings); - - var appendResult = await client.AppendToStreamAsync( - Fixture.GetStreamName(), - StreamState.Any, - Fixture.CreateTestEvents(1) - ); - - appendResult.ShouldNotBeNull(); - } - - async Task AssertAppendFailsWithCertificate(string connectionString, Type expectedExceptionType) { - var settings = EventStoreClientSettings.Create(connectionString); - - await using var client = new EventStoreClient(settings); - - await client.AppendToStreamAsync( - Fixture.GetStreamName(), - StreamState.Any, - Fixture.CreateTestEvents(1) - ).ShouldThrowAsync(expectedExceptionType); - } -} diff --git a/test/EventStore.Client.Plugins.Tests/ClientCertificateTests.cs b/test/EventStore.Client.Plugins.Tests/ClientCertificateTests.cs new file mode 100644 index 000000000..e50fde0a5 --- /dev/null +++ b/test/EventStore.Client.Plugins.Tests/ClientCertificateTests.cs @@ -0,0 +1,67 @@ +namespace EventStore.Client.Plugins.Tests; + +[Trait("Category", "Target:Plugins")] +[Trait("Category", "Type:UserCertificate")] +public class ClientCertificateTests(ITestOutputHelper output, EventStoreFixture fixture) : EventStoreTests(output, fixture) { + [Theory, BadClientCertificatesTestCases] + async Task bad_certificates_combinations_should_return_authentication_error(string userCertFile, string userKeyFile, string tlsCaFile) { + var stream = Fixture.GetStreamName(); + var seedEvents = Fixture.CreateTestEvents(); + var connectionString = $"esdb://localhost:2113/?tls=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; + + var settings = EventStoreClientSettings.Create(connectionString); + settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); + + await using var client = new EventStoreClient(settings); + + await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents).ShouldThrowAsync(); + } + + [Theory, ValidClientCertificatesTestCases] + async Task valid_certificates_combinations_should_write_to_stream(string userCertFile, string userKeyFile, string tlsCaFile) { + var stream = Fixture.GetStreamName(); + var seedEvents = Fixture.CreateTestEvents(); + var connectionString = $"esdb://localhost:2113/?userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; + + var settings = EventStoreClientSettings.Create(connectionString); + settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); + + await using var client = new EventStoreClient(settings); + + var result = await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents); + result.ShouldNotBeNull(); + } + + [Theory, BadClientCertificatesTestCases] + async Task basic_authentication_should_take_precedence(string userCertFile, string userKeyFile, string tlsCaFile) { + var stream = Fixture.GetStreamName(); + var seedEvents = Fixture.CreateTestEvents(); + var connectionString = $"esdb://admin:changeit@localhost:2113/?userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; + + var settings = EventStoreClientSettings.Create(connectionString); + settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); + + await using var client = new EventStoreClient(settings); + + var result = await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents); + result.ShouldNotBeNull(); + } + + class BadClientCertificatesTestCases : TestCaseGenerator { + protected override IEnumerable Data() { + yield return [Certificates.Invalid.CertAbsolute, Certificates.Invalid.KeyAbsolute, Certificates.TlsCa.Absolute]; + yield return [Certificates.Invalid.CertRelative, Certificates.Invalid.KeyRelative, Certificates.TlsCa.Absolute]; + yield return [Certificates.Invalid.CertAbsolute, Certificates.Invalid.KeyAbsolute, Certificates.TlsCa.Relative]; + yield return [Certificates.Invalid.CertRelative, Certificates.Invalid.KeyRelative, Certificates.TlsCa.Relative]; + } + } + + class ValidClientCertificatesTestCases : TestCaseGenerator { + protected override IEnumerable Data() { + yield return [Certificates.Admin.CertAbsolute, Certificates.Admin.KeyAbsolute, Certificates.TlsCa.Absolute]; + yield return [Certificates.Admin.CertRelative, Certificates.Admin.KeyRelative, Certificates.TlsCa.Absolute]; + yield return [Certificates.Admin.CertAbsolute, Certificates.Admin.KeyAbsolute, Certificates.TlsCa.Relative]; + yield return [Certificates.Admin.CertRelative, Certificates.Admin.KeyRelative, Certificates.TlsCa.Relative]; + } + } +} diff --git a/test/EventStore.Client.Tests.Common/Certificates.cs b/test/EventStore.Client.Tests.Common/Certificates.cs new file mode 100644 index 000000000..efd167d67 --- /dev/null +++ b/test/EventStore.Client.Tests.Common/Certificates.cs @@ -0,0 +1,32 @@ +// ReSharper disable InconsistentNaming + +namespace EventStore.Client.Tests; + +public static class Certificates { + static readonly string BaseDirectory = AppDomain.CurrentDomain.BaseDirectory; + const string CertsFolder = "certs"; + + public static class TlsCa { + public static string Absolute => GetAbsolutePath(CertsFolder, "ca", "ca.crt"); + public static string Relative => GetRelativePath(CertsFolder, "ca", "ca.crt"); + } + + public static class Admin { + public static string CertAbsolute => GetAbsolutePath(CertsFolder, "user-admin", "user-admin.crt"); + public static string CertRelative => GetRelativePath(CertsFolder, "user-admin", "user-admin.crt"); + + public static string KeyAbsolute => GetAbsolutePath(CertsFolder, "user-admin", "user-admin.key"); + public static string KeyRelative => GetRelativePath(CertsFolder, "user-admin", "user-admin.key"); + } + + public static class Invalid { + public static string CertAbsolute => GetAbsolutePath(CertsFolder, "user-invalid", "user-invalid.crt"); + public static string CertRelative => GetRelativePath(CertsFolder, "user-invalid", "user-invalid.crt"); + + public static string KeyAbsolute => GetAbsolutePath(CertsFolder, "user-invalid", "user-invalid.key"); + public static string KeyRelative => GetRelativePath(CertsFolder, "user-invalid", "user-invalid.key"); + } + + static string GetAbsolutePath(params string[] paths) => Path.Combine(BaseDirectory, Path.Combine(paths)); + static string GetRelativePath(params string[] paths) => Path.Combine(paths); +} diff --git a/test/EventStore.Client.Tests.Common/TestCaseGenerator.cs b/test/EventStore.Client.Tests.Common/TestCaseGenerator.cs new file mode 100644 index 000000000..781d19063 --- /dev/null +++ b/test/EventStore.Client.Tests.Common/TestCaseGenerator.cs @@ -0,0 +1,25 @@ +namespace EventStore.Client.Tests; +using System.Collections; +using Bogus; + +public abstract class TestCaseGenerator : ClassDataAttribute, IEnumerable { + protected TestCaseGenerator() : base(typeof(T)) { + Faker = new Faker(); + + // ReSharper disable once VirtualMemberCallInConstructor + Generated.AddRange(Data()); + + if (Generated.Count == 0) + throw new InvalidOperationException($"TestDataGenerator<{typeof(T).Name}> must provide at least one test case."); + } + + protected Faker Faker { get; } + + List Generated { get; } = []; + + public IEnumerator GetEnumerator() => Generated.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + protected abstract IEnumerable Data(); +}