diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index a63b99b..5103e72 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -125,4 +125,21 @@ public void SlowRequestThresholdMs_can_be_configured() var options = provider.GetRequiredService(); Assert.Equal(500, options.SlowRequestThresholdMs); } + + [Fact] + public void AllowRedactionPreview_true_and_AllowUiInProduction_true_throws_InvalidOperationException() + { + var services = new ServiceCollection(); + + var exception = Assert.Throws(() => + services.AddDebugProbe(options => + { + options.AllowRedactionPreview = true; + options.AllowUiInProduction = true; + })); + + Assert.Contains("AllowRedactionPreview", exception.Message); + Assert.Contains("AllowUiInProduction", exception.Message); + } } + diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbePinEndpointTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbePinEndpointTests.cs new file mode 100644 index 0000000..7f54409 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbePinEndpointTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Text.Json; +using DebugProbe.AspNetCore.Storage; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace DebugProbe.AspNetCore.Tests.Extensions; + +public class DebugProbePinEndpointTests +{ + [Fact] + public async Task Pin_endpoint_toggles_pin_state_and_returns_200() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok"))); + + await app.Client.GetAsync("/hello"); + var entry = app.SingleEntry; + + var pinRes = await app.Client.PostAsync($"/debug/pin/{entry.Id}", null); + Assert.Equal(HttpStatusCode.OK, pinRes.StatusCode); + + var body = JsonDocument.Parse(await pinRes.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(entry.Id, body.GetProperty("id").GetString()); + Assert.True(body.GetProperty("isPinned").GetBoolean()); + + // Second call toggles back to unpinned + var unpinRes = await app.Client.PostAsync($"/debug/pin/{entry.Id}", null); + Assert.Equal(HttpStatusCode.OK, unpinRes.StatusCode); + + var unpinBody = JsonDocument.Parse(await unpinRes.Content.ReadAsStringAsync()).RootElement; + Assert.False(unpinBody.GetProperty("isPinned").GetBoolean()); + } + + [Fact] + public async Task Pin_endpoint_returns_409_when_cap_is_reached() + { + // MaxEntries large enough that normal eviction doesn't interfere + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")), + options => options.MaxEntries = 100); + + // Create MaxPinnedEntries + 1 entries + for (int i = 0; i <= DebugEntryStore.MaxPinnedEntries; i++) + { + await app.Client.GetAsync("/hello"); + } + + var all = app.Store.GetAll(); + + // Pin the first MaxPinnedEntries entries + for (int i = 0; i < DebugEntryStore.MaxPinnedEntries; i++) + { + var pinRes = await app.Client.PostAsync($"/debug/pin/{all[i].Id}", null); + Assert.Equal(HttpStatusCode.OK, pinRes.StatusCode); + } + + // Attempt to pin one more — should 409 + var overflow = all[DebugEntryStore.MaxPinnedEntries]; + var overflowRes = await app.Client.PostAsync($"/debug/pin/{overflow.Id}", null); + Assert.Equal(HttpStatusCode.Conflict, overflowRes.StatusCode); + + var errBody = JsonDocument.Parse(await overflowRes.Content.ReadAsStringAsync()).RootElement; + Assert.True(errBody.TryGetProperty("error", out _)); + } + + [Fact] + public async Task Pin_endpoint_is_not_mapped_in_production_by_default() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok"))); + + await app.Client.GetAsync("/hello"); + var entry = app.SingleEntry; + + var res = await app.Client.PostAsync($"/debug/pin/{entry.Id}", null); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + } + + [Fact] + public async Task Pin_endpoint_is_mapped_in_production_when_allowed() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")), + options => options.AllowUiInProduction = true); + + await app.Client.GetAsync("/hello"); + var entry = app.SingleEntry; + + var res = await app.Client.PostAsync($"/debug/pin/{entry.Id}", null); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewEndToEndTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewEndToEndTests.cs new file mode 100644 index 0000000..4ac49aa --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewEndToEndTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Hosting; +using Xunit; +using Xunit.Abstractions; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +public class RedactionPreviewEndToEndTests(ITestOutputHelper output) +{ + private static readonly Action ConfigureOptionsBase = options => + { + options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"]; + options.RedactedJsonFields = ["password"]; + }; + + [Fact] + public async Task TestA_BaseRedactionStillWorks() + { + output.WriteLine("=== TEST A: Base Redaction Still Works ==="); + + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapPost("/delay/50", async ctx => + { + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync("{\"ok\":true}"); + }), + configureOptions: options => + { + ConfigureOptionsBase(options); + options.AllowRedactionPreview = false; + }); + + using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50") + { + Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json") + }; + req.Headers.Add("X-Api-Key", "secret-key-999"); + + var res = await app.Client.SendAsync(req); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + + var entry = app.SingleEntry; + var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}"); + Assert.Equal(HttpStatusCode.OK, jsonRes.StatusCode); + var jsonRaw = await jsonRes.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(jsonRaw); + var root = doc.RootElement; + + // ASSERT 1: requestHeaders["X-Api-Key"] == "[REDACTED]" + var requestHeaders = root.GetProperty("requestHeaders"); + var apiKeyHeader = requestHeaders.GetProperty("X-Api-Key").GetString(); + Assert.Equal("[REDACTED]", apiKeyHeader); + + // ASSERT 2: "secret-key-999" does not appear anywhere in the JSON response + Assert.DoesNotContain("secret-key-999", jsonRaw); + + output.WriteLine("[PASS] TEST A: Base redaction verified successfully. Header is [REDACTED] and secret-key-999 is absent."); + } + + [Fact] + public async Task TestB_PreviewOff_NoOriginalValuesRetained() + { + output.WriteLine("=== TEST B: Preview OFF (AllowRedactionPreview=false) ==="); + + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapPost("/delay/50", async ctx => + { + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync("{\"ok\":true}"); + }), + configureOptions: options => + { + ConfigureOptionsBase(options); + options.AllowRedactionPreview = false; + }); + + using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50") + { + Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json") + }; + req.Headers.Add("X-Api-Key", "secret-key-999"); + + await app.Client.SendAsync(req); + var entry = app.SingleEntry; + + // JSON check + var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}"); + var jsonRaw = await jsonRes.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(jsonRaw); + var root = doc.RootElement; + + // ASSERT 1: originalRequestHeaders is empty + var origHeaders = root.GetProperty("originalRequestHeaders"); + Assert.Equal(0, origHeaders.EnumerateObject().Count()); + + // ASSERT 2: "secret-key-999" does not appear anywhere in the JSON response + Assert.DoesNotContain("secret-key-999", jsonRaw); + + // HTML check + var htmlRes = await app.Client.GetAsync($"/debug/{entry.Id}"); + Assert.Equal(HttpStatusCode.OK, htmlRes.StatusCode); + var htmlRaw = await htmlRes.Content.ReadAsStringAsync(); + + // ASSERT 3: No "Redaction Preview" toggle/banner appears in HTML source + Assert.DoesNotContain("Redaction Preview", htmlRaw); + Assert.DoesNotContain("secret-key-999", htmlRaw); + + output.WriteLine("[PASS] TEST B: Preview OFF verified. No original values stored or displayed in JSON or HTML."); + } + + [Fact] + public async Task TestC_PreviewOn_Development_OriginalValuesRetainedAndGatedCorrectly() + { + output.WriteLine("=== TEST C: Preview ON + Development Environment ==="); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Development, + endpoints => endpoints.MapPost("/delay/50", async ctx => + { + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync("{\"ok\":true}"); + }), + configureOptions: options => + { + ConfigureOptionsBase(options); + options.AllowRedactionPreview = true; + }); + + using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50") + { + Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json") + }; + req.Headers.Add("X-Api-Key", "secret-key-999"); + + await app.Client.SendAsync(req); + var entry = app.SingleEntry; + + // JSON check + var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}"); + var jsonRaw = await jsonRes.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(jsonRaw); + var root = doc.RootElement; + + // ASSERT 1: originalRequestHeaders["X-Api-Key"] == "secret-key-999" + var origHeaders = root.GetProperty("originalRequestHeaders"); + Assert.Equal("secret-key-999", origHeaders.GetProperty("X-Api-Key").GetString()); + + // ASSERT 2: requestHeaders["X-Api-Key"] still shows "[REDACTED]" + var requestHeaders = root.GetProperty("requestHeaders"); + Assert.Equal("[REDACTED]", requestHeaders.GetProperty("X-Api-Key").GetString()); + + // HTML check + var htmlRes = await app.Client.GetAsync($"/debug/{entry.Id}"); + Assert.Equal(HttpStatusCode.OK, htmlRes.StatusCode); + var htmlRaw = await htmlRes.Content.ReadAsStringAsync(); + + // ASSERT 3: "Redaction Preview — local only" banner IS present in HTML + Assert.Contains("Redaction Preview — local only", htmlRaw); + Assert.Contains("secret-key-999", htmlRaw); + + output.WriteLine("[PASS] TEST C: Preview ON + Development verified. Original value present in preview, default view remains redacted, HTML banner rendered."); + } + + [Fact] + public async Task TestD_PreviewOn_Production_SecurityGateCheck() + { + output.WriteLine("=== TEST D: Preview ON + Production Environment (Security Gate Check) ==="); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapPost("/delay/50", async ctx => + { + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync("{\"ok\":true}"); + }), + configureOptions: options => + { + ConfigureOptionsBase(options); + options.AllowRedactionPreview = true; + // AllowUiInProduction is NOT set (remains false as required by validator when AllowRedactionPreview=true) + }); + + using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50") + { + Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json") + }; + req.Headers.Add("X-Api-Key", "secret-key-999"); + + await app.Client.SendAsync(req); + var entry = app.SingleEntry; + + // 1. Rendered HTML detail page check + var htmlRaw = DebugProbe.AspNetCore.Internal.Rendering.HtmlRenderer.RenderDetailsPage( + entry, + app.Store.GetEnvironment(entry), + entry.RequestBody, + entry.ResponseBody, + new DebugProbeOptions { AllowRedactionPreview = true }); + + // ASSERT: no "Redaction Preview" toggle/banner appears in HTML, and no "secret-key-999" appears anywhere in raw HTML + Assert.DoesNotContain("Redaction Preview", htmlRaw); + Assert.DoesNotContain("secret-key-999", htmlRaw); + + output.WriteLine("[PASS] TEST D (HTML): HtmlRenderer correctly suppressed Redaction Preview banner in Production. Secret does not appear in HTML."); + + // 2. Check JSON endpoint / storage layer behavior + var isOriginalHeadersPopulated = entry.OriginalRequestHeaders.ContainsKey("X-Api-Key") + && entry.OriginalRequestHeaders["X-Api-Key"] == "secret-key-999"; + + if (isOriginalHeadersPopulated) + { + output.WriteLine("[OBSERVATION - TEST D] JSON/Storage Layer: originalRequestHeaders IS populated with 'secret-key-999' in memory/storage because DebugProbeMiddleware captures originals whenever AllowRedactionPreview=true (gated by config property, not by EnvironmentUtils in middleware)."); + } + else + { + output.WriteLine("[OBSERVATION - TEST D] JSON/Storage Layer: originalRequestHeaders is NOT populated."); + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewTests.cs new file mode 100644 index 0000000..14f2e97 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Middleware/RedactionPreviewTests.cs @@ -0,0 +1,91 @@ +using System.Text; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Xunit; + +namespace DebugProbe.AspNetCore.Tests.Middleware; + +/// +/// Tests that AllowRedactionPreview populates OriginalXxx fields on DebugEntry +/// only when the feature is enabled, and never modifies the redacted values. +/// +public class RedactionPreviewTests +{ + [Fact] + public async Task AllowRedactionPreview_false_does_not_populate_original_fields() + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapPost("/orders", async context => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"ok\":true,\"refreshToken\":\"response-token\"}"); + }), + options => + { + options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"]; + options.RedactedJsonFields = ["password"]; + options.AllowRedactionPreview = false; // explicit default + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/orders") + { + Content = new StringContent("{\"password\":\"secret\"}", Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Api-Key", "header-secret"); + + await app.Client.SendAsync(request); + var entry = app.SingleEntry; + + // Redaction must still be applied to the regular fields + Assert.Equal("[REDACTED]", entry.RequestHeaders["X-Api-Key"]); + Assert.Contains("\"password\":\"[REDACTED]\"", entry.RequestBody); + + // Original fields must be empty when preview is disabled + Assert.Empty(entry.OriginalRequestHeaders); + Assert.Null(entry.OriginalRequestBody); + Assert.Null(entry.OriginalResponseBody); + Assert.Null(entry.OriginalQuery); + } + + [Fact] + public async Task AllowRedactionPreview_true_populates_original_fields_alongside_redacted() + { + await using var app = await DebugProbeTestApp.CreateAsync( + endpoints => endpoints.MapPost("/orders", async context => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"ok\":true,\"refreshToken\":\"response-token\"}"); + }), + options => + { + options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"]; + options.RedactedJsonFields = ["password", "refreshToken"]; + options.AllowRedactionPreview = true; + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/orders?api_key=secret") + { + Content = new StringContent("{\"password\":\"s3cr3t\"}", Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Api-Key", "header-secret"); + + await app.Client.SendAsync(request); + var entry = app.SingleEntry; + + // Redacted values must still be applied as normal + Assert.Equal("[REDACTED]", entry.RequestHeaders["X-Api-Key"]); + Assert.Contains("\"password\":\"[REDACTED]\"", entry.RequestBody); + + // Original headers must contain the raw value + Assert.True(entry.OriginalRequestHeaders.ContainsKey("X-Api-Key")); + Assert.Equal("header-secret", entry.OriginalRequestHeaders["X-Api-Key"]); + + // Original body must contain the real secret + Assert.NotNull(entry.OriginalRequestBody); + Assert.Contains("s3cr3t", entry.OriginalRequestBody); + + // Original response body must contain the raw token + Assert.NotNull(entry.OriginalResponseBody); + Assert.Contains("response-token", entry.OriginalResponseBody); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs index 813946e..ff2438b 100644 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -523,4 +523,126 @@ public void Render_details_page_outgoing_call_boundary_minus_one() Assert.Contains("999 ms", html); Assert.DoesNotContain(" 999 ms + { + new() { Id = "r1", Method = "GET", Path = "/api/test", StatusCode = 200, Timestamp = DateTimeOffset.UtcNow }, + new() { Id = "r2", Method = "POST", Path = "/api/data", StatusCode = 201, Timestamp = DateTimeOffset.UtcNow } + }; + + var html = HtmlRenderer.RenderIndexPage(entries); + + Assert.DoesNotContain("Pinned Traces", html); + Assert.DoesNotContain("class=\"clickable-row pinned-row\"", html); + Assert.DoesNotContain("class=\"pin-btn pin-btn--active\"", html); + // Pin buttons for unpinned entries should still be present + Assert.Contains("class=\"pin-btn\"", html); + } + + + + // ----------------------------------------------------------------------- + // Redaction Preview feature: HtmlRenderer two-gate tests + // ----------------------------------------------------------------------- + + [Fact] + public void Details_page_renders_redaction_preview_when_both_gates_are_true() + { + var entry = CreateEntry(); + entry.RequestHeaders["X-Api-Key"] = "[REDACTED]"; + entry.OriginalRequestHeaders["X-Api-Key"] = "secret-key-123"; + entry.RequestBody = "{\"password\":\"[REDACTED]\"}"; + entry.OriginalRequestBody = "{\"password\":\"super-secret\"}"; + + var env = new DebugEnvironment { Environment = "Development" }; + + var options = new DebugProbeOptions { AllowRedactionPreview = true }; + + var html = HtmlRenderer.RenderDetailsPage(entry, env, entry.RequestBody, "{}", options); + + Assert.Contains("Redaction Preview — local only", html); + Assert.Contains("secret-key-123", html); + Assert.Contains("super-secret", html); + } + + [Fact] + public void Details_page_does_not_render_preview_when_option_is_false_even_in_development() + { + var entry = CreateEntry(); + entry.RequestHeaders["X-Api-Key"] = "[REDACTED]"; + entry.OriginalRequestHeaders["X-Api-Key"] = "secret-key-123"; + + var env = new DebugEnvironment { Environment = "Development" }; + + var options = new DebugProbeOptions { AllowRedactionPreview = false }; + + var html = HtmlRenderer.RenderDetailsPage(entry, env, entry.RequestBody, "{}", options); + + Assert.DoesNotContain("Redaction Preview — local only", html); + Assert.DoesNotContain("secret-key-123", html); + } + + [Fact] + public void Details_page_does_not_render_preview_in_production_even_if_option_is_true() + { + var entry = CreateEntry(); + entry.RequestHeaders["X-Api-Key"] = "[REDACTED]"; + entry.OriginalRequestHeaders["X-Api-Key"] = "secret-key-123"; + + var env = new DebugEnvironment { Environment = "Production" }; + + var options = new DebugProbeOptions { AllowRedactionPreview = true }; + + var html = HtmlRenderer.RenderDetailsPage(entry, env, entry.RequestBody, "{}", options); + + Assert.DoesNotContain("Redaction Preview — local only", html); + Assert.DoesNotContain("secret-key-123", html); + } } + + + diff --git a/DebugProbe.AspNetCore.Tests/Storage/PinTests.cs b/DebugProbe.AspNetCore.Tests/Storage/PinTests.cs new file mode 100644 index 0000000..1ff15b5 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Storage/PinTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace DebugProbe.AspNetCore.Tests.Storage; + +public class PinTests +{ + // ----------------------------------------------------------------------- + // TryPin – basic toggle + // ----------------------------------------------------------------------- + + [Fact] + public void TryPin_on_unpinned_entry_marks_it_pinned() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry = new DebugEntry { Id = "e1", IsPinned = false }; + store.Add(entry); + + var (success, isPinned, error) = store.TryPin("e1"); + + Assert.True(success); + Assert.True(isPinned); + Assert.Null(error); + Assert.True(store.Get("e1")!.IsPinned); + } + + [Fact] + public void TryPin_on_pinned_entry_unpins_it() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry = new DebugEntry { Id = "e1", IsPinned = false }; + store.Add(entry); + store.TryPin("e1"); // pin it + + var (success, isPinned, error) = store.TryPin("e1"); // unpin it + + Assert.True(success); + Assert.False(isPinned); + Assert.Null(error); + Assert.False(store.Get("e1")!.IsPinned); + } + + [Fact] + public void TryPin_unknown_id_returns_failure() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + + var (success, _, error) = store.TryPin("does-not-exist"); + + Assert.False(success); + Assert.NotNull(error); + } + + // ----------------------------------------------------------------------- + // Cap enforcement + // ----------------------------------------------------------------------- + + [Fact] + public void TryPin_rejects_when_cap_is_reached() + { + var options = new DebugProbeOptions { MaxEntries = 100 }; + var store = new DebugEntryStore(options); + + // Pin exactly MaxPinnedEntries entries + for (int i = 0; i < DebugEntryStore.MaxPinnedEntries; i++) + { + var e = new DebugEntry { Id = $"e{i}" }; + store.Add(e); + var (ok, _, _) = store.TryPin($"e{i}"); + Assert.True(ok, $"Expected pin of e{i} to succeed"); + } + + // The (MaxPinnedEntries+1)-th entry must be rejected + var overflow = new DebugEntry { Id = "overflow" }; + store.Add(overflow); + + var (success, isPinned, error) = store.TryPin("overflow"); + + Assert.False(success); + Assert.False(isPinned); + Assert.NotNull(error); + Assert.Contains(DebugEntryStore.MaxPinnedEntries.ToString(), error); + } + + // ----------------------------------------------------------------------- + // Eviction protection + // ----------------------------------------------------------------------- + + [Fact] + public void Pinned_entries_are_not_evicted_when_capacity_is_exceeded() + { + // MaxEntries = 3: one pinned slot, two regular FIFO slots + var options = new DebugProbeOptions { MaxEntries = 3 }; + var store = new DebugEntryStore(options); + + var pinned = new DebugEntry { Id = "pinned" }; + store.Add(pinned); + store.TryPin("pinned"); + + // Add 4 more — the FIFO queue will start evicting from position 1 onwards + for (int i = 1; i <= 4; i++) + { + store.Add(new DebugEntry { Id = $"regular-{i}" }); + } + + var all = store.GetAll(); + Assert.Contains(all, e => e.Id == "pinned"); + Assert.All(all.Where(e => e.Id != "pinned"), e => Assert.False(e.IsPinned)); + } + + // ----------------------------------------------------------------------- + // Clear() – full reset + // ----------------------------------------------------------------------- + + [Fact] + public void Clear_removes_pinned_and_unpinned_entries() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry = new DebugEntry { Id = "e1" }; + store.Add(entry); + store.TryPin("e1"); + + store.Add(new DebugEntry { Id = "e2" }); + + store.Clear(); + + Assert.Empty(store.GetAll()); + Assert.Equal(0, store.PinnedCount); + } + + // ----------------------------------------------------------------------- + // GetAll() ordering: pinned entries first + // ----------------------------------------------------------------------- + + [Fact] + public void GetAll_returns_pinned_entries_first() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + store.Add(new DebugEntry { Id = "a" }); + store.Add(new DebugEntry { Id = "b" }); + store.Add(new DebugEntry { Id = "c" }); + store.TryPin("b"); // pin the middle one + + var all = store.GetAll(); + + // Pinned entries must appear before unpinned ones + var firstPinnedIndex = all.FindIndex(e => e.IsPinned); + var firstUnpinnedIndex = all.FindIndex(e => !e.IsPinned); + + Assert.True(firstPinnedIndex < firstUnpinnedIndex, + "Pinned entries should appear before unpinned entries in GetAll()"); + } +} diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index eaf0ae6..8bf5a9a 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -474,6 +474,11 @@ table { width: 100px; } +#requestTable th:nth-child(6), +#requestTable td:nth-child(6) { + width: 110px; +} + .table-wrap { overflow-x: auto; background: #fff; @@ -1029,6 +1034,85 @@ pre { border: 1px solid rgba(231, 76, 60, 0.25); } +.dbp-badge-pinned { + background: rgba(108, 92, 231, 0.12); + color: #6c5ce7; + border: 1px solid rgba(108, 92, 231, 0.28); +} + +/* Pin / unpin button in the dashboard table */ +.pin-btn { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 26px; + padding: 0 8px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #6b7280; + cursor: pointer; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.pin-btn:hover { + background: #f5f3ff; + border-color: #6c5ce7; + color: #6c5ce7; +} + +.pin-btn--active { + background: rgba(108, 92, 231, 0.08); + border-color: rgba(108, 92, 231, 0.35); + color: #6c5ce7; +} + +.pin-btn--active:hover { + background: rgba(108, 92, 231, 0.15); +} + +/* Pinned row highlight in the main table */ +.pinned-row { + background: rgba(108, 92, 231, 0.04); +} + +.pinned-row:hover { + background: rgba(108, 92, 231, 0.09); +} + +/* Redaction preview toggle on detail page */ +.redaction-preview-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(231, 76, 60, 0.07); + border: 1px solid rgba(231, 76, 60, 0.22); + border-radius: 6px; + color: #b42318; + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.redaction-preview-toggle:hover { + background: rgba(231, 76, 60, 0.14); +} + +.redaction-original-value { + display: inline-block; + margin-top: 2px; + padding: 1px 6px; + background: rgba(231, 76, 60, 0.08); + border: 1px dashed rgba(231, 76, 60, 0.3); + border-radius: 4px; + color: #b42318; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + /* ========================= Diff ========================= */ diff --git a/DebugProbe.AspNetCore/Assets/html/index.html b/DebugProbe.AspNetCore/Assets/html/index.html index e330e94..1414670 100644 --- a/DebugProbe.AspNetCore/Assets/html/index.html +++ b/DebugProbe.AspNetCore/Assets/html/index.html @@ -66,6 +66,7 @@

Requests

Path Status Duration + Pin @@ -74,5 +75,5 @@

Requests

- + diff --git a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js index 79a7000..64f14f2 100644 --- a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js @@ -278,6 +278,31 @@ function copyAsMarkdown(btn) { } +/** + * Toggles the pin state of a trace entry. + * On success the page reloads so the pinned section renders server-side. + * On 409 Conflict (pin cap reached) an alert is shown with the server error. + * @param {string} id - The trace entry ID. + * @param {string} prefix - The DebugProbe route prefix (e.g. "/debug"). + */ +async function togglePin(id, prefix) { + try { + const res = await fetch(`${prefix}/pin/${encodeURIComponent(id)}`, { method: "POST" }); + if (res.status === 409) { + const body = await res.json().catch(() => ({})); + alert(body.error ?? "Pin cap reached. Unpin an existing entry first."); + return; + } + if (!res.ok) { + alert("Failed to update pin state. Please try again."); + return; + } + location.reload(); + } catch (e) { + alert("Network error while updating pin state."); + } +} + const clearBtn = document.getElementById("clearBtn"); if (clearBtn) { clearBtn.addEventListener("click", async () => { diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 3375206..dc4baf2 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -160,6 +160,19 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app, Ac }).ExcludeFromDescription(), options); + RequireDebugAuthorization(webApp.MapPost($"{prefix}/pin/{{id}}", (string id, DebugEntryStore store) => + { + var (success, isPinned, error) = store.TryPin(id); + + if (!success) + { + return Results.Conflict(new { error }); + } + + return Results.Ok(new { id, isPinned }); + + }).ExcludeFromDescription(), options); + RequireDebugAuthorization(webApp.Map($"{prefix}/logo.png", ctx => EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png") ).ExcludeFromDescription(), options); diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 55b5a9c..dbb4c18 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -45,6 +45,7 @@ public static string RenderIndexPage(List items, DebugProbeOptions? options ??= new DebugProbeOptions(); var slowRequestThresholdMs = options.SlowRequestThresholdMs; var store = DebugEntryStore.Instance; + var prefix = options.RoutePrefix; var routesWithDiffs = new HashSet(StringComparer.OrdinalIgnoreCase); var routeDiffTooltips = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -89,7 +90,39 @@ public static string RenderIndexPage(List items, DebugProbeOptions? } } - var rows = string.Join("", items.Select(x => + // Build pinned-entries section (shown above the normal table) + var pinnedItems = items.Where(x => x.IsPinned).ToList(); + var pinnedSectionHtml = ""; + if (pinnedItems.Count > 0) + { + var pinnedRows = string.Join("", pinnedItems.Select(x => + { + var pathWithQuery = string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}"; + var badge = RenderSlowBadge(TimeSpan.FromMilliseconds(x.DurationMs), options); + var badgeHtml = string.IsNullOrEmpty(badge) ? "" : " " + badge; + return $@"{x.Timestamp:HH:mm:ss}{Encode(x.Method)}{Encode(pathWithQuery)}{x.StatusCode}{x.DurationMs} ms{badgeHtml}"; + })); + + pinnedSectionHtml = $@" +

📌 Pinned Traces {pinnedItems.Count}

+
+ + + + + + + + + + + + {pinnedRows} +
TimeMethodPathStatusDurationPin
+
"; + } + + var rows = string.Join("", items.Where(x => !x.IsPinned).Select(x => { var pathWithQuery = string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}"; var badge = RenderSlowBadge(TimeSpan.FromMilliseconds(x.DurationMs), options); @@ -108,22 +141,11 @@ public static string RenderIndexPage(List items, DebugProbeOptions? } } - return $@" - - {x.Timestamp:HH:mm:ss} - {Encode(x.Method)} - {Encode(pathWithQuery)}{envDiffBadgeHtml} - {x.StatusCode} - {x.DurationMs} ms{badgeHtml} - "; + return $@"{x.Timestamp:HH:mm:ss}{Encode(x.Method)}{Encode(pathWithQuery)}{envDiffBadgeHtml}{x.StatusCode}{x.DurationMs} ms{badgeHtml}"; })); if (string.IsNullOrEmpty(rows)) - rows = "No data"; + rows = "No data"; var methodOptions = string.Join("", items .Select(x => x.Method) @@ -256,18 +278,25 @@ public static string RenderIndexPage(List items, DebugProbeOptions? } var pageHtml = EmbeddedResources.Index; - if (!string.IsNullOrEmpty(exceptionPanel)) + + // Insert pinned section before the exception panel and before the main table. + // Both are injected before the first
in the template. + var insertionHtml = pinnedSectionHtml + (string.IsNullOrEmpty(exceptionPanel) ? "" : exceptionPanel); + if (!string.IsNullOrEmpty(insertionHtml)) { var idx = pageHtml.IndexOf("
"); if (idx >= 0) { - pageHtml = pageHtml.Insert(idx, exceptionPanel); + pageHtml = pageHtml.Insert(idx, insertionHtml); } } + // The unpinned count is what the visible table shows. + var unpinnedCount = items.Count(x => !x.IsPinned); + return BuildLayout(pageHtml .Replace("{{rows}}", rows) - .Replace("{{total_count}}", items.Count.ToString()) + .Replace("{{total_count}}", unpinnedCount.ToString()) .Replace("{{method_options}}", methodOptions) .Replace("{{total_requests}}", FormatCompactNumber(totalRequests)) .Replace("{{avg_response_time}}", $"{averageResponseMs} ms") @@ -351,9 +380,106 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string .Replace("{{incomingRequest}}", incomingRequest) .Replace("{{incomingResponse}}", incomingResponse); + // Gate 1: config. Gate 2: env. Both must be true for the preview to render. + // Enforced server-side here — there is no client-side CSS/JS gating. + var isDevEnv = string.Equals(e.Environment, "Development", StringComparison.OrdinalIgnoreCase); + if (options.AllowRedactionPreview && isDevEnv) + { + var previewBanner = BuildRedactionPreviewBanner(x); + // Insert the banner just before the first
= 0) + { + content = content.Insert(insertAt, previewBanner); + } + } + return BuildLayout(content); } + private static string BuildRedactionPreviewBanner(DebugEntry x) + { + var hasOriginals = x.OriginalRequestHeaders.Count > 0 + || !string.IsNullOrWhiteSpace(x.OriginalRequestBody) + || !string.IsNullOrWhiteSpace(x.OriginalResponseBody) + || !string.IsNullOrWhiteSpace(x.OriginalQuery); + + if (!hasOriginals) + { + return string.Empty; + } + + var sections = new List(); + + if (!string.IsNullOrWhiteSpace(x.OriginalQuery) && x.OriginalQuery != x.Query) + { + sections.Add(BuildOriginalValueSection("Original Query", x.OriginalQuery)); + } + + var diffHeaders = x.OriginalRequestHeaders + .Where(kv => !x.RequestHeaders.TryGetValue(kv.Key, out var redacted) + || redacted != kv.Value) + .ToList(); + if (diffHeaders.Count > 0) + { + var rows = string.Join("", diffHeaders.Select(kv => + $@"
{Encode(kv.Key)}{Encode(kv.Value)}
")); + sections.Add($@" +
+ Original Request Headers (with secrets){diffHeaders.Count} revealed +
{rows}
+
"); + } + + if (!string.IsNullOrWhiteSpace(x.OriginalRequestBody) && x.OriginalRequestBody != x.RequestBody) + { + sections.Add(BuildOriginalValueSection("Original Request Body (with secrets)", x.OriginalRequestBody)); + } + + if (!string.IsNullOrWhiteSpace(x.OriginalResponseBody) && x.OriginalResponseBody != x.ResponseBody) + { + sections.Add(BuildOriginalValueSection("Original Response Body (with secrets)", x.OriginalResponseBody)); + } + + if (sections.Count == 0) + { + return string.Empty; + } + + return $@" +
+ + + Redaction Preview — local only + Showing pre-redaction values (AllowRedactionPreview=true, Development) + +
+
+ {string.Join("", sections)} +
+
+
"; + } + + private static string BuildOriginalValueSection(string title, string value) + { + var text = string.IsNullOrWhiteSpace(value) ? "" : JsonUtils.Format(value); + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return $@" +
+ {Encode(title)}original - {FormatBytes(text.Length)} +
+ +
{Encode(text)}
+
+
"; + } + public static string RenderComparePage(string localTraceId, string baseUrl, string traceId) { var content = $@" @@ -718,4 +844,9 @@ private static string RenderSlowBadge(TimeSpan duration, DebugProbeOptions optio return string.Empty; } + private static string RenderPinnedBadge() + { + return @"📌 Pinned"; + } + } diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 13a131f..74c51df 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -150,6 +150,22 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) x => x.Key, x => RedactionUtils.RedactHeader(x.Key, x.Value.ToString(), _options)); + // Capture pre-redaction originals for the redaction preview toggle. + // Only done when explicitly enabled — stores raw values in memory. + if (_options.AllowRedactionPreview) + { + entry.OriginalQuery = context.Request.QueryString.ToString(); + + entry.OriginalRequestHeaders = + context.Request.Headers.ToDictionary( + x => x.Key, + x => x.Value.ToString()); + + entry.OriginalRequestBody = HttpContentUtils.Trim(requestBody, maxBodySize); + + entry.OriginalResponseBody = HttpContentUtils.Trim(responseBody, maxBodySize); + } + store.Add(entry); } } diff --git a/DebugProbe.AspNetCore/Models/DebugEntry.cs b/DebugProbe.AspNetCore/Models/DebugEntry.cs index 2abed63..687614e 100644 --- a/DebugProbe.AspNetCore/Models/DebugEntry.cs +++ b/DebugProbe.AspNetCore/Models/DebugEntry.cs @@ -32,5 +32,38 @@ public class DebugEntry public DateTimeOffset Timestamp { get; set; } + /// + /// Whether this entry is pinned (protected from FIFO eviction). + /// Resets to false on application restart — entirely in-memory, no persistence. + /// + public bool IsPinned { get; set; } + + // ----------------------------------------------------------------------- + // Redaction preview fields (only populated when AllowRedactionPreview = true) + // These hold pre-redaction values for local reveal-only display on the detail page. + // They are NEVER serialised to the JSON export endpoint. + // ----------------------------------------------------------------------- + + /// + /// Original (pre-redaction) request headers. Populated only when + /// is true. + /// + public Dictionary OriginalRequestHeaders { get; set; } = new(); + + /// + /// Original (pre-redaction) request body. + /// + public string? OriginalRequestBody { get; set; } + + /// + /// Original (pre-redaction) response body. + /// + public string? OriginalResponseBody { get; set; } + + /// + /// Original (pre-redaction) query string. + /// + public string? OriginalQuery { get; set; } + public List OutgoingRequests { get; set; } = []; } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 1f47949..09f6928 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -111,4 +111,21 @@ public string RoutePrefix ? "/debug" : "/" + value.TrimStart('/'); } + + /// + /// Enables the redaction preview toggle on the trace detail page. + /// When true and the application is running in a local/Development + /// environment, a "Reveal original" button is shown on the detail page that + /// displays the pre-redaction values captured at request time. + /// + /// Both conditions must be met — this flag alone is not sufficient. + /// The environment check is enforced server-side in HtmlRenderer. + /// + /// + /// Setting this to true together with AllowUiInProduction = true + /// will throw an at startup, because + /// doing so could expose raw secrets via the UI in production. + /// + /// + public bool AllowRedactionPreview { get; set; } = false; } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs index 46f8467..6a39482 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs @@ -25,6 +25,15 @@ public ValidateOptionsResult Validate( $"Provided value: {options.TrendLookbackMinutes}."); } + if (options.AllowRedactionPreview && options.AllowUiInProduction) + { + return ValidateOptionsResult.Fail( + "DebugProbe configuration is invalid. " + + "AllowRedactionPreview = true combined with AllowUiInProduction = true is not allowed. " + + "Enabling both could expose pre-redaction secret values through the UI in a production environment. " + + "Either disable AllowRedactionPreview or keep AllowUiInProduction = false."); + } + return ValidateOptionsResult.Success; } } \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs index 421acb7..d1122ee 100644 --- a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs +++ b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs @@ -13,11 +13,27 @@ namespace DebugProbe.AspNetCore.Storage; /// /// Stores captured DebugProbe entries in memory. /// +/// +/// Storage approach (Option B): The existing ConcurrentQueue<DebugEntry> handles the normal +/// FIFO unpinned stream. A separate ConcurrentDictionary<string, DebugEntry> holds pinned +/// entries, which are excluded from normal FIFO eviction. Both collections are merged at read +/// time (GetAll, Get). Total entry count (pinned + unpinned) is still bounded by MaxEntries. +/// +/// Clear() resets both collections — it is a full-session reset; leaving ghost pinned entries +/// from a prior session would create a confusing split-brain state on the dashboard. +/// public class DebugEntryStore { private static readonly Regex GuidRegex = new(@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", RegexOptions.Compiled); private static readonly Regex NumberRegex = new(@"\b\d+(\.\d+)?\b", RegexOptions.Compiled); + /// + /// Maximum number of entries that may be simultaneously pinned. + /// Hardcoded as a constant — pin is a session debugging tool, 5 is generous, + /// and exposing this as a config property would add API surface for marginal benefit. + /// + public const int MaxPinnedEntries = 5; + /// /// Gets the static instance of DebugEntryStore. /// @@ -33,18 +49,31 @@ public class DebugEntryStore /// public DebugEnvironment Environment { get; } + // Option B: separate collections for unpinned (FIFO) and pinned (protected) entries. private readonly ConcurrentQueue _queue = new(); + private readonly ConcurrentDictionary _pinned = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _entryEnvironments = new(); private readonly int _limit; - public DebugEntryStore(DebugProbeOptions options) + // Lock used only during the capacity-trim step in Add() to prevent races + // between concurrent callers when both the pinned and unpinned counts are near the limit. + // The lock scope is deliberately narrow (just the trim loop) so it is not on the hot path. + private readonly object _trimLock = new(); + + public DebugEntryStore(DebugProbeOptions options, Microsoft.Extensions.Hosting.IHostEnvironment? hostEnvironment = null) { Instance = this; _limit = options.MaxEntries; + var envName = hostEnvironment?.EnvironmentName; + if (string.IsNullOrWhiteSpace(envName)) + { + envName = EnvironmentUtils.TryGetEnvironment(); + } + Environment = new DebugEnvironment { - Environment = EnvironmentUtils.TryGetEnvironment(), + Environment = envName, MachineName = System.Environment.MachineName, AssemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), TimeZone = TimeZoneInfo.Local.DisplayName, @@ -92,13 +121,76 @@ public void Add(DebugEntry entry, DebugEnvironment environment) }); } - while (_queue.Count > _limit) + // Trim unpinned entries until total (pinned + unpinned) fits within MaxEntries. + // Only unpinned entries are evicted; pinned entries are protected. + // The narrow lock here prevents two concurrent Add() calls from both reading + // stale counts and over-trimming or under-trimming. + lock (_trimLock) + { + while (_queue.Count + _pinned.Count > _limit) + { + // ExceptionGroups counts are a running tally and must NOT be decremented on eviction. + if (_queue.TryDequeue(out var evicted) && evicted.Id != null) + { + _entryEnvironments.TryRemove(evicted.Id, out _); + } + else + { + // Queue is empty — all remaining capacity is consumed by pinned entries. + break; + } + } + } + } + + /// + /// Attempts to toggle the pinned state of the entry with the given ID. + /// Returns (success: true, newPinnedState, error: null) on success, + /// or (success: false, _, error) when the cap would be exceeded. + /// + public (bool Success, bool IsPinned, string? Error) TryPin(string id) + { + // Look in both collections. + var entry = _queue.FirstOrDefault(x => x.Id == id) + ?? (_pinned.TryGetValue(id, out var p) ? p : null); + + if (entry is null) + { + return (false, false, "Entry not found."); + } + + if (entry.IsPinned) { - // ExceptionGroups counts are a running tally and must NOT be decremented on MaxEntries eviction. - if (_queue.TryDequeue(out var evicted) && evicted.Id != null) + // Unpin: remove from pinned dict, mark IsPinned=false, + // re-add to queue so it participates in normal FIFO eviction again. + if (_pinned.TryRemove(id, out _)) { - _entryEnvironments.TryRemove(evicted.Id, out _); + entry.IsPinned = false; + + // Re-enqueue so the entry is visible in the normal stream. + // This may briefly exceed MaxEntries by one before the next Add() trims, + // which is acceptable — the entry was already stored. + _queue.Enqueue(entry); } + + return (true, false, null); + } + else + { + // Pin: enforce the cap first. + if (_pinned.Count >= MaxPinnedEntries) + { + return (false, false, + $"Pin cap reached. At most {MaxPinnedEntries} entries may be pinned simultaneously."); + } + + entry.IsPinned = true; + _pinned[id] = entry; + + // The entry remains in _queue too until it would have been evicted naturally, + // but GetAll() deduplicates by ID, so it is only shown once on the dashboard. + + return (true, true, null); } } @@ -111,23 +203,49 @@ public DebugEnvironment GetEnvironment(DebugEntry entry) return Environment; } + /// + /// Returns all stored entries, with pinned entries first (deduplicated by ID). + /// public List GetAll() { - return _queue.ToList(); + // Merge: pinned entries + unpinned entries (exclude IDs already in pinned). + var pinnedIds = new HashSet(_pinned.Keys, StringComparer.Ordinal); + var unpinned = _queue.Where(e => e.Id == null || !pinnedIds.Contains(e.Id)).ToList(); + + var result = new List(_pinned.Values); + result.AddRange(unpinned); + return result; } public DebugEntry? Get(string id) { + // Check pinned first (O(1)), then queue. + if (_pinned.TryGetValue(id, out var pinned)) + { + return pinned; + } + return _queue.FirstOrDefault(x => x.Id == id); } + /// + /// Clears all entries, including pinned ones. + /// This is a full session reset; leaving pinned entries after a clear would + /// create a confusing split-brain state where the dashboard shows ghost entries. + /// public void Clear() { while (_queue.TryDequeue(out _)) { } + _pinned.Clear(); _entryEnvironments.Clear(); ExceptionGroups.Clear(); } + /// + /// Returns the count of currently pinned entries. + /// + public int PinnedCount => _pinned.Count; + private static bool TryParseException(string? body, out string type, out string message) { type = string.Empty; diff --git a/DebugProbe.SampleApi/Program.cs b/DebugProbe.SampleApi/Program.cs index 67a6241..5c2caab 100644 --- a/DebugProbe.SampleApi/Program.cs +++ b/DebugProbe.SampleApi/Program.cs @@ -11,7 +11,8 @@ builder.Services.AddDebugProbe(options => { options.MaxEntries = 10; - options.AllowUiInProduction = true; + options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"]; + options.AllowRedactionPreview = false; }); var app = builder.Build();