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");
+ }
+}