diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoApplicationBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoApplicationBuilderExtensions.cs new file mode 100644 index 000000000000..31355c09364c --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoApplicationBuilderExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Umbraco.Cms.Web.Common.ApplicationBuilder; + +namespace Umbraco.Extensions; + +/// +/// extensions for the Umbraco Delivery API. +/// +public static class DeliveryApiApplicationBuilderExtensions +{ + /// + /// Sets up routes for the Umbraco Delivery API. + /// + /// + /// This method maps attribute-routed controllers including the Delivery API endpoints. + /// Call this when using AddDeliveryApi() without AddBackOffice(), as the + /// backoffice endpoints normally handle the controller mapping. + /// + /// The Umbraco endpoint builder context. + /// The for chaining. + public static IUmbracoEndpointBuilderContext UseDeliveryApiEndpoints(this IUmbracoEndpointBuilderContext builder) + { + builder.EndpointRouteBuilder.MapControllers(); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index a0860a342234..e3daf9e9a836 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -22,7 +22,6 @@ using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Notifications; -using Umbraco.Cms.Core.Security; using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.ApplicationBuilder; @@ -30,8 +29,20 @@ namespace Umbraco.Extensions; public static class UmbracoBuilderExtensions { + /// + /// Add services for the Umbraco Delivery API (headless content delivery). + /// + /// + /// This method assumes that either AddBackOffice() or AddCore() has already been called. + /// It registers Delivery API-specific services such as controllers, output caching, and member authentication. + /// + /// The Umbraco builder. + /// The Umbraco builder. public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) { + // Delivery API supports member authentication for protected content + builder.AddMembersIdentity(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs index 5ba6e80bd0e9..2a6d93d4b5c0 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs @@ -4,7 +4,6 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; -using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Web.Common.Hosting; namespace Umbraco.Extensions; @@ -15,28 +14,29 @@ namespace Umbraco.Extensions; public static partial class UmbracoBuilderExtensions { /// - /// Adds all required components to run the Umbraco back office + /// Adds all required components to run the Umbraco back office. /// - public static IUmbracoBuilder - AddBackOffice(this IUmbracoBuilder builder, Action? configureMvc = null) => builder - .AddConfiguration() - .AddUmbracoCore() - .AddWebComponents() - .AddHelpers() - .AddBackOfficeCore() - .AddBackOfficeIdentity() - .AddBackOfficeAuthentication() - .AddTokenRevocation() - .AddMembersIdentity() - .AddUmbracoProfiler() - .AddMvcAndRazor(configureMvc) - .AddBackgroundJobs() - .AddUmbracoHybridCache() - .AddDistributedCache() - .AddCoreNotifications(); + /// + /// This method calls AddCore() internally to register all core services, + /// then adds backoffice-specific services on top. + /// + /// The Umbraco builder. + /// Optional action to configure the MVC builder. + /// The Umbraco builder. + public static IUmbracoBuilder AddBackOffice(this IUmbracoBuilder builder, Action? configureMvc = null) => + builder + .AddCore(configureMvc) // All core services + .AddBackOfficeCore() // Backoffice-specific: IBackOfficePathGenerator + .AddBackOfficeIdentity() // Backoffice user identity + .AddBackOfficeAuthentication() // OpenIddict, authorization policies + .AddTokenRevocation() // Token cleanup handlers + .AddMembersIdentity(); // Member identity (also needed for backoffice admin) public static IUmbracoBuilder AddBackOfficeCore(this IUmbracoBuilder builder) { + // Register marker indicating backoffice is enabled. + builder.Services.AddSingleton(); + builder.Services.AddUnique(); builder.Services.AddUnique(factory => { diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index cff818223209..da952e434ecc 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -89,7 +89,8 @@ private static BackOfficeIdentityBuilder BuildUmbracoBackOfficeIdentity(this IUm services.TryAddScoped(); services.TryAddSingleton(); // We need to know in the core services if local logins is denied, so we register the providers with a core friendly interface. - services.TryAddSingleton(); + // Use AddUnique to replace the default NoopLocalLoginSettingProvider registered in core services. + services.AddUnique(); services.TryAddSingleton(); services.AddTransient(); diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 5e8bff68c1a6..6ad47fa8be4f 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -6,7 +6,15 @@ namespace Umbraco.Cms.Api.Management; public class ManagementApiComposer : IComposer { - public void Compose(IUmbracoBuilder builder) => + public void Compose(IUmbracoBuilder builder) + { + // Only register Management API services if backoffice is enabled. + if (builder.Services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)) is false) + { + return; + } + builder.AddUmbracoManagementApi(); + } } diff --git a/src/Umbraco.Cms.DevelopmentMode.Backoffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.DevelopmentMode.Backoffice/DependencyInjection/UmbracoBuilderExtensions.cs index 3da32bcf9bc1..879f6fc12bfe 100644 --- a/src/Umbraco.Cms.DevelopmentMode.Backoffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.DevelopmentMode.Backoffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -107,6 +107,10 @@ private static IUmbracoBuilder AddInMemoryModelsRazorEngine(this IUmbracoBuilder // Since these services expect the ModelsMode to be InMemoryAuto if (builder.Config.GetModelsMode() == ModelsModeConstants.InMemoryAuto) { + // Ensure ModelsBuilder services (including UmbracoServices) are registered. + // This is normally done by AddWebsite(), but in a delivery-API-only setup + // it may not have been called. AddModelsBuilder() is idempotent. + builder.AddModelsBuilder(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Core/DependencyInjection/IBackOfficeEnabledMarker.cs b/src/Umbraco.Core/DependencyInjection/IBackOfficeEnabledMarker.cs new file mode 100644 index 000000000000..a17e383c579c --- /dev/null +++ b/src/Umbraco.Core/DependencyInjection/IBackOfficeEnabledMarker.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.DependencyInjection; + +/// +/// Marker interface indicating the backoffice is enabled. +/// Used to conditionally register Management API controllers and services. +/// +public interface IBackOfficeEnabledMarker { } + +/// +/// Marker class implementation for . +/// +public sealed class BackOfficeEnabledMarker : IBackOfficeEnabledMarker { } diff --git a/src/Umbraco.Core/Security/NoopLocalLoginSettingProvider.cs b/src/Umbraco.Core/Security/NoopLocalLoginSettingProvider.cs new file mode 100644 index 000000000000..c2e5f103e032 --- /dev/null +++ b/src/Umbraco.Core/Security/NoopLocalLoginSettingProvider.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Security; + +/// +/// A default implementation of that always allows local login. +/// +/// +/// This is used when the backoffice is not registered, as the backoffice provides its own implementation +/// that checks external login provider settings. +/// +public sealed class NoopLocalLoginSettingProvider : ILocalLoginSettingProvider +{ + /// + public bool HasDenyLocalLogin() => false; +} diff --git a/src/Umbraco.Core/Services/NoopConflictingRouteService.cs b/src/Umbraco.Core/Services/NoopConflictingRouteService.cs new file mode 100644 index 000000000000..86b9366352a4 --- /dev/null +++ b/src/Umbraco.Core/Services/NoopConflictingRouteService.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Core.Services; + +/// +/// A default implementation of that never reports conflicts. +/// +/// +/// This is used when the backoffice is not registered, as route conflict detection +/// is primarily relevant for backoffice controller routes. +/// +public sealed class NoopConflictingRouteService : IConflictingRouteService +{ + /// + public bool HasConflictingRoutes(out string controllerName) + { + controllerName = string.Empty; + return false; + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 02940483a0d6..b0e9689c69bf 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -64,6 +64,7 @@ using Umbraco.Cms.Infrastructure.Serialization; using Umbraco.Cms.Infrastructure.Services.Implement; using Umbraco.Extensions; +using Microsoft.Extensions.DependencyInjection.Extensions; using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider; namespace Umbraco.Cms.Infrastructure.DependencyInjection; @@ -248,6 +249,18 @@ public static IUmbracoBuilder AddCoreInitialServices(this IUmbracoBuilder builde builder.Services.AddUnique, PasswordChanger>(); builder.Services.AddUnique, PasswordChanger>(); + + // Register default local login setting provider (allows local login by default). + // This will be replaced by the backoffice implementation when AddBackOffice() is called. + builder.Services.TryAddSingleton(); + + // Register default conflicting route service (no conflicts when backoffice not enabled). + // This will be replaced by the backoffice implementation when AddBackOffice() is called. + builder.Services.TryAddSingleton(); + + // Register URL assembler (needed by DefaultMediaUrlProvider). + builder.Services.TryAddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs index 707fdc4e1e4e..68b527dbda6f 100644 --- a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs +++ b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; @@ -73,7 +74,11 @@ public void RegisterDefaultRequiredMiddleware() AppBuilder.UseUmbracoMediaFileProvider(); - AppBuilder.UseUmbracoBackOfficeRewrites(); + // Only use backoffice rewrites if backoffice is enabled + if (ApplicationServices.GetService() is not null) + { + AppBuilder.UseUmbracoBackOfficeRewrites(); + } AppBuilder.UseStaticFiles(); diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 86bea154c610..7f524719ec59 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.Data.Common; using System.Net.Http.Headers; using System.Reflection; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -60,6 +61,7 @@ using Umbraco.Cms.Web.Common.Security; using Umbraco.Cms.Web.Common.Templates; using Umbraco.Cms.Web.Common.UmbracoContext; +using Umbraco.Cms.Web.Common.Authorization; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Extensions; @@ -71,6 +73,59 @@ namespace Umbraco.Extensions; /// public static partial class UmbracoBuilderExtensions { + /// + /// Adds all core Umbraco services required to run without the backoffice. + /// Use this for delivery-only scenarios (Delivery API, Website) without the management backoffice. + /// For full Umbraco with backoffice, use AddBackOffice() instead. + /// + /// + /// This method is idempotent - calling it multiple times has no effect after the first call. + /// The individual service registration methods are also idempotent, so calling both + /// AddBackOffice() and is safe (though not expected). + /// + /// The Umbraco builder. + /// Optional action to configure the MVC builder. + /// The Umbraco builder. + public static IUmbracoBuilder AddCore(this IUmbracoBuilder builder, Action? configureMvc = null) + { + // Idempotency check - safe to call multiple times. + if (builder.Services.Any(s => s.ServiceType == typeof(AddCoreMarker))) + { + return builder; + } + + builder.Services.AddSingleton(); + + // Register the feature authorization handler and policy. + // This enables the UmbracoFeatureEnabled policy used by Delivery API and other controllers. + // Use TryAddEnumerable because IAuthorizationHandler is a multi-registration service - + // TryAddSingleton would skip registration if ANY handler exists, not just this specific one. + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.AddAuthorization(options => + { + // Only add the policy if it doesn't already exist. + if (options.GetPolicy(AuthorizationPolicies.UmbracoFeatureEnabled) is null) + { + options.AddPolicy(AuthorizationPolicies.UmbracoFeatureEnabled, policy => + { + policy.Requirements.Add(new FeatureAuthorizeRequirement()); + }); + } + }); + + return builder + .AddConfiguration() + .AddUmbracoCore() + .AddWebComponents() + .AddHelpers() + .AddUmbracoProfiler() + .AddMvcAndRazor(configureMvc) + .AddBackgroundJobs() + .AddUmbracoHybridCache() + .AddDistributedCache() + .AddCoreNotifications(); + } + /// /// Creates an and registers basic Umbraco services /// @@ -207,6 +262,10 @@ private static IUmbracoBuilder AddHttpClients(this IUmbracoBuilder builder) public static IUmbracoBuilder AddMvcAndRazor(this IUmbracoBuilder builder, Action? mvcBuilding = null) { + // NOTE: AddControllersWithViews() is already idempotent for service registration. + // We intentionally do NOT add an idempotency check here because the mvcBuilding callback + // may contain important configuration (e.g., Razor runtime compilation) that needs to + // be applied even if MVC services were already registered by a previous call. // TODO: We need to figure out if we can work around this because calling AddControllersWithViews modifies the global app and order is very important // this will directly affect developers who need to call that themselves. IMvcBuilder mvcBuilder = builder.Services.AddControllersWithViews(); @@ -335,4 +394,11 @@ private static IHostingEnvironment GetTemporaryHostingEnvironment( wrappedWebRoutingSettings, webHostEnvironment); } + + /// + /// Marker class to ensure AddCore is only executed once. + /// + private sealed class AddCoreMarker + { + } } diff --git a/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs b/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs index ceb53882514e..013143432fce 100644 --- a/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs +++ b/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Web.Website.Controllers; using Umbraco.Extensions; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; +using IBackOfficeEnabledMarker = Umbraco.Cms.Core.DependencyInjection.IBackOfficeEnabledMarker; namespace Umbraco.Cms.Web.UI.Composers { @@ -53,8 +54,19 @@ public static IMvcBuilder AddControllersAsServicesWithoutChangingActivator(this var feature = new ControllerFeature(); builder.PartManager.PopulateFeature(feature); + // Check if backoffice is enabled via marker interface. + bool backofficeEnabled = builder.Services + .Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)); + foreach (Type controller in feature.Controllers.Select(c => c.AsType())) { + // Skip Management API controllers if backoffice not enabled. + if (backofficeEnabled is false && + controller.Assembly.GetName().Name?.StartsWith("Umbraco.Cms.Api.Management", StringComparison.Ordinal) == true) + { + continue; + } + builder.Services.TryAddTransient(controller, controller); } diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 0f4dd2be6d8a..70999b7b03f1 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -29,8 +29,14 @@ namespace Umbraco.Extensions; public static partial class UmbracoBuilderExtensions { /// - /// Add services for the umbraco front-end website + /// Add services for the umbraco front-end website. /// + /// + /// This method assumes that either AddBackOffice() or AddCore() has already been called. + /// It registers website-specific services such as Surface controllers, view engines, and routing. + /// + /// The Umbraco builder. + /// The Umbraco builder. public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder) { builder.WithCollectionBuilder() @@ -79,10 +85,9 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder) // Partial view cache invalidators builder.Services.AddUnique(); - builder - .AddDistributedCache() - .AddModelsBuilder(); + builder.AddModelsBuilder(); + // Member identity for public member login builder.AddMembersIdentity(); return builder; diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationHttpTests.cs b/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationHttpTests.cs new file mode 100644 index 000000000000..71980f856c2c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationHttpTests.cs @@ -0,0 +1,398 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Persistence.Sqlite; +using Umbraco.Cms.Persistence.SqlServer; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.TestServerTest; + +/// +/// HTTP integration tests to verify that Umbraco can boot with different configurations. +/// These tests create a full web application and verify it starts successfully. +/// +/// +/// +/// These tests verify the supported Umbraco configuration scenarios: +/// +/// +/// Full: AddBackOffice() + AddWebsite() + AddDeliveryApi() +/// Delivery-only: AddCore() + AddWebsite() + AddDeliveryApi() (no backoffice) +/// Core + Website: AddCore() + AddWebsite() +/// Core + Delivery: AddCore() + AddDeliveryApi() +/// +/// +/// Note: AddBackOffice() without AddWebsite() is not a supported scenario because +/// the Management API depends on services registered by AddWebsite(). +/// +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console, Boot = true)] +public class CoreConfigurationHttpTests : UmbracoIntegrationTestBase +{ + /// + /// Gets the content root directory for the test project. + /// Walks up the directory tree from the assembly location until we leave the bin/obj folders. + /// + private static string GetTestContentRoot() + { + var assemblyLocation = Assembly.GetExecutingAssembly().Location; + var directory = new DirectoryInfo(Path.GetDirectoryName(assemblyLocation) + ?? throw new InvalidOperationException("Could not determine assembly directory.")); + + // Walk up parent directories until we're no longer in a bin or obj folder + while (directory.Parent is not null) + { + var name = directory.Name; + if (name.Equals("bin", StringComparison.OrdinalIgnoreCase) || + name.Equals("obj", StringComparison.OrdinalIgnoreCase)) + { + // Found bin/obj folder, return its parent (the project directory) + return directory.Parent.FullName; + } + + directory = directory.Parent; + } + + // No bin/obj folder found, return the original directory + return Path.GetDirectoryName(assemblyLocation) + ?? throw new InvalidOperationException("Could not determine content root directory."); + } + + private WebApplicationFactory CreateFactory( + Action configureUmbraco, + Action configureApp) + { + var contentRoot = GetTestContentRoot(); + + return new UmbracoWebApplicationFactory(() => CreateHostBuilder(configureUmbraco, configureApp)) + .WithWebHostBuilder(builder => + { + builder.UseContentRoot(contentRoot); + builder.ConfigureTestServices(services => + { + services.AddSingleton(); + }); + }); + } + + private IHostBuilder CreateHostBuilder( + Action configureUmbraco, + Action configureApp) + { + var hostBuilder = Host.CreateDefaultBuilder() + .ConfigureUmbracoDefaults() + .ConfigureAppConfiguration((context, configBuilder) => + { + context.HostingEnvironment = TestHelper.GetWebHostEnvironment(); + configBuilder.Sources.Clear(); + configBuilder.AddInMemoryCollection(InMemoryConfiguration); + configBuilder.AddConfiguration(GlobalSetupTeardown.TestConfiguration); + + Configuration = configBuilder.Build(); + }) + .ConfigureWebHost(builder => + { + builder.ConfigureServices((context, services) => + { + context.HostingEnvironment = TestHelper.GetWebHostEnvironment(); + ConfigureServices(services, configureUmbraco); + services.AddUnique(CreateLoggerFactory()); + }); + + builder.Configure(app => configureApp(app)); + }) + .UseDefaultServiceProvider(cfg => + { + cfg.ValidateOnBuild = true; + cfg.ValidateScopes = true; + }); + + return hostBuilder; + } + + private void ConfigureServices(IServiceCollection services, Action configureUmbraco) + { + services.AddTransient(); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + Configuration); + + services.AddLogger(TestHelper.GetWebHostEnvironment(), Configuration); + + var builder = new UmbracoBuilder( + services, + Configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + builder.Services.AddTransient(sp => + new TestDatabaseHostedLifecycleService(() => UseTestDatabase(sp))); + + // Let the test configure Umbraco + configureUmbraco(builder); + + builder.Build(); + } + + /// + /// Verifies that full Umbraco (backoffice + website + delivery API) boots successfully. + /// + [Test] + public async Task FullConfiguration_BootsSuccessfully() + { + // Arrange + using var factory = CreateFactory( + configureUmbraco: builder => + { + builder + .AddBackOffice() + .AddWebsite() + .AddDeliveryApi() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + }, + configureApp: app => + { + app.UseUmbraco() + .WithMiddleware(u => + { + u.UseBackOffice(); + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseBackOfficeEndpoints(); + u.UseWebsiteEndpoints(); + }); + }); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost/", UriKind.Absolute), + }); + + // Act - Just verify that the app responds (any response means it booted) + var response = await client.GetAsync("/"); + + // Assert - Any HTTP response means the app started successfully + // We don't care about the status code (could be 404, 200, etc.) + Assert.That(response, Is.Not.Null, "Application should respond to requests"); + TestContext.WriteLine($"Full configuration: Received HTTP {(int)response.StatusCode} {response.StatusCode}"); + } + + /// + /// Verifies that core + website (no backoffice) boots successfully. + /// + [Test] + public async Task CoreWithWebsite_BootsSuccessfully() + { + // Arrange + using var factory = CreateFactory( + configureUmbraco: builder => + { + builder + .AddCore() + .AddWebsite() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + }, + configureApp: app => + { + app.UseUmbraco() + .WithMiddleware(u => + { + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseWebsiteEndpoints(); + }); + }); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost/", UriKind.Absolute), + }); + + // Act + var response = await client.GetAsync("/"); + + // Assert + Assert.That(response, Is.Not.Null, "Application should respond to requests"); + TestContext.WriteLine($"Core + Website configuration: Received HTTP {(int)response.StatusCode} {response.StatusCode}"); + + // Verify backoffice marker is NOT registered + var backofficeMarker = factory.Services.GetService(); + Assert.That(backofficeMarker, Is.Null, "IBackOfficeEnabledMarker should NOT be registered when using AddCore() without AddBackOffice()"); + } + + /// + /// Verifies that core + delivery API (no backoffice, no website) boots successfully. + /// + [Test] + public async Task CoreWithDeliveryApi_BootsSuccessfully() + { + // Arrange + using var factory = CreateFactory( + configureUmbraco: builder => + { + builder + .AddCore() + .AddDeliveryApi() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + }, + configureApp: app => + { + app.UseUmbraco() + .WithMiddleware(u => + { + // Delivery API doesn't need special middleware + }) + .WithEndpoints(u => + { + u.UseDeliveryApiEndpoints(); + }); + }); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost/", UriKind.Absolute), + }); + + // Act + var response = await client.GetAsync("/"); + + // Assert + Assert.That(response, Is.Not.Null, "Application should respond to requests"); + TestContext.WriteLine($"Core + Delivery API configuration: Received HTTP {(int)response.StatusCode} {response.StatusCode}"); + + // Verify backoffice marker is NOT registered + var backofficeMarker = factory.Services.GetService(); + Assert.That(backofficeMarker, Is.Null, "IBackOfficeEnabledMarker should NOT be registered when using AddCore() without AddBackOffice()"); + } + + /// + /// Verifies that the delivery-only scenario (core + website + delivery API, no backoffice) boots successfully. + /// + [Test] + public async Task DeliveryOnlyScenario_BootsSuccessfully() + { + // Arrange + using var factory = CreateFactory( + configureUmbraco: builder => + { + builder + .AddCore() + .AddWebsite() + .AddDeliveryApi() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + }, + configureApp: app => + { + app.UseUmbraco() + .WithMiddleware(u => + { + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseWebsiteEndpoints(); + u.UseDeliveryApiEndpoints(); + }); + }); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost/", UriKind.Absolute), + }); + + // Act + var response = await client.GetAsync("/"); + + // Assert + Assert.That(response, Is.Not.Null, "Application should respond to requests"); + TestContext.WriteLine($"Delivery-only scenario: Received HTTP {(int)response.StatusCode} {response.StatusCode}"); + + // Verify backoffice marker is NOT registered + var backofficeMarker = factory.Services.GetService(); + Assert.That(backofficeMarker, Is.Null, "IBackOfficeEnabledMarker should NOT be registered in delivery-only scenario"); + } + + /// + /// Verifies that full backoffice configuration (with website) boots successfully. + /// + [Test] + public async Task BackOfficeWithWebsite_BootsSuccessfully() + { + // Arrange + using var factory = CreateFactory( + configureUmbraco: builder => + { + builder + .AddBackOffice() + .AddWebsite() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + }, + configureApp: app => + { + app.UseUmbraco() + .WithMiddleware(u => + { + u.UseBackOffice(); + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseBackOfficeEndpoints(); + u.UseWebsiteEndpoints(); + }); + }); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost/", UriKind.Absolute), + }); + + // Act + var response = await client.GetAsync("/"); + + // Assert + Assert.That(response, Is.Not.Null, "Application should respond to requests"); + TestContext.WriteLine($"Backoffice + Website configuration: Received HTTP {(int)response.StatusCode} {response.StatusCode}"); + + // Verify backoffice marker IS registered + var backofficeMarker = factory.Services.GetService(); + Assert.That(backofficeMarker, Is.Not.Null, "IBackOfficeEnabledMarker should be registered when AddBackOffice() is called"); + } +} diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationTests.cs b/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationTests.cs new file mode 100644 index 000000000000..bfb335597ccd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/TestServerTest/CoreConfigurationTests.cs @@ -0,0 +1,377 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Persistence.Sqlite; +using Umbraco.Cms.Persistence.SqlServer; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.TestServerTest; + +/// +/// Integration tests to verify that the Umbraco DI container can be built with different configurations. +/// These tests verify that AddCore(), AddBackOffice(), AddWebsite(), and AddDeliveryApi() can be +/// called in various combinations without throwing exceptions during service registration. +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)] +public class CoreConfigurationTests : UmbracoIntegrationTestBase +{ + /// + /// Verifies that AddCore() can be called and all core services are registered. + /// + [Test] + public void AddCore_RegistersRequiredServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Register core services only (no backoffice) + builder + .AddCore() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + builder.Build(); + + // Assert - Key services should be registered + var provider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => provider.GetRequiredService()); + } + + /// + /// Verifies that AddBackOffice() calls AddCore() internally and registers backoffice services. + /// + [Test] + public void AddBackOffice_CallsAddCoreInternally() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Register backoffice (which should call AddCore internally) + builder + .AddBackOffice() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + builder.Build(); + + // Assert - Both core and backoffice services should be registered + var provider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => provider.GetRequiredService()); + // Verify backoffice marker is registered + Assert.That( + services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)), + Is.True, + "IBackOfficeEnabledMarker should be registered when AddBackOffice() is called"); + } + + /// + /// Verifies that AddCore() followed by AddWebsite() works without AddBackOffice(). + /// + [Test] + public void AddCore_WithWebsite_RegistersWebsiteServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Register core + website (no backoffice) + builder + .AddCore() + .AddWebsite() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + builder.Build(); + + // Assert - Core services should be registered, no backoffice marker + var provider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => provider.GetRequiredService()); + Assert.That( + services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)), + Is.False, + "IBackOfficeEnabledMarker should NOT be registered when only AddCore() + AddWebsite() are called"); + } + + /// + /// Verifies that AddCore() followed by AddDeliveryApi() works without AddBackOffice(). + /// + [Test] + public void AddCore_WithDeliveryApi_RegistersDeliveryApiServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Register core + delivery API (no backoffice) + builder + .AddCore() + .AddDeliveryApi() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + builder.Build(); + + // Assert - Core services should be registered, no backoffice marker + var provider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => provider.GetRequiredService()); + Assert.That( + services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)), + Is.False, + "IBackOfficeEnabledMarker should NOT be registered when only AddCore() + AddDeliveryApi() are called"); + } + + /// + /// Verifies that AddCore() is idempotent - calling it multiple times doesn't cause issues. + /// + [Test] + public void AddCore_IsIdempotent() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Call AddCore multiple times + builder + .AddCore() + .AddCore() // Second call should be no-op + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + // Assert - Should not throw + Assert.DoesNotThrow(() => builder.Build()); + } + + /// + /// Verifies that calling both AddBackOffice() and AddCore() is safe due to idempotency. + /// + [Test] + public void AddBackOffice_ThenAddCore_IsSafe() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Call AddBackOffice first (which calls AddCore internally), then AddCore again + builder + .AddBackOffice() + .AddCore() // Should be no-op due to idempotency + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + // Assert - Should not throw + Assert.DoesNotThrow(() => builder.Build()); + } + + /// + /// Verifies that calling AddCore() then AddBackOffice() is safe. + /// + [Test] + public void AddCore_ThenAddBackOffice_IsSafe() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Call AddCore first, then AddBackOffice + builder + .AddCore() + .AddBackOffice() // AddCore inside should be no-op + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport(); + + // Assert - Should not throw, and backoffice marker should be registered + Assert.DoesNotThrow(() => builder.Build()); + Assert.That( + services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)), + Is.True, + "IBackOfficeEnabledMarker should be registered when AddBackOffice() is called"); + } + + /// + /// Verifies the complete delivery-only scenario with website and delivery API. + /// + [Test] + public void DeliveryOnlyScenario_RegistersAllRequiredServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(InMemoryConfiguration) + .Build(); + + // Register IConfiguration which is required by many services + services.AddSingleton(configuration); + + TypeLoader typeLoader = services.AddTypeLoader( + GetType().Assembly, + TestHelper.ConsoleLoggerFactory, + configuration); + + var builder = new UmbracoBuilder( + services, + configuration, + typeLoader, + TestHelper.ConsoleLoggerFactory, + TestHelper.Profiler, + AppCaches.NoCache); + + // Act - Typical delivery-only setup + builder + .AddCore() + .AddWebsite() + .AddDeliveryApi() + .AddUmbracoSqlServerSupport() + .AddUmbracoSqliteSupport() + .AddComposers(); + + builder.Build(); + + // Assert + var provider = services.BuildServiceProvider(); + + // Core services should be available + Assert.DoesNotThrow(() => provider.GetRequiredService()); + + // Backoffice marker should NOT be registered + Assert.That( + services.Any(s => s.ServiceType == typeof(IBackOfficeEnabledMarker)), + Is.False, + "IBackOfficeEnabledMarker should NOT be registered in delivery-only scenario"); + } +}