From 528bf3295a375db4192bff9d7834d4100854ba9a Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 16 May 2026 16:35:02 +0300 Subject: [PATCH] test: add automated coverage for middleware, rendering, and compare validation --- .../Compare/CompareUrlValidatorTests.cs | 64 ++++++++++ .../Configuration/DebugProbeOptionsTests.cs | 44 +++++++ .../DebugProbe.AspNetCore.Tests.csproj | 28 +++++ DebugProbe.AspNetCore.Tests/GlobalUsings.cs | 2 + .../Infrastructure/DebugProbeTestApp.cs | 65 ++++++++++ .../Middleware/ExceptionHandlingTests.cs | 92 ++++++++++++++ .../MiddlewareExecutionFlowTests.cs | 105 ++++++++++++++++ .../Middleware/RequestBodyCaptureTests.cs | 82 +++++++++++++ .../Middleware/ResponseBodyCaptureTests.cs | 74 ++++++++++++ .../Rendering/HtmlRendererTests.cs | 114 ++++++++++++++++++ DebugProbe.AspNetCore.sln | 36 +++++- .../Properties/AssemblyInfo.cs | 3 + 12 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 DebugProbe.AspNetCore.Tests/Compare/CompareUrlValidatorTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/DebugProbe.AspNetCore.Tests.csproj create mode 100644 DebugProbe.AspNetCore.Tests/GlobalUsings.cs create mode 100644 DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs create mode 100644 DebugProbe.AspNetCore.Tests/Middleware/ExceptionHandlingTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs create mode 100644 DebugProbe.AspNetCore/Properties/AssemblyInfo.cs diff --git a/DebugProbe.AspNetCore.Tests/Compare/CompareUrlValidatorTests.cs b/DebugProbe.AspNetCore.Tests/Compare/CompareUrlValidatorTests.cs new file mode 100644 index 0000000..c659b77 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Compare/CompareUrlValidatorTests.cs @@ -0,0 +1,64 @@ +using DebugProbe.AspNetCore.Internal.Compare; +using DebugProbe.AspNetCore.Options; + +namespace DebugProbe.AspNetCore.Tests.Compare; + +public class CompareUrlValidatorTests +{ + [Theory] + [InlineData("https://example.com")] + [InlineData("http://example.com:8080/path")] + public async Task Validates_valid_http_and_https_urls(string url) + { + var result = await CompareUrlValidator.ValidateCompareBaseUrlAsync(url, new DebugProbeOptions()); + + Assert.True(result.IsValid); + Assert.NotNull(result.BaseUri); + Assert.Equal(new Uri(url).GetLeftPart(UriPartial.Authority), result.BaseUri.ToString().TrimEnd('/')); + } + + [Theory] + [InlineData("ftp://example.com")] + [InlineData("file:///tmp/test")] + public async Task Rejects_invalid_schemes(string url) + { + var result = await CompareUrlValidator.ValidateCompareBaseUrlAsync(url, new DebugProbeOptions()); + + Assert.False(result.IsValid); + Assert.Equal("Compare server URL must use http or https", result.Error); + } + + [Fact] + public async Task Rejects_localhost_by_default() + { + var result = await CompareUrlValidator.ValidateCompareBaseUrlAsync("http://localhost:5000", new DebugProbeOptions()); + + Assert.False(result.IsValid); + Assert.Equal("Compare server URL cannot target localhost", result.Error); + } + + [Fact] + public async Task Allows_localhost_when_local_compare_targets_are_enabled() + { + var result = await CompareUrlValidator.ValidateCompareBaseUrlAsync( + "http://localhost:5000/debug", + new DebugProbeOptions { AllowLocalCompareTargets = true }); + + Assert.True(result.IsValid); + Assert.Equal("http://localhost:5000", result.BaseUri?.ToString().TrimEnd('/')); + } + + [Fact] + public async Task Validates_allow_local_compare_targets_for_private_addresses() + { + var blocked = await CompareUrlValidator.ValidateCompareBaseUrlAsync( + "http://127.0.0.1:5000", + new DebugProbeOptions()); + var allowed = await CompareUrlValidator.ValidateCompareBaseUrlAsync( + "http://127.0.0.1:5000", + new DebugProbeOptions { AllowLocalCompareTargets = true }); + + Assert.False(blocked.IsValid); + Assert.True(allowed.IsValid); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs new file mode 100644 index 0000000..d8f5ccf --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -0,0 +1,44 @@ +using DebugProbe.AspNetCore.Extensions; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace DebugProbe.AspNetCore.Tests.Configuration; + +public class DebugProbeOptionsTests +{ + [Fact] + public void Defaults_work_correctly() + { + var options = new DebugProbeOptions(); + + Assert.Equal(20, options.MaxEntries); + Assert.Equal(256, options.MaxBodyCaptureSizeKb); + Assert.False(options.AllowLocalCompareTargets); + Assert.Empty(options.IgnorePaths); + } + + [Fact] + public void Custom_options_are_registered_and_used() + { + var services = new ServiceCollection(); + + services.AddDebugProbe(options => + { + options.MaxEntries = 2; + options.MaxBodyCaptureSizeKb = 4; + options.AllowLocalCompareTargets = true; + options.IgnorePaths = ["/health"]; + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + var store = provider.GetRequiredService(); + + Assert.Equal(2, options.MaxEntries); + Assert.Equal(4, options.MaxBodyCaptureSizeKb); + Assert.True(options.AllowLocalCompareTargets); + Assert.Equal(["/health"], options.IgnorePaths); + Assert.NotNull(store.Environment); + } +} diff --git a/DebugProbe.AspNetCore.Tests/DebugProbe.AspNetCore.Tests.csproj b/DebugProbe.AspNetCore.Tests/DebugProbe.AspNetCore.Tests.csproj new file mode 100644 index 0000000..eb1de0c --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/DebugProbe.AspNetCore.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/DebugProbe.AspNetCore.Tests/GlobalUsings.cs b/DebugProbe.AspNetCore.Tests/GlobalUsings.cs new file mode 100644 index 0000000..d7a04e2 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; diff --git a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs new file mode 100644 index 0000000..fd6dcb7 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeTestApp.cs @@ -0,0 +1,65 @@ +using DebugProbe.AspNetCore.Extensions; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DebugProbe.AspNetCore.Tests.Infrastructure; + +internal sealed class DebugProbeTestApp : IAsyncDisposable +{ + private readonly IHost _host; + + private DebugProbeTestApp(IHost host) + { + _host = host; + Client = host.GetTestClient(); + Store = host.Services.GetRequiredService(); + } + + public HttpClient Client { get; } + + public DebugEntryStore Store { get; } + + public DebugEntry SingleEntry => Assert.Single(Store.GetAll()); + + public static async Task CreateAsync( + Action mapEndpoints, + Action? configureOptions = null, + Action? configureAfterDebugProbe = null) + { + var host = await new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.ConfigureServices(services => + { + services.AddRouting(); + services.AddDebugProbe(configureOptions); + }); + webHost.Configure(app => + { + app.UseRouting(); + app.UseDebugProbe(); + configureAfterDebugProbe?.Invoke(app); + app.UseEndpoints(mapEndpoints); + }); + }) + .StartAsync(); + + return new DebugProbeTestApp(host); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _host.StopAsync(); + _host.Dispose(); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/ExceptionHandlingTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/ExceptionHandlingTests.cs new file mode 100644 index 0000000..9710f01 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/ExceptionHandlingTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Text; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.AspNetCore.Diagnostics; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +public class ExceptionHandlingTests +{ + [Fact] + public async Task Captures_exception_response_text() + { + await using var app = await CreateExceptionAppAsync("handled error"); + + var response = await app.Client.PostAsync("/throw", JsonContent("{\"id\":42}")); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("handled error", await response.Content.ReadAsStringAsync()); + Assert.Equal("handled error", app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Stores_exception_information_when_exception_is_not_handled() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/throw", (HttpContext _) => throw new InvalidOperationException("unhandled failure")); + }); + + await Assert.ThrowsAsync(() => app.Client.GetAsync("/throw")); + + var entry = app.SingleEntry; + Assert.Equal(500, entry.StatusCode); + Assert.Contains("InvalidOperationException", entry.ResponseBody); + Assert.Contains("unhandled failure", entry.ResponseBody); + } + + [Fact] + public async Task Handles_empty_error_responses() + { + await using var app = await CreateExceptionAppAsync(string.Empty); + + var response = await app.Client.GetAsync("/throw"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal(string.Empty, app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Issue_38_exception_endpoint_captures_status_request_body_and_response_body() + { + await using var app = await CreateExceptionAppAsync("{\"error\":\"boom\"}", "application/json"); + + var response = await app.Client.PostAsync("/throw", JsonContent("{\"name\":\"Ada\"}")); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + var entry = app.SingleEntry; + Assert.Equal(500, entry.StatusCode); + Assert.Equal("{\"name\":\"Ada\"}", entry.RequestBody); + Assert.Equal("{\"error\":\"boom\"}", entry.ResponseBody); + } + + private static Task CreateExceptionAppAsync( + string errorBody, + string contentType = "text/plain") + { + return DebugProbeTestApp.CreateAsync( + endpoints => + { + endpoints.MapPost("/throw", (HttpContext _) => throw new InvalidOperationException("boom")); + endpoints.MapGet("/throw", (HttpContext _) => throw new InvalidOperationException("boom")); + }, + configureAfterDebugProbe: builder => + { + builder.UseExceptionHandler(errorApp => + { + errorApp.Run(async context => + { + _ = context.Features.Get(); + context.Response.StatusCode = 500; + context.Response.ContentType = contentType; + await context.Response.WriteAsync(errorBody); + }); + }); + }); + } + + private static StringContent JsonContent(string value) + { + return new StringContent(value, Encoding.UTF8, "application/json"); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs new file mode 100644 index 0000000..fff8bbd --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using DebugProbe.AspNetCore.Middleware; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +public class MiddlewareExecutionFlowTests +{ + [Fact] + public async Task Middleware_executes_and_request_continues_through_pipeline() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/hello", () => Results.Text("hello from endpoint")); + }); + + var response = await app.Client.GetAsync("/hello"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("hello from endpoint", await response.Content.ReadAsStringAsync()); + + var entry = app.SingleEntry; + Assert.Equal("GET", entry.Method); + Assert.Equal("/hello", entry.Path); + Assert.Equal(200, entry.StatusCode); + Assert.Equal("hello from endpoint", entry.ResponseBody); + } + + [Fact] + public async Task Ignored_paths_are_skipped() + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapGet("/health", () => Results.Ok()), + options => options.IgnorePaths = ["/health"]); + + var response = await app.Client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(app.Store.GetAll()); + } + + [Fact] + public async Task Debug_paths_are_skipped() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/debug/custom", () => Results.Text("debug")); + }); + + var response = await app.Client.GetAsync("/debug/custom"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(app.Store.GetAll()); + } + + [Fact] + public async Task Response_stream_is_restored_after_successful_request() + { + var originalBody = new MemoryStream(); + var context = CreateHttpContext(originalBody); + var store = new DebugEntryStore(new DebugProbeOptions()); + var middleware = new DebugProbeMiddleware( + async httpContext => await httpContext.Response.WriteAsync("ok"), + new DebugProbeOptions()); + + await middleware.Invoke(context, store); + + Assert.Same(originalBody, context.Response.Body); + Assert.Equal("ok", Assert.Single(store.GetAll()).ResponseBody); + } + + [Fact] + public async Task Response_stream_is_restored_after_exception() + { + var originalBody = new MemoryStream(); + var context = CreateHttpContext(originalBody); + var store = new DebugEntryStore(new DebugProbeOptions()); + var middleware = new DebugProbeMiddleware( + _ => throw new InvalidOperationException("broken"), + new DebugProbeOptions()); + + await Assert.ThrowsAsync(() => middleware.Invoke(context, store)); + + Assert.Same(originalBody, context.Response.Body); + var entry = Assert.Single(store.GetAll()); + Assert.Equal(500, entry.StatusCode); + Assert.Contains("broken", entry.ResponseBody); + } + + private static DefaultHttpContext CreateHttpContext(Stream responseBody) + { + var context = new DefaultHttpContext(); + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, EndpointMetadataCollection.Empty, "test")); + context.Request.Method = HttpMethods.Get; + context.Request.Scheme = "http"; + context.Request.Host = new HostString("example.test"); + context.Request.Path = "/direct"; + context.Response.Body = responseBody; + context.Response.ContentType = "text/plain"; + return context; + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs new file mode 100644 index 0000000..cf0183c --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Text; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Tests.Infrastructure; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +public class RequestBodyCaptureTests +{ + [Fact] + public async Task Captures_json_request_body() + { + await using var app = await CreateEchoAppAsync(); + var content = JsonContent("{\"name\":\"Ada\"}"); + + await app.Client.PostAsync("/echo", content); + + Assert.Equal("{\"name\":\"Ada\"}", app.SingleEntry.RequestBody); + } + + [Fact] + public async Task Handles_empty_request_body() + { + await using var app = await CreateEchoAppAsync(); + + await app.Client.PostAsync("/echo", new StringContent(string.Empty)); + + Assert.Equal(string.Empty, app.SingleEntry.RequestBody); + } + + [Fact] + public async Task Handles_large_request_body_within_limit() + { + await using var app = await CreateEchoAppAsync(options => options.MaxBodyCaptureSizeKb = 2); + var body = new string('a', 1500); + + await app.Client.PostAsync("/echo", JsonContent(body)); + + Assert.Equal(body, app.SingleEntry.RequestBody); + } + + [Fact] + public async Task Respects_max_body_capture_size_for_request() + { + await using var app = await CreateEchoAppAsync(options => options.MaxBodyCaptureSizeKb = 1); + var body = new string('a', 1200); + + await app.Client.PostAsync("/echo", JsonContent(body)); + + Assert.Equal("[Body too large]", app.SingleEntry.RequestBody); + } + + [Fact] + public async Task Handles_non_text_request_content_safely() + { + await using var app = await CreateEchoAppAsync(); + var content = new ByteArrayContent([0, 1, 2, 3]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + await app.Client.PostAsync("/echo", content); + + Assert.Equal("[Body not captured: non-text content]", app.SingleEntry.RequestBody); + } + + private static Task CreateEchoAppAsync(Action? configureOptions = null) + { + return DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapPost("/echo", async context => + { + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); + var body = await reader.ReadToEndAsync(); + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(body); + }), + configureOptions); + } + + private static StringContent JsonContent(string value) + { + return new StringContent(value, Encoding.UTF8, "application/json"); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs new file mode 100644 index 0000000..b6a1ab6 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs @@ -0,0 +1,74 @@ +using System.Net.Http.Headers; +using DebugProbe.AspNetCore.Tests.Infrastructure; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +public class ResponseBodyCaptureTests +{ + [Fact] + public async Task Captures_json_response_body() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/json", () => Results.Json(new { name = "Ada" })); + }); + + await app.Client.GetAsync("/json"); + + Assert.Contains("\"name\":\"Ada\"", app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Handles_empty_response() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/empty", () => Results.NoContent()); + }); + + await app.Client.GetAsync("/empty"); + + Assert.Equal(string.Empty, app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Handles_large_response_within_limit() + { + var body = new string('x', 1500); + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapGet("/large", () => Results.Text(body, "text/plain")), + options => options.MaxBodyCaptureSizeKb = 2); + + await app.Client.GetAsync("/large"); + + Assert.Equal(body, app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Handles_binary_response_safely() + { + await using var app = await DebugProbeTestApp.CreateAsync(endpoints => + { + endpoints.MapGet("/binary", () => Results.Bytes([0, 1, 2, 3], "application/octet-stream")); + }); + + await app.Client.GetAsync("/binary"); + + Assert.Equal("[Body not captured: non-text content]", app.SingleEntry.ResponseBody); + } + + [Fact] + public async Task Validates_bounded_response_capture_behavior() + { + var body = new string('x', 1200); + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapGet("/too-large", () => Results.Text(body, "text/plain")), + options => options.MaxBodyCaptureSizeKb = 1); + + var response = await app.Client.GetAsync("/too-large"); + + var entry = app.SingleEntry; + Assert.Equal(body, await response.Content.ReadAsStringAsync()); + Assert.Equal("[Body too large]", entry.ResponseBody); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs new file mode 100644 index 0000000..421699b --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -0,0 +1,114 @@ +using DebugProbe.AspNetCore.Internal.Rendering; +using DebugProbe.AspNetCore.Models; + +namespace DebugProbe.AspNetCore.Tests.Rendering; + +public class HtmlRendererTests +{ + [Fact] + public void Render_index_page_builds_page_with_entries() + { + var html = HtmlRenderer.RenderIndexPage( + [ + new DebugEntry + { + Id = "trace-1", + Method = "GET", + Path = "/orders", + StatusCode = 200, + Timestamp = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero) + } + ]); + + Assert.Contains("/debug/trace-1", html); + Assert.Contains("GET", html); + Assert.Contains("/orders", html); + Assert.Contains("200", html); + } + + [Fact] + public void Details_page_renders_captured_values() + { + var entry = CreateEntry(); + + var html = HtmlRenderer.RenderDetailsPage( + entry, + CreateEnvironment(), + "{\"request\":true}", + "{\"response\":true}"); + + Assert.Contains("POST", html); + Assert.Contains("/orders?id=10", html); + Assert.Contains("500 InternalServerError", html); + Assert.Contains("http://example.test/orders?id=10", html); + Assert.Contains(""request":true", html); + Assert.Contains(""response":true", html); + } + + [Fact] + public void Payload_badges_render_for_json_empty_text_and_hidden_payloads() + { + var jsonHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "{\"ok\":true}", "plain"); + var emptyHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "", ""); + var hiddenHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "[Body too large]", "[Body too large]"); + + Assert.Contains("payload-json", jsonHtml); + Assert.Contains("payload-text", jsonHtml); + Assert.Contains("payload-empty", emptyHtml); + Assert.Contains("payload-hidden", hiddenHtml); + } + + [Fact] + public void Html_encoding_escapes_untrusted_values() + { + var entry = CreateEntry(); + entry.Method = ""; + entry.Path = "/orders/"; + entry.Headers["X-Unsafe"] = ""; + + var html = HtmlRenderer.RenderDetailsPage( + entry, + CreateEnvironment(), + "", + "bad"); + + Assert.DoesNotContain("", html); + Assert.Contains("<script>alert(1)</script>", html); + Assert.Contains("/orders/<bad>", html); + } + + private static DebugEntry CreateEntry() + { + return new DebugEntry + { + Id = "trace-1", + Method = "POST", + Path = "/orders", + Query = "?id=10", + RequestUrl = "http://example.test/orders?id=10", + StatusCode = 500, + RequestBody = "{\"request\":true}", + ResponseBody = "{\"response\":true}", + RequestSize = 16, + ResponseSize = 17, + DurationMs = 12, + Timestamp = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), + RequestTimeUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), + Headers = new Dictionary { ["X-Test"] = "yes" } + }; + } + + private static DebugEnvironment CreateEnvironment() + { + return new DebugEnvironment + { + Environment = "Testing", + Culture = "en-US", + MachineName = "test-machine", + TimeZone = "UTC", + DecimalSeparator = ".", + DateFormat = "M/d/yyyy", + AssemblyVersion = "1.0.0" + }; + } +} diff --git a/DebugProbe.AspNetCore.sln b/DebugProbe.AspNetCore.sln index e3f60f6..25bb5c9 100644 --- a/DebugProbe.AspNetCore.sln +++ b/DebugProbe.AspNetCore.sln @@ -1,26 +1,60 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36717.8 d17.14 +VisualStudioVersion = 17.14.36717.8 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DebugProbe.AspNetCore", "DebugProbe.AspNetCore\DebugProbe.AspNetCore.csproj", "{D0EF416F-72E2-40CD-9962-CF5A217F326E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DebugProbe.SampleApi", "DebugProbe.SampleApi\DebugProbe.SampleApi.csproj", "{60F878D2-1C66-4D44-BC55-AD9B6D1E0647}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DebugProbe.AspNetCore.Tests", "DebugProbe.AspNetCore.Tests\DebugProbe.AspNetCore.Tests.csproj", "{733BEBB9-A65C-408A-9818-FA43A2B3928C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|x64.Build.0 = Debug|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Debug|x86.Build.0 = Debug|Any CPU {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|Any CPU.Build.0 = Release|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|x64.ActiveCfg = Release|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|x64.Build.0 = Release|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|x86.ActiveCfg = Release|Any CPU + {D0EF416F-72E2-40CD-9962-CF5A217F326E}.Release|x86.Build.0 = Release|Any CPU {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|x64.ActiveCfg = Debug|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|x64.Build.0 = Debug|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|x86.ActiveCfg = Debug|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Debug|x86.Build.0 = Debug|Any CPU {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|Any CPU.ActiveCfg = Release|Any CPU {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|Any CPU.Build.0 = Release|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|x64.ActiveCfg = Release|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|x64.Build.0 = Release|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|x86.ActiveCfg = Release|Any CPU + {60F878D2-1C66-4D44-BC55-AD9B6D1E0647}.Release|x86.Build.0 = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|x64.ActiveCfg = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|x64.Build.0 = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|x86.ActiveCfg = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Debug|x86.Build.0 = Debug|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|Any CPU.Build.0 = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|x64.ActiveCfg = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|x64.Build.0 = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|x86.ActiveCfg = Release|Any CPU + {733BEBB9-A65C-408A-9818-FA43A2B3928C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DebugProbe.AspNetCore/Properties/AssemblyInfo.cs b/DebugProbe.AspNetCore/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c740d88 --- /dev/null +++ b/DebugProbe.AspNetCore/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DebugProbe.AspNetCore.Tests")]