2026-07-06 21:33:57 +01:00
using Microsoft.AspNetCore.Builder ;
using Microsoft.AspNetCore.Hosting ;
2026-07-12 03:48:49 +01:00
using Microsoft.AspNetCore.Hosting.Server ;
using Microsoft.AspNetCore.Hosting.Server.Features ;
2026-07-06 21:33:57 +01:00
using Microsoft.Extensions.DependencyInjection ;
using System.Net ;
using System.Security.Cryptography ;
using System.Security.Cryptography.X509Certificates ;
namespace Yavsc.Tests.Shared ;
/// <summary>
/// Base class for ASP.NET Core integration test hosts. Provides the
/// cross-cutting plumbing shared by every test fixture in the
/// repository:
///
/// <list type="bullet">
/// <item><description>Kestrel with a self-signed HTTPS certificate
2026-07-12 05:48:47 +01:00
/// on a fixture-defined fixed port for deterministic integration
/// test endpoints.</description></item>
2026-07-06 21:33:57 +01:00
/// <item><description>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.</description></item>
2026-07-12 03:48:49 +01:00
/// <item><description>Address discovery via
/// <see cref="IServerAddressesFeature"/>.</description></item>
2026-07-06 21:33:57 +01:00
/// </list>
///
/// The actual service registration, middleware pipeline and route
/// mapping are the responsibility of the subclass, through
/// <see cref="BuildApp"/>.
/// </summary>
2026-08-29 04:00:10 +01:00
public abstract class WebHostFixture : IBackendFixture
2026-07-06 21:33:57 +01:00
{
private static readonly Lazy < X509Certificate2 > _selfSignedCertificate =
new Lazy < X509Certificate2 > ( CreateSelfSignedCertificate ) ;
private static readonly object _sync = new object ( ) ;
private static WebApplication ? _app ;
private static bool _isInitialized ;
private static readonly List < string > _sharedAddresses = new ( ) ;
private static IServiceProvider ? _sharedServices ;
/// <summary>HTTPS listen URLs the host bound to.</summary>
public IReadOnlyList < string > Addresses { get ; private set ; } = Array . Empty < string > ( ) ;
/// <summary>The DI service provider of the running host. Read from
/// the shared static slot so every fixture instance (xUnit creates
/// one per <c>IClassFixture</c>) sees the same provider after the
/// first initialisation. Throws if <see cref="IsInitialized"/> is
/// false.</summary>
public IServiceProvider Services = > _sharedServices
? ? throw new InvalidOperationException (
"WebHostFixture has not been initialised. Call InitializeAsync first." ) ;
/// <summary>True once <see cref="InitializeAsync"/> has completed
/// successfully and the host is running.</summary>
public bool IsInitialized { get ; private set ; }
2026-08-29 04:00:10 +01:00
#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
2026-07-06 21:33:57 +01:00
protected WebHostFixture ( )
2026-08-29 04:00:10 +01:00
#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
2026-07-06 21:33:57 +01:00
{
lock ( _sync )
{
if ( ! _isInitialized )
{
InitializeAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
_isInitialized = true ;
}
CopySharedState ( ) ;
CopySpecialisedSharedState ( ) ;
}
}
private void CopySharedState ( )
{
Addresses = _sharedAddresses . ToArray ( ) ;
}
/// <summary>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
/// <see cref="InitializeAsync"/> has populated the shared state
/// the first time.</summary>
protected virtual void CopySpecialisedSharedState ( ) { }
/// <summary>Specialisations register their services and middleware
2026-07-12 05:48:47 +01:00
/// here. The base class has already configured Kestrel HTTPS on
/// the fixture-defined test port — do not bind additional
/// listeners.</summary>
2026-07-06 21:33:57 +01:00
/// <param name="builder">The <see cref="WebApplicationBuilder"/>
2026-07-12 05:48:47 +01:00
/// configured with Kestrel HTTPS on the fixture-defined test port
/// and the shared self-signed certificate.</param>
2026-07-06 21:33:57 +01:00
/// <returns>The fully built <see cref="WebApplication"/>, ready
/// for <c>ConfigurePipeline</c> + <c>StartAsync</c>.</returns>
protected abstract WebApplication BuildApp ( WebApplicationBuilder builder ) ;
/// <summary>Apply the production pipeline to <paramref name="app"/>.
/// Defaults to identity + routing + auth + MapStaticAssets; override
/// only if your host needs a different shape.</summary>
protected virtual async Task < WebApplication > ConfigurePipelineAsync ( WebApplication app )
{
await Task . CompletedTask ;
return app ;
}
2026-07-12 05:48:47 +01:00
/// <summary>HTTPS port used by this fixture's Kestrel host.
/// Override in derived fixtures when they must not share the same
/// listen port.</summary>
protected virtual int HttpsPort = > 5101 ;
2026-08-29 04:00:10 +01:00
public WebApplication App { get ; private set ; }
2026-07-06 21:33:57 +01:00
private async Task InitializeAsync ( )
{
var builder = WebApplication . CreateBuilder ( ) ;
builder . WebHost . ConfigureKestrel ( options = >
{
2026-07-12 05:48:47 +01:00
options . Listen ( IPAddress . Loopback , HttpsPort , listenOptions = >
2026-07-06 21:33:57 +01:00
{
listenOptions . UseHttps ( _selfSignedCertificate . Value ) ;
} ) ;
} ) ;
2026-08-29 04:00:10 +01:00
this . App = BuildApp ( builder ) ;
this . App = await ConfigurePipelineAsync ( this . App ) ;
await this . App . StartAsync ( ) ;
2026-07-06 21:33:57 +01:00
2026-08-29 04:00:10 +01:00
_app = this . App ;
_sharedServices = this . App . Services ;
2026-07-06 21:33:57 +01:00
2026-08-29 04:00:10 +01:00
var server = this . App . Services . GetRequiredService < IServer > ( ) ;
2026-07-12 03:48:49 +01:00
var addressFeatures = server . Features . Get < IServerAddressesFeature > ( ) ;
2026-07-06 21:33:57 +01:00
_sharedAddresses . Clear ( ) ;
2026-07-12 03:48:49 +01:00
if ( addressFeatures ? . Addresses is not null )
{
foreach ( var address in addressFeatures . Addresses )
{
_sharedAddresses . Add ( address ) ;
}
}
2026-07-06 21:33:57 +01:00
Addresses = _sharedAddresses . ToArray ( ) ;
IsInitialized = true ;
}
public virtual void Dispose ( )
{
lock ( _sync )
{
2026-08-29 04:00:10 +01:00
if ( ! IsInitialized )
throw new InvalidOperationException ( "Cannot tear down a fixture that has not been initialized." ) ;
this . App . StopAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
IsInitialized = false ;
_isInitialized = false ;
_sharedAddresses . Clear ( ) ;
_sharedServices = null ;
2026-07-06 21:33:57 +01:00
}
}
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 ) ) ) ;
}
}