diff --git a/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj b/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj index 44160cbd..673de83f 100644 --- a/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj +++ b/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj @@ -75,6 +75,8 @@ NLog 5 release post: https://nlog-project.org/2021/08/25/nlog-5-0-preview1-ready $(DefineConstants);ASP_NET_CORE;ASP_NET_CORE3 + + @@ -100,7 +102,7 @@ NLog 5 release post: https://nlog-project.org/2021/08/25/nlog-5-0-preview1-ready - + true \ diff --git a/src/NLog.Web.AspNetCore/NLogResponseBodyMiddleware.cs b/src/NLog.Web.AspNetCore/NLogResponseBodyMiddleware.cs new file mode 100644 index 00000000..c8797e52 --- /dev/null +++ b/src/NLog.Web.AspNetCore/NLogResponseBodyMiddleware.cs @@ -0,0 +1,169 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.IO; +using NLog.Common; +using NLog.Web.LayoutRenderers; + +namespace NLog.Web +{ + /// + /// This class is to intercept the HTTP pipeline and to allow additional logging of the following + /// + /// Response body + /// + /// The following are saved in the HttpContext.Items collection + /// + /// __nlog-aspnet-response-body + /// + /// Usage: app.UseMiddleware<NLogResponseBodyMiddleware>(); where app is an IApplicationBuilder + /// Register the NLogBodyMiddlewareOptions in the IoC so that the config gets passed to the constructor + /// Please use with caution, this will temporarily use 2x the memory for the response, so this may be + /// suitable only for responses < 64 KB + /// + public class NLogResponseBodyMiddleware + { + private readonly RequestDelegate _next; + + private readonly NLogResponseBodyMiddlewareOptions _options; + + // Using this instead of new MemoryStream() is important to the performance. + // According to the articles, this should be used as a static and not as an instance. + // This will manage a pool of MemoryStream instead of creating a new MemoryStream every response. + private static readonly RecyclableMemoryStreamManager Manager = new RecyclableMemoryStreamManager(); + + /// + /// Constructor that takes a configuration + /// + /// + /// + public NLogResponseBodyMiddleware(RequestDelegate next, NLogResponseBodyMiddlewareOptions options) + { + _next = next; + _options = options; + } + + /// + /// This allows interception of the HTTP pipeline for logging purposes + /// + /// + /// + public async Task Invoke(HttpContext context) + { + if (ShouldCaptureResponseBody(context)) + { + using (var memoryStream = Manager.GetStream()) + { + // Save away the true response stream + var originalStream = context.Response.Body; + + // Make the Http Context Response Body refer to the Memory Stream + context.Response.Body = memoryStream; + + // The Http Context Response then writes to the Memory Stream + await _next(context).ConfigureAwait(false); + + var responseBody = await GetString(memoryStream).ConfigureAwait(false); + + // Copy the contents of the memory stream back to the true response stream + await memoryStream.CopyToAsync(originalStream).ConfigureAwait(false); + + // This next line enables NLog to log the response + if (!string.IsNullOrEmpty(responseBody) && _options.ShouldRetainCapture(context)) + { + context.Items[AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey] = responseBody; + } + } + } + else + { + if (context != null) + { + await _next(context).ConfigureAwait(false); + } + } + } + + private bool ShouldCaptureResponseBody(HttpContext context) + { + // Perform null checking + if (context == null) + { + InternalLogger.Debug("NLogRequestPostedBodyMiddleware: HttpContext is null"); + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + return false; + } + + // Perform null checking + if (context.Response == null) + { + InternalLogger.Debug("NLogResponseBodyMiddleware: HttpContext.Response is null"); + return false; + } + + // Perform null checking + if (context.Response.Body == null) + { + InternalLogger.Debug("NLogResponseBodyMiddleware: HttpContext.Response.Body stream is null"); + return false; + } + + // If we cannot write the response stream we cannot capture the body + if (!context.Response.Body.CanWrite) + { + InternalLogger.Debug("NLogResponseBodyMiddleware: HttpContext.Response.Body stream is non-writeable"); + return false; + } + + // Use the predicate in the configuration instance that takes the HttpContext as an argument + if (!_options.ShouldCapture(context)) + { + InternalLogger.Debug("NLogResponseBodyMiddleware: _configuration.ShouldCapture(HttpContext) predicate returned false"); + return false; + } + + return true; + } + + /// + /// Convert the stream to a String for logging. + /// If the stream is binary please do not utilize this middleware + /// Arguably, logging a byte array in a sensible format is simply not possible. + /// + /// + /// The contents of the Stream read fully from start to end as a String + private async Task GetString(Stream stream) + { + // Save away the original stream position + var originalPosition = stream.Position; + + // This is required to reset the stream position to the beginning in order to properly read all of the stream. + stream.Position = 0; + + string responseText = null; + + // The last argument, leaveOpen, is set to true, so that the stream is not pre-maturely closed + // therefore preventing the next reader from reading the stream. + // The middle three arguments are from the configuration instance + // These default to UTF-8, true, and 1024. + using (var streamReader = new StreamReader( + stream, + Encoding.UTF8, + true, + 1024, + leaveOpen: true)) + { + // This is the most straight forward logic to read the entire body + responseText = await streamReader.ReadToEndAsync().ConfigureAwait(false); + } + + // This is required to reset the stream position to the original, in order to + // properly let the next reader process the stream from the original point + stream.Position = originalPosition; + + // Return the string of the body + return responseText; + } + } +} diff --git a/src/NLog.Web.AspNetCore/NLogResponseBodyMiddlewareOptions.cs b/src/NLog.Web.AspNetCore/NLogResponseBodyMiddlewareOptions.cs new file mode 100644 index 00000000..874826fb --- /dev/null +++ b/src/NLog.Web.AspNetCore/NLogResponseBodyMiddlewareOptions.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace NLog.Web +{ + /// + /// Contains the configuration for the NLogRequestPostedBodyMiddleware + /// + public class NLogResponseBodyMiddlewareOptions + { + /// + /// The default configuration + /// + internal static readonly NLogResponseBodyMiddlewareOptions Default = new NLogResponseBodyMiddlewareOptions(); + + /// + /// Default Constructor + /// + public NLogResponseBodyMiddlewareOptions() + { + ShouldRetainCapture = DefaultRetainCapture; + ShouldCapture = DefaultShouldCapture; + } + + /// + /// The maximum response size that will be captured + /// Defaults to 30KB + /// + public int MaximumResponseSize { get; set; } = 30 * 1024; + + /// + /// If this returns true, the response body will be captured + /// Defaults to true + /// This can be used to capture only certain content types, + /// only certain hosts, only below a certain request body size, and so forth + /// + /// + public Predicate ShouldCapture { get; set; } + + /// + /// The default predicate for ShouldCapture + /// Returns true + /// + private bool DefaultShouldCapture(HttpContext context) + { + return true; + } + + /// + /// Defaults to true if content length <= 30KB + /// + public Predicate ShouldRetainCapture { get; set; } + + /// + /// The default predicate for ShouldCapture + /// Returns true if content length <= 30KB + /// + private bool DefaultRetainCapture(HttpContext context) + { + return context?.Response?.ContentLength != null && context?.Response?.ContentLength <= MaximumResponseSize; + } + } +} diff --git a/src/Shared/Internal/HttpContextExtensions.cs b/src/Shared/Internal/HttpContextExtensions.cs index c90c8917..9abedc0a 100644 --- a/src/Shared/Internal/HttpContextExtensions.cs +++ b/src/Shared/Internal/HttpContextExtensions.cs @@ -62,42 +62,6 @@ internal static HttpResponse TryGetResponse(this HttpContext context) } #endif -#if ASP_NET_CORE2 - internal static string GetString(this ISession session, string key) - { - if (!session.TryGetValue(key, out var data)) - { - return null; - } - - if (data == null) - { - return null; - } - - if (data.Length == 0) - { - return string.Empty; - } - - return Encoding.UTF8.GetString(data); - } - - public static int? GetInt32(this ISession session, string key) - { - if (!session.TryGetValue(key, out var data)) - { - return null; - } - - if (data == null || data.Length < 4) - { - return null; - } - return data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]; - } -#endif - #if !ASP_NET_CORE internal static HttpSessionStateBase TryGetSession(this HttpContextBase context) { diff --git a/src/Shared/LayoutRenderers/AspNetLayoutMultiValueRendererBase.cs b/src/Shared/LayoutRenderers/AspNetLayoutMultiValueRendererBase.cs index 160ceefa..19a3ee04 100644 --- a/src/Shared/LayoutRenderers/AspNetLayoutMultiValueRendererBase.cs +++ b/src/Shared/LayoutRenderers/AspNetLayoutMultiValueRendererBase.cs @@ -170,7 +170,7 @@ private void SerializePairsFlat(IEnumerable> pairs, #endregion - + #region Singles /// @@ -193,7 +193,7 @@ protected void SerializeSingles(IEnumerable values, StringBuilder builde } } - private void SerializeSinglesJson(IEnumerable values, StringBuilder builder) + private static void SerializeSinglesJson(IEnumerable values, StringBuilder builder) { var firstItem = true; foreach (var item in values) diff --git a/src/Shared/LayoutRenderers/AspNetResponseBodyLayoutRenderer.cs b/src/Shared/LayoutRenderers/AspNetResponseBodyLayoutRenderer.cs new file mode 100644 index 00000000..c28c25b6 --- /dev/null +++ b/src/Shared/LayoutRenderers/AspNetResponseBodyLayoutRenderer.cs @@ -0,0 +1,58 @@ +using System.Text; +using NLog.LayoutRenderers; +#if ASP_NET_CORE +using Microsoft.AspNetCore.Http; +#else +using System.Web; +#endif +namespace NLog.Web.LayoutRenderers +{ + /// + /// ASP.NET response body + /// + [LayoutRenderer("aspnet-response-body")] + public class AspNetResponseBodyLayoutRenderer : AspNetLayoutRendererBase + { + + /// + /// The object for the key in HttpContext.Items for the response body + /// + internal static readonly object NLogResponseBodyKey = new object(); + + /// Renders the ASP.NET response body + /// The to append the rendered data to. + /// Logging event. + protected override void DoAppend(StringBuilder builder, LogEventInfo logEvent) + { + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext == null) + { + return; + } + + var items = httpContext.Items; + if (items == null) + { + return; + } + + if (httpContext.Items.Count == 0) + { + return; + } + +#if !ASP_NET_CORE + if (!items.Contains(NLogResponseBodyKey)) + { + return; + } +#else + if (!items.ContainsKey(NLogResponseBodyKey)) + { + return; + } +#endif + builder.Append(items[NLogResponseBodyKey] as string); + } + } +} diff --git a/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs b/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs new file mode 100644 index 00000000..66b3b628 --- /dev/null +++ b/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog.Config; +using NLog.LayoutRenderers; +using NLog.Web.Enums; +using NLog.Web.Internal; +#if !ASP_NET_CORE +using System.Collections.Specialized; +using System.Web; +using Cookies = System.Web.HttpCookieCollection; +#else +using Microsoft.Net.Http.Headers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +#endif + +namespace NLog.Web.LayoutRenderers +{ + /// + /// ASP.NET Response Cookie + /// + /// + /// Example usage of ${aspnet-response-cookie} + /// + /// ${aspnet-response-cookie:OutputFormat=Flat} + /// ${aspnet-response-cookie:OutputFormat=JsonArray} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary:CookieNames=username} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary:Exclude=access_token} + /// + /// + [LayoutRenderer("aspnet-response-cookie")] + public class AspNetResponseCookieLayoutRenderer : AspNetLayoutMultiValueRendererBase + { + /// + /// Cookie names to be rendered. + /// If null or empty array, all cookies will be rendered. + /// + public List CookieNames { get; set; } + + /// + /// Gets or sets the keys to exclude from the output. If omitted, none are excluded. + /// + /// +#if ASP_NET_CORE + public ISet Exclude { get; set; } +#else + public HashSet Exclude { get; set; } +#endif + + /// + /// Initializes a new instance of the class. + /// + public AspNetResponseCookieLayoutRenderer() + { + Exclude = new HashSet(new[] { "AUTH", "SESS_ID" }, StringComparer.OrdinalIgnoreCase); + } + +#if !ASP_NET_CORE + /// + /// Renders the ASP.NET Cookie appends it to the specified . + /// + /// The to append the rendered data to. + /// Logging event. + protected override void DoAppend(StringBuilder builder, LogEventInfo logEvent) + { + var httpResponse = HttpContextAccessor.HttpContext.TryGetResponse(); + if (httpResponse == null) + { + return; + } + + var cookies = httpResponse.Cookies; + + if (cookies?.Count > 0) + { + bool checkForExclude = (CookieNames == null || CookieNames.Count == 0) && Exclude?.Count > 0; + var cookieValues = GetCookieValues(cookies, checkForExclude); + SerializePairs(cookieValues, builder, logEvent); + } + } + + private IEnumerable> GetCookieValues(HttpCookieCollection cookies, bool checkForExclude) + { + var cookieNames = GetCookieNames(cookies); + foreach (var cookieName in cookieNames) + { + if (checkForExclude && Exclude.Contains(cookieName)) + continue; + + var httpCookie = cookies[cookieName]; + if (httpCookie == null) + { + continue; + } + + if (OutputFormat != AspNetRequestLayoutOutputFormat.Flat) + { + // Split multi-valued cookie, as allowed for in the HttpCookie API for backwards compatibility with classic ASP + var isFirst = true; + foreach (var multiValueKey in httpCookie.Values.AllKeys) + { + var cookieKey = multiValueKey; + if (isFirst) + { + cookieKey = cookieName; + isFirst = false; + } + yield return new KeyValuePair(cookieKey, httpCookie.Values[multiValueKey]); + } + } + else + { + yield return new KeyValuePair(cookieName, httpCookie.Value); + } + } + } + + private List GetCookieNames(HttpCookieCollection cookies) + { + return CookieNames?.Count > 0 ? CookieNames : cookies.Keys.Cast().ToList(); + } +#else + /// + /// Renders the ASP.NET Cookie appends it to the specified . + /// + /// The to append the rendered data to. + /// Logging event. + protected override void DoAppend(StringBuilder builder, LogEventInfo logEvent) + { + var httpResponse = HttpContextAccessor.HttpContext.TryGetResponse(); + if (httpResponse == null) + { + return; + } + + IList cookies = httpResponse.GetTypedHeaders().SetCookie; + + if (cookies?.Count > 0) + { + bool checkForExclude = (CookieNames == null || CookieNames.Count == 0) && Exclude?.Count > 0; + var cookieValues = GetCookieValues(cookies, checkForExclude); + SerializePairs(cookieValues, builder, logEvent); + } + } + + private List GetCookieNames(IList cookies) + { + if (CookieNames?.Count > 0) + { + return CookieNames; + } + + var response = new List(); + + foreach (var cookie in cookies) + { + response.Add(cookie.Name.ToString()); + } + + return response; + } + + private IEnumerable> GetCookieValues(IList cookies, bool checkForExclude) + { + var cookieNames = GetCookieNames(cookies); + foreach (var cookieName in cookieNames) + { + if (checkForExclude && Exclude.Contains(cookieName)) + continue; + + var httpCookie = cookies.SingleOrDefault(cookie => cookie.Name.ToString() == cookieName); + if (httpCookie == null) + { + continue; + } + + yield return new KeyValuePair(cookieName, httpCookie.Value.ToString()); + } + } +#endif + } +} \ No newline at end of file diff --git a/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareOptionsTests.cs b/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareOptionsTests.cs new file mode 100644 index 00000000..d00661ff --- /dev/null +++ b/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareOptionsTests.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace NLog.Web.Tests +{ + public class NLogResponseBodyMiddlewareOptionsTests + { + [Fact] + public void SetMaximumRequestSizeTest() + { + var config = new NLogResponseBodyMiddlewareOptions(); + var size = new Random().Next(); + config.MaximumResponseSize = size; + + Assert.Equal(size, config.MaximumResponseSize); + } + + [Fact] + public void GetDefault() + { + var config = NLogResponseBodyMiddlewareOptions.Default; + + Assert.NotNull(config); + } + + [Fact] + public void DefaultCaptureTrue() + { + var config = NLogResponseBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpResponse response = Substitute.For(); + + response.ContentLength.Returns(NLogResponseBodyMiddlewareOptions.Default.MaximumResponseSize - 1); + + httpContext.Response.Returns(response); + + Assert.True(config.ShouldRetainCapture(httpContext)); + } + + [Fact] + public void DefaultCaptureFalseNullContentLength() + { + var config = NLogResponseBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpResponse response = Substitute.For(); + + response.ContentLength.Returns((long?)null); + + httpContext.Response.Returns(response); + + Assert.False(config.ShouldRetainCapture(httpContext)); + } + + [Fact] + public void DefaultCaptureExcessiveContentLength() + { + var config = NLogResponseBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpResponse response = Substitute.For(); + + response.ContentLength.Returns(NLogResponseBodyMiddlewareOptions.Default.MaximumResponseSize + 1); + + httpContext.Response.Returns(response); + + Assert.False(config.ShouldRetainCapture(httpContext)); + } + } +} diff --git a/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareTests.cs b/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareTests.cs new file mode 100644 index 00000000..2d03f965 --- /dev/null +++ b/tests/NLog.Web.AspNetCore.Tests/NLogResponseBodyMiddlewareTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog.Web.LayoutRenderers; +using NSubstitute; +using Xunit; + +namespace NLog.Web.Tests +{ + public class NLogResponseBodyMiddlewareTests + { + /// + /// This acts as a parameter for the RequestDelegate parameter for the middleware InvokeAsync method + /// + /// + /// + private Task Next(HttpContext context) + { + byte[] bodyBytes = Encoding.UTF8.GetBytes("This is a test response body"); + context.Response.Body.Write(bodyBytes, 0, bodyBytes.Length); + return Task.CompletedTask; + } + + /// + /// This acts as a parameter for the RequestDelegate parameter for the middleware InvokeAsync method + /// + /// + /// + private Task NextNone(HttpContext context) + { + return Task.CompletedTask; + } + + [Fact] + public void SuccessTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Response.Body = new MemoryStream(); + defaultContext.Response.ContentLength = "This is a test response body".Length; + + // Act + var middlewareInstance = new NLogResponseBodyMiddleware(Next,NLogResponseBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Single(defaultContext.Items); + Assert.NotNull(defaultContext.Items[AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey]); + Assert.True(defaultContext.Items[AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey] is string); + Assert.Equal("This is a test response body", defaultContext.Items[AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey] as string); + } + + [Fact] + public void NullContextTest() + { + // Arrange + HttpContext defaultContext = null; + + // Act + var middlewareInstance = new NLogResponseBodyMiddleware(Next, NLogResponseBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + // Assert that we got to this point without NullReferenceException + Assert.True(true); + } + + [Fact] + public void EmptyBodyTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Response.Body = new MemoryStream(); + + // Act + var middlewareInstance = new NLogResponseBodyMiddleware(NextNone,NLogResponseBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void NullResponseTest() + { + // Arrange + HttpContext defaultContext = Substitute.For(); + defaultContext.Response.Body.Returns((Stream) null); + + var middlewareInstance = new NLogResponseBodyMiddleware(NextNone,NLogResponseBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + } +} diff --git a/tests/Shared/LayoutRenderers/AspNetResponseBodyLayoutRendererTests.cs b/tests/Shared/LayoutRenderers/AspNetResponseBodyLayoutRendererTests.cs new file mode 100644 index 00000000..2deb1db4 --- /dev/null +++ b/tests/Shared/LayoutRenderers/AspNetResponseBodyLayoutRendererTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +#if ASP_NET_CORE +using Microsoft.AspNetCore.Http; +#else +using System.Web; +#endif +using NLog.Web.LayoutRenderers; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace NLog.Web.Tests.LayoutRenderers +{ + public class AspNetResponseBodyLayoutRendererTests : LayoutRenderersTestBase + { + [Fact] + public void RequestPostedBodyPresentRenderNonEmptyString() + { + // Arrange + var (renderer, httpContext) = CreateWithHttpContext(); + string expected = "This is a test of the response body layout renderer."; + var items = new Dictionary(); + items.Add(AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey, expected); + httpContext.Items.Returns(items); + // Act + var result = renderer.Render(new LogEventInfo()); + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void NullItemsRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.ReturnsNull(); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void EmptyItemsRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary()); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NonEmptyItemsWithoutResponseBodyRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary + { + {AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey + "X","Not the Response Body Value"} + }); + + string result = renderer.Render(new LogEventInfo()); + + Assert.NotEmpty(httpContext.Items); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NotStringTypeRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary + { + {AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey, 42} + }); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NullHttpContextRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + renderer.HttpContextAccessor = Substitute.For(); + renderer.HttpContextAccessor.HttpContext.ReturnsNull(); + + string expected = "This is a test of the response body layout renderer."; + var items = new Dictionary {{ AspNetResponseBodyLayoutRenderer.NLogResponseBodyKey, expected}}; + httpContext.Items.Returns(items); + + // Act + var result = renderer.Render(new LogEventInfo()); + + // Assert + Assert.Equal(string.Empty, result); + } + } +} diff --git a/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs b/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs new file mode 100644 index 00000000..49a9b938 --- /dev/null +++ b/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog.Web.LayoutRenderers; +using NLog.Web.Enums; +using Xunit; +#if !ASP_NET_CORE +using NSubstitute; +using System.Web; +#else +using Microsoft.Extensions.Primitives; +#endif + +namespace NLog.Web.Tests.LayoutRenderers +{ + public class AspNetResponseCookieLayoutRendererTests : TestInvolvingAspNetHttpContext + { + [Fact] + public void NullKeyRendersAllCookies() + { + var expectedResult = "key=TEST,Key1=TEST1"; + var renderer = CreateRenderer(); + renderer.CookieNames = null; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void NullKeyRendersAllCookiesExceptExcluded() + { + var expectedResult = "Key1=TEST1"; + var renderer = CreateRenderer(); + renderer.CookieNames = null; + renderer.Exclude.Add("key"); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Flat_Formatting() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.Flat; + renderer.CookieNames = new List { "notfound" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Json_Formatting() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + renderer.CookieNames = new List { "notfound" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting() + { + var expectedResult = "key=TEST,Key1=TEST1"; + + var renderer = CreateRenderer(); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting_separators() + { + var expectedResult = "key:TEST|Key1:TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = ":"; + renderer.ItemSeparator = "|"; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting_separators_layouts() + { + var expectedResult = "key>TEST" + Environment.NewLine + "Key1>TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = "${event-properties:valueSeparator1}"; + renderer.ItemSeparator = "${newline}"; + + var logEventInfo = new LogEventInfo(); + logEventInfo.Properties["valueSeparator1"] = ">"; + + // Act + string result = renderer.Render(logEventInfo); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Flat_Formatting() + { + var expectedResult = "key=TEST"; + + var renderer = CreateRenderer(addSecondCookie: false); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"}]"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Json_Formatting_no_array() + { + var expectedResult = "{\"key\":\"TEST\"}"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonDictionary; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"}]"; + + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Json_Formatting_no_array() + { + var expectedResult = "{\"key\":\"TEST\",\"Key1\":\"TEST1\"}"; + + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonDictionary; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Flat_Formatting_ValuesOnly() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.Flat; + renderer.CookieNames = new List { "notfound" }; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Json_Formatting_ValuesOnly() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + renderer.CookieNames = new List { "notfound" }; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyFoundRendersValue_Cookie_Multiple_Items_Flat_Formatting_ValuesOnly() + { + var expectedResult = "TEST,TEST1"; + + var renderer = CreateRenderer(); + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Cookie_Multiple_Items_Flat_Formatting_separators_ValuesOnly() + { + var expectedResult = "TEST|TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = ":"; + renderer.ItemSeparator = "|"; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Item_Flat_Formatting_ValuesOnly() + { + var expectedResult = "TEST"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(AspNetRequestLayoutOutputFormat.JsonArray)] + [InlineData(AspNetRequestLayoutOutputFormat.JsonDictionary)] + public void KeyFoundRendersValue_Single_Item_JsonArray_Formatting_ValuesOnly(AspNetRequestLayoutOutputFormat outputFormat) + { + var expectedResult = "[\"TEST\"]"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = outputFormat; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(AspNetRequestLayoutOutputFormat.JsonArray)] + [InlineData(AspNetRequestLayoutOutputFormat.JsonDictionary)] + public void KeyFoundRendersValue_Multiple_Items_Json_Formatting_ValuesOnly(AspNetRequestLayoutOutputFormat outputFormat) + { + var expectedResult = "[\"TEST\",\"TEST1\"]"; + + var renderer = CreateRenderer(); + + renderer.OutputFormat = outputFormat; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + //no multivalue cookie keys in ASP.NET core +#if !ASP_NET_CORE + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_And_Cookie_Values_Flat_Formatting() + { + var expectedResult = "key=TEST,key2=Test&key3=Test456"; + + var renderer = CreateRenderer(addSecondCookie: true, addMultiValueCookieKey: true); + renderer.CookieNames = new List { "key", "key2" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_And_Cookie_Values_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"},{\"key2\":\"Test\"},{\"key3\":\"Test456\"}]"; + var renderer = CreateRenderer(addSecondCookie: true, addMultiValueCookieKey: true); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } +#endif + +#if !ASP_NET_CORE //todo + + [Fact] + public void CommaSeperatedCookieNamesTest_Multiple_Cookie_Values_Flat_Formatting() + { + // Arrange + var expectedResult = "key=TEST&Key1=TEST1"; + + var cookie = new HttpCookie("key", "TEST") { ["Key1"] = "TEST1" }; + + var layoutRender = new AspNetRequestCookieLayoutRenderer() + { + CookieNames = new List { "key", "key1" } + }; + + var httpContextAccessorMock = CreateHttpContextAccessorMockWithCookie(cookie); + layoutRender.HttpContextAccessor = httpContextAccessorMock; + + // Act + var result = layoutRender.Render(LogEventInfo.CreateNullEvent()); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public void CommaSeperatedCookieNamesTest_Multiple_Cookie_Values_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"}]"; + + var cookie = new HttpCookie("key", "TEST") { ["Key1"] = "TEST1" }; + + var layoutRender = new AspNetRequestCookieLayoutRenderer() + { + CookieNames = new List { "key", "key1" }, + OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray + }; + + var httpContextAccessorMock = CreateHttpContextAccessorMockWithCookie(cookie); + layoutRender.HttpContextAccessor = httpContextAccessorMock; + + var result = layoutRender.Render(LogEventInfo.CreateNullEvent()); + + Assert.Equal(expectedResult, result); + } + + private static IHttpContextAccessor CreateHttpContextAccessorMockWithCookie(HttpCookie cookie) + { + var httpCookieCollection = new HttpCookieCollection { cookie }; + var httpContextAccessorMock = Substitute.For(); + httpContextAccessorMock.HttpContext.Request.Cookies.Returns(httpCookieCollection); + return httpContextAccessorMock; + } + +#endif + + /// + /// Create cookie renderer with mocked HTTP context + /// + /// Add second cookie + /// Make cookie multi-value by adding a second value to it + /// Created cookie layout renderer + /// + /// The parameter allows creation of multi-valued cookies with the same key, + /// as provided in the HttpCookie API for backwards compatibility with classic ASP. + /// This is not supported in ASP.NET Core. For further details, see: + /// https://docs.microsoft.com/en-us/dotnet/api/system.web.httpcookie.item?view=netframework-4.7.1#System_Web_HttpCookie_Item_System_String_ + /// https://stackoverflow.com/a/43831482/6651 + /// https://github.com/aspnet/HttpAbstractions/issues/831 + /// + private AspNetResponseCookieLayoutRenderer CreateRenderer(bool addSecondCookie = true, bool addMultiValueCookieKey = false) + { + var cookieNames = new List(); +#if ASP_NET_CORE + var httpContext = HttpContext; +#else + var httpContext = Substitute.For(); +#endif + +#if ASP_NET_CORE + void AddCookie(string key, string value) + { + cookieNames.Add(key); + + httpContext.Response.Cookies.Append(key,value); + } + + AddCookie("key", "TEST"); + + if (addSecondCookie) + { + AddCookie("Key1", "TEST1"); + } + + if (addMultiValueCookieKey) + { + throw new NotSupportedException("Multi-valued cookie keys are not supported in ASP.NET Core"); + } + +#else + + var cookie1 = new HttpCookie("key", "TEST"); + var cookies = new HttpCookieCollection { cookie1 }; + cookieNames.Add("key"); + + if (addSecondCookie) + { + var cookie2 = new HttpCookie("Key1", "TEST1"); + cookies.Add(cookie2); + cookieNames.Add("Key1"); + } + + if (addMultiValueCookieKey) + { + var multiValueCookie = new HttpCookie("key2", "Test"); + multiValueCookie["key3"] = "Test456"; + cookies.Add(multiValueCookie); + cookieNames.Add("key2"); + } + + httpContext.Response.Cookies.Returns(cookies); +#endif + + var renderer = new AspNetResponseCookieLayoutRenderer(); + renderer.HttpContextAccessor = new FakeHttpContextAccessor(httpContext); + renderer.CookieNames = cookieNames; + return renderer; + } + } +}