diff --git a/Dockerfile b/Dockerfile index cbcaac61c..c948abd84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ RUN dotnet restore COPY backend . # Test Backend -RUN dotnet test --no-restore --filter Category!=Dependencies +RUN dotnet test --no-restore --filter Category!=Dependencies -v n # Publish RUN dotnet publish --no-restore src/Squidex/Squidex.csproj --output /build/ --configuration Release -p:version=$SQUIDEX__VERSION diff --git a/backend/src/Squidex.Web/ApiExceptionConverter.cs b/backend/src/Squidex.Web/ApiExceptionConverter.cs index bf496b73e..a9fadd03f 100644 --- a/backend/src/Squidex.Web/ApiExceptionConverter.cs +++ b/backend/src/Squidex.Web/ApiExceptionConverter.cs @@ -34,11 +34,20 @@ namespace Squidex.Web [500] = "https://tools.ietf.org/html/rfc7231#section-6.6.1" }; + public static (ErrorDto Error, bool WellKnown) ToErrorDto(int statusCode, HttpContext? httpContext) + { + var error = new ErrorDto { StatusCode = statusCode }; + + Enrich(httpContext, error); + + return (error, true); + } + public static (ErrorDto Error, bool WellKnown) ToErrorDto(this ProblemDetails problem, HttpContext? httpContext) { Guard.NotNull(problem, nameof(problem)); - var error = new ErrorDto { Message = problem.Title, StatusCode = problem.Status }; + var error = CreateError(problem.Status ?? 500, problem.Title); Enrich(httpContext, error); @@ -60,10 +69,12 @@ namespace Squidex.Web { error.TraceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier; - if (error.StatusCode.HasValue) + if (error.StatusCode == 0) { - error.Type = Links.GetOrDefault(error.StatusCode.Value); + error.StatusCode = 500; } + + error.Type = Links.GetOrDefault(error.StatusCode); } private static (ErrorDto Error, bool WellKnown) CreateError(Exception exception) @@ -71,64 +82,38 @@ namespace Squidex.Web switch (exception) { case ValidationException ex: - return (new ErrorDto - { - StatusCode = 400, - Message = ex.Summary, - Details = ToDetails(ex) - }, true); + return (CreateError(400, ex.Summary, ToDetails(ex)), true); case DomainObjectNotFoundException _: - return (new ErrorDto - { - StatusCode = 404, - Message = null! - }, true); + return (CreateError(404), true); case DomainObjectVersionException _: - return (new ErrorDto - { - StatusCode = 412, - Message = exception.Message - }, true); + return (CreateError(412, exception.Message), true); case DomainForbiddenException _: - return (new ErrorDto - { - StatusCode = 403, - Message = exception.Message - }, true); + return (CreateError(403, exception.Message), true); case DomainException _: - return (new ErrorDto - { - StatusCode = 400, - Message = exception.Message - }, true); + return (CreateError(400, exception.Message), true); case SecurityException _: - return (new ErrorDto - { - StatusCode = 403, - Message = "Forbidden" - }, false); + return (CreateError(403), false); case DecoderFallbackException _: - return (new ErrorDto - { - StatusCode = 400, - Message = exception.Message - }, true); + return (CreateError(400, exception.Message), true); default: - return (new ErrorDto - { - StatusCode = 500, - Message = "Server Error" - }, false); + return (CreateError(500), false); } } + private static ErrorDto CreateError(int status, string? message = null, string[]? details = null) + { + var error = new ErrorDto { StatusCode = status, Message = message, Details = details }; + + return error; + } + private static string[] ToDetails(ValidationException ex) { return ex.Errors.Select(e => diff --git a/backend/src/Squidex.Web/ErrorDto.cs b/backend/src/Squidex.Web/ErrorDto.cs index fe3dfb892..e659674e3 100644 --- a/backend/src/Squidex.Web/ErrorDto.cs +++ b/backend/src/Squidex.Web/ErrorDto.cs @@ -13,7 +13,7 @@ namespace Squidex.Web { [Required] [Display(Description = "Error message.")] - public string Message { get; set; } + public string? Message { get; set; } [Display(Description = "The optional trace id.")] public string? TraceId { get; set; } @@ -25,6 +25,6 @@ namespace Squidex.Web public string[]? Details { get; set; } [Display(Description = "Status code of the http response.")] - public int? StatusCode { get; set; } = 400; + public int StatusCode { get; set; } = 400; } } diff --git a/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs b/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs index 2a4da249e..3a4abba81 100644 --- a/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs @@ -7,7 +7,12 @@ using System; using System.Threading.Tasks; +using Grpc.Core; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; @@ -15,27 +20,69 @@ namespace Squidex.Web.Pipeline { public sealed class RequestExceptionMiddleware : IMiddleware { + private static readonly ActionDescriptor EmptyActionDescriptor = new ActionDescriptor(); + private static readonly RouteData EmptyRouteData = new RouteData(); + private readonly IActionResultExecutor resultWriter; private readonly ISemanticLog log; - public RequestExceptionMiddleware(ISemanticLog log) + public RequestExceptionMiddleware(IActionResultExecutor resultWriter, ISemanticLog log) { + Guard.NotNull(resultWriter, nameof(resultWriter)); Guard.NotNull(log, nameof(log)); + this.resultWriter = resultWriter; + this.log = log; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + if (context.Request.Query.TryGetValue("error", out var header) && int.TryParse(header, out var statusCode) && IsErrorStatusCode(statusCode)) + { + var (error, _) = ApiExceptionConverter.ToErrorDto(statusCode, context); + + await WriteErrorAsync(context, error); + return; + } + try { await next(context); } catch (Exception ex) { - log.LogError(ex, w => w.WriteProperty("messag", "An unexpected exception has occurred.")); + log.LogError(ex, w => w.WriteProperty("message", "An unexpected exception has occurred.")); + + if (!context.Response.HasStarted) + { + var (error, _) = ex.ToErrorDto(context); + + await WriteErrorAsync(context, error); + } + } + + if (IsErrorStatusCode(context.Response.StatusCode) && !context.Response.HasStarted) + { + var (error, _) = ApiExceptionConverter.ToErrorDto(context.Response.StatusCode, context); - context.Response.StatusCode = 500; + await WriteErrorAsync(context, error); } } + + private async Task WriteErrorAsync(HttpContext context, ErrorDto error) + { + var actionRouteData = context.GetRouteData() ?? EmptyRouteData; + var actionContext = new ActionContext(context, actionRouteData, EmptyActionDescriptor); + + await resultWriter.ExecuteAsync(actionContext, new ObjectResult(error) + { + StatusCode = error.StatusCode + }); + } + + private static bool IsErrorStatusCode(int statusCode) + { + return statusCode >= 400 && statusCode < 600; + } } } diff --git a/backend/src/Squidex/wwwroot/scripts/editor-json-schema.html b/backend/src/Squidex/wwwroot/scripts/editor-json-schema.html new file mode 100644 index 000000000..281af4e8b --- /dev/null +++ b/backend/src/Squidex/wwwroot/scripts/editor-json-schema.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs index e24013483..fd46f42d0 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs @@ -14,6 +14,7 @@ using Xunit; namespace Squidex.Infrastructure.Orleans { + [Trait("Category", "Dependencies")] public class PubSubTests { [Fact] diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs new file mode 100644 index 000000000..4500ce80d --- /dev/null +++ b/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs @@ -0,0 +1,172 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Elasticsearch.Net; +using FakeItEasy; +using GraphQL; +using Grpc.Core.Logging; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Xunit; + +namespace Squidex.Web.Pipeline +{ + public class RequestExceptionMiddlewareTests + { + private readonly ISemanticLog log = A.Fake(); + private readonly IActionResultExecutor resultWriter = A.Fake>(); + private readonly IHttpResponseFeature responseFeature = A.Fake(); + private readonly HttpContext httpContext = new DefaultHttpContext(); + private readonly RequestDelegate next; + private readonly RequestExceptionMiddleware sut; + private bool isNextCalled; + + public RequestExceptionMiddlewareTests() + { + next = new RequestDelegate(context => + { + isNextCalled = true; + + return Task.CompletedTask; + }); + + httpContext.Features.Set(responseFeature); + + sut = new RequestExceptionMiddleware(resultWriter, log); + } + + [Fact] + public async Task Should_write_test_error_if_valid_status_code() + { + httpContext.Request.QueryString = new QueryString("?error=412"); + + await sut.InvokeAsync(httpContext, next); + + Assert.False(isNextCalled); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, + A.That.Matches(x => x.StatusCode == 412 && x.Value is ErrorDto))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_test_error_if_invalid_status_code() + { + httpContext.Request.QueryString = new QueryString("?error=hello"); + + await sut.InvokeAsync(httpContext, next); + + Assert.True(isNextCalled); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_test_error_if_invalid_error_status_code() + { + httpContext.Request.QueryString = new QueryString("?error=99"); + + await sut.InvokeAsync(httpContext, next); + + Assert.True(isNextCalled); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_handle_exception() + { + var failingNext = new RequestDelegate(context => + { + throw new InvalidOperationException(); + }); + + await sut.InvokeAsync(httpContext, failingNext); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, + A.That.Matches(x => x.StatusCode == 500 && x.Value is ErrorDto))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_log_exception() + { + var ex = new InvalidOperationException(); + + var failingNext = new RequestDelegate(context => + { + throw ex; + }); + + await sut.InvokeAsync(httpContext, failingNext); + + A.CallTo(() => log.Log(SemanticLogLevel.Error, ex, A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_handle_exception_if_response_body_written() + { + A.CallTo(() => responseFeature.HasStarted) + .Returns(true); + + var failingNext = new RequestDelegate(context => + { + throw new InvalidOperationException(); + }); + + await sut.InvokeAsync(httpContext, failingNext); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_handle_error_status_code() + { + var failingNext = new RequestDelegate(context => + { + context.Response.StatusCode = 412; + + return Task.CompletedTask; + }); + + await sut.InvokeAsync(httpContext, failingNext); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, + A.That.Matches(x => x.StatusCode == 412 && x.Value is ErrorDto))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_handle_error_status_code_if_response_body_written() + { + A.CallTo(() => responseFeature.HasStarted) + .Returns(true); + + var failingNext = new RequestDelegate(context => + { + context.Response.StatusCode = 412; + + return Task.CompletedTask; + }); + + await sut.InvokeAsync(httpContext, failingNext); + + A.CallTo(() => resultWriter.ExecuteAsync(A._, A._)) + .MustNotHaveHappened(); + } + } +}