diff --git a/DebugProbe.AspNetCore.Tests/BodyCaptureConsistencyTests.cs b/DebugProbe.AspNetCore.Tests/BodyCaptureConsistencyTests.cs new file mode 100644 index 0000000..71bcbfc --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/BodyCaptureConsistencyTests.cs @@ -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 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; + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs b/DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs new file mode 100644 index 0000000..d88f1bc --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/CaptureFailureIsolationTests.cs @@ -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(() => 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(() => + 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); + + 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()); + 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(() => client.GetAsync("https://example.test"))); + 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(cancellation.Token)); + using var client = CreateClient(context, transport); + + await Assert.ThrowsAnyAsync(() => client.GetAsync("https://example.test")); + 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()); + + 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(async () => await capture.WriteAsync("hello"u8.ToArray())); + } + + 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> send) : HttpMessageHandler + { + public int Calls { get; private set; } + protected override Task 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 ReadAsync(Memory 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); + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/ConcurrencyTests.cs b/DebugProbe.AspNetCore.Tests/ConcurrencyTests.cs new file mode 100644 index 0000000..bce66f4 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/ConcurrencyTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Text.Json; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DebugProbe.AspNetCore.Tests; + +public class ConcurrencyTests +{ + [Fact] + public async Task Parallel_adds_preserve_all_counts_and_keep_latest_requests() + { + const int total = 2000; + const int limit = 20; + var store = new DebugEntryStore(new DebugProbeOptions { MaxRequests = limit }); + + await Parallel.ForAsync(0, total, (i, _) => + { + store.Add(new DebugEntry { Id = i.ToString(), ResponseBody = "System.Exception: same failure" }); + Assert.InRange(store.GetAll().Count, 1, limit); + return ValueTask.CompletedTask; + }); + + Assert.Equal(limit, store.GetAll().Count); + Assert.Equal(limit, store.GetAll().Select(entry => entry.Id).Distinct().Count()); + Assert.Equal(total, Assert.Single(store.ExceptionGroups.Values).Count); + } + + [Fact] + public async Task Parallel_add_clear_and_reads_remain_bounded_and_consistent() + { + const int limit = 10; + var store = new DebugEntryStore(new DebugProbeOptions { MaxRequests = limit }); + var remoteEnvironment = new DebugEnvironment { Environment = "remote" }; + + await Parallel.ForAsync(0, 2000, (i, _) => + { + if (i % 7 == 0) + store.Clear(); + else + store.Add(new DebugEntry { Id = i.ToString(), ResponseBody = $"Type{i}Exception: failure" }, remoteEnvironment); + + var requests = store.GetAll(); + var groups = store.ExceptionGroups; + Assert.InRange(requests.Count, 0, limit); + Assert.InRange(groups.Count, 0, limit); + foreach (var entry in requests) + { + store.Get(entry.Id); + store.GetEnvironment(entry); + } + Assert.All(groups, pair => Assert.Equal(pair.Key, pair.Value.Fingerprint)); + return ValueTask.CompletedTask; + }); + + var previous = store.GetAll(); + store.Clear(); + Assert.Empty(store.GetAll()); + Assert.Empty(store.ExceptionGroups); + Assert.All(previous, entry => Assert.Same(store.Environment, store.GetEnvironment(entry))); + store.Add(new DebugEntry { Id = "fresh", ResponseBody = "System.Exception: fresh" }); + Assert.Equal("fresh", Assert.Single(store.GetAll()).Id); + Assert.Equal(1, Assert.Single(store.ExceptionGroups.Values).Count); + } + + [Fact] + public void Exception_group_snapshots_remain_stable_after_updates_and_clear() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry = new DebugEntry { Id = "test", ResponseBody = "System.Exception: failure" }; + store.Add(entry); + var snapshot = store.ExceptionGroups; + store.Add(entry); + Assert.Equal(1, Assert.Single(snapshot.Values).Count); + Assert.Equal(2, Assert.Single(store.ExceptionGroups.Values).Count); + store.Clear(); + Assert.Single(snapshot); + } + + [Fact] + public async Task Parallel_http_calls_are_recorded_once_and_attached_to_the_correct_request() + { + const int requestCount = 12; + const int outgoingCount = 64; + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapGet("/proxy/{id}", async (string id, IHttpClientFactory factory) => + { + using var client = factory.CreateClient("remote"); + await Task.WhenAll(Enumerable.Range(0, outgoingCount).Select(async i => + { + using var response = await client.GetAsync($"https://remote.test/{id}/{i}"); + response.EnsureSuccessStatusCode(); + })); + return Results.Text("ok"); + }), + options => + { + options.MaxRequests = requestCount; + options.CaptureRequestBody = false; + options.CaptureResponseBody = false; + }, + configureServices: services => services.AddHttpClient("remote") + .ConfigurePrimaryHttpMessageHandler(() => new ParallelHandler())); + + await Task.WhenAll(Enumerable.Range(0, requestCount).Select(async id => + Assert.Equal("ok", await app.Client.GetStringAsync($"/proxy/{id}")))); + + var requests = app.Store.GetAll(); + Assert.Equal(requestCount, requests.Count); + foreach (var entry in requests) + { + var id = entry.Path.Split('/').Last(); + Assert.Equal(outgoingCount, entry.OutgoingRequests.Count); + Assert.Equal(outgoingCount, entry.OutgoingRequests.Select(call => call.Url).Distinct().Count()); + Assert.All(entry.OutgoingRequests, call => Assert.StartsWith($"https://remote.test/{id}/", call.Url)); + + // Remote comparison uses System.Text.Json to round-trip captured traces. + var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(entry))!; + Assert.Equal(entry.OutgoingRequests.Select(call => call.Url), copy.OutgoingRequests.Select(call => call.Url)); + } + } + + private sealed class ParallelHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Yield(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("ok") }; + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index a63b99b..bf8d2f1 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -2,6 +2,7 @@ using DebugProbe.AspNetCore.Options; using DebugProbe.AspNetCore.Storage; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace DebugProbe.AspNetCore.Tests.Configuration; @@ -12,17 +13,19 @@ public void Defaults_work_correctly() { var options = new DebugProbeOptions(); - Assert.Equal(20, options.MaxEntries); - Assert.Equal(32, options.MaxBodyCaptureSizeKb); + Assert.Equal(20, options.MaxRequests); + Assert.Equal(32, options.MaxBodySizeKb); Assert.Null(options.AllowLocalCompareTargets); Assert.False(options.AllowUiInProduction); Assert.Null(options.AuthorizationPolicy); - Assert.True(options.CaptureOutgoingHttpClientRequests); - Assert.Empty(options.IgnorePaths); + Assert.True(options.CaptureOutgoingRequests); + Assert.Contains("/health", options.ExcludedPaths); + Assert.True(options.Enabled); + Assert.True(options.CaptureRequestBody); + Assert.True(options.CaptureResponseBody); Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders); Assert.Empty(options.RedactedQueryParameters); Assert.Empty(options.RedactedJsonFields); - Assert.Equal("[REDACTED]", options.RedactionText); } [Fact] @@ -32,97 +35,83 @@ public void Custom_options_are_registered_and_used() services.AddDebugProbe(options => { - options.MaxEntries = 2; - options.MaxBodyCaptureSizeKb = 4; + options.MaxRequests = 2; + options.MaxBodySizeKb = 4; options.AllowLocalCompareTargets = true; options.AuthorizationPolicy = "DebugProbePolicy"; - options.IgnorePaths = ["/health"]; - options.CaptureOutgoingHttpClientRequests = false; + options.ExcludedPaths = ["/health"]; + options.CaptureOutgoingRequests = false; options.RedactedHeaders = ["X-Api-Key"]; options.RedactedQueryParameters = ["token"]; options.RedactedJsonFields = ["password"]; - options.RedactionText = "***"; }); using var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService(); var store = provider.GetRequiredService(); - Assert.Equal(2, options.MaxEntries); - Assert.Equal(4, options.MaxBodyCaptureSizeKb); + Assert.Equal(2, options.MaxRequests); + Assert.Equal(4, options.MaxBodySizeKb); Assert.True(options.AllowLocalCompareTargets); Assert.Equal("DebugProbePolicy", options.AuthorizationPolicy); - Assert.Equal(["/health"], options.IgnorePaths); - Assert.False(options.CaptureOutgoingHttpClientRequests); + Assert.Equal(["/health"], options.ExcludedPaths); + Assert.False(options.CaptureOutgoingRequests); Assert.Equal(["X-Api-Key"], options.RedactedHeaders); Assert.Equal(["token"], options.RedactedQueryParameters); Assert.Equal(["password"], options.RedactedJsonFields); - Assert.Equal("***", options.RedactionText); Assert.NotNull(store.Environment); } - [Fact] - public void MaxEntries_zero_throws_InvalidOperationException() + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Invalid_request_limit_is_rejected(int limit) { var services = new ServiceCollection(); - - var exception = Assert.Throws(() => - services.AddDebugProbe(options => - { - options.MaxEntries = 0; - })); - - Assert.Contains("MaxEntries", exception.Message); + services.AddDebugProbe(options => options.MaxRequests = limit); + using var provider = services.BuildServiceProvider(); + var exception = Assert.Throws(() => provider.GetRequiredService()); + Assert.Contains("MaxRequests", exception.Message); } - [Fact] - public void MaxEntries_negative_throws_InvalidOperationException() + [Theory] + [InlineData(-1)] + [InlineData(int.MaxValue)] + public void Invalid_body_limit_is_rejected(int limit) { var services = new ServiceCollection(); - - var exception = Assert.Throws(() => - services.AddDebugProbe(options => - { - options.MaxEntries = -1; - })); - - Assert.Contains("MaxEntries", exception.Message); - } - - [Fact] - public void MaxBodyCaptureSizeKb_negative_throws_ArgumentOutOfRangeException() - { - var options = new DebugProbeOptions(); - - Assert.Throws(() => options.MaxBodyCaptureSizeKb = -1); + services.AddDebugProbe(options => options.MaxBodySizeKb = limit); + using var provider = services.BuildServiceProvider(); + var exception = Assert.Throws(() => provider.GetRequiredService()); + Assert.Contains("MaxBodySizeKb", exception.Message); } [Fact] - public void MaxBodyCaptureSizeKb_zero_is_valid() + public void MaxBodySizeKb_zero_is_valid() { var options = new DebugProbeOptions(); - options.MaxBodyCaptureSizeKb = 0; + options.MaxBodySizeKb = 0; - Assert.Equal(0, options.MaxBodyCaptureSizeKb); + Assert.Equal(0, options.MaxBodySizeKb); } [Fact] - public void SlowRequestThresholdMs_defaults_to_1000() + public void SlowRequestThreshold_defaults_to_1000() { var options = new DebugProbeOptions(); - Assert.Equal(1000, options.SlowRequestThresholdMs); + Assert.Equal(TimeSpan.FromSeconds(1), options.SlowRequestThreshold); } [Fact] - public void SlowRequestThresholdMs_can_be_configured() + public void SlowRequestThreshold_can_be_configured() { var services = new ServiceCollection(); services.AddDebugProbe(options => { - options.SlowRequestThresholdMs = 500; + options.SlowRequestThreshold = TimeSpan.FromMilliseconds(500); }); using var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService(); - Assert.Equal(500, options.SlowRequestThresholdMs); + Assert.Equal(TimeSpan.FromMilliseconds(500), options.SlowRequestThreshold); } } diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs index 630a33c..34f12a0 100644 --- a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs @@ -55,7 +55,7 @@ public async Task Debug_endpoints_require_configured_authorization_policy() app.UseAuthentication(); app.UseAuthorization(); }, - configureUseDebugProbe: options => options.AuthorizationPolicy = "DebugProbePolicy"); + configureOptions: options => options.AuthorizationPolicy = "DebugProbePolicy"); await app.Client.GetAsync("/hello"); var traceId = app.SingleEntry.Id; diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeConfigurationTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeConfigurationTests.cs new file mode 100644 index 0000000..d80a66d --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeConfigurationTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using DebugProbe.AspNetCore.Extensions; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace DebugProbe.AspNetCore.Tests.Extensions; + +public class DebugProbeConfigurationTests +{ + [Fact] + public async Task Custom_prefix_applies_to_navigation_assets_compare_and_clear() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapGet("/hello", () => Results.Text("hello")), + options => options.RoutePrefix = "tools/probe/"); + + await app.Client.GetAsync("/hello"); + var id = app.SingleEntry.Id; + var html = await app.Client.GetStringAsync("/tools/probe"); + Assert.Contains($"data-url=\"/tools/probe/{id}\"", html); + Assert.Contains("src=\"/tools/probe/js/debugprobe-ui.js\"", html); + Assert.Contains("data-debugprobe-prefix=\"/tools/probe\"", html); + Assert.DoesNotContain("{{route_prefix}}", html); + var details = await app.Client.GetStringAsync($"/tools/probe/{id}"); + Assert.Contains("href=\"/tools/probe\"", details); + var compare = await app.Client.GetStringAsync($"/tools/probe/compare?localTraceId={id}"); + Assert.Contains("data-debugprobe-prefix=\"/tools/probe\"", compare); + var script = await app.Client.GetStringAsync("/tools/probe/js/debugprobe-ui.js"); + Assert.Contains("dataset.debugprobePrefix", script); + Assert.DoesNotContain("/debug/clear", script); + Assert.Equal(HttpStatusCode.NotFound, (await app.Client.GetAsync("/debug")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await app.Client.PostAsync("/tools/probe/clear", null)).StatusCode); + Assert.Empty(app.Store.GetAll()); + } + + [Fact] + public async Task Disabled_probe_leaves_application_working_without_capture_or_endpoints() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapGet("/hello", () => Results.Text("hello")), + options => options.Enabled = false); + + Assert.Equal("hello", await app.Client.GetStringAsync("/hello")); + Assert.Equal(HttpStatusCode.NotFound, (await app.Client.GetAsync("/debug")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await app.Client.GetAsync("/debug/environment")).StatusCode); + Assert.Empty(app.Store.GetAll()); + } + + [Fact] + public void Options_can_be_bound_from_configuration() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["DebugProbe:MaxRequests"] = "50", + ["DebugProbe:RoutePrefix"] = "tools/probe/", + ["DebugProbe:SlowRequestThreshold"] = "00:00:02", + ["DebugProbe:CaptureResponseBody"] = "false" + }).Build(); + var services = new ServiceCollection(); + services.AddDebugProbe(); + services.Configure(configuration.GetSection("DebugProbe")); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + Assert.Same(options, provider.GetRequiredService()); + Assert.Equal(50, options.MaxRequests); + Assert.Equal("/tools/probe", options.RoutePrefix); + Assert.Equal(TimeSpan.FromSeconds(2), options.SlowRequestThreshold); + Assert.False(options.CaptureResponseBody); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + [InlineData("/bad?query")] + public void Invalid_prefix_is_rejected(string prefix) + { + var services = new ServiceCollection(); + services.AddDebugProbe(options => options.RoutePrefix = prefix); + using var provider = services.BuildServiceProvider(); + Assert.Throws(() => provider.GetRequiredService()); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs index 4f2fe84..9897bc8 100644 --- a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs @@ -15,7 +15,7 @@ public async Task Production_does_not_map_ui_endpoints_by_default() var capturedResponse = await app.Client.GetAsync("/hello"); var debugResponse = await app.Client.GetAsync("/debug"); - var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}"); + var comparePageResponse = await app.Client.GetAsync($"/debug/compare?localTraceId={app.SingleEntry.Id}"); var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js"); var logoResponse = await app.Client.GetAsync("/debug/logo.png"); var environmentResponse = await app.Client.GetAsync("/debug/environment"); @@ -44,7 +44,7 @@ public async Task Production_maps_ui_endpoints_when_explicitly_allowed() var debugResponse = await app.Client.GetAsync("/debug"); var detailsResponse = await app.Client.GetAsync($"/debug/{app.SingleEntry.Id}"); - var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}"); + var comparePageResponse = await app.Client.GetAsync($"/debug/compare?localTraceId={app.SingleEntry.Id}"); var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js"); var logoResponse = await app.Client.GetAsync("/debug/logo.png"); var environmentResponse = await app.Client.GetAsync("/debug/environment"); diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs index 15fbe46..d4a3d0c 100644 --- a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs +++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs @@ -9,6 +9,28 @@ namespace DebugProbe.AspNetCore.Tests.Handlers; public class DebugProbeHttpClientHandlerBodyTests { + [Theory] + [InlineData(false, true, 32)] + [InlineData(true, false, 32)] + [InlineData(true, true, 0)] + public async Task Body_capture_controls_preserve_outgoing_response(bool request, bool response, int limit) + { + var (client, entry) = BuildClient(new DebugProbeOptions + { + CaptureRequestBody = request, + CaptureResponseBody = response, + MaxBodySizeKb = limit + }, new StringContent("pong")); + using (client) + using (var result = await client.PostAsync("https://example.test/", new StringContent("ping"))) + { + Assert.Equal("pong", await result.Content.ReadAsStringAsync()); + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.Equal(request && limit > 0 ? "ping" : "", outgoing.RequestBody); + Assert.Equal(response && limit > 0 ? "pong" : "", outgoing.ResponseBody); + } + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -49,7 +71,7 @@ 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 options = new DebugProbeOptions { MaxBodySizeKb = limitKb }; var (client, entry) = BuildClient(options, responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); @@ -66,7 +88,7 @@ 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 options = new DebugProbeOptions { MaxBodySizeKb = limitKb }; var (client, entry) = BuildClient(options, responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); @@ -83,7 +105,7 @@ 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 options = new DebugProbeOptions { MaxBodySizeKb = limitKb }; var (client, entry) = BuildClient(options, responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain")); @@ -106,7 +128,7 @@ public async Task Capturing_streamed_response_body_does_not_consume_body_for_cal CharSet = Encoding.UTF8.WebName }; - var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb }; + var options = new DebugProbeOptions { MaxBodySizeKb = limitKb }; var (client, entry) = BuildClient(options, responseContent); using var response = await client.GetAsync("https://example.test/"); @@ -123,7 +145,7 @@ 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 options = new DebugProbeOptions { MaxBodySizeKb = 1 }; var (client, entry) = BuildClient(options, responseContent); using var response = await client.GetAsync("https://example.test/"); @@ -159,7 +181,7 @@ public async Task Non_text_content_returns_empty_string() await client.GetAsync("https://example.test/"); var outgoing = Assert.Single(entry.OutgoingRequests); - Assert.Equal(string.Empty, outgoing.ResponseBody); + Assert.Equal("[Body not captured: non-text content]", outgoing.ResponseBody); } // --------------------------------------------------------------------------- diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs index 5c48917..27cf64a 100644 --- a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs +++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs @@ -48,8 +48,8 @@ public async Task Captures_outgoing_http_call_on_active_trace() Assert.True(outgoing.IsSuccessStatusCode); Assert.Equal("[REDACTED]", outgoing.RequestHeaders["Authorization"]); Assert.Equal("remote", outgoing.ResponseHeaders["X-Trace"]); - Assert.Contains("\"name\": \"Ada\"", outgoing.RequestBody); - Assert.Contains("\"ok\": true", outgoing.ResponseBody); + Assert.Contains("\"name\":\"Ada\"", outgoing.RequestBody); + Assert.Contains("\"ok\":true", outgoing.ResponseBody); } [Fact] @@ -91,8 +91,8 @@ public async Task Redacts_configured_outgoing_url_headers_and_json_fields() Assert.Equal("https://api.example.test/orders?token=[REDACTED]&safe=yes", outgoing.Url); Assert.Equal("[REDACTED]", outgoing.RequestHeaders["X-Api-Key"]); - Assert.Contains("\"password\": \"[REDACTED]\"", outgoing.RequestBody); - Assert.Contains("\"refreshToken\": \"[REDACTED]\"", outgoing.ResponseBody); + Assert.Contains("\"password\":\"[REDACTED]\"", outgoing.RequestBody); + Assert.Contains("\"refreshToken\":\"[REDACTED]\"", outgoing.ResponseBody); } private sealed class StubHandler(Func send) : HttpMessageHandler diff --git a/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs index 4b5aa10..a149d1e 100644 --- a/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Handlers/OutgoingHttpClientCaptureOptionsTests.cs @@ -21,7 +21,7 @@ public async Task Disabled_outgoing_capture_does_not_store_outgoing_traces() return Results.Text(body); }); }, - configureOptions: options => options.CaptureOutgoingHttpClientRequests = false, + configureOptions: options => options.CaptureOutgoingRequests = false, configureServices: services => { services.AddHttpClient("outgoing") diff --git a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs index 1b6042e..ed1c014 100644 --- a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs +++ b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs @@ -30,8 +30,7 @@ public static async Task CreateAsync( Action? mapEndpoints = null, Action? configureOptions = null, Action? configureServices = null, - Action? configureBeforeDebugProbe = null, - Action? configureUseDebugProbe = null) + Action? configureBeforeDebugProbe = null) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { @@ -48,7 +47,7 @@ public static async Task CreateAsync( app.UseRouting(); configureBeforeDebugProbe?.Invoke(app); - app.UseDebugProbe(configureUseDebugProbe); + app.UseDebugProbe(); mapEndpoints?.Invoke(app); diff --git a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs index 730f86a..fb3a19d 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs @@ -9,6 +9,43 @@ namespace DebugProbe.AspNetCore.Tests.Middleware; public class MiddlewareExecutionFlowTests { + [Theory] + [InlineData(false, true, 32)] + [InlineData(true, false, 32)] + [InlineData(true, true, 0)] + public async Task Body_capture_controls_preserve_application_data(bool request, bool response, int limit) + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapPost("/echo", async (HttpContext context) => + { + using var reader = new StreamReader(context.Request.Body); + return Results.Text(await reader.ReadToEndAsync()); + }), + options => + { + options.CaptureRequestBody = request; + options.CaptureResponseBody = response; + options.MaxBodySizeKb = limit; + }); + + using var content = new StringContent("hello"); + var result = await app.Client.PostAsync("/echo", content); + Assert.Equal("hello", await result.Content.ReadAsStringAsync()); + Assert.Equal(request && limit > 0 ? "hello" : "", app.SingleEntry.RequestBody); + Assert.Equal(response && limit > 0 ? "hello" : "", app.SingleEntry.ResponseBody); + Assert.Equal(5, app.SingleEntry.ResponseSize); + } + + [Fact] + public async Task Default_path_exclusions_can_be_removed() + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapGet("/health", () => Results.Text("ok")), + options => options.ExcludedPaths = []); + await app.Client.GetAsync("/health"); + Assert.Equal("/health", app.SingleEntry.Path); + } + [Fact] public async Task Middleware_executes_and_request_continues_through_pipeline() { @@ -34,7 +71,7 @@ public async Task Ignored_paths_are_skipped() { await using var app = await DebugProbeTestApp.CreateAsync( endpoints => endpoints.MapGet("/health", () => Results.Ok()), - options => options.IgnorePaths = ["/health"]); + options => options.ExcludedPaths = ["/health"]); var response = await app.Client.GetAsync("/health"); @@ -46,7 +83,7 @@ public async Task Similar_paths_are_not_skipped() { await using var app = await DebugProbeTestApp.CreateAsync( endpoints => endpoints.MapGet("/healthcare", () => Results.Ok()), - options => options.IgnorePaths = ["/health"]); + options => options.ExcludedPaths = ["/health"]); var response = await app.Client.GetAsync("/healthcare"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs index cf0183c..a9c886c 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/RequestBodyCaptureTests.cs @@ -31,7 +31,7 @@ public async Task Handles_empty_request_body() [Fact] public async Task Handles_large_request_body_within_limit() { - await using var app = await CreateEchoAppAsync(options => options.MaxBodyCaptureSizeKb = 2); + await using var app = await CreateEchoAppAsync(options => options.MaxBodySizeKb = 2); var body = new string('a', 1500); await app.Client.PostAsync("/echo", JsonContent(body)); @@ -42,12 +42,12 @@ public async Task Handles_large_request_body_within_limit() [Fact] public async Task Respects_max_body_capture_size_for_request() { - await using var app = await CreateEchoAppAsync(options => options.MaxBodyCaptureSizeKb = 1); + await using var app = await CreateEchoAppAsync(options => options.MaxBodySizeKb = 1); var body = new string('a', 1200); await app.Client.PostAsync("/echo", JsonContent(body)); - Assert.Equal("[Body too large]", app.SingleEntry.RequestBody); + Assert.Equal(body[..1024] + "[truncated]", app.SingleEntry.RequestBody); } [Fact] diff --git a/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs index 17f338b..0327b5a 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs @@ -36,7 +36,7 @@ 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); + options => options.MaxBodySizeKb = 2); await app.Client.GetAsync("/large"); @@ -64,13 +64,13 @@ 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); + options => options.MaxBodySizeKb = 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); + Assert.Equal(body[..1024] + "[truncated]", entry.ResponseBody); Assert.Equal(body.Length, entry.ResponseSize); } } diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs deleted file mode 100644 index 6bb19d7..0000000 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Collections.Generic; -using DebugProbe.AspNetCore.Internal.Rendering; -using DebugProbe.AspNetCore.Models; -using DebugProbe.AspNetCore.Options; -using DebugProbe.AspNetCore.Storage; -using Xunit; - -[assembly: CollectionBehavior(DisableTestParallelization = true)] - -namespace DebugProbe.AspNetCore.Tests.Rendering; - -public class HtmlRendererEnvDiffTests -{ - [Fact] - public void Render_index_page_with_AutoEnvironmentDiff_disabled_does_not_compare() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = false }; - var store = new DebugEntryStore(options); - - var entry1 = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - var entry2 = new DebugEntry - { - Id = "2", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"error\"}" - }; - - var devEnv = new DebugEnvironment { Environment = "Development" }; - var prodEnv = new DebugEnvironment { Environment = "Production" }; - - store.Add(entry1, devEnv); - store.Add(entry2, prodEnv); - - // Act - var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - - // Assert - Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); - } - - [Fact] - public void Render_index_page_with_same_environment_shows_no_badge() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; - var store = new DebugEntryStore(options); - - var entry1 = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - var entry2 = new DebugEntry - { - Id = "2", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"error\"}" - }; - - var devEnv = new DebugEnvironment { Environment = "Development" }; - - store.Add(entry1, devEnv); - store.Add(entry2, devEnv); - - // Act - var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - - // Assert - Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); - } - - [Fact] - public void Render_index_page_with_different_environments_and_identical_payloads_shows_no_badge() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; - var store = new DebugEntryStore(options); - - var entry1 = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - var entry2 = new DebugEntry - { - Id = "2", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - - var devEnv = new DebugEnvironment { Environment = "Development" }; - var prodEnv = new DebugEnvironment { Environment = "Production" }; - - store.Add(entry1, devEnv); - store.Add(entry2, prodEnv); - - // Act - var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - - // Assert - Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); - } - - [Fact] - public void Render_index_page_with_different_environments_and_differing_payloads_shows_badge() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; - var store = new DebugEntryStore(options); - - var entry1 = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - var entry2 = new DebugEntry - { - Id = "2", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"error\"}" - }; - - var devEnv = new DebugEnvironment { Environment = "Development" }; - var prodEnv = new DebugEnvironment { Environment = "Production" }; - - store.Add(entry1, devEnv); - store.Add(entry2, prodEnv); - - // Act - var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - - // Assert - Assert.Contains("class=\"dbp-badge dbp-badge-envdiff\"", html); - Assert.Contains("Payload differences detected between: Production, Development", html); - } - - [Fact] - public void Route_normalization_matches_slash_and_query_string() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; - var store = new DebugEntryStore(options); - - // entry1 path has trailing slash, entry2 path has query string. They should normalize to /api/users - var entry1 = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), - Method = "GET", - Path = "/api/users/", - ResponseBody = "{\"status\": \"ok\"}" - }; - var entry2 = new DebugEntry - { - Id = "2", - Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), - Path = "/api/users?id=123", - Method = "GET", - ResponseBody = "{\"status\": \"error\"}" - }; - - var devEnv = new DebugEnvironment { Environment = "Development" }; - var prodEnv = new DebugEnvironment { Environment = "Production" }; - - store.Add(entry1, devEnv); - store.Add(entry2, prodEnv); - - // Act - var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - - // Assert - Assert.Contains("class=\"dbp-badge dbp-badge-envdiff\"", html); - } - - [Fact] - public void Empty_store_and_single_entry_show_no_badge() - { - // Arrange - var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; - var store = new DebugEntryStore(options); - - // Act & Assert (empty) - var htmlEmpty = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", htmlEmpty); - - // Add single entry - var entry = new DebugEntry - { - Id = "1", - Timestamp = DateTimeOffset.UtcNow, - Method = "GET", - Path = "/api/users", - ResponseBody = "{\"status\": \"ok\"}" - }; - store.Add(entry, new DebugEnvironment { Environment = "Development" }); - - // Act & Assert (single entry) - var htmlSingle = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", htmlSingle); - } -} diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs index 813946e..18acd8e 100644 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -118,7 +118,7 @@ public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_ var entry = CreateEntry(); entry.DurationMs = 500; - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api.test/v1/users?id=123", @@ -128,7 +128,7 @@ public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_ IsSuccessStatusCode = true }); - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "POST", Url = "https://untrusted-api.test/v1/search?query=a&b=%3Cscript%3E", @@ -171,7 +171,7 @@ public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_ public void Details_page_renders_curl_copy_attributes_and_buttons() { var entry = CreateEntry(); - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "PUT", Url = "https://external-api.test/v1/update", @@ -209,7 +209,7 @@ public void Details_page_renders_curl_copy_attributes_and_buttons() public void Details_page_renders_csharp_copy_attributes_and_buttons() { var entry = CreateEntry(); - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "PUT", Url = "https://external-api.test/v1/update", @@ -247,7 +247,7 @@ public void Details_page_renders_csharp_copy_attributes_and_buttons() public void Details_page_renders_markdown_copy_attributes_and_buttons() { var entry = CreateEntry(); - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "PUT", Url = "https://external-api.test/v1/update", @@ -291,7 +291,7 @@ public void Render_index_page_with_slow_badge() }; // Case 1: Threshold = 1000 (Default) - var htmlDefault = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var htmlDefault = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); Assert.Contains("/debug/1", htmlDefault); Assert.Contains("/debug/2", htmlDefault); @@ -299,11 +299,11 @@ public void Render_index_page_with_slow_badge() Assert.Contains("1500 ms SLOW", htmlDefault); // Case 2: Threshold = 0 (disabled) - var htmlDisabled = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThresholdMs = 0 }); + var htmlDisabled = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(0) }); Assert.DoesNotContain(" 1000 - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api.test", @@ -321,7 +321,7 @@ public void Render_details_page_with_slow_badge() TimestampUtc = entry.Timestamp.UtcDateTime.AddMilliseconds(200), IsSuccessStatusCode = true }); - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api2.test", @@ -332,7 +332,7 @@ public void Render_details_page_with_slow_badge() }); // Case 1: Threshold = 1000 - var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); // Detail Header should have badge Assert.Contains("1200 ms SLOW", html); @@ -344,7 +344,7 @@ public void Render_details_page_with_slow_badge() Assert.Contains("200 ms", html); // Case 2: Threshold = 0 - var htmlDisabled = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThresholdMs = 0 }); + var htmlDisabled = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(0) }); Assert.DoesNotContain("SLOW", html); } @@ -409,7 +409,7 @@ public void Render_slow_badge_boundary_threshold_minus_one() { new DebugEntry { Id = "1", Method = "GET", Path = "/minus-one", DurationMs = 999, StatusCode = 200 } }; - var html = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var html = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); Assert.Contains("999 ms", html); Assert.DoesNotContain("SLOW", html); } @@ -432,19 +432,7 @@ public void Render_slow_badge_boundary_zero_threshold_disabled() { new DebugEntry { Id = "1", Method = "GET", Path = "/zero", DurationMs = 5000, StatusCode = 200 } }; - var html = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThresholdMs = 0 }); - Assert.Contains("5000 ms", html); - Assert.DoesNotContain(" - { - new DebugEntry { Id = "1", Method = "GET", Path = "/negative", DurationMs = 5000, StatusCode = 200 } - }; - var html = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThresholdMs = -100 }); + var html = HtmlRenderer.RenderIndexPage(items, new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(0) }); Assert.Contains("5000 ms", html); Assert.DoesNotContain("SLOW", html); } @@ -465,7 +453,7 @@ public void Render_details_page_independent_outgoing_call_badge_behavior() { var entry = CreateEntry(); entry.DurationMs = 500; // < 1000 (not slow) - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api.test", @@ -475,7 +463,7 @@ public void Render_details_page_independent_outgoing_call_badge_behavior() IsSuccessStatusCode = true }); - var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); // Root request should not have the slow badge Assert.Contains("500 ms", html); @@ -490,7 +478,7 @@ public void Render_details_page_outgoing_call_boundary_exact() { var entry = CreateEntry(); entry.DurationMs = 200; - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api.test", @@ -500,7 +488,7 @@ public void Render_details_page_outgoing_call_boundary_exact() IsSuccessStatusCode = true }); - var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); Assert.Contains("1000 ms SLOW", html); } @@ -509,7 +497,7 @@ public void Render_details_page_outgoing_call_boundary_minus_one() { var entry = CreateEntry(); entry.DurationMs = 200; - entry.OutgoingRequests.Add(new DebugOutgoingRequest + entry.OutgoingRequests.Enqueue(new DebugOutgoingRequest { Method = "GET", Url = "https://external-api.test", @@ -519,7 +507,7 @@ public void Render_details_page_outgoing_call_boundary_minus_one() IsSuccessStatusCode = true }); - var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThresholdMs = 1000 }); + var html = HtmlRenderer.RenderDetailsPage(entry, CreateEnvironment(), "{}", "{}", new DebugProbeOptions { SlowRequestThreshold = TimeSpan.FromMilliseconds(1000) }); Assert.Contains("999 ms", html); Assert.DoesNotContain(" 999 ms - { - // Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate. - new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" }, - // Current half: [now - 5m, now]. 1 request, 1 error -> 100% error rate. - new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 500, Method = "GET", Path = "/api" } - }; - - var html = HtmlRenderer.RenderIndexPage(items, options); - - Assert.Contains("trend-up", html); - Assert.Contains("↑", html); - } - - [Fact] - public void Render_index_page_with_decreased_errors_shows_trend_down() - { - var now = DateTimeOffset.UtcNow; - var options = new DebugProbeOptions { TrendLookbackMinutes = 10 }; - - var items = new List - { - // Preceding half: [now - 10m, now - 5m). 1 request, 1 error -> 100% error rate. - new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 500, Method = "GET", Path = "/api" }, - // Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate. - new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" } - }; - - var html = HtmlRenderer.RenderIndexPage(items, options); - - Assert.Contains("trend-down", html); - Assert.Contains("↓", html); - } - - [Fact] - public void Render_index_page_with_unchanged_errors_shows_trend_neutral() - { - var now = DateTimeOffset.UtcNow; - var options = new DebugProbeOptions { TrendLookbackMinutes = 10 }; - - var items = new List - { - // Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate. - new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" }, - // Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate. - new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" } - }; - - var html = HtmlRenderer.RenderIndexPage(items, options); - - Assert.Contains("trend-neutral", html); - Assert.Contains("→", html); - } - - [Fact] - public void Render_index_page_scenario_a_and_b_regression_test() - { - var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 }; - - var now = DateTimeOffset.UtcNow; - - // Scenario A: 8 successes at t = now - 90s, then 5 errors at t = now - 5s - // Since MaxEntries = 10, 3 successes are evicted. 5 successes remain in previous window, 5 errors in current. - // TotalB = 5 > 0, TotalA = 5 > 0. - var itemsA = new List(); - for (int i = 0; i < 5; i++) - { - itemsA.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" }); - } - for (int i = 0; i < 5; i++) - { - itemsA.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); - } - - // Expected: should show Trend Up (↑) because previous window is valid and error rate went up from 0% to 100% - var htmlA = HtmlRenderer.RenderIndexPage(itemsA, options); - Assert.Contains("trend-up", htmlA); - Assert.Contains("↑", htmlA); - - // Scenario B: 8 recovery successes are sent, evicting all baseline successes and some errors. - // Queue now has 8 recovery successes and 2 errors (all in current window). - // Previous window is empty (TotalB = 0). - var itemsB = new List(); - for (int i = 0; i < 2; i++) - { - itemsB.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); - } - for (int i = 0; i < 8; i++) - { - itemsB.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" }); - } - - // Expected: should show Trend Neutral (→) because previous window is empty - var htmlB = HtmlRenderer.RenderIndexPage(itemsB, options); - Assert.Contains("trend-neutral", htmlB); - Assert.Contains("→", htmlB); - } - - [Fact] - public void Render_index_page_previous_window_evicted_shows_neutral() - { - var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 }; - var items = new List(); - - var now = DateTimeOffset.UtcNow; - // All baseline entries from previous window are evicted. Previous window has 0 entries. - for (int i = 0; i < 10; i++) - { - items.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" }); - } - - var html = HtmlRenderer.RenderIndexPage(items, options); - Assert.Contains("trend-neutral", html); - Assert.Contains("→", html); - } - - [Fact] - public void Render_index_page_previous_window_no_traffic_shows_neutral() - { - var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 }; - var items = new List(); - - var now = DateTimeOffset.UtcNow; - // Only 5 errors are sent now. No traffic occurred in the previous window. - for (int i = 0; i < 5; i++) - { - items.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); - } - - var html = HtmlRenderer.RenderIndexPage(items, options); - Assert.Contains("trend-neutral", html); - Assert.Contains("→", html); - } - - [Fact] - public void Render_index_page_happy_path_with_no_eviction_flips_trend() - { - var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 }; - var store = new DebugEntryStore(options); - - var now = DateTimeOffset.UtcNow; - // Phase 1: 8 successes at t = now - 90s (previous window) - for (int i = 0; i < 8; i++) - { - store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" }); - } - - // Phase 2: 5 errors at t = now - 5s (current window) - for (int i = 0; i < 5; i++) - { - store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); - } - - var htmlErrors = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - Assert.Contains("trend-up", htmlErrors); - Assert.Contains("↑", htmlErrors); - - store.Clear(); - - // Phase 3: 5 errors at t = now - 90s (previous window) - for (int i = 0; i < 5; i++) - { - store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 500, Method = "GET", Path = "/api" }); - } - - // Phase 4: 8 successes at t = now - 5s (current window) - for (int i = 0; i < 8; i++) - { - store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 200, Method = "GET", Path = "/api" }); - } - - var htmlRecovery = HtmlRenderer.RenderIndexPage(store.GetAll(), options); - Assert.Contains("trend-down", htmlRecovery); - Assert.Contains("↓", htmlRecovery); - } - - [Fact] - public void Options_validator_rejects_trend_lookback_less_than_two() - { - var validator = new DebugProbeOptionsValidator(); - var options = new DebugProbeOptions { TrendLookbackMinutes = 1 }; - - var result = validator.Validate(null, options); - - Assert.True(result.Failed); - Assert.Contains("TrendLookbackMinutes must be greater than or equal to 2", result.FailureMessage); - } -} diff --git a/DebugProbe.AspNetCore.Tests/Rendering/RendererIsolationTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/RendererIsolationTests.cs new file mode 100644 index 0000000..40c5ddb --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Rendering/RendererIsolationTests.cs @@ -0,0 +1,52 @@ +using DebugProbe.AspNetCore.Internal.Rendering; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Hosting; + +namespace DebugProbe.AspNetCore.Tests.Rendering; + +public class RendererIsolationTests +{ + [Fact] + public void Renderer_uses_only_explicitly_supplied_groups() + { + var first = new DebugEntryStore(new DebugProbeOptions()); + first.Add(new DebugEntry { Id = "first", ResponseBody = "System.Exception: first application" }); + var second = new DebugEntryStore(new DebugProbeOptions()); + second.Add(new DebugEntry { Id = "second", ResponseBody = "System.Exception: second application" }); + + var firstHtml = HtmlRenderer.RenderIndexPage([], exceptionGroups: first.ExceptionGroups.Values.ToArray()); + var secondHtml = HtmlRenderer.RenderIndexPage([], exceptionGroups: second.ExceptionGroups.Values.ToArray()); + var emptyHtml = HtmlRenderer.RenderIndexPage([]); + + Assert.Contains("first application", firstHtml); + Assert.DoesNotContain("second application", firstHtml); + Assert.Contains("second application", secondHtml); + Assert.DoesNotContain("first application", secondHtml); + Assert.DoesNotContain("Exception Groups", emptyHtml); + first.Clear(); + Assert.Contains("second application", HtmlRenderer.RenderIndexPage([], exceptionGroups: second.ExceptionGroups.Values.ToArray())); + } + + [Fact] + public async Task Two_running_applications_display_their_own_exception_groups() + { + await using var first = await DebugProbeWebApplication.CreateAsync(Environments.Development, + endpoints => endpoints.MapGet("/failure", () => Results.Text("System.Exception: first application"))); + await using var second = await DebugProbeWebApplication.CreateAsync(Environments.Development, + endpoints => endpoints.MapGet("/failure", () => Results.Text("System.Exception: second application"))); + + await Task.WhenAll(first.Client.GetAsync("/failure"), second.Client.GetAsync("/failure")); + var pages = await Task.WhenAll(first.Client.GetStringAsync("/debug"), second.Client.GetStringAsync("/debug")); + + Assert.Contains("first application", pages[0]); + Assert.DoesNotContain("second application", pages[0]); + Assert.Contains("second application", pages[1]); + Assert.DoesNotContain("first application", pages[1]); + await first.Client.PostAsync("/debug/clear", null); + Assert.DoesNotContain("Exception Groups", await first.Client.GetStringAsync("/debug")); + Assert.Contains("second application", await second.Client.GetStringAsync("/debug")); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs b/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs index b43aa7a..73b80fa 100644 --- a/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs +++ b/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs @@ -8,6 +8,20 @@ namespace DebugProbe.AspNetCore.Tests.Storage; public class DebugEntryStoreTests { + [Fact] + public void Exception_groups_are_bounded_without_resetting_existing_counts() + { + var store = new DebugEntryStore(new DebugProbeOptions { MaxRequests = 2 }); + foreach (var message in new[] { "first", "first", "second", "third" }) + store.Add(new DebugEntry { Id = Guid.NewGuid().ToString(), ResponseBody = $"System.Exception: {message}" }); + + Assert.Equal(2, store.GetAll().Count); + Assert.Equal(2, store.ExceptionGroups.Count); + Assert.DoesNotContain(store.ExceptionGroups.Values, group => group.SampleMessage == "first"); + store.Add(new DebugEntry { Id = "next", ResponseBody = "System.Exception: third" }); + Assert.Equal(2, store.ExceptionGroups.Values.Single(group => group.SampleMessage == "third").Count); + } + [Fact] public void Add_identical_type_and_message_increments_count() { diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index eaf0ae6..5f9024d 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -75,59 +75,6 @@ h4 { margin-bottom: 14px; } -.stats-bar { - display: grid; - grid-template-columns: repeat(5, minmax(150px, 1fr)); - gap: 10px; - margin-bottom: 14px; -} - -.stat-tile { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 52px; - padding: 0 16px; - background: #fff; - border: 1px solid #e9e9e9; - border-radius: 8px; - text-align: center; -} - -.stat-tile strong { - color: #1f2937; - font-size: 22px; - line-height: 1; -} - -.stat-tile span { - color: #666; - font-size: 13px; - font-weight: 700; - text-transform: uppercase; -} - -.trend-arrow { - display: inline-block; - font-size: 16px; - margin-left: 4px; - vertical-align: middle; - line-height: 1; -} - -.trend-up { - color: #e74c3c; -} - -.trend-down { - color: #27ae60; -} - -.trend-neutral { - color: #9ca3af; -} - .filters input, .filters select { @@ -687,10 +634,6 @@ tbody tr:last-child td { grid-template-columns: 1fr; } - .stats-bar { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .compare-controls { grid-template-columns: 1fr; } diff --git a/DebugProbe.AspNetCore/Assets/html/_shared/layout.html b/DebugProbe.AspNetCore/Assets/html/_shared/layout.html index fd65aad..8b18b7a 100644 --- a/DebugProbe.AspNetCore/Assets/html/_shared/layout.html +++ b/DebugProbe.AspNetCore/Assets/html/_shared/layout.html @@ -1,19 +1,19 @@ - + {{styles}} - +
{{env_block}}
{{content}} - - - + + + diff --git a/DebugProbe.AspNetCore/Assets/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html index cf30155..8c6a3ed 100644 --- a/DebugProbe.AspNetCore/Assets/html/details.html +++ b/DebugProbe.AspNetCore/Assets/html/details.html @@ -1,6 +1,6 @@
- ← Back + ← Back
diff --git a/DebugProbe.AspNetCore/Assets/html/index.html b/DebugProbe.AspNetCore/Assets/html/index.html index e330e94..19d6593 100644 --- a/DebugProbe.AspNetCore/Assets/html/index.html +++ b/DebugProbe.AspNetCore/Assets/html/index.html @@ -6,31 +6,6 @@

Requests

-
-
- {{total_requests}} - Requests -
-
- {{avg_response_time}} - Avg -
-
- {{slow_requests}} - Over 1s -
-
- {{error_rate}}{{error_trend}} - Errors -
-
-
- {{sparkline}} -
- Activity -
-
-