diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs new file mode 100644 index 0000000..15fbe46 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs @@ -0,0 +1,180 @@ +using System.Net; +using System.Net.Http.Headers; +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 Capturing_streamed_response_body_does_not_consume_body_for_caller() + { + const int limitKb = 1; + var bodyText = new string('D', 2048); + var responseContent = new StreamContent(new NonSeekableMemoryStream(Encoding.UTF8.GetBytes(bodyText))); + responseContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain") + { + CharSet = Encoding.UTF8.WebName + }; + + var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb }; + var (client, entry) = BuildClient(options, responseContent); + + using var response = await client.GetAsync("https://example.test/"); + + Assert.Equal(bodyText, await response.Content.ReadAsStringAsync()); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.EndsWith("[truncated]", outgoing.ResponseBody); + } + + [Fact] + public async Task Captured_body_uses_content_type_charset() + { + const string bodyText = "Hello \u0100"; + var responseContent = new StringContent(bodyText, Encoding.Unicode, "text/plain"); + + var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = 1 }; + var (client, entry) = BuildClient(options, responseContent); + + using var response = await client.GetAsync("https://example.test/"); + + Assert.Equal(bodyText, await response.Content.ReadAsStringAsync()); + + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.Contains(bodyText, 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)); + } + + private sealed class NonSeekableMemoryStream(byte[] buffer) : MemoryStream(buffer) + { + public override bool CanSeek => false; + } +} diff --git a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs index b7f606e..812ea16 100644 --- a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs +++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs @@ -1,4 +1,6 @@ -using System.Diagnostics; +using System.Diagnostics; +using System.Net; +using System.Text; using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; @@ -33,13 +35,13 @@ protected override async Task SendAsync(HttpRequestMessage { var response = await base.SendAsync(request, cancellationToken); - await CaptureRequest(request, response, null, started.ElapsedMilliseconds); + await CaptureRequest(request, response, null, started.ElapsedMilliseconds, cancellationToken); return response; } catch (Exception ex) { - await CaptureRequest(request, null, ex, started.ElapsedMilliseconds); + await CaptureRequest(request, null, ex, started.ElapsedMilliseconds, cancellationToken); throw; } @@ -48,7 +50,12 @@ protected override async Task SendAsync(HttpRequestMessage /// /// Captures outgoing request details and stores them in the active DebugProbe entry. /// - private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long durationMs) + private async Task CaptureRequest( + HttpRequestMessage request, + HttpResponseMessage? response, + Exception? exception, + long durationMs, + CancellationToken cancellationToken) { if (!TryGetActiveEntry(out var entry)) { @@ -76,9 +83,9 @@ private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessag ResponseHeaders = response != null ? response.Headers.ToDictionary(x => x.Key, x => RedactionUtils.RedactHeader(x.Key, string.Join(", ", x.Value), _options)) : [] }; - outgoing.RequestBody = await CaptureBodyAsync(request.Content); + outgoing.RequestBody = (await CaptureBodyAsync(request.Content, cancellationToken)).Body; - outgoing.ResponseBody = await CaptureBodyAsync(response?.Content); + outgoing.ResponseBody = await CaptureResponseBodyAsync(response, cancellationToken); entry.OutgoingRequests.Add(outgoing); } @@ -100,7 +107,25 @@ private bool TryGetActiveEntry(out DebugEntry entry) return true; } - private async Task CaptureBodyAsync(HttpContent? content) + private async Task CaptureResponseBodyAsync(HttpResponseMessage? response, CancellationToken cancellationToken) + { + var content = response?.Content; + if (content == null) + { + return string.Empty; + } + + var result = await CaptureBodyAsync(content, cancellationToken); + + if (result.BytesRead.Length > 0) + { + response!.Content = new PrefixReplayHttpContent(content, result.Stream, result.BytesRead); + } + + return result.Body; + } + + private async Task CaptureBodyAsync(HttpContent? content, CancellationToken cancellationToken) { if (content == null || !HttpContentUtils.IsTextContent(content.Headers.ContentType?.MediaType)) @@ -108,10 +133,107 @@ 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(cancellationToken); + + int bytesRead; + while (totalRead < buffer.Length && + (bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)) > 0) + { + totalRead += bytesRead; + } + + var truncated = totalRead > limit; + var encoding = GetEncoding(content); + var body = encoding.GetString(buffer, 0, Math.Min(totalRead, limit)); + + if (truncated) + { + body += "[truncated]"; + } + + return new BodyCaptureResult( + JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options)), + stream, + buffer[..totalRead]); + } - return JsonUtils.Format(RedactionUtils.RedactJsonFields( - HttpContentUtils.Trim(body, _options.MaxBodyCaptureSizeBytes), - _options)); + private static Encoding GetEncoding(HttpContent content) + { + var charset = content.Headers.ContentType?.CharSet; + if (string.IsNullOrWhiteSpace(charset)) + { + return Encoding.UTF8; + } + + try + { + return Encoding.GetEncoding(charset.Trim('"')); + } + catch + { + return Encoding.UTF8; + } + } + + private readonly record struct BodyCaptureResult(string Body, Stream Stream, byte[] BytesRead) + { + public static implicit operator BodyCaptureResult(string body) => new(body, Stream.Null, []); + } + + private sealed class PrefixReplayHttpContent : HttpContent + { + private readonly HttpContent _innerContent; + + private readonly Stream _remainingStream; + + private readonly byte[] _prefix; + + public PrefixReplayHttpContent(HttpContent innerContent, Stream remainingStream, byte[] prefix) + { + _innerContent = innerContent; + _remainingStream = remainingStream; + _prefix = prefix; + + foreach (var header in innerContent.Headers) + { + Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await stream.WriteAsync(_prefix); + await _remainingStream.CopyToAsync(stream); + } + + protected override bool TryComputeLength(out long length) + { + if (_innerContent.Headers.ContentLength is { } contentLength) + { + length = contentLength; + return true; + } + + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _remainingStream.Dispose(); + _innerContent.Dispose(); + } + + base.Dispose(disposing); + } } }