Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.0.x
dotnet-version: 10.0.x

- name: Restore
run: dotnet restore DebugProbe.AspNetCore.sln
Expand Down
15 changes: 12 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Thank you for your interest in contributing to DebugProbe.AspNetCore.

## Getting Started

Clone the repository and open the solution:
co
Install the .NET 10 SDK, then clone the repository and open the solution:

```bash
git clone https://github.com/DebugProbe/DebugProbe.AspNetCore.git
```
Expand All @@ -26,7 +26,16 @@ Use the sample application for local development and testing:
DebugProbe.SampleApi/
```

Run the sample API project to test DebugProbe functionality and UI behavior during development.
Run the sample API project to test DebugProbe functionality and UI behavior during development:

```bash
dotnet restore DebugProbe.AspNetCore.sln
dotnet build DebugProbe.AspNetCore.sln --no-restore --configuration Release
dotnet test DebugProbe.AspNetCore.sln --no-build --configuration Release
dotnet run --project DebugProbe.SampleApi
```

Tests use xUnit v3 with VSTest; the existing `dotnet test` workflow remains supported.

## Branch Naming

Expand Down
118 changes: 118 additions & 0 deletions DebugProbe.AspNetCore.Tests/BodyCaptureConsistencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using DebugProbe.AspNetCore.Handlers;
using DebugProbe.AspNetCore.Middleware;
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;
using DebugProbe.AspNetCore.Storage;

namespace DebugProbe.AspNetCore.Tests;

public class BodyCaptureConsistencyTests
{
[Theory]
[InlineData("empty")]
[InlineData("under")]
[InlineData("exact")]
[InlineData("over")]
[InlineData("utf8-boundary")]
[InlineData("utf16-boundary")]
[InlineData("charset")]
[InlineData("invalid-charset")]
[InlineData("unsupported-charset")]
[InlineData("binary")]
[InlineData("redacted")]
[InlineData("truncated-redacted")]
public async Task All_four_capture_paths_use_the_same_rules_and_preserve_the_payload(string scenario)
{
var text = scenario switch
{
"empty" => "",
"exact" => new string('a', 1024),
"over" => new string('a', 1500),
"utf8-boundary" => new string('a', 1023) + "€tail",
"utf16-boundary" => new string('a', 511) + "😀tail",
"charset" => "café",
"redacted" => "{\"password\":\"secret\",\"name\":\"Ada\"}",
"truncated-redacted" => "{\"password\":\"secret\",\"padding\":\"" + new string('a', 1500) + "\"}",
_ => "hello"
};
var encoding = scenario == "charset" ? Encoding.Latin1 : scenario == "utf16-boundary" ? Encoding.Unicode : Encoding.UTF8;
var contentType = scenario switch
{
"charset" => "text/plain; charset=iso-8859-1",
"utf16-boundary" => "text/plain; charset=utf-16",
"invalid-charset" => "text/plain; charset=not-a-real-charset",
"unsupported-charset" => "text/plain; charset=utf-7",
"binary" => "application/octet-stream",
"redacted" or "truncated-redacted" => "application/json; charset=utf-8",
_ => "text/plain; charset=utf-8"
};
var expected = scenario switch
{
"over" => new string('a', 1024) + "[truncated]",
"utf8-boundary" => new string('a', 1023) + "[truncated]",
"utf16-boundary" => new string('a', 511) + "[truncated]",
"binary" => "[Body not captured: non-text content]",
"redacted" => "{\"password\":\"[REDACTED]\",\"name\":\"Ada\"}",
"truncated-redacted" => "[Body not captured: truncated content cannot be redacted]",
_ => text
};
var bytes = encoding.GetBytes(text);
var options = new DebugProbeOptions
{
MaxBodySizeKb = 1,
RedactedJsonFields = scenario.Contains("redacted") ? ["password"] : []
};
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/echo";
context.Request.ContentType = contentType;
// No Content-Length: the limit must also work for chunked requests.
context.Request.Body = new MemoryStream(bytes);
context.Response.Body = new MemoryStream();
context.Response.ContentType = contentType;
var store = new DebugEntryStore(options);
var middleware = new DebugProbeMiddleware(async ctx =>
{
using var received = new MemoryStream();
await ctx.Request.Body.CopyToAsync(received);
Assert.Equal(bytes, received.ToArray());
await ctx.Response.Body.WriteAsync(bytes);
}, options);

await middleware.Invoke(context, store);
var incoming = Assert.Single(store.GetAll());
Assert.Equal(expected, incoming.RequestBody);
Assert.Equal(expected, incoming.ResponseBody);
Assert.Equal(bytes, ((MemoryStream)context.Response.Body).ToArray());

var entry = new DebugEntry();
context.Items["DebugProbeEntry"] = entry;
using var client = new HttpClient(new DebugProbeHttpClientHandler(new HttpContextAccessor { HttpContext = context }, options)
{
InnerHandler = new EchoHandler()
});
using var content = new ByteArrayContent(bytes);
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
using var response = await client.PostAsync("https://example.test/echo", content);
Assert.Equal(bytes, await response.Content.ReadAsByteArrayAsync());
var outgoing = Assert.Single(entry.OutgoingRequests);
Assert.Equal(expected, outgoing.RequestBody);
Assert.Equal(expected, outgoing.ResponseBody);
}

private sealed class EchoHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(await request.Content!.ReadAsByteArrayAsync(cancellationToken))
};
response.Content.Headers.ContentType = request.Content.Headers.ContentType;
return response;
}
}
}
209 changes: 209 additions & 0 deletions DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using DebugProbe.AspNetCore.Handlers;
using DebugProbe.AspNetCore.Internal.Streams;
using DebugProbe.AspNetCore.Middleware;
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;
using DebugProbe.AspNetCore.Storage;

namespace DebugProbe.AspNetCore.Tests;

public class CaptureFailureIsolationTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Incoming_capture_read_failure_preserves_body_and_runs_application_once(bool seekable)
{
var context = CreateContext();
context.Request.Method = "POST";
context.Request.ContentType = "text/plain";
context.Request.Body = new FailOnceStream(seekable);
var calls = 0;
var middleware = new DebugProbeMiddleware(async ctx =>
{
calls++;
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
Assert.Equal("hello", await reader.ReadToEndAsync());
await ctx.Response.WriteAsync("ok");
}, new DebugProbeOptions());

await middleware.Invoke(context, new DebugEntryStore(new DebugProbeOptions()));

Assert.Equal(1, calls);
Assert.Equal("ok", Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()));
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Finalization_failure_preserves_response_or_original_application_exception(bool failApplication)
{
var context = CreateContext();
var originalBody = context.Response.Body;
var options = new DebugProbeOptions();
var store = new DebugEntryStore(options);
var originalException = new InvalidOperationException("application failed");
var calls = 0;
var middleware = new DebugProbeMiddleware(async ctx =>
{
calls++;
await ctx.Response.WriteAsync("original response");
// Simulate a diagnostics-only failure while formatting captured headers.
ctx.Request.Headers["X-Test"] = "value";
options.RedactedHeaders = null!;
if (failApplication)
throw originalException;
}, options);

if (failApplication)
Assert.Same(originalException, await Assert.ThrowsAsync<InvalidOperationException>(() => middleware.Invoke(context, store)));
else
await middleware.Invoke(context, store);

Assert.Equal(1, calls);
Assert.Same(originalBody, context.Response.Body);
Assert.Equal("original response", Encoding.UTF8.GetString(((MemoryStream)originalBody).ToArray()));
Assert.False(context.Items.ContainsKey("DebugProbeEntry"));
Assert.Empty(store.GetAll());
}

[Fact]
public async Task Exception_formatting_failure_does_not_replace_application_exception()
{
var original = new UnformattableException();
var context = CreateContext();
var originalBody = context.Response.Body;
var middleware = new DebugProbeMiddleware(_ => throw original, new DebugProbeOptions());

Assert.Same(original, await Assert.ThrowsAsync<UnformattableException>(() =>
middleware.Invoke(context, new DebugEntryStore(new DebugProbeOptions()))));
Assert.Same(originalBody, context.Response.Body);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Outgoing_partial_capture_failure_replays_all_response_bytes(bool seekable)
{
var context = CreateContext();
var entry = new DebugEntry();
context.Items["DebugProbeEntry"] = entry;
var response = new HttpResponseMessage(HttpStatusCode.Accepted)
{
Content = new StreamContent(new FailOnceStream(seekable))
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
response.Headers.Add("X-Original", "yes");
var transport = new StubHandler(_ => Task.FromResult(response));
using var client = CreateClient(context, transport);

using var result = await client.GetAsync("https://example.test", HttpCompletionOption.ResponseHeadersRead);

Check warning on line 103 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Assert.Same(response, result);
Assert.Equal(HttpStatusCode.Accepted, result.StatusCode);
Assert.Equal("yes", Assert.Single(result.Headers.GetValues("X-Original")));
Assert.Equal("hello", await result.Content.ReadAsStringAsync());

Check warning on line 108 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
Assert.Equal(1, transport.Calls);
Assert.Empty(entry.OutgoingRequests);
}

[Fact]
public async Task Outgoing_capture_failure_does_not_replace_transport_exception()
{
var original = new UnformattableException();
var context = CreateContext();
context.Items["DebugProbeEntry"] = new DebugEntry();
var transport = new StubHandler(_ => throw original);
using var client = CreateClient(context, transport);

Assert.Same(original, await Assert.ThrowsAsync<UnformattableException>(() => client.GetAsync("https://example.test")));

Check warning on line 122 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
Assert.Equal(1, transport.Calls);
}

[Fact]
public async Task Transport_cancellation_is_not_swallowed()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var context = CreateContext();
context.Items["DebugProbeEntry"] = new DebugEntry();
var transport = new StubHandler(_ => Task.FromCanceled<HttpResponseMessage>(cancellation.Token));
using var client = CreateClient(context, transport);

await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.GetAsync("https://example.test"));

Check warning on line 136 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
Assert.Equal(1, transport.Calls);
}

[Fact]
public async Task Diagnostic_copy_failure_does_not_block_response_writes()
{
using var original = new MemoryStream();
var capture = new BoundedResponseCaptureStream(original, 32);
capture.Dispose(); // Make the diagnostic memory buffer unavailable.

await capture.WriteAsync("hello"u8.ToArray());

Check warning on line 147 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Assert.Equal("hello", Encoding.UTF8.GetString(original.ToArray()));
}

[Fact]
public async Task Application_response_stream_errors_are_not_swallowed()
{
using var original = new MemoryStream();
using var capture = new BoundedResponseCaptureStream(original, 32);
original.Dispose();

await Assert.ThrowsAsync<ObjectDisposedException>(async () => await capture.WriteAsync("hello"u8.ToArray()));

Check warning on line 159 in DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
}

private static DefaultHttpContext CreateContext()
{
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Path = "/test";
context.Request.Scheme = "http";
context.Request.Host = new HostString("example.test");
context.Response.ContentType = "text/plain";
context.Response.Body = new MemoryStream();
return context;
}

private static HttpClient CreateClient(HttpContext context, HttpMessageHandler transport) => new(
new DebugProbeHttpClientHandler(new HttpContextAccessor { HttpContext = context }, new DebugProbeOptions())
{
InnerHandler = transport
});

private sealed class UnformattableException : Exception
{
public override string ToString() => throw new InvalidOperationException("diagnostic formatting failed");
}

private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
public int Calls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
return send(request);
}
}

// Consume a prefix, fail once without consuming data, then let the application read normally.
private sealed class FailOnceStream(bool seekable) : MemoryStream("hello"u8.ToArray())
{
private int _reads;
public override bool CanSeek => seekable;

public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
_reads++;
if (_reads == 2)
throw new IOException("capture read failed");
return base.ReadAsync(_reads == 1 ? buffer[..Math.Min(2, buffer.Length)] : buffer, cancellationToken);
}
}
}
Loading
Loading