using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; namespace Yavsc.Tests.Shared; /// /// Base class for ASP.NET Core integration test hosts. Provides the /// cross-cutting plumbing shared by every test fixture in the /// repository: /// /// /// Kestrel with a self-signed HTTPS certificate /// on a fixture-defined fixed port for deterministic integration /// test endpoints. /// A per-process single-instance host initialised /// on first construction and torn down when the last fixture is /// disposed — same lazy + lock + count pattern as the original Org /// fixture, lifted out of the specialisation. /// Address discovery via /// . /// /// /// The actual service registration, middleware pipeline and route /// mapping are the responsibility of the subclass, through /// . /// public abstract class WebHostFixture : IDisposable { private static readonly Lazy _selfSignedCertificate = new Lazy(CreateSelfSignedCertificate); private static readonly object _sync = new object(); private static WebApplication? _app; private static bool _isInitialized; private static int _instanceCount; private static readonly List _sharedAddresses = new(); private static IServiceProvider? _sharedServices; /// HTTPS listen URLs the host bound to. public IReadOnlyList Addresses { get; private set; } = Array.Empty(); /// The DI service provider of the running host. Read from /// the shared static slot so every fixture instance (xUnit creates /// one per IClassFixture) sees the same provider after the /// first initialisation. Throws if is /// false. public IServiceProvider Services => _sharedServices ?? throw new InvalidOperationException( "WebHostFixture has not been initialised. Call InitializeAsync first."); /// True once has completed /// successfully and the host is running. public bool IsInitialized { get; private set; } protected WebHostFixture() { lock (_sync) { _instanceCount++; if (!_isInitialized) { InitializeAsync().GetAwaiter().GetResult(); _isInitialized = true; } CopySharedState(); CopySpecialisedSharedState(); } } private void CopySharedState() { Addresses = _sharedAddresses.ToArray(); } /// Hook for specialisations to copy any other shared /// state (test client credentials, user names, factories, etc.) /// from the static slots exposed by the base class onto instance /// properties. Called once per fixture construction, after /// has populated the shared state /// the first time. protected virtual void CopySpecialisedSharedState() { } /// Specialisations register their services and middleware /// here. The base class has already configured Kestrel HTTPS on /// the fixture-defined test port — do not bind additional /// listeners. /// The /// configured with Kestrel HTTPS on the fixture-defined test port /// and the shared self-signed certificate. /// The fully built , ready /// for ConfigurePipeline + StartAsync. protected abstract WebApplication BuildApp(WebApplicationBuilder builder); /// Apply the production pipeline to . /// Defaults to identity + routing + auth + MapStaticAssets; override /// only if your host needs a different shape. protected virtual async Task ConfigurePipelineAsync(WebApplication app) { await Task.CompletedTask; return app; } /// HTTPS port used by this fixture's Kestrel host. /// Override in derived fixtures when they must not share the same /// listen port. protected virtual int HttpsPort => 5101; private async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => { options.Listen(IPAddress.Loopback, HttpsPort, listenOptions => { listenOptions.UseHttps(_selfSignedCertificate.Value); }); }); var app = BuildApp(builder); app = await ConfigurePipelineAsync(app); await app.StartAsync(); _app = app; _sharedServices = app.Services; var server = app.Services.GetRequiredService(); var addressFeatures = server.Features.Get(); _sharedAddresses.Clear(); if (addressFeatures?.Addresses is not null) { foreach (var address in addressFeatures.Addresses) { _sharedAddresses.Add(address); } } Addresses = _sharedAddresses.ToArray(); IsInitialized = true; } public virtual void Dispose() { lock (_sync) { _instanceCount--; if (_instanceCount == 0 && _app is not null) { _app.StopAsync().GetAwaiter().GetResult(); _app = null; _isInitialized = false; _sharedAddresses.Clear(); _sharedServices = null; } } } private static X509Certificate2 CreateSelfSignedCertificate() { var rsa = RSA.Create(2048); var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); certRequest.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false)); certRequest.CertificateExtensions.Add( new X509EnhancedKeyUsageExtension( new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); return certRequest.CreateSelfSigned( new DateTimeOffset(DateTime.UtcNow.AddDays(-1)), new DateTimeOffset(DateTime.UtcNow.AddDays(3650))); } }