diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs new file mode 100644 index 0000000..65b1437 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text; +using DebugProbe.AspNetCore.Handlers; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; + +namespace DebugProbe.AspNetCore.Tests.Handlers; + +public class DebugProbeHttpClientHandlerBodyTests +{ + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// + /// Builds a handler wired to the given DebugEntry and options, with a stub + /// inner handler that echoes back. + /// + private static (HttpClient client, DebugEntry entry) BuildClient( + DebugProbeOptions options, + HttpContent? responseContent = null) + { + var entry = new DebugEntry(); + var context = new DefaultHttpContext(); + context.Items["DebugProbeEntry"] = entry; + + var handler = new DebugProbeHttpClientHandler( + new HttpContextAccessor { HttpContext = context }, + options) + { + InnerHandler = new StubHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = responseContent + }) + }; + + return (new HttpClient(handler), entry); + } + + // --------------------------------------------------------------------------- + // Test cases + // --------------------------------------------------------------------------- + + [Fact] + public async Task Body_under_limit_is_captured_without_truncation_marker() + { + const int limitKb = 1; // 1 KB limit + var bodyText = new string('A', 100); // 100 bytes — well under 1 KB + + var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb }; + var (client, entry) = BuildClient(options, + responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); + + await client.GetAsync("https://example.test/"); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.DoesNotContain("[truncated]", outgoing.ResponseBody); + Assert.Contains(bodyText, outgoing.ResponseBody); + } + + [Fact] + public async Task Body_exactly_at_limit_is_captured_without_truncation_marker() + { + const int limitKb = 1; + var bodyText = new string('B', 1024); // exactly 1 KB + + var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb }; + var (client, entry) = BuildClient(options, + responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); + + await client.GetAsync("https://example.test/"); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.DoesNotContain("[truncated]", outgoing.ResponseBody); + Assert.Contains(bodyText, outgoing.ResponseBody); + } + + [Fact] + public async Task Body_over_limit_is_truncated_and_marker_is_appended() + { + const int limitKb = 1; + var bodyText = new string('C', 2048); // 2 KB — double the limit + + var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb }; + var (client, entry) = BuildClient(options, + responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); + + await client.GetAsync("https://example.test/"); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.EndsWith("[truncated]", outgoing.ResponseBody); + // Captured prefix must be exactly the limit (1024 'C' chars) + Assert.StartsWith(new string('C', 1024), outgoing.ResponseBody); + } + + [Fact] + public async Task Null_content_returns_empty_string() + { + var options = new DebugProbeOptions(); + var (client, entry) = BuildClient(options, responseContent: null); + + await client.GetAsync("https://example.test/"); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.Equal(string.Empty, outgoing.ResponseBody); + } + + [Fact] + public async Task Non_text_content_returns_empty_string() + { + var binaryContent = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]); // PNG header bytes + binaryContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + + var options = new DebugProbeOptions(); + var (client, entry) = BuildClient(options, responseContent: binaryContent); + + await client.GetAsync("https://example.test/"); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.Equal(string.Empty, outgoing.ResponseBody); + } + + // --------------------------------------------------------------------------- + // Stub inner handler + // --------------------------------------------------------------------------- + + private sealed class StubHandler(Func send) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(send(request)); + } +} diff --git a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs index b7f606e..a0fb6a3 100644 --- a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs +++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; @@ -108,10 +108,31 @@ private async Task CaptureBodyAsync(HttpContent? content) return string.Empty; } - var body = await content.ReadAsStringAsync(); + if (_options.MaxBodyCaptureSizeBytes <= 0) + return string.Empty; + + var limit = _options.MaxBodyCaptureSizeBytes; + var buffer = new byte[limit + 1]; + var totalRead = 0; + + var stream = await content.ReadAsStreamAsync(); + + int bytesRead; + while (totalRead < buffer.Length && + (bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead))) > 0) + { + totalRead += bytesRead; + } + + var truncated = totalRead > limit; + var encoding = System.Text.Encoding.UTF8; + var body = encoding.GetString(buffer, 0, Math.Min(totalRead, limit)); + + if (truncated) + { + body += "[truncated]"; + } - return JsonUtils.Format(RedactionUtils.RedactJsonFields( - HttpContentUtils.Trim(body, _options.MaxBodyCaptureSizeBytes), - _options)); + return JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options)); } }