diff --git a/src/OrchardCore.Modules/OrchardCore.AutoSetup/AutoSetupMiddleware.cs b/src/OrchardCore.Modules/OrchardCore.AutoSetup/AutoSetupMiddleware.cs index 19411e2f406..cdb8a7e4b3d 100644 --- a/src/OrchardCore.Modules/OrchardCore.AutoSetup/AutoSetupMiddleware.cs +++ b/src/OrchardCore.Modules/OrchardCore.AutoSetup/AutoSetupMiddleware.cs @@ -1,14 +1,11 @@ -using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OrchardCore.Abstractions.Setup; using OrchardCore.AutoSetup.Extensions; using OrchardCore.AutoSetup.Options; +using OrchardCore.AutoSetup.Services; using OrchardCore.Environment.Shell; using OrchardCore.Locking.Distributed; -using OrchardCore.Setup.Services; namespace OrchardCore.AutoSetup; @@ -47,11 +44,6 @@ public class AutoSetupMiddleware /// private readonly AutoSetupOptions _options; - /// - /// The logger. - /// - private readonly ILogger _logger; - /// /// The auto setup lock options. /// @@ -70,16 +62,14 @@ public class AutoSetupMiddleware /// The shell settings. /// The shell settings manager. /// The distributed lock. - /// The auto setup options. - /// The logger. + /// The auto setup options. public AutoSetupMiddleware( RequestDelegate next, IShellHost shellHost, ShellSettings shellSettings, IShellSettingsManager shellSettingsManager, IDistributedLock distributedLock, - IOptions options, - ILogger logger) + IOptions options) { _next = next; _shellHost = shellHost; @@ -87,8 +77,6 @@ public AutoSetupMiddleware( _shellSettingsManager = shellSettingsManager; _distributedLock = distributedLock; _options = options.Value; - _logger = logger; - _lockOptions = _options.LockOptions; _setupOptions = _options.Tenants.FirstOrDefault(options => _shellSettings.Name == options.ShellName); } @@ -124,20 +112,23 @@ public async Task InvokeAsync(HttpContext httpContext) } // Check if the tenant was installed by another instance. - using var settings = (await _shellSettingsManager - .LoadSettingsAsync(_shellSettings.Name)) - .AsDisposable(); + using var settings = await _shellSettingsManager.LoadSettingsAsync(_shellSettings.Name); - if (!settings.IsUninitialized()) + if (settings != null) { - await _shellHost.ReloadShellContextAsync(_shellSettings, eventSource: false); - httpContext.Response.Redirect(pathBase); - - return; + settings.AsDisposable(); + if (!settings.IsUninitialized()) + { + await _shellHost.ReloadShellContextAsync(_shellSettings, eventSource: false); + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await httpContext.Response.WriteAsync("The requested tenant is not initialized."); + return; + } } - var setupService = httpContext.RequestServices.GetRequiredService(); - if (await SetupTenantAsync(setupService, _setupOptions, _shellSettings)) + var autoSetupService = httpContext.RequestServices.GetRequiredService(); + (var setupContext, var isSuccess) = await autoSetupService.SetupTenantAsync(_setupOptions, _shellSettings); + if (isSuccess) { if (_setupOptions.IsDefault) { @@ -146,13 +137,17 @@ public async Task InvokeAsync(HttpContext httpContext) { if (_setupOptions != setupOptions) { - await CreateTenantSettingsAsync(setupOptions); + await autoSetupService.CreateTenantSettingsAsync(setupOptions); } } } httpContext.Response.Redirect(pathBase); - + } + else + { + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await httpContext.Response.WriteAsync($"The AutoSetup failed installing the site."); return; } } @@ -160,109 +155,4 @@ public async Task InvokeAsync(HttpContext httpContext) await _next.Invoke(httpContext); } - - /// - /// Sets up a tenant. - /// - /// The setup service. - /// The tenant setup options. - /// The tenant shell settings. - /// - /// Returns true if successfully setup. - /// - public async Task SetupTenantAsync(ISetupService setupService, TenantSetupOptions setupOptions, ShellSettings shellSettings) - { - var setupContext = await GetSetupContextAsync(setupOptions, setupService, shellSettings); - - _logger.LogInformation("AutoSetup is initializing the site"); - - await setupService.SetupAsync(setupContext); - - if (setupContext.Errors.Count == 0) - { - _logger.LogInformation("AutoSetup successfully provisioned the site '{SiteName}'.", setupOptions.SiteName); - - return true; - } - - var stringBuilder = new StringBuilder(); - foreach (var error in setupContext.Errors) - { - stringBuilder.AppendLine($"{error.Key} : '{error.Value}'"); - } - - _logger.LogError("AutoSetup failed installing the site '{SiteName}' with errors: {Errors}", setupOptions.SiteName, stringBuilder); - - return false; - } - - /// - /// Creates a tenant shell settings. - /// - /// The setup options. - /// The . - public async Task CreateTenantSettingsAsync(TenantSetupOptions setupOptions) - { - using var shellSettings = _shellSettingsManager - .CreateDefaultSettings() - .AsUninitialized() - .AsDisposable(); - - shellSettings.Name = setupOptions.ShellName; - shellSettings.RequestUrlHost = setupOptions.RequestUrlHost; - shellSettings.RequestUrlPrefix = setupOptions.RequestUrlPrefix; - - shellSettings["ConnectionString"] = setupOptions.DatabaseConnectionString; - shellSettings["TablePrefix"] = setupOptions.DatabaseTablePrefix; - shellSettings["Schema"] = setupOptions.DatabaseSchema; - shellSettings["DatabaseProvider"] = setupOptions.DatabaseProvider; - shellSettings["Secret"] = Guid.NewGuid().ToString(); - shellSettings["RecipeName"] = setupOptions.RecipeName; - shellSettings["FeatureProfile"] = setupOptions.FeatureProfile; - - await _shellHost.UpdateShellSettingsAsync(shellSettings); - - return shellSettings; - } - - /// - /// Gets a setup context from the configuration. - /// - /// The tenant setup options. - /// The setup service. - /// The tenant shell settings. - /// The used to setup the site. - private static async Task GetSetupContextAsync(TenantSetupOptions options, ISetupService setupService, ShellSettings shellSettings) - { - var recipes = await setupService.GetSetupRecipesAsync(); - - var recipe = recipes.SingleOrDefault(r => r.Name == options.RecipeName); - - var setupContext = new SetupContext - { - Recipe = recipe, - ShellSettings = shellSettings, - Errors = new Dictionary() - }; - - if (shellSettings.IsDefaultShell()) - { - // The 'Default' shell is first created by the infrastructure, - // so the following 'Autosetup' options need to be passed. - shellSettings.RequestUrlHost = options.RequestUrlHost; - shellSettings.RequestUrlPrefix = options.RequestUrlPrefix; - } - - setupContext.Properties[SetupConstants.AdminEmail] = options.AdminEmail; - setupContext.Properties[SetupConstants.AdminPassword] = options.AdminPassword; - setupContext.Properties[SetupConstants.AdminUsername] = options.AdminUsername; - setupContext.Properties[SetupConstants.DatabaseConnectionString] = options.DatabaseConnectionString; - setupContext.Properties[SetupConstants.DatabaseProvider] = options.DatabaseProvider; - setupContext.Properties[SetupConstants.DatabaseTablePrefix] = options.DatabaseTablePrefix; - setupContext.Properties[SetupConstants.DatabaseSchema] = options.DatabaseSchema; - setupContext.Properties[SetupConstants.SiteName] = options.SiteName; - setupContext.Properties[SetupConstants.SiteTimeZone] = options.SiteTimeZone; - - return setupContext; - } } diff --git a/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/AutoSetupService.cs b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/AutoSetupService.cs new file mode 100644 index 00000000000..fa16bb59dfc --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/AutoSetupService.cs @@ -0,0 +1,111 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using OrchardCore.Abstractions.Setup; +using OrchardCore.AutoSetup.Options; +using OrchardCore.Environment.Shell; +using OrchardCore.Setup.Services; + +namespace OrchardCore.AutoSetup.Services; + +public class AutoSetupService : IAutoSetupService +{ + private readonly IShellHost _shellHost; + private readonly IShellSettingsManager _shellSettingsManager; + private readonly ISetupService _setupService; + private readonly ILogger _logger; + + public AutoSetupService( + IShellHost shellHost, + IShellSettingsManager shellSettingsManager, + ISetupService setupService, + ILogger logger + ) + { + _shellHost = shellHost; + _shellSettingsManager = shellSettingsManager; + _setupService = setupService; + _logger = logger; + } + + public async Task<(SetupContext, bool)> SetupTenantAsync(TenantSetupOptions setupOptions, ShellSettings shellSettings) + { + var setupContext = await GetSetupContextAsync(setupOptions, shellSettings); + + _logger.LogInformation("The AutoSetup is initializing the site."); + + await _setupService.SetupAsync(setupContext); + + if (setupContext.Errors.Count == 0) + { + _logger.LogInformation("The AutoSetup successfully provisioned the site '{SiteName}'.", setupOptions.SiteName); + + return (setupContext, true); + } + + var stringBuilder = new StringBuilder(); + foreach (var error in setupContext.Errors) + { + stringBuilder.AppendLine($"{error.Key} : '{error.Value}'"); + } + + _logger.LogError("The AutoSetup failed installing the site '{SiteName}' with errors: {Errors}.", setupOptions.SiteName, stringBuilder); + + return (setupContext, false); + } + + public async Task CreateTenantSettingsAsync(TenantSetupOptions setupOptions) + { + using var shellSettings = _shellSettingsManager + .CreateDefaultSettings() + .AsUninitialized() + .AsDisposable(); + + shellSettings.Name = setupOptions.ShellName; + shellSettings.RequestUrlHost = setupOptions.RequestUrlHost; + shellSettings.RequestUrlPrefix = setupOptions.RequestUrlPrefix; + shellSettings["ConnectionString"] = setupOptions.DatabaseConnectionString; + shellSettings["TablePrefix"] = setupOptions.DatabaseTablePrefix; + shellSettings["Schema"] = setupOptions.DatabaseSchema; + shellSettings["DatabaseProvider"] = setupOptions.DatabaseProvider; + shellSettings["Secret"] = Guid.NewGuid().ToString(); + shellSettings["RecipeName"] = setupOptions.RecipeName; + shellSettings["FeatureProfile"] = setupOptions.FeatureProfile; + + await _shellHost.UpdateShellSettingsAsync(shellSettings); + + return shellSettings; + } + + public async Task GetSetupContextAsync(TenantSetupOptions options, ShellSettings shellSettings) + { + var recipe = (await _setupService.GetSetupRecipesAsync()) + .SingleOrDefault(r => r.Name == options.RecipeName); + + var setupContext = new SetupContext + { + Recipe = recipe, + ShellSettings = shellSettings, + Errors = new Dictionary() + }; + + if (shellSettings.IsDefaultShell()) + { + // The 'Default' shell is first created by the infrastructure, + // so the following 'Autosetup' options need to be passed. + shellSettings.RequestUrlHost = options.RequestUrlHost; + shellSettings.RequestUrlPrefix = options.RequestUrlPrefix; + } + + setupContext.Properties[SetupConstants.AdminEmail] = options.AdminEmail; + setupContext.Properties[SetupConstants.AdminPassword] = options.AdminPassword; + setupContext.Properties[SetupConstants.AdminUsername] = options.AdminUsername; + setupContext.Properties[SetupConstants.DatabaseConnectionString] = options.DatabaseConnectionString; + setupContext.Properties[SetupConstants.DatabaseProvider] = options.DatabaseProvider; + setupContext.Properties[SetupConstants.DatabaseTablePrefix] = options.DatabaseTablePrefix; + setupContext.Properties[SetupConstants.DatabaseSchema] = options.DatabaseSchema; + setupContext.Properties[SetupConstants.SiteName] = options.SiteName; + setupContext.Properties[SetupConstants.SiteTimeZone] = options.SiteTimeZone; + + return setupContext; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/IAutoSetupService.cs b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/IAutoSetupService.cs new file mode 100644 index 00000000000..93f46b1288d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Services/IAutoSetupService.cs @@ -0,0 +1,33 @@ +using OrchardCore.AutoSetup.Options; +using OrchardCore.Environment.Shell; +using OrchardCore.Setup.Services; + +namespace OrchardCore.AutoSetup.Services; + +public interface IAutoSetupService +{ + /// + /// Creates a tenant shell settings. + /// + /// The setup options. + /// The . + Task CreateTenantSettingsAsync(TenantSetupOptions setupOptions); + + /// + /// Gets a setup context from the configuration. + /// + /// The tenant setup options. + /// The tenant shell settings. + /// The used to setup the site. + Task GetSetupContextAsync(TenantSetupOptions options, ShellSettings shellSettings); + + /// + /// Sets up a tenant. + /// + /// The tenant setup options. + /// The tenant shell settings. + /// + /// Returns if successfully setup. + /// + Task<(SetupContext, bool)> SetupTenantAsync(TenantSetupOptions setupOptions, ShellSettings shellSettings); +} diff --git a/src/OrchardCore.Modules/OrchardCore.AutoSetup/Startup.cs b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Startup.cs index fe2567d190e..8198afb7e3c 100644 --- a/src/OrchardCore.Modules/OrchardCore.AutoSetup/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.AutoSetup/Startup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.AutoSetup.Options; +using OrchardCore.AutoSetup.Services; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Configuration; using OrchardCore.Modules; @@ -67,6 +68,8 @@ public override void ConfigureServices(IServiceCollection services) { services.Configure(o => o.ConfigurationExists = true); } + + services.AddScoped(); } /// diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.AutoSetup/AutoSetupMiddlewareTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.AutoSetup/AutoSetupMiddlewareTests.cs new file mode 100644 index 00000000000..522080760cd --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.AutoSetup/AutoSetupMiddlewareTests.cs @@ -0,0 +1,148 @@ +using OrchardCore.AutoSetup; +using OrchardCore.AutoSetup.Options; +using OrchardCore.AutoSetup.Services; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Models; +using OrchardCore.Locking; +using OrchardCore.Locking.Distributed; +using OrchardCore.Setup.Services; + +namespace OrchardCore.Tests.Modules.OrchardCore.AutoSetup; + +public class AutoSetupMiddlewareTests +{ + private readonly Mock _mockShellHost; + private readonly ShellSettings _shellSettings; + private readonly Mock _mockShellSettingsManager; + private readonly Mock _mockDistributedLock; + private readonly Mock> _mockOptions; + private readonly Mock _mockAutoSetupService; + private bool _nextCalled; + public AutoSetupMiddlewareTests() + { + _shellSettings = new ShellSettings(); + _shellSettings.AsDefaultShell(); + _mockShellHost = new Mock(); + _mockShellSettingsManager = new Mock(); + _mockDistributedLock = new Mock(); + _mockOptions = new Mock>(); + _mockAutoSetupService = new Mock(); + + _mockOptions.Setup(o => o.Value).Returns(new AutoSetupOptions + { + LockOptions = new LockOptions(), + Tenants = new List + { + new TenantSetupOptions { ShellName = ShellSettings.DefaultShellName } + } + }); + } + + [Fact] + public async Task InvokeAsync_InitializedShell_SkipsSetup() + { + // Arrange + _shellSettings.State = TenantState.Running; + + var httpContext = new DefaultHttpContext(); + + var middleware = CreateMiddleware(); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.True(_nextCalled); + _mockAutoSetupService.Verify(s => s.SetupTenantAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task InvokeAsync_FailedSetup_ReturnsServiceUnavailable() + { + // Arrange + _shellSettings.State = TenantState.Uninitialized; + + SetupDistributedLockMock(true); + + var setupContext = new SetupContext { Errors = new Dictionary { { "Error", "Test error" } } }; + _mockAutoSetupService.Setup(s => s.SetupTenantAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((setupContext, false)); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(_mockAutoSetupService.Object) + .BuildServiceProvider(); + + var middleware = CreateMiddleware(); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status503ServiceUnavailable, httpContext.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_UnInitializedShell_PerformsSetup() + { + // Arrange + _shellSettings.State = TenantState.Uninitialized; + + SetupDistributedLockMock(true); + + var setupContext = new SetupContext(); + _mockAutoSetupService.Setup(s => s.SetupTenantAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((setupContext, true)); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(_mockAutoSetupService.Object) + .BuildServiceProvider(); + + var middleware = CreateMiddleware(); + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status302Found, httpContext.Response.StatusCode); // Redirect + _mockAutoSetupService.Verify(s => s.SetupTenantAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task InvokeAsync_FailedLockAcquisition_ThrowsTimeoutException() + { + // Arrange + _shellSettings.State = TenantState.Uninitialized; + + SetupDistributedLockMock(false); + + var httpContext = new DefaultHttpContext(); + + var middleware = CreateMiddleware(); + + // Act & Assert + await Assert.ThrowsAsync(() => middleware.InvokeAsync(httpContext)); + } + + private void SetupDistributedLockMock(bool acquireLock) + { + var mockLocker = new Mock(); + _mockDistributedLock + .Setup(d => d.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((mockLocker.Object, acquireLock)); + } + + private AutoSetupMiddleware CreateMiddleware() + => new AutoSetupMiddleware( + next: (innerHttpContext) => + { + _nextCalled = true; + + return Task.CompletedTask; + }, + _mockShellHost.Object, + _shellSettings, + _mockShellSettingsManager.Object, + _mockDistributedLock.Object, + _mockOptions.Object); +}