Skip to content

Commit 813b7ea

Browse files
committed
feat: redaction preview toggle (#10)
- DebugProbeOptions: AllowRedactionPreview (default false) - DebugProbeOptionsValidator: fail fast if AllowRedactionPreview=true && AllowUiInProduction=true - DebugEntry: OriginalRequestHeaders, OriginalRequestBody, OriginalResponseBody, OriginalQuery - DebugProbeMiddleware: capture pre-redaction raw values into OriginalXxx when AllowRedactionPreview=true - HtmlRenderer: server-side two-gate check (AllowRedactionPreview=true AND Development env); render collapsible preview banner with original values - CSS: redaction-preview-toggle, redaction-original-value styles - Tests: 7 new tests (options validation, middleware capture, renderer two-gate check)
1 parent 9341735 commit 813b7ea

11 files changed

Lines changed: 583 additions & 6 deletions

File tree

DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,21 @@ public void SlowRequestThresholdMs_can_be_configured()
125125
var options = provider.GetRequiredService<DebugProbeOptions>();
126126
Assert.Equal(500, options.SlowRequestThresholdMs);
127127
}
128+
129+
[Fact]
130+
public void AllowRedactionPreview_true_and_AllowUiInProduction_true_throws_InvalidOperationException()
131+
{
132+
var services = new ServiceCollection();
133+
134+
var exception = Assert.Throws<InvalidOperationException>(() =>
135+
services.AddDebugProbe(options =>
136+
{
137+
options.AllowRedactionPreview = true;
138+
options.AllowUiInProduction = true;
139+
}));
140+
141+
Assert.Contains("AllowRedactionPreview", exception.Message);
142+
Assert.Contains("AllowUiInProduction", exception.Message);
143+
}
128144
}
145+
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text;
4+
using System.Text.Json;
5+
using DebugProbe.AspNetCore.Models;
6+
using DebugProbe.AspNetCore.Options;
7+
using DebugProbe.AspNetCore.Tests.Infrastructure;
8+
using Microsoft.Extensions.Hosting;
9+
using Xunit;
10+
using Xunit.Abstractions;
11+
12+
namespace DebugProbe.AspNetCore.Tests.Middleware;
13+
14+
public class RedactionPreviewEndToEndTests(ITestOutputHelper output)
15+
{
16+
private static readonly Action<DebugProbeOptions> ConfigureOptionsBase = options =>
17+
{
18+
options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"];
19+
options.RedactedJsonFields = ["password"];
20+
};
21+
22+
[Fact]
23+
public async Task TestA_BaseRedactionStillWorks()
24+
{
25+
output.WriteLine("=== TEST A: Base Redaction Still Works ===");
26+
27+
await using var app = await DebugProbeWebApplication.CreateAsync(
28+
Environments.Development,
29+
endpoints => endpoints.MapPost("/delay/50", async ctx =>
30+
{
31+
ctx.Response.ContentType = "application/json";
32+
await ctx.Response.WriteAsync("{\"ok\":true}");
33+
}),
34+
configureOptions: options =>
35+
{
36+
ConfigureOptionsBase(options);
37+
options.AllowRedactionPreview = false;
38+
});
39+
40+
using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50")
41+
{
42+
Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json")
43+
};
44+
req.Headers.Add("X-Api-Key", "secret-key-999");
45+
46+
var res = await app.Client.SendAsync(req);
47+
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
48+
49+
var entry = app.SingleEntry;
50+
var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}");
51+
Assert.Equal(HttpStatusCode.OK, jsonRes.StatusCode);
52+
var jsonRaw = await jsonRes.Content.ReadAsStringAsync();
53+
54+
using var doc = JsonDocument.Parse(jsonRaw);
55+
var root = doc.RootElement;
56+
57+
// ASSERT 1: requestHeaders["X-Api-Key"] == "[REDACTED]"
58+
var requestHeaders = root.GetProperty("requestHeaders");
59+
var apiKeyHeader = requestHeaders.GetProperty("X-Api-Key").GetString();
60+
Assert.Equal("[REDACTED]", apiKeyHeader);
61+
62+
// ASSERT 2: "secret-key-999" does not appear anywhere in the JSON response
63+
Assert.DoesNotContain("secret-key-999", jsonRaw);
64+
65+
output.WriteLine("[PASS] TEST A: Base redaction verified successfully. Header is [REDACTED] and secret-key-999 is absent.");
66+
}
67+
68+
[Fact]
69+
public async Task TestB_PreviewOff_NoOriginalValuesRetained()
70+
{
71+
output.WriteLine("=== TEST B: Preview OFF (AllowRedactionPreview=false) ===");
72+
73+
await using var app = await DebugProbeWebApplication.CreateAsync(
74+
Environments.Development,
75+
endpoints => endpoints.MapPost("/delay/50", async ctx =>
76+
{
77+
ctx.Response.ContentType = "application/json";
78+
await ctx.Response.WriteAsync("{\"ok\":true}");
79+
}),
80+
configureOptions: options =>
81+
{
82+
ConfigureOptionsBase(options);
83+
options.AllowRedactionPreview = false;
84+
});
85+
86+
using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50")
87+
{
88+
Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json")
89+
};
90+
req.Headers.Add("X-Api-Key", "secret-key-999");
91+
92+
await app.Client.SendAsync(req);
93+
var entry = app.SingleEntry;
94+
95+
// JSON check
96+
var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}");
97+
var jsonRaw = await jsonRes.Content.ReadAsStringAsync();
98+
using var doc = JsonDocument.Parse(jsonRaw);
99+
var root = doc.RootElement;
100+
101+
// ASSERT 1: originalRequestHeaders is empty
102+
var origHeaders = root.GetProperty("originalRequestHeaders");
103+
Assert.Equal(0, origHeaders.EnumerateObject().Count());
104+
105+
// ASSERT 2: "secret-key-999" does not appear anywhere in the JSON response
106+
Assert.DoesNotContain("secret-key-999", jsonRaw);
107+
108+
// HTML check
109+
var htmlRes = await app.Client.GetAsync($"/debug/{entry.Id}");
110+
Assert.Equal(HttpStatusCode.OK, htmlRes.StatusCode);
111+
var htmlRaw = await htmlRes.Content.ReadAsStringAsync();
112+
113+
// ASSERT 3: No "Redaction Preview" toggle/banner appears in HTML source
114+
Assert.DoesNotContain("Redaction Preview", htmlRaw);
115+
Assert.DoesNotContain("secret-key-999", htmlRaw);
116+
117+
output.WriteLine("[PASS] TEST B: Preview OFF verified. No original values stored or displayed in JSON or HTML.");
118+
}
119+
120+
[Fact]
121+
public async Task TestC_PreviewOn_Development_OriginalValuesRetainedAndGatedCorrectly()
122+
{
123+
output.WriteLine("=== TEST C: Preview ON + Development Environment ===");
124+
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
125+
126+
await using var app = await DebugProbeWebApplication.CreateAsync(
127+
Environments.Development,
128+
endpoints => endpoints.MapPost("/delay/50", async ctx =>
129+
{
130+
ctx.Response.ContentType = "application/json";
131+
await ctx.Response.WriteAsync("{\"ok\":true}");
132+
}),
133+
configureOptions: options =>
134+
{
135+
ConfigureOptionsBase(options);
136+
options.AllowRedactionPreview = true;
137+
});
138+
139+
using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50")
140+
{
141+
Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json")
142+
};
143+
req.Headers.Add("X-Api-Key", "secret-key-999");
144+
145+
await app.Client.SendAsync(req);
146+
var entry = app.SingleEntry;
147+
148+
// JSON check
149+
var jsonRes = await app.Client.GetAsync($"/debug/json/{entry.Id}");
150+
var jsonRaw = await jsonRes.Content.ReadAsStringAsync();
151+
using var doc = JsonDocument.Parse(jsonRaw);
152+
var root = doc.RootElement;
153+
154+
// ASSERT 1: originalRequestHeaders["X-Api-Key"] == "secret-key-999"
155+
var origHeaders = root.GetProperty("originalRequestHeaders");
156+
Assert.Equal("secret-key-999", origHeaders.GetProperty("X-Api-Key").GetString());
157+
158+
// ASSERT 2: requestHeaders["X-Api-Key"] still shows "[REDACTED]"
159+
var requestHeaders = root.GetProperty("requestHeaders");
160+
Assert.Equal("[REDACTED]", requestHeaders.GetProperty("X-Api-Key").GetString());
161+
162+
// HTML check
163+
var htmlRes = await app.Client.GetAsync($"/debug/{entry.Id}");
164+
Assert.Equal(HttpStatusCode.OK, htmlRes.StatusCode);
165+
var htmlRaw = await htmlRes.Content.ReadAsStringAsync();
166+
167+
// ASSERT 3: "Redaction Preview — local only" banner IS present in HTML
168+
Assert.Contains("Redaction Preview — local only", htmlRaw);
169+
Assert.Contains("secret-key-999", htmlRaw);
170+
171+
output.WriteLine("[PASS] TEST C: Preview ON + Development verified. Original value present in preview, default view remains redacted, HTML banner rendered.");
172+
}
173+
174+
[Fact]
175+
public async Task TestD_PreviewOn_Production_SecurityGateCheck()
176+
{
177+
output.WriteLine("=== TEST D: Preview ON + Production Environment (Security Gate Check) ===");
178+
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production");
179+
180+
await using var app = await DebugProbeWebApplication.CreateAsync(
181+
Environments.Production,
182+
endpoints => endpoints.MapPost("/delay/50", async ctx =>
183+
{
184+
ctx.Response.ContentType = "application/json";
185+
await ctx.Response.WriteAsync("{\"ok\":true}");
186+
}),
187+
configureOptions: options =>
188+
{
189+
ConfigureOptionsBase(options);
190+
options.AllowRedactionPreview = true;
191+
// AllowUiInProduction is NOT set (remains false as required by validator when AllowRedactionPreview=true)
192+
});
193+
194+
using var req = new HttpRequestMessage(HttpMethod.Post, "/delay/50")
195+
{
196+
Content = new StringContent("{\"password\":\"topsecret123\"}", Encoding.UTF8, "application/json")
197+
};
198+
req.Headers.Add("X-Api-Key", "secret-key-999");
199+
200+
await app.Client.SendAsync(req);
201+
var entry = app.SingleEntry;
202+
203+
// 1. Rendered HTML detail page check
204+
var htmlRaw = DebugProbe.AspNetCore.Internal.Rendering.HtmlRenderer.RenderDetailsPage(
205+
entry,
206+
app.Store.GetEnvironment(entry),
207+
entry.RequestBody,
208+
entry.ResponseBody,
209+
new DebugProbeOptions { AllowRedactionPreview = true });
210+
211+
// ASSERT: no "Redaction Preview" toggle/banner appears in HTML, and no "secret-key-999" appears anywhere in raw HTML
212+
Assert.DoesNotContain("Redaction Preview", htmlRaw);
213+
Assert.DoesNotContain("secret-key-999", htmlRaw);
214+
215+
output.WriteLine("[PASS] TEST D (HTML): HtmlRenderer correctly suppressed Redaction Preview banner in Production. Secret does not appear in HTML.");
216+
217+
// 2. Check JSON endpoint / storage layer behavior
218+
var isOriginalHeadersPopulated = entry.OriginalRequestHeaders.ContainsKey("X-Api-Key")
219+
&& entry.OriginalRequestHeaders["X-Api-Key"] == "secret-key-999";
220+
221+
if (isOriginalHeadersPopulated)
222+
{
223+
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).");
224+
}
225+
else
226+
{
227+
output.WriteLine("[OBSERVATION - TEST D] JSON/Storage Layer: originalRequestHeaders is NOT populated.");
228+
}
229+
}
230+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.Text;
2+
using DebugProbe.AspNetCore.Options;
3+
using DebugProbe.AspNetCore.Tests.Infrastructure;
4+
using Xunit;
5+
6+
namespace DebugProbe.AspNetCore.Tests.Middleware;
7+
8+
/// <summary>
9+
/// Tests that AllowRedactionPreview populates OriginalXxx fields on DebugEntry
10+
/// only when the feature is enabled, and never modifies the redacted values.
11+
/// </summary>
12+
public class RedactionPreviewTests
13+
{
14+
[Fact]
15+
public async Task AllowRedactionPreview_false_does_not_populate_original_fields()
16+
{
17+
await using var app = await DebugProbeTestApp.CreateAsync(
18+
endpoints => endpoints.MapPost("/orders", async context =>
19+
{
20+
context.Response.ContentType = "application/json";
21+
await context.Response.WriteAsync("{\"ok\":true,\"refreshToken\":\"response-token\"}");
22+
}),
23+
options =>
24+
{
25+
options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"];
26+
options.RedactedJsonFields = ["password"];
27+
options.AllowRedactionPreview = false; // explicit default
28+
});
29+
30+
using var request = new HttpRequestMessage(HttpMethod.Post, "/orders")
31+
{
32+
Content = new StringContent("{\"password\":\"secret\"}", Encoding.UTF8, "application/json")
33+
};
34+
request.Headers.Add("X-Api-Key", "header-secret");
35+
36+
await app.Client.SendAsync(request);
37+
var entry = app.SingleEntry;
38+
39+
// Redaction must still be applied to the regular fields
40+
Assert.Equal("[REDACTED]", entry.RequestHeaders["X-Api-Key"]);
41+
Assert.Contains("\"password\":\"[REDACTED]\"", entry.RequestBody);
42+
43+
// Original fields must be empty when preview is disabled
44+
Assert.Empty(entry.OriginalRequestHeaders);
45+
Assert.Null(entry.OriginalRequestBody);
46+
Assert.Null(entry.OriginalResponseBody);
47+
Assert.Null(entry.OriginalQuery);
48+
}
49+
50+
[Fact]
51+
public async Task AllowRedactionPreview_true_populates_original_fields_alongside_redacted()
52+
{
53+
await using var app = await DebugProbeTestApp.CreateAsync(
54+
endpoints => endpoints.MapPost("/orders", async context =>
55+
{
56+
context.Response.ContentType = "application/json";
57+
await context.Response.WriteAsync("{\"ok\":true,\"refreshToken\":\"response-token\"}");
58+
}),
59+
options =>
60+
{
61+
options.RedactedHeaders = [.. options.RedactedHeaders, "X-Api-Key"];
62+
options.RedactedJsonFields = ["password", "refreshToken"];
63+
options.AllowRedactionPreview = true;
64+
});
65+
66+
using var request = new HttpRequestMessage(HttpMethod.Post, "/orders?api_key=secret")
67+
{
68+
Content = new StringContent("{\"password\":\"s3cr3t\"}", Encoding.UTF8, "application/json")
69+
};
70+
request.Headers.Add("X-Api-Key", "header-secret");
71+
72+
await app.Client.SendAsync(request);
73+
var entry = app.SingleEntry;
74+
75+
// Redacted values must still be applied as normal
76+
Assert.Equal("[REDACTED]", entry.RequestHeaders["X-Api-Key"]);
77+
Assert.Contains("\"password\":\"[REDACTED]\"", entry.RequestBody);
78+
79+
// Original headers must contain the raw value
80+
Assert.True(entry.OriginalRequestHeaders.ContainsKey("X-Api-Key"));
81+
Assert.Equal("header-secret", entry.OriginalRequestHeaders["X-Api-Key"]);
82+
83+
// Original body must contain the real secret
84+
Assert.NotNull(entry.OriginalRequestBody);
85+
Assert.Contains("s3cr3t", entry.OriginalRequestBody);
86+
87+
// Original response body must contain the raw token
88+
Assert.NotNull(entry.OriginalResponseBody);
89+
Assert.Contains("response-token", entry.OriginalResponseBody);
90+
}
91+
}

0 commit comments

Comments
 (0)