Merge pull request #8122 from AndyButland/feature/7799-netcore-web-api-component-migration

Netcore: Implemented various Web API components
This commit is contained in:
Bjarke Berg
2020-05-18 12:00:07 +02:00
committed by GitHub
17 changed files with 639 additions and 5 deletions

View File

@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\Umbraco.Tests.Common\Umbraco.Tests.Common.csproj" />
<ProjectReference Include="..\Umbraco.Web.BackOffice\Umbraco.Web.BackOffice.csproj" />
<ProjectReference Include="..\Umbraco.Web.Common\Umbraco.Web.Common.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Moq;
using NUnit.Framework;
using Umbraco.Core.Models.Membership;
using Umbraco.Web;
using Umbraco.Web.BackOffice.Filters;
using Umbraco.Web.Security;
namespace Umbraco.Tests.UnitTests.Umbraco.Web.BackOffice.Filters
{
[TestFixture]
public class AppendUserModifiedHeaderAttributeTests
{
[Test]
public void Appends_Header_When_No_User_Parameter_Provider()
{
// Arrange
var context = CreateContext();
var attribute = new AppendUserModifiedHeaderAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
context.HttpContext.Response.Headers.TryGetValue("X-Umb-User-Modified", out var headerValue);
Assert.AreEqual("1", headerValue[0]);
}
[Test]
public void Does_Not_Append_Header_If_Already_Exists()
{
// Arrange
var context = CreateContext(headerValue: "0");
var attribute = new AppendUserModifiedHeaderAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
context.HttpContext.Response.Headers.TryGetValue("X-Umb-User-Modified", out var headerValue);
Assert.AreEqual("0", headerValue[0]);
}
[Test]
public void Does_Not_Append_Header_When_User_Id_Parameter_Provided_And_Does_Not_Match_Current_User()
{
// Arrange
var context = CreateContext(actionArgument: new KeyValuePair<string, object>("UserId", 99));
var userIdParameter = "UserId";
var attribute = new AppendUserModifiedHeaderAttribute(userIdParameter);
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsFalse(context.HttpContext.Response.Headers.ContainsKey("X-Umb-User-Modified"));
}
[Test]
public void Appends_Header_When_User_Id_Parameter_Provided_And_Does_Not_Match_Current_User()
{
// Arrange
var context = CreateContext(actionArgument: new KeyValuePair<string, object>("UserId", 100));
var userIdParameter = "UserId";
var attribute = new AppendUserModifiedHeaderAttribute(userIdParameter);
// Act
attribute.OnActionExecuting(context);
// Assert
context.HttpContext.Response.Headers.TryGetValue("X-Umb-User-Modified", out var headerValue);
Assert.AreEqual("1", headerValue[0]);
}
private static ActionExecutingContext CreateContext(string headerValue = null, KeyValuePair<string, object> actionArgument = default)
{
var httpContext = new DefaultHttpContext();
if (!string.IsNullOrEmpty(headerValue))
{
httpContext.Response.Headers.Add("X-Umb-User-Modified", headerValue);
}
var currentUserMock = new Mock<IUser>();
currentUserMock
.SetupGet(x => x.Id)
.Returns(100);
var webSecurityMock = new Mock<IWebSecurity>();
webSecurityMock
.SetupGet(x => x.CurrentUser)
.Returns(currentUserMock.Object);
var umbracoContextMock = new Mock<IUmbracoContext>();
umbracoContextMock
.SetupGet(x => x.Security)
.Returns(webSecurityMock.Object);
var umbracoContextAccessorMock = new Mock<IUmbracoContextAccessor>();
umbracoContextAccessorMock
.SetupGet(x => x.UmbracoContext)
.Returns(umbracoContextMock.Object);
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock
.Setup(x => x.GetService(typeof(IUmbracoContextAccessor)))
.Returns(umbracoContextAccessorMock.Object);
httpContext.RequestServices = serviceProviderMock.Object;
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
var context = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new Mock<Controller>().Object);
if (!EqualityComparer<KeyValuePair<string, object>>.Default.Equals(actionArgument, default))
{
context.ActionArguments.Add(actionArgument);
}
return context;
}
}
}

View File

@@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Moq;
using NUnit.Framework;
using Umbraco.Web.BackOffice.Filters;
namespace Umbraco.Tests.UnitTests.Umbraco.Web.BackOffice.Filters
{
[TestFixture]
public class OnlyLocalRequestsAttributeTests
{
[Test]
public void Does_Not_Set_Result_When_No_Remote_Address()
{
// Arrange
var context = CreateContext();
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsNull(context.Result);
}
[Test]
public void Does_Not_Set_Result_When_Remote_Address_Is_Null_Ip_Address()
{
// Arrange
var context = CreateContext(remoteIpAddress: "::1");
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsNull(context.Result);
}
[Test]
public void Does_Not_Set_Result_When_Remote_Address_Matches_Local_Address()
{
// Arrange
var context = CreateContext(remoteIpAddress: "100.1.2.3", localIpAddress: "100.1.2.3");
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsNull(context.Result);
}
[Test]
public void Returns_Not_Found_When_Remote_Address_Does_Not_Match_Local_Address()
{
// Arrange
var context = CreateContext(remoteIpAddress: "100.1.2.3", localIpAddress: "100.1.2.2");
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
var typedResult = context.Result as NotFoundResult;
Assert.IsNotNull(typedResult);
}
[Test]
public void Does_Not_Set_Result_When_Remote_Address_Matches_LoopBack_Address()
{
// Arrange
var context = CreateContext(remoteIpAddress: "127.0.0.1", localIpAddress: "::1");
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsNull(context.Result);
}
[Test]
public void Returns_Not_Found_When_Remote_Address_Does_Not_Match_LoopBack_Address()
{
// Arrange
var context = CreateContext(remoteIpAddress: "100.1.2.3", localIpAddress: "::1");
var attribute = new OnlyLocalRequestsAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
var typedResult = context.Result as NotFoundResult;
Assert.IsNotNull(typedResult);
}
private static ActionExecutingContext CreateContext(string remoteIpAddress = null, string localIpAddress = null)
{
var httpContext = new DefaultHttpContext();
if (!string.IsNullOrEmpty(remoteIpAddress))
{
httpContext.Connection.RemoteIpAddress = IPAddress.Parse(remoteIpAddress);
}
if (!string.IsNullOrEmpty(localIpAddress))
{
httpContext.Connection.LocalIpAddress = IPAddress.Parse(localIpAddress);
}
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
return new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new Mock<Controller>().Object);
}
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using Moq;
using NUnit.Framework;
using Umbraco.Web.BackOffice.Filters;
namespace Umbraco.Tests.UnitTests.Umbraco.Web.BackOffice.Filters
{
[TestFixture]
public class ValidationFilterAttributeTests
{
[Test]
public void Does_Not_Set_Result_When_No_Errors_In_Model_State()
{
// Arrange
var context = CreateContext();
var attribute = new ValidationFilterAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
Assert.IsNull(context.Result);
}
[Test]
public void Returns_Bad_Request_When_Errors_In_Model_State()
{
// Arrange
var context = CreateContext(withError: true);
var attribute = new ValidationFilterAttribute();
// Act
attribute.OnActionExecuting(context);
// Assert
var typedResult = context.Result as BadRequestObjectResult;
Assert.IsNotNull(typedResult);
}
private static ActionExecutingContext CreateContext(bool withError = false)
{
var httpContext = new DefaultHttpContext();
var modelState = new ModelStateDictionary();
if (withError)
{
modelState.AddModelError(string.Empty, "Error");
}
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor(), modelState);
return new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new Mock<Controller>().Object);
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -12,7 +11,7 @@ using Umbraco.Core.Models.PublishedContent;
using Umbraco.Web.Common.ModelBinders;
using Umbraco.Web.Models;
namespace Umbraco.Tests.UnitTests.Umbraco.Web.Common
namespace Umbraco.Tests.UnitTests.Umbraco.Web.Common.ModelBinders
{
[TestFixture]
public class ContentModelBinderTests
@@ -92,9 +91,7 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Web.Common
var httpContext = new DefaultHttpContext();
var routeData = new RouteData();
if (withUmbracoDataToken)
{
routeData.DataTokens.Add(Constants.Web.UmbracoDataToken, source);
}
var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor());
var metadataProvider = new EmptyModelMetadataProvider();

View File

@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;
using NUnit.Framework;
using Umbraco.Web.Common.ModelBinders;
namespace Umbraco.Tests.UnitTests.Umbraco.Web.Common.ModelBinders
{
[TestFixture]
public class HttpQueryStringModelBinderTests
{
[Test]
public void Binds_Query_To_FormCollection()
{
// Arrange
var bindingContext = CreateBindingContext("?foo=bar&baz=buzz");
var binder = new HttpQueryStringModelBinder();
// Act
binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var typedModel = bindingContext.Result.Model as FormCollection;
Assert.IsNotNull(typedModel);
Assert.AreEqual(typedModel["foo"], "bar");
Assert.AreEqual(typedModel["baz"], "buzz");
}
[Test]
public void Sets_Culture_Form_Value_From_Query_If_Provided()
{
// Arrange
var bindingContext = CreateBindingContext("?foo=bar&baz=buzz&culture=en-gb");
var binder = new HttpQueryStringModelBinder();
// Act
binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var typedModel = bindingContext.Result.Model as FormCollection;
Assert.IsNotNull(typedModel);
Assert.AreEqual(typedModel["culture"], "en-gb");
}
[Test]
public void Sets_Culture_Form_Value_From_Header_If_Not_Provided_In_Query()
{
// Arrange
var bindingContext = CreateBindingContext("?foo=bar&baz=buzz");
var binder = new HttpQueryStringModelBinder();
// Act
binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var typedModel = bindingContext.Result.Model as FormCollection;
Assert.IsNotNull(typedModel);
Assert.AreEqual(typedModel["culture"], "en-gb");
}
private ModelBindingContext CreateBindingContext(string querystring)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.QueryString = new QueryString(querystring);
httpContext.Request.Headers.Add("X-UMB-CULTURE", new StringValues("en-gb"));
var routeData = new RouteData();
var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor());
var metadataProvider = new EmptyModelMetadataProvider();
var routeValueDictionary = new RouteValueDictionary();
var valueProvider = new RouteValueProvider(BindingSource.Path, routeValueDictionary);
var modelType = typeof(FormCollection);
return new DefaultModelBindingContext
{
ActionContext = actionContext,
ModelMetadata = metadataProvider.GetMetadataForType(modelType),
ModelName = modelType.Name,
ValueProvider = valueProvider,
};
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Core;
namespace Umbraco.Web.BackOffice.Filters
{
/// <summary>
/// Appends a custom response header to notify the UI that the current user data has been modified
/// </summary>
public sealed class AppendUserModifiedHeaderAttribute : ActionFilterAttribute
{
private readonly string _userIdParameter;
/// <summary>
/// An empty constructor which will always set the header.
/// </summary>
public AppendUserModifiedHeaderAttribute()
{
}
/// <summary>
/// A constructor specifying the action parameter name containing the user id to match against the
/// current user and if they match the header will be appended.
/// </summary>
/// <param name="userIdParameter"></param>
public AppendUserModifiedHeaderAttribute(string userIdParameter)
{
_userIdParameter = userIdParameter ?? throw new ArgumentNullException(nameof(userIdParameter));
}
public override void OnActionExecuting(ActionExecutingContext context)
{
if (_userIdParameter.IsNullOrWhiteSpace())
{
AppendHeader(context);
}
else
{
if (!context.ActionArguments.ContainsKey(_userIdParameter))
{
throw new InvalidOperationException($"No argument found for the current action with the name: {_userIdParameter}");
}
var umbracoContextAccessor = context.HttpContext.RequestServices.GetService<IUmbracoContextAccessor>();
var user = umbracoContextAccessor.UmbracoContext.Security.CurrentUser;
if (user == null)
{
return;
}
var userId = GetUserIdFromParameter(context.ActionArguments[_userIdParameter]);
if (userId == user.Id)
{
AppendHeader(context);
}
}
}
public static void AppendHeader(ActionExecutingContext context)
{
const string HeaderName = "X-Umb-User-Modified";
if (context.HttpContext.Response.Headers.ContainsKey(HeaderName) == false)
{
context.HttpContext.Response.Headers.Add(HeaderName, "1");
}
}
private int GetUserIdFromParameter(object parameterValue)
{
if (parameterValue is int)
{
return (int)parameterValue;
}
throw new InvalidOperationException($"The id type: {parameterValue.GetType()} is not a supported id.");
}
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Web.Common.Extensions;
namespace Umbraco.Web.BackOffice.Filters
{
public class OnlyLocalRequestsAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.HttpContext.Request.IsLocal())
{
context.Result = new NotFoundResult();
}
}
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Umbraco.Web.BackOffice.Filters
{
/// <summary>
/// An action filter used to do basic validation against the model and return a result
/// straight away if it fails.
/// </summary>
internal sealed class ValidationFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var modelState = context.ModelState;
if (!modelState.IsValid)
{
context.Result = new BadRequestObjectResult(modelState);
}
}
}
}

View File

@@ -14,6 +14,12 @@
<PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Configuration\Umbraco.Configuration.csproj" />
<ProjectReference Include="..\Umbraco.Core\Umbraco.Core.csproj" />

View File

@@ -0,0 +1,42 @@
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Umbraco.Web.Common.Extensions
{
public static class HttpRequestExtensions
{
internal static string ClientCulture(this HttpRequest request)
{
return request.Headers.TryGetValue("X-UMB-CULTURE", out var values) ? values[0] : null;
}
/// <summary>
/// Determines if a request is local.
/// </summary>
/// <returns>True if request is local</returns>
/// <remarks>
/// Hat-tip: https://stackoverflow.com/a/41242493/489433
/// </remarks>
public static bool IsLocal(this HttpRequest request)
{
var connection = request.HttpContext.Connection;
if (connection.RemoteIpAddress.IsSet())
{
// We have a remote address set up
return connection.LocalIpAddress.IsSet()
// Is local is same as remote, then we are local
? connection.RemoteIpAddress.Equals(connection.LocalIpAddress)
// else we are remote if the remote IP address is not a loopback address
: IPAddress.IsLoopback(connection.RemoteIpAddress);
}
return true;
}
private static bool IsSet(this IPAddress address)
{
const string NullIpAddress = "::1";
return address != null && address.ToString() != NullIpAddress;
}
}
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Primitives;
using Umbraco.Core;
using Umbraco.Web.Common.Extensions;
namespace Umbraco.Web.Common.ModelBinders
{
/// <summary>
/// Allows an Action to execute with an arbitrary number of QueryStrings
/// </summary>
/// <remarks>
/// Just like you can POST an arbitrary number of parameters to an Action, you can't GET an arbitrary number
/// but this will allow you to do it.
/// </remarks>
public sealed class HttpQueryStringModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var queryStrings = GetQueryAsDictionary(bindingContext.ActionContext.HttpContext.Request.Query);
var queryStringKeys = queryStrings.Select(kvp => kvp.Key).ToArray();
if (queryStringKeys.InvariantContains("culture") == false)
{
queryStrings.Add("culture", new StringValues(bindingContext.ActionContext.HttpContext.Request.ClientCulture()));
}
var formData = new FormCollection(queryStrings);
bindingContext.Result = ModelBindingResult.Success(formData);
return Task.CompletedTask;
}
private Dictionary<string, StringValues> GetQueryAsDictionary(IQueryCollection query)
{
var result = new Dictionary<string, StringValues>();
if (query == null)
{
return result;
}
foreach (var item in query)
{
result.Add(item.Key, item.Value);
}
return result;
}
}
}

View File

@@ -147,6 +147,7 @@
<Compile Include="Composing\CompositionExtensions\Installer.cs" />
<Compile Include="Composing\LightInject\LightInjectContainer.cs" />
<Compile Include="Security\IdentityFactoryMiddleware.cs" />
<Compile Include="WebApi\Filters\OnlyLocalRequestsAttribute.cs" />
<Compile Include="WebAssets\CDF\ClientDependencyRuntimeMinifier.cs" />
<Compile Include="Models\NoNodesViewModel.cs" />
<Compile Include="Mvc\RenderNoContentController.cs" />
@@ -225,7 +226,6 @@
<Compile Include="UmbracoDbProviderFactoryCreator.cs" />
<Compile Include="ViewDataExtensions.cs" />
<Compile Include="WebApi\Filters\AdminUsersAuthorizeAttribute.cs" />
<Compile Include="WebApi\Filters\OnlyLocalRequestsAttribute.cs" />
<Compile Include="Runtime\WebInitialComposer.cs" />
<Compile Include="Security\ActiveDirectoryBackOfficeUserPasswordChecker.cs" />
<Compile Include="Security\BackOfficeUserPasswordCheckerResult.cs" />

View File

@@ -9,6 +9,7 @@ namespace Umbraco.Web.WebApi.Filters
/// <summary>
/// Appends a custom response header to notify the UI that the current user data has been modified
/// </summary>
/// Migrated to NET core
public sealed class AppendUserModifiedHeaderAttribute : ActionFilterAttribute
{
private readonly string _userIdParameter;

View File

@@ -14,6 +14,7 @@ namespace Umbraco.Web.WebApi.Filters
/// Just like you can POST an arbitrary number of parameters to an Action, you can't GET an arbitrary number
/// but this will allow you to do it
/// </remarks>
/// Migrated to .NET core
public sealed class HttpQueryStringModelBinder : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)

View File

@@ -6,6 +6,7 @@ using System.Web.Http.Filters;
namespace Umbraco.Web.WebApi.Filters
{
// Migrated to .NET Core
public class OnlyLocalRequestsAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)

View File

@@ -13,6 +13,7 @@ namespace Umbraco.Web.WebApi.Filters
/// An action filter used to do basic validation against the model and return a result
/// straight away if it fails.
/// </summary>
/// Migrated to .NET core
internal sealed class ValidationFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)