diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 9cd0359742..a7fd5bd009 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -19,8 +19,6 @@ namespace Umbraco.Cms.Api.Management.Routing; /// public sealed class BackOfficeAreaRoutes : IAreaRoutes { - private readonly GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; private readonly IRuntimeState _runtimeState; private readonly string _umbracoPathSegment; @@ -32,10 +30,18 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes IHostingEnvironment hostingEnvironment, IRuntimeState runtimeState) { - _globalSettings = globalSettings.Value; - _hostingEnvironment = hostingEnvironment; _runtimeState = runtimeState; - _umbracoPathSegment = _globalSettings.GetUmbracoMvcArea(_hostingEnvironment); + _umbracoPathSegment = globalSettings.Value.GetUmbracoMvcArea(hostingEnvironment); + } + + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 15.")] + public BackOfficeAreaRoutes( + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState, + UmbracoApiControllerTypeCollection apiControllers) : this(globalSettings, hostingEnvironment, runtimeState) + { + } /// diff --git a/src/Umbraco.Core/UmbracoApiControllerTypeCollection.cs b/src/Umbraco.Core/UmbracoApiControllerTypeCollection.cs new file mode 100644 index 0000000000..747a5553e1 --- /dev/null +++ b/src/Umbraco.Core/UmbracoApiControllerTypeCollection.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core; + +[Obsolete("This will be removed in Umbraco 15.")] +public class UmbracoApiControllerTypeCollection : BuilderCollectionBase +{ + public UmbracoApiControllerTypeCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Web.Common/Attributes/UmbracoApiControllerAttribute.cs b/src/Umbraco.Web.Common/Attributes/UmbracoApiControllerAttribute.cs new file mode 100644 index 0000000000..668ed3e6d5 --- /dev/null +++ b/src/Umbraco.Web.Common/Attributes/UmbracoApiControllerAttribute.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Web.Common.ApplicationModels; + +namespace Umbraco.Cms.Web.Common.Attributes; + + +[AttributeUsage(AttributeTargets.Class)] +[Obsolete("No-op attribute. Will be removed in Umbraco 15.")] +public sealed class UmbracoApiControllerAttribute : Attribute +{ +} diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoApiController.cs b/src/Umbraco.Web.Common/Controllers/UmbracoApiController.cs new file mode 100644 index 0000000000..115727d9eb --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/UmbracoApiController.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Web.Common.Controllers; + +/// +/// Provides a base class for auto-routed Umbraco API controllers. +/// +[Obsolete(""" +WARNING +The UmbracoAPIController does not work exactly as in previous versions of Umbraco because serialization is now done using System.Text.Json. +Please verify your API responses still work as expect. + +We recommend using regular ASP.NET Core ApiControllers for your APIs so that OpenAPI specifications are generated. +Read more about this here: https://learn.microsoft.com/en-us/aspnet/core/web-api/ + +UmbracoAPIController will be removed in Umbraco 15. +""")] +public abstract class UmbracoApiController : UmbracoApiControllerBase, IDiscoverable +{ + /// + /// Initializes a new instance of the class. + /// + protected UmbracoApiController() + { + } +} diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerBase.cs b/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerBase.cs new file mode 100644 index 0000000000..53f5ff52f5 --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerBase.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Features; +using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Web.Common.Controllers; + +/// +/// Provides a base class for Umbraco API controllers. +/// +/// +/// These controllers are NOT auto-routed. +/// The base class is which are netcore API controllers without any view support +/// +[Authorize(Policy = AuthorizationPolicies.UmbracoFeatureEnabled)] +[UmbracoApiController] +[Obsolete("This will be removed in Umbraco 15.")] +public abstract class UmbracoApiControllerBase : ControllerBase, IUmbracoFeature +{ + /// + /// Initializes a new instance of the class. + /// + protected UmbracoApiControllerBase() + { + } +} diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerTypeCollectionBuilder.cs b/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerTypeCollectionBuilder.cs new file mode 100644 index 0000000000..73fd9908e9 --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/UmbracoApiControllerTypeCollectionBuilder.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Web.Common.Controllers; + +[Obsolete("This will be removed in Umbraco 15.")] +public class UmbracoApiControllerTypeCollectionBuilder : TypeCollectionBuilderBase< + UmbracoApiControllerTypeCollectionBuilder, UmbracoApiControllerTypeCollection, UmbracoApiController> +{ + protected override UmbracoApiControllerTypeCollectionBuilder This => this; +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 685adf2fbd..bc34221aad 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -44,6 +44,7 @@ using Umbraco.Cms.Web.Common.ApplicationModels; using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Blocks; using Umbraco.Cms.Web.Common.Configuration; +using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.FileProviders; using Umbraco.Cms.Web.Common.Helpers; @@ -287,6 +288,10 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); + var umbracoApiControllerTypes = builder.TypeLoader.GetUmbracoApiControllers().ToList(); + builder.WithCollectionBuilder() + .Add(umbracoApiControllerTypes); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs b/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs index 8bf96bb69f..276925e796 100644 --- a/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/LinkGeneratorExtensions.cs @@ -12,6 +12,36 @@ namespace Umbraco.Extensions; public static class LinkGeneratorExtensions { + /// + /// Return the Url for a Web Api service + /// + /// The + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService(this LinkGenerator linkGenerator, string actionName, object? id = null) + where T : UmbracoApiControllerBase => linkGenerator.GetUmbracoControllerUrl( + actionName, + typeof(T), + new Dictionary { ["id"] = id }); + + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService(this LinkGenerator linkGenerator, string actionName, IDictionary? values) + where T : UmbracoApiControllerBase => linkGenerator.GetUmbracoControllerUrl(actionName, typeof(T), values); + + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiServiceBaseUrl( + this LinkGenerator linkGenerator, + Expression> methodSelector) + where T : UmbracoApiControllerBase + { + MethodInfo? method = ExpressionHelper.GetMethodInfo(methodSelector); + if (method == null) + { + throw new MissingMethodException("Could not find the method " + methodSelector + " on type " + typeof(T) + + " or the result "); + } + + return linkGenerator.GetUmbracoApiService(method.Name)?.TrimEnd(method.Name); + } /// /// Return the Url for an Umbraco controller @@ -101,4 +131,26 @@ public static class LinkGeneratorExtensions return linkGenerator.GetUmbracoControllerUrl(actionName, ControllerExtensions.GetControllerName(controllerType), area, values); } + + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService( + this LinkGenerator linkGenerator, + Expression> methodSelector) + where T : UmbracoApiController + { + MethodInfo? method = ExpressionHelper.GetMethodInfo(methodSelector); + IDictionary? methodParams = ExpressionHelper.GetMethodParams(methodSelector); + if (method == null) + { + throw new MissingMethodException( + $"Could not find the method {methodSelector} on type {typeof(T)} or the result "); + } + + if (methodParams?.Any() == false) + { + return linkGenerator.GetUmbracoApiService(method.Name); + } + + return linkGenerator.GetUmbracoApiService(method.Name, methodParams); + } } diff --git a/src/Umbraco.Web.Common/Extensions/TypeLoaderExtensions.cs b/src/Umbraco.Web.Common/Extensions/TypeLoaderExtensions.cs new file mode 100644 index 0000000000..423ea52536 --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/TypeLoaderExtensions.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Web.Common.Controllers; + +namespace Umbraco.Extensions; + +public static class TypeLoaderExtensions +{ + /// + /// Gets all types implementing . + /// + public static IEnumerable GetUmbracoApiControllers(this TypeLoader typeLoader) + => typeLoader.GetTypes(); +} diff --git a/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs b/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs index 7edb173b21..34bc870f94 100644 --- a/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs @@ -21,6 +21,219 @@ namespace Umbraco.Extensions; public static class UrlHelperExtensions { + /// + /// Return the Url for a Web Api service + /// + /// + /// + /// + /// + /// + /// + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService( + this IUrlHelper url, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + string actionName, + object? id = null) + where T : UmbracoApiController => + url.GetUmbracoApiService(umbracoApiControllerTypeCollection, actionName, typeof(T), id); + + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService( + this IUrlHelper url, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + Expression> methodSelector) + where T : UmbracoApiController + { + MethodInfo? method = ExpressionHelper.GetMethodInfo(methodSelector); + IDictionary? methodParams = ExpressionHelper.GetMethodParams(methodSelector); + if (method == null) + { + throw new MissingMethodException("Could not find the method " + methodSelector + " on type " + typeof(T) + + " or the result "); + } + + if (methodParams?.Any() == false) + { + return url.GetUmbracoApiService(umbracoApiControllerTypeCollection, method.Name); + } + + return url.GetUmbracoApiService(umbracoApiControllerTypeCollection, method.Name, methodParams?.Values.First()); + } + + /// + /// Return the Url for a Web Api service + /// + /// + /// + /// + /// + /// + /// + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiService( + this IUrlHelper url, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + string actionName, + Type apiControllerType, + object? id = null) + { + if (actionName == null) + { + throw new ArgumentNullException(nameof(actionName)); + } + + if (string.IsNullOrWhiteSpace(actionName)) + { + throw new ArgumentException( + "Value can't be empty or consist only of white-space characters.", + nameof(actionName)); + } + + if (apiControllerType == null) + { + throw new ArgumentNullException(nameof(apiControllerType)); + } + + var area = string.Empty; + + Type? apiController = umbracoApiControllerTypeCollection.SingleOrDefault(x => x == apiControllerType); + if (apiController == null) + { + throw new InvalidOperationException("Could not find the umbraco api controller of type " + + apiControllerType.FullName); + } + + PluginControllerMetadata metaData = PluginController.GetMetadata(apiController); + if (metaData.AreaName.IsNullOrWhiteSpace() == false) + { + // set the area to the plugin area + area = metaData.AreaName; + } + + return url.GetUmbracoApiService(actionName, ControllerExtensions.GetControllerName(apiControllerType), area!, id); + } + + /// + /// Return the Url for a Web Api service + /// + /// + /// + /// + /// + /// + public static string? GetUmbracoApiService(this IUrlHelper url, string actionName, string controllerName, object? id = null) => url.GetUmbracoApiService(actionName, controllerName, string.Empty, id); + + /// + /// Return the Url for a Web Api service + /// + /// + /// + /// + /// + /// + /// + public static string? GetUmbracoApiService( + this IUrlHelper url, + string actionName, + string controllerName, + string area, + object? id = null) + { + if (actionName == null) + { + throw new ArgumentNullException(nameof(actionName)); + } + + if (string.IsNullOrWhiteSpace(actionName)) + { + throw new ArgumentException( + "Value can't be empty or consist only of white-space characters.", + nameof(actionName)); + } + + if (controllerName == null) + { + throw new ArgumentNullException(nameof(controllerName)); + } + + if (string.IsNullOrWhiteSpace(controllerName)) + { + throw new ArgumentException( + "Value can't be empty or consist only of white-space characters.", + nameof(controllerName)); + } + + if (area.IsNullOrWhiteSpace()) + { + if (id == null) + { + return url.Action(actionName, controllerName); + } + + return url.Action(actionName, controllerName, new { id }); + } + + if (id == null) + { + return url.Action(actionName, controllerName, new { area }); + } + + return url.Action(actionName, controllerName, new { area, id }); + } + + /// + /// Return the Base Url (not including the action) for a Web Api service + /// + /// + /// + /// + /// + /// + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiServiceBaseUrl( + this IUrlHelper url, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + string actionName) + where T : UmbracoApiController => + url.GetUmbracoApiService(umbracoApiControllerTypeCollection, actionName)?.TrimEnd(actionName); + + [Obsolete("This will be removed in Umbraco 15.")] + public static string? GetUmbracoApiServiceBaseUrl( + this IUrlHelper url, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + Expression> methodSelector) + where T : UmbracoApiController + { + MethodInfo? method = ExpressionHelper.GetMethodInfo(methodSelector); + if (method == null) + { + throw new MissingMethodException("Could not find the method " + methodSelector + " on type " + typeof(T) + + " or the result "); + } + + return url.GetUmbracoApiService(umbracoApiControllerTypeCollection, method.Name)?.TrimEnd(method.Name); + } + + /// + /// Return the Url for an action with a cache-busting hash appended + /// + /// + public static string GetUrlWithCacheBust( + this IUrlHelper url, + string actionName, + string controllerName, + RouteValueDictionary routeVals, + IHostingEnvironment hostingEnvironment, + IUmbracoVersion umbracoVersion) + { + var applicationJs = url.Action(actionName, controllerName, routeVals); + applicationJs = applicationJs + "?umb__rnd=" + + GetCacheBustHash(hostingEnvironment, umbracoVersion); + return applicationJs; + } + /// /// /// diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs index a8e2fc63dc..1ba9a52526 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs @@ -60,7 +60,17 @@ public sealed class ConfigureMemberCookieOptions : IConfigureNamedOptions { - new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx); + // When the controller is an UmbracoAPIController, we want to return a StatusCode instead of a redirect. + // All other cases should use the default Redirect of the CookieAuthenticationEvent. + var controllerDescriptor = ctx.HttpContext.GetEndpoint()?.Metadata + .OfType() + .FirstOrDefault(); + + if (!controllerDescriptor?.ControllerTypeInfo.IsSubclassOf(typeof(UmbracoApiController)) ?? false) + { + new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx); + } + return Task.CompletedTask; }, }; diff --git a/src/Umbraco.Web.Website/Extensions/TypeLoaderExtensions.cs b/src/Umbraco.Web.Website/Extensions/TypeLoaderExtensions.cs index f0709d99c2..f088e53a9a 100644 --- a/src/Umbraco.Web.Website/Extensions/TypeLoaderExtensions.cs +++ b/src/Umbraco.Web.Website/Extensions/TypeLoaderExtensions.cs @@ -15,4 +15,10 @@ public static class TypeLoaderExtensions /// internal static IEnumerable GetSurfaceControllers(this TypeLoader typeLoader) => typeLoader.GetTypes(); + + /// + /// Gets all types implementing . + /// + internal static IEnumerable GetUmbracoApiControllers(this TypeLoader typeLoader) + => typeLoader.GetTypes(); } diff --git a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs index 9b992b8276..c67a5acfa4 100644 --- a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs +++ b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web.Mvc; @@ -17,10 +19,25 @@ namespace Umbraco.Cms.Web.Website.Routing; /// public sealed class FrontEndRoutes : IAreaRoutes { + private readonly UmbracoApiControllerTypeCollection _apiControllers; private readonly IRuntimeState _runtimeState; private readonly SurfaceControllerTypeCollection _surfaceControllerTypeCollection; private readonly string _umbracoPathSegment; + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 15.")] + public FrontEndRoutes( + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState, + SurfaceControllerTypeCollection surfaceControllerTypeCollection, + UmbracoApiControllerTypeCollection apiControllers) + { + _runtimeState = runtimeState; + _surfaceControllerTypeCollection = surfaceControllerTypeCollection; + _apiControllers = apiControllers; + _umbracoPathSegment = globalSettings.Value.GetUmbracoMvcArea(hostingEnvironment); + } + /// /// Initializes a new instance of the class. /// @@ -29,10 +46,13 @@ public sealed class FrontEndRoutes : IAreaRoutes IHostingEnvironment hostingEnvironment, IRuntimeState runtimeState, SurfaceControllerTypeCollection surfaceControllerTypeCollection) + : this( + globalSettings, + hostingEnvironment, + runtimeState, + surfaceControllerTypeCollection, + StaticServiceProvider.Instance.GetRequiredService()) { - _runtimeState = runtimeState; - _surfaceControllerTypeCollection = surfaceControllerTypeCollection; - _umbracoPathSegment = globalSettings.Value.GetUmbracoMvcArea(hostingEnvironment); } /// @@ -45,6 +65,7 @@ public sealed class FrontEndRoutes : IAreaRoutes case RuntimeLevel.Run: AutoRouteSurfaceControllers(endpoints); + AutoRouteFrontEndApiControllers(endpoints); break; case RuntimeLevel.BootFailed: case RuntimeLevel.Unknown: @@ -71,4 +92,28 @@ public sealed class FrontEndRoutes : IAreaRoutes meta.AreaName); } } + + /// + /// Auto-routes all front-end api controllers + /// + private void AutoRouteFrontEndApiControllers(IEndpointRouteBuilder endpoints) + { + foreach (Type controller in _apiControllers) + { + PluginControllerMetadata meta = PluginController.GetMetadata(controller); + + // exclude back-end api controllers + if (meta.IsBackOffice) + { + continue; + } + + endpoints.MapUmbracoApiRoute( + meta.ControllerType, + _umbracoPathSegment, + meta.AreaName, + meta.IsBackOffice, + string.Empty); // no default action (this is what we had before) + } + } } diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index fef38f2ef9..dbb5e436e9 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -105,6 +105,18 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest }); } + /// + /// Prepare a url before using . + /// This returns the url but also sets the HttpContext.request into to use this url. + /// + /// The string URL of the controller action. + protected string PrepareApiControllerUrl(Expression> methodSelector) + where T : UmbracoApiController + { + var url = LinkGenerator.GetUmbracoApiService(methodSelector); + return PrepareUrl(url); + } + protected string GetManagementApiUrl(Expression> methodSelector) where T : ManagementApiControllerBase { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs index d28cbe03f4..35bea8bc0e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs @@ -67,6 +67,36 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.Website.Security Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); Assert.AreEqual(cookieAuthenticationOptions.Value.AccessDeniedPath.ToString(), response.Headers.Location?.AbsolutePath); } + + [Test] + [LongRunning] + public async Task Secure_ApiController_Should_Return_Unauthorized_WhenNotLoggedIn() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(false); + var url = PrepareApiControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Test] + [LongRunning] + public async Task Secure_ApiController_Should_Return_Forbidden_WhenNotAuthorized() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(true); + _memberManagerMock.Setup(x => x.IsMemberAuthorizedAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny>())) + .ReturnsAsync(false); + + var url = PrepareApiControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } } public class TestSurfaceController : SurfaceController @@ -91,4 +121,10 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.Website.Security [UmbracoMemberAuthorize] public IActionResult Secure() => NoContent(); } + + public class TestApiController : UmbracoApiController + { + [UmbracoMemberAuthorize] + public IActionResult Secure() => NoContent(); + } } diff --git a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs index 11baa6229e..ea794faeb7 100644 --- a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs +++ b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs @@ -47,6 +47,14 @@ internal class UmbracoCustomizations : ICustomization fixture.Customize(x => x.With(settings => settings.ApplicationVirtualPath, string.Empty)); + fixture.Customize(u => u.FromFactory( + () => new BackOfficeAreaRoutes( + Options.Create(new GlobalSettings()), + Mock.Of(x => + x.ToAbsolute(It.IsAny()) == "/umbraco" && x.ApplicationVirtualPath == string.Empty), + Mock.Of(x => x.Level == RuntimeLevel.Run), + new UmbracoApiControllerTypeCollection(Enumerable.Empty)))); + fixture.Customize(u => u.FromFactory( () => new PreviewRoutes( Options.Create(new GlobalSettings()), diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs new file mode 100644 index 0000000000..c444e591ab --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Security; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Extensions; +using static Umbraco.Cms.Core.Constants.Web.Routing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Routing; + +[TestFixture] +public class BackOfficeAreaRoutesTests +{ + [TestCase(RuntimeLevel.BootFailed)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Boot)] + public void RuntimeState_No_Routes(RuntimeLevel level) + { + var routes = GetBackOfficeAreaRoutes(level); + var endpoints = new TestRouteBuilder(); + routes.CreateRoutes(endpoints); + + Assert.AreEqual(0, endpoints.DataSources.Count); + } + + [Test] + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.Install)] + public void RuntimeState_All_Routes(RuntimeLevel level) + { + var routes = GetBackOfficeAreaRoutes(level); + var endpoints = new TestRouteBuilder(); + routes.CreateRoutes(endpoints); + + Assert.AreEqual(1, endpoints.DataSources.Count); + var route = endpoints.DataSources.First(); + Assert.AreEqual(2, route.Endpoints.Count); + + AssertMinimalBackOfficeRoutes(route); + } + + private void AssertMinimalBackOfficeRoutes(EndpointDataSource route) + { + var endpoint1 = (RouteEndpoint)route.Endpoints[0]; + Assert.AreEqual("umbraco/{action}/{id?}", endpoint1.RoutePattern.RawText); + Assert.AreEqual("Index", endpoint1.RoutePattern.Defaults[ActionToken]); + Assert.AreEqual(ControllerExtensions.GetControllerName(), endpoint1.RoutePattern.Defaults[ControllerToken]); + } + + private BackOfficeAreaRoutes GetBackOfficeAreaRoutes(RuntimeLevel level) + { + var globalSettings = new GlobalSettings(); + var routes = new BackOfficeAreaRoutes( + Options.Create(globalSettings), + Mock.Of(x => + x.ToAbsolute(It.IsAny()) == "/umbraco" && x.ApplicationVirtualPath == string.Empty), + Mock.Of(x => x.Level == level), + new UmbracoApiControllerTypeCollection(() => new[] { typeof(Testing1Controller) })); + + return routes; + } + + [IsBackOffice] + private class Testing1Controller : UmbracoApiController + { + } +}