diff --git a/src/Alba.Testing/Acceptance/host_stop_usage.cs b/src/Alba.Testing/Acceptance/host_stop_usage.cs new file mode 100644 index 0000000..e16fd52 --- /dev/null +++ b/src/Alba.Testing/Acceptance/host_stop_usage.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using WebApp; + +namespace Alba.Testing.Acceptance; + +public class host_stop_usage +{ + [Fact] + public async Task stop_for_hosted_service_is_called_for_minimal_api() + { + await using var host = await AlbaHost.For(x => + { + x.ConfigureServices(services => + services.AddHostedService() + ); + }); + var hostedService = (SimpleHostedService)host.Services.GetRequiredService(); + + await host.StopAsync(TestContext.Current.CancellationToken); + + hostedService.Events.Take(2).ShouldBe(["Started", "Stopped"]); + } + + [Fact] + public async Task stop_for_hosted_service_is_called_on_host_disposal_for_minimal_api() + { + var host = await AlbaHost.For(x => + { + x.ConfigureServices(services => + services.AddHostedService() + ); + }); + var hostedService = (SimpleHostedService)host.Services.GetRequiredService(); + + await host.DisposeAsync(); + + hostedService.Events.Take(2).ShouldBe(["Started", "Stopped"]); + } + + [Fact] + public async Task stop_for_hosted_service_is_called_on_host_stop_for_mvc_app() + { + await using var host = await AlbaHost.For(x => + { + x.ConfigureServices(services => + services.AddHostedService() + ); + }); + var hostedService = (SimpleHostedService)host.Services.GetRequiredService(); + + await host.StopAsync(TestContext.Current.CancellationToken); + + hostedService.Events.Take(2).ShouldBe(["Started", "Stopped"]); + } + + [Fact] + public async Task stop_for_hosted_service_is_called_on_host_disposal_for_mvc_app() + { + var host = await AlbaHost.For(x => + { + x.ConfigureServices(services => + services.AddHostedService() + ); + }); + var hostedService = (SimpleHostedService)host.Services.GetRequiredService(); + + await host.DisposeAsync(); + + hostedService.Events.Distinct().ShouldBe(["Started", "Stopped"]); + } + + public class SimpleHostedService : IHostedService + { + private readonly List _events = []; + + public IEnumerable Events => _events; + + public Task StartAsync(CancellationToken cancellationToken) + { + _events.Add("Started"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _events.Add("Stopped"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Alba.Testing/Acceptance/host_stop_usage_repeated.cs b/src/Alba.Testing/Acceptance/host_stop_usage_repeated.cs new file mode 100644 index 0000000..ba7b689 --- /dev/null +++ b/src/Alba.Testing/Acceptance/host_stop_usage_repeated.cs @@ -0,0 +1,188 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Shouldly; +using WebApp; + +namespace Alba.Testing.Acceptance; + +public class host_stop_usage_repeated(ITestOutputHelper output) +{ + private const int Count = 10; + + [Theory] + [InlineData(typeof(Runtime1))] + [InlineData(typeof(Runtime2))] + public async Task does_not_fail_on_host_disposal_for_minimal_api(Type type) + { + var failedIterations = await Repeat(Count, + async () => + { + await using var host = await AlbaHost.For(builder => + { + builder.ConfigureServices(services => + services.AddSingleton(typeof(IHostedService), type) + .AddSingleton(output)); + }); + } + ); + + failedIterations.ShouldBe(0); + } + + [Theory] + [InlineData(typeof(Runtime1))] + [InlineData(typeof(Runtime2))] + public async Task does_not_fail_on_host_stop_for_minimal_api(Type type) + { + var failedIterations = await Repeat(Count, + async () => + { + await using var host = await AlbaHost.For(builder => + { + builder.ConfigureServices(services => + services.AddSingleton(typeof(IHostedService), type) + .AddSingleton(output)); + }); + + await host.StopAsync(TestContext.Current.CancellationToken); + } + ); + + failedIterations.ShouldBe(0); + } + + [Theory] + [InlineData(typeof(Runtime1))] + [InlineData(typeof(Runtime2))] + public async Task does_not_fail_on_host_disposal_for_mvc_app(Type type) + { + var failedIterations = await Repeat(Count, + async () => + { + await using var host = await AlbaHost.For(builder => + { + builder.ConfigureServices(services => + services.AddSingleton(typeof(IHostedService), type) + .AddSingleton(output)); + }); + } + ); + + failedIterations.ShouldBe(0); + } + + [Theory] + [InlineData(typeof(Runtime1))] + [InlineData(typeof(Runtime2))] + public async Task does_not_fail_on_host_stop_for_mvc_app(Type type) + { + var failedIterations = await Repeat(Count, + async () => + { + await using var host = await AlbaHost.For(builder => + { + builder.ConfigureServices(services => + services.AddSingleton(typeof(IHostedService), type) + .AddSingleton(output)); + }); + + await host.StopAsync(TestContext.Current.CancellationToken); + } + ); + + failedIterations.ShouldBe(0); + } + + private async Task Repeat(int count, Func action) + { + var failedIterations = 0; + for (var i = 0; i < count; i++) + { + output.WriteLine($"Iteration #{i}: started, Thread: {Environment.CurrentManagedThreadId}"); + try + { + await action(); + } + catch (AggregateException ex) when (ex.InnerException is ObjectDisposedException or NullReferenceException) + { + failedIterations++; + } + + output.WriteLine( + $"Iteration #{i}: stopped, Thread: {Environment.CurrentManagedThreadId}{Environment.NewLine}"); + } + + return failedIterations; + } + + private class Runtime1(ILoggerFactory loggers, ITestOutputHelper output) : IHostedService + { + private bool _hasStopped; + private readonly ILogger _logger = loggers.CreateLogger(); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_hasStopped) + return; + + await Task.Delay(0, cancellationToken); // just to use async + output.WriteLine($"Stopping {GetHashCode()}, Thread: {Environment.CurrentManagedThreadId}"); + _hasStopped = true; + + try + { + _logger.LogWarning("_endpoints.DrainAsync()"); + } + catch (Exception ex) when (ex.InnerException is ObjectDisposedException or NullReferenceException) + { + output.WriteLine($"Failed with {ex.InnerException.GetType()} {GetHashCode()}, " + + $"Thread: {Environment.CurrentManagedThreadId}"); + throw; + } + + output.WriteLine($"Stopped {GetHashCode()}, Thread: {Environment.CurrentManagedThreadId}"); + } + } + + private class Runtime2(ILoggerFactory loggers, ITestOutputHelper output) : IHostedService + { + private bool _hasStopped; + private readonly SemaphoreSlim _lock = new(1); + private readonly ILogger _logger = loggers.CreateLogger(); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_hasStopped) return; + await _lock.WaitAsync(cancellationToken); + try + { + if (_hasStopped) return; + + output.WriteLine($"Stopping {GetHashCode()}, Thread: {Environment.CurrentManagedThreadId}"); + _hasStopped = true; + + try + { + _logger.LogWarning("_endpoints.DrainAsync()"); + } + catch (Exception ex) when (ex.InnerException is ObjectDisposedException or NullReferenceException) + { + output.WriteLine($"Failed {ex.InnerException.GetType()} {GetHashCode()}, " + + $"Thread: {Environment.CurrentManagedThreadId}"); + throw; + } + + output.WriteLine($"Stopped {GetHashCode()}, Thread: {Environment.CurrentManagedThreadId}"); + } + finally + { + _lock.Release(); + } + } + } +} \ No newline at end of file diff --git a/src/Alba.Testing/Security/web_api_authentication_with_individual_stub.cs b/src/Alba.Testing/Security/web_api_authentication_with_individual_stub.cs index 6c77fd5..ce87b6d 100644 --- a/src/Alba.Testing/Security/web_api_authentication_with_individual_stub.cs +++ b/src/Alba.Testing/Security/web_api_authentication_with_individual_stub.cs @@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Abstractions; using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Logging; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Shouldly; @@ -16,13 +18,15 @@ public class web_api_authentication_with_individual_stub public async Task can_stub_individual_scheme() { #region sample_bootstrapping_with_stub_scheme_extension + // Stub out an individual scheme var securityStub = new AuthenticationStub("custom") .With("foo", "bar") .With(JwtRegisteredClaimNames.Email, "guy@company.com") .WithName("jeremy"); - + await using var host = await AlbaHost.For(securityStub); + #endregion await host.Scenario(s => @@ -30,15 +34,14 @@ await host.Scenario(s => s.Get.Url("/identity2"); s.StatusCodeShouldBeOk(); }); - + await host.Scenario(s => { s.Get.Url("/identity"); s.StatusCodeShouldBe(HttpStatusCode.Unauthorized); }); - } - + [Fact] public async Task can_stub_individual_scheme_jwt() { @@ -56,28 +59,27 @@ await host.Scenario(s => s.Get.Url("/identity"); s.StatusCodeShouldBeOk(); }); - + await host.Scenario(s => { s.Get.Url("/identity2"); s.StatusCodeShouldBe(HttpStatusCode.Unauthorized); }); - } - + [Fact] public async Task can_stub_individual_schemes_microsoft_identity_web() - { + { var securityStub1 = new AuthenticationStub("custom") .With("foo", "bar") .With(JwtRegisteredClaimNames.Email, "guy@company.com") .WithName("jeremy"); - + var securityStub2 = new JwtSecurityStub(JwtBearerDefaults.AuthenticationScheme) .With("foo", "bar") .With(JwtRegisteredClaimNames.Email, "guy@company.com") .WithName("jeremy"); - + var securityStub3 = new JwtSecurityStub("AzureAuthentication") .With("iss", "bar") .With("tid", "tenantid") @@ -86,28 +88,73 @@ public async Task can_stub_individual_schemes_microsoft_identity_web() .WithName("jeremy"); // We're calling your real web service's configuration - await using var host = await AlbaHost.For(securityStub1, securityStub2, securityStub3); - var postConfigures = host.Services.GetRequiredService>().Get("AzureAuthentication"); - + await using var host = await AlbaHost.For( + securityStub1, securityStub2, securityStub3); + var postConfigures = host.Services.GetRequiredService>() + .Get("AzureAuthentication"); + postConfigures.ConfigurationManager.ShouldBeOfType>(); - + await host.Scenario(s => { s.Get.Url("/identity"); s.StatusCodeShouldBeOk(); }); - + await host.Scenario(s => { s.Get.Url("/identity2"); s.StatusCodeShouldBeOk(); }); - + await host.Scenario(s => { s.Get.Url("/identity3"); s.StatusCodeShouldBeOk(); }); - + } + + [Fact] + public async Task can_stub_schemes_for_different_hosts_with_identity_logger_disabled_by_default() + { + var securityStub1 = new JwtSecurityStub("AzureAuthentication") + .With("iss", "bar") + .With("tid", "tenantid") + .With("roles", "") + .With(JwtRegisteredClaimNames.Email, "guy@company.com") + .WithName("jeremy"); + + var securityStub2 = new JwtSecurityStub(JwtBearerDefaults.AuthenticationScheme) + .With("foo", "bar") + .With(JwtRegisteredClaimNames.Email, "guy@company.com") + .WithName("jeremy"); + + var checkpoints = new HashSet { LogHelper.Logger }; + await using (var host = await AlbaHost.For(securityStub1)) + { + checkpoints.Add(LogHelper.Logger); + await host.Scenario(s => + { + s.Get.Url("/identity3"); + s.StatusCodeShouldBeOk(); + }); + checkpoints.Add(LogHelper.Logger); + } + + checkpoints.Add(LogHelper.Logger); + await using (var host = await AlbaHost.For(securityStub2)) + { + checkpoints.Add(LogHelper.Logger); + await host.Scenario(s => + { + s.Get.Url("/identity"); + s.StatusCodeShouldBeOk(); + }); + checkpoints.Add(LogHelper.Logger); + } + + checkpoints.Add(LogHelper.Logger); + + checkpoints.ShouldBe([NullIdentityModelLogger.Instance]); } } \ No newline at end of file diff --git a/src/Alba/AlbaHost.cs b/src/Alba/AlbaHost.cs index ae784df..a2e260d 100644 --- a/src/Alba/AlbaHost.cs +++ b/src/Alba/AlbaHost.cs @@ -36,7 +36,7 @@ private AlbaHost(IHost host, params IAlbaExtension[] extensions) Server.AllowSynchronousIO = true; Extensions = extensions; - + var jsonInput = findInputFormatter("application/json"); var jsonOutput = findOutputFormatter("application/json"); @@ -48,7 +48,6 @@ private AlbaHost(IHost host, params IAlbaExtension[] extensions) MinimalApiStrategy = new SystemTextJsonSerializer(this); DefaultJson = MvcStrategy ?? MinimalApiStrategy; - } public AlbaHost(IHostBuilder builder, params IAlbaExtension[] extensions) @@ -83,7 +82,7 @@ public AlbaHost(IHostBuilder builder, params IAlbaExtension[] extensions) DefaultJson = MvcStrategy ?? MinimalApiStrategy; } - + internal IJsonStrategy? MvcStrategy { get; } internal IJsonStrategy MinimalApiStrategy { get; } internal IJsonStrategy DefaultJson { get; } @@ -100,9 +99,14 @@ public AlbaHost(IHostBuilder builder, params IAlbaExtension[] extensions) return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = new()) + public async Task StopAsync(CancellationToken cancellationToken = new()) { - return _host == null ? Task.CompletedTask : _host.StopAsync(cancellationToken); + var factoryHost = _factory?.CreatedHost; + if (factoryHost is not null) + await factoryHost.StopAsync(cancellationToken); + + if (_host is not null) + await _host.StopAsync(cancellationToken); } /// @@ -178,6 +182,7 @@ public IAlbaHost AfterEachAsync(Func afterEach) } #region sample_ScenarioSignature + /// /// Define and execute an integration test by running an Http request through /// your ASP.Net Core system @@ -187,21 +192,21 @@ public IAlbaHost AfterEachAsync(Func afterEach) /// public async Task Scenario( Action configure) + #endregion { var scenario = new Scenario(this); - + configure(scenario); scenario.Rewind(); - + HttpContext? context = null; try { context = await Invoke(c => { - try { if (scenario.Claims.Any()) @@ -250,7 +255,7 @@ public async Task Scenario( { context.Response.Body.Position = 0; } - + return new ScenarioResult(this, context); } @@ -263,7 +268,7 @@ public async ValueTask DisposeAsync() await _host.StopAsync(); _host.Dispose(); } - + Server.Dispose(); if (_factory is not null) @@ -327,8 +332,7 @@ public static async Task For(WebApplicationBuilder builder, Action /// Creates an AlbaHost using an underlying WebApplicationFactory. /// @@ -336,7 +340,8 @@ public static async Task For(WebApplicationBuilder builder, Action /// /// - public static async Task For(Action configuration, params IAlbaExtension[] extensions) where TEntryPoint : class + public static async Task For(Action configuration, + params IAlbaExtension[] extensions) where TEntryPoint : class { var factory = new AlbaWebApplicationFactory(configuration, extensions); @@ -358,7 +363,7 @@ public static async Task For(Action con /// public static Task For(params IAlbaExtension[] extensions) where TEntryPoint : class { - return For(_ => {}, extensions); + return For(_ => { }, extensions); } private AlbaHost(IAlbaWebApplicationFactory factory, params IAlbaExtension[] extensions) @@ -370,7 +375,7 @@ private AlbaHost(IAlbaWebApplicationFactory factory, params IAlbaExtension[] ext Server.AllowSynchronousIO = true; Extensions = extensions; - + var jsonInput = findInputFormatter("application/json"); var jsonOutput = findOutputFormatter("application/json"); @@ -435,4 +440,4 @@ public static AlbaHost For(Action configuration) return new AlbaHost(builder); } -} +} \ No newline at end of file diff --git a/src/Alba/AlbaWebApplicationFactory.cs b/src/Alba/AlbaWebApplicationFactory.cs index 36a8347..a0097f4 100644 --- a/src/Alba/AlbaWebApplicationFactory.cs +++ b/src/Alba/AlbaWebApplicationFactory.cs @@ -2,14 +2,19 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Alba; /// -internal sealed class AlbaWebApplicationFactory : WebApplicationFactory, IAlbaWebApplicationFactory where TEntryPoint : class +internal sealed class AlbaWebApplicationFactory : WebApplicationFactory, + IAlbaWebApplicationFactory where TEntryPoint : class { private readonly Action _configuration; private readonly IAlbaExtension[] _extensions; + + public IHost? CreatedHost { get; private set; } + public AlbaWebApplicationFactory(Action configuration, IAlbaExtension[] extensions) { _configuration = configuration; @@ -18,16 +23,30 @@ public AlbaWebApplicationFactory(Action configuration, IAlbaExt protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => - { - services.AddHttpContextAccessor(); - }); + DisableIdentityLogger(builder); + DisableWindowsEventLog(builder); + + builder.ConfigureServices(services => { services.AddHttpContextAccessor(); }); _configuration(builder); base.ConfigureWebHost(builder); } + private static void DisableIdentityLogger(IWebHostBuilder builder) + { + // Avoid using LogHelper.Logger static singleton from Microsoft.Identity.Web + // as it captures ILogger from the "active" host potentially introducing side effects for other hosts. + // See https://github.com/AzureAD/microsoft-identity-web/blob/50cbeb29b399dea8936e73cca6c846e3664d57c5/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityBaseAuthenticationBuilder.cs#L70 + builder.UseSetting("Logging:LogLevel:Microsoft.Identity.Web", nameof(LogLevel.None)); + } + + private static void DisableWindowsEventLog(IWebHostBuilder builder) + { + // Avoid using Windows EventLog as it can cause exceptions during host stop/disposal. + builder.UseSetting("Logging:EventLog:LogLevel:Default", nameof(LogLevel.None)); + } + protected override IHost CreateHost(IHostBuilder builder) { foreach (var extension in _extensions) @@ -35,7 +54,8 @@ protected override IHost CreateHost(IHostBuilder builder) extension.Configure(builder); } - return base.CreateHost(builder); - } + CreatedHost = base.CreateHost(builder); + return CreatedHost; + } } \ No newline at end of file diff --git a/src/Alba/IAlbaWebApplicationFactory.cs b/src/Alba/IAlbaWebApplicationFactory.cs index eeb0abf..6493820 100644 --- a/src/Alba/IAlbaWebApplicationFactory.cs +++ b/src/Alba/IAlbaWebApplicationFactory.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; namespace Alba; @@ -6,4 +7,5 @@ internal interface IAlbaWebApplicationFactory : IDisposable, IAsyncDisposable { public TestServer Server { get; } public IServiceProvider Services { get; } + IHost? CreatedHost { get; } } \ No newline at end of file