Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

679 lines
29 KiB
C#

using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// <summary>
/// ScadaBridge audit re-architecture stage C2 tests for
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
/// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction /
/// body-regex / SQL-param / safety-net / truncation pipeline operating on
/// canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> records built via
/// <see cref="AuditDetailsCodec"/>.
/// </summary>
public class ScadaBridgeAuditRedactorTests
{
/// <summary>Verifies that every field of an error row is redacted and the error message is capped, whether the redactor is configured globally or per target.</summary>
/// <param name="perTarget">True to configure the redactor through a per-target override; false to configure it globally.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EI11_ErrorMessage_AllFieldsRedactedAndCapped(bool perTarget)
{
var opts = new AuditLogOptions { ErrorCapBytes = 128 };
if (perTarget) opts.PerTargetOverrides["target"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = ["SECRET123"] };
else opts.GlobalBodyRedactors = ["SECRET123"];
var evt = NewEvent(status: AuditStatus.Failed, outcome: AuditOutcome.Failure, target: "target", errorDetail: "SECRET123", request: "SECRET123", response: "SECRET123", extra: "SECRET123");
evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "SECRET123" + new string('x', 1000) }) };
var result = Redactor(opts).Apply(evt);
Assert.DoesNotContain("SECRET123", result.DetailsJson!);
Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128);
}
/// <summary>Verifies that the SQL parameter policy does not persist values echoed back in an exception message.</summary>
[Fact]
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
{
var opts = new AuditLogOptions();
opts.PerTargetOverrides["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" };
var evt = NewEvent(channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure,
target: "PrimaryDb.Query", request: "{\"parameters\":{\"@token\":\"SECRET123\"}}", errorDetail: "SqlException duplicate key value is (SECRET123)");
evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "duplicate key SECRET123" }) };
var result = Redactor(opts).Apply(evt);
Assert.DoesNotContain("SECRET123", result.DetailsJson!);
Assert.Equal("Failed", Details(result).Status);
}
private static ScadaBridgeAuditRedactor Redactor(
AuditLogOptions? opts = null,
IAuditRedactionFailureCounter? counter = null) =>
new(new StaticMonitor(opts ?? new AuditLogOptions()),
NullLogger<ScadaBridgeAuditRedactor>.Instance,
counter);
/// <summary>
/// Build a canonical event whose <see cref="AuditEvent.DetailsJson"/> carries the
/// supplied summaries + channel/status, mirroring what the C3 emit boundary
/// will produce. Category = channel name (so the ApiInbound branch is keyed
/// correctly); Status travels inside DetailsJson for the fine-grained error
/// cap selection.
/// </summary>
private static AuditEvent NewEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditStatus status = AuditStatus.Delivered,
AuditOutcome outcome = AuditOutcome.Success,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
string? target = null,
bool detailsPayloadTruncated = false)
{
var details = new AuditDetails
{
Channel = channel.ToString(),
Status = status.ToString(),
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = detailsPayloadTruncated,
};
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "tester",
Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall),
Category = AuditFieldBuilders.BuildCategory(channel),
Outcome = outcome,
Target = target,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
private static AuditDetails Details(AuditEvent evt) =>
AuditDetailsCodec.Deserialize(evt.DetailsJson);
// ---- Header redaction (ports HeaderRedactionTests) ---------------------
/// <summary>Verifies that an Authorization bearer header is redacted.</summary>
[Fact]
public void HeaderRedaction_AuthorizationBearer_Redacted()
{
var request = "{\"headers\":{\"Authorization\":\"Bearer secret-token-xyz\",\"Content-Type\":\"application/json\"},\"body\":\"hello\"}";
var evt = NewEvent(request: request);
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.NotNull(d.RequestSummary);
Assert.Contains("\"Authorization\":\"<redacted>\"", d.RequestSummary);
Assert.DoesNotContain("secret-token-xyz", d.RequestSummary);
Assert.Contains("application/json", d.RequestSummary);
}
/// <summary>Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted.</summary>
[Fact]
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
{
var request = "{\"headers\":{\"authorization\":\"Bearer secret-token-xyz\"},\"body\":\"hi\"}";
var evt = NewEvent(request: request);
var result = Redactor().Apply(evt);
Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
}
/// <summary>Verifies that a custom redact list redacts a custom header name.</summary>
[Fact]
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
{
var opts = new AuditLogOptions { HeaderRedactList = new List<string> { "X-Custom-Secret" } };
var request = "{\"headers\":{\"X-Custom-Secret\":\"topsecret\",\"Authorization\":\"Bearer keep-me\"},\"body\":\"hi\"}";
var evt = NewEvent(request: request);
var result = Redactor(opts).Apply(evt);
var d = Details(result);
Assert.Contains("\"X-Custom-Secret\":\"<redacted>\"", d.RequestSummary!);
Assert.Contains("Bearer keep-me", d.RequestSummary!);
}
/// <summary>Verifies that a request summary that is not JSON passes through header redaction unchanged.</summary>
[Fact]
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
{
var evt = NewEvent(request: "this is not JSON at all");
var result = Redactor().Apply(evt);
Assert.Equal("this is not JSON at all", Details(result).RequestSummary);
}
// ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
/// <summary>Verifies that the global regex redacts a Hunter-style password value.</summary>
[Fact]
public void GlobalRegex_HunterPassword_Redacted()
{
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
};
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
var result = Redactor(opts).Apply(evt);
var d = Details(result);
Assert.Contains("<redacted>", d.RequestSummary!);
Assert.DoesNotContain("hunter2", d.RequestSummary!);
Assert.Contains("alice", d.RequestSummary!);
}
/// <summary>Verifies that the body regex is applied to the error detail and the extra field.</summary>
[Fact]
public void BodyRegex_AppliesToErrorDetailAndExtra()
{
var opts = new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
};
var evt = NewEvent(
errorDetail: "boom SECRET-AAA111 boom",
extra: "ctx SECRET-BBB222 ctx");
var result = Redactor(opts).Apply(evt);
var d = Details(result);
Assert.DoesNotContain("SECRET-AAA111", d.ErrorDetail!);
Assert.DoesNotContain("SECRET-BBB222", d.Extra!);
Assert.Contains("<redacted>", d.ErrorDetail!);
Assert.Contains("<redacted>", d.Extra!);
}
/// <summary>Verifies that a per-target regex is applied only to the matching target.</summary>
[Fact]
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["esg.A"] = new PerTargetRedactionOverride
{
AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
},
},
};
const string input = "token=SECRET-XYZ123 normal-text";
var matched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.A"));
Assert.Contains("<redacted>", Details(matched).RequestSummary!);
Assert.DoesNotContain("SECRET-XYZ123", Details(matched).RequestSummary!);
var unmatched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.B"));
Assert.Equal(input, Details(unmatched).RequestSummary);
}
/// <summary>Verifies that a field is left unchanged when no regex is configured.</summary>
[Fact]
public void NoRegexConfigured_FieldUnchanged()
{
var evt = NewEvent(request: "{\"password\":\"hunter2\"}");
var result = Redactor().Apply(evt);
Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
}
/// <summary>Verifies that a regex that times out turns the field into the redacted marker and increments the failure counter.</summary>
[Fact]
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
{
var opts = new AuditLogOptions { GlobalBodyRedactors = new List<string> { "^(a+)+$" } };
var counter = new CountingRedactionFailureCounter();
var evt = NewEvent(request: new string('a', 30) + "!");
var result = Redactor(opts, counter).Apply(evt);
Assert.Equal("<redacted: redactor error>", Details(result).RequestSummary);
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
}
// ---- SQL parameter redaction (ports SqlParamRedactionTests) ------------
private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
{
var sb = new StringBuilder();
sb.Append("{\"sql\":\"").Append(sql).Append('"');
if (parameters.Length > 0)
{
sb.Append(",\"parameters\":{");
for (var i = 0; i < parameters.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append('"').Append(parameters[i].name).Append("\":\"")
.Append(parameters[i].value).Append('"');
}
sb.Append('}');
}
sb.Append('}');
return sb.ToString();
}
/// <summary>Verifies that SQL parameters are kept verbatim when no opt-in is configured.</summary>
[Fact]
public void NoOptIn_ParamsVerbatim_Unchanged()
{
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token) VALUES (@name, @token)",
("@name", "Alice"), ("@token", "secret-xyz"));
var evt = NewEvent(
channel: AuditChannel.DbOutbound, status: AuditStatus.Delivered,
request: input, target: "PrimaryDb.INSERT INTO Users");
var result = Redactor().Apply(evt);
Assert.Equal(input, Details(result).RequestSummary);
}
/// <summary>Verifies that an opt-in regex on parameter names redacts the matching values and keeps the others.</summary>
[Fact]
public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@(token|apikey)$" },
},
};
var input = DbRequestSummary(
"INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
("@name", "Alice"), ("@token", "secret-xyz"), ("@apikey", "k-987"));
var evt = NewEvent(
channel: AuditChannel.DbOutbound, request: input, target: "PrimaryDb.INSERT INTO Users");
var result = Redactor(opts).Apply(evt);
var d = Details(result);
Assert.Contains("\"@name\":\"Alice\"", d.RequestSummary!);
Assert.Contains("\"@token\":\"<redacted>\"", d.RequestSummary!);
Assert.Contains("\"@apikey\":\"<redacted>\"", d.RequestSummary!);
Assert.DoesNotContain("secret-xyz", d.RequestSummary!);
Assert.DoesNotContain("k-987", d.RequestSummary!);
}
/// <summary>Verifies that a channel other than database-outbound is not affected by the SQL parameter policy.</summary>
[Fact]
public void NonDbOutboundChannel_NotAffected()
{
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
{
["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" },
},
};
var input = DbRequestSummary("SELECT @token", ("@token", "should-survive"));
// ApiOutbound channel whose summary *looks* like the DbOutbound shape.
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, request: input, target: "PrimaryDb.SELECT");
var result = Redactor(opts).Apply(evt);
Assert.Equal(input, Details(result).RequestSummary);
}
// ---- Truncation + cap selection (ports TruncationTests) ----------------
/// <summary>Verifies that a 10 KB request summary on a success row is truncated to 8 KB and flagged as payload-truncated.</summary>
[Fact]
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
{
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered,
outcome: AuditOutcome.Success, request: new string('a', 10 * 1024));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
Assert.True(d.PayloadTruncated);
}
/// <summary>Verifies that a 10 KB request summary on a failed row is not truncated because it is under the error cap.</summary>
[Fact]
public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
{
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
outcome: AuditOutcome.Failure, request: new string('b', 10 * 1024));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal(new string('b', 10 * 1024), d.RequestSummary);
Assert.False(d.PayloadTruncated);
}
/// <summary>Verifies that a 70 KB request summary on a failed row is truncated to 64 KB.</summary>
[Fact]
public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
{
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
outcome: AuditOutcome.Failure, request: new string('c', 70 * 1024));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal(65536, Encoding.UTF8.GetByteCount(d.RequestSummary!));
Assert.True(d.PayloadTruncated);
}
/// <summary>Verifies that an attempted status is treated as an error and uses the error cap even though the outcome is success.</summary>
[Fact]
public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
{
// Attempted projects to Outcome.Success, yet IsErrorStatus(Attempted)==true.
// Faithful port must read d.Status and pick the error cap — a 10 KB body
// must survive (under the 64 KiB error cap), NOT truncate to 8 KiB.
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Attempted,
outcome: AuditOutcome.Success, request: new string('d', 10 * 1024));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal(new string('d', 10 * 1024), d.RequestSummary);
Assert.False(d.PayloadTruncated);
}
/// <summary>Verifies that a skipped status is treated as an error and uses the error cap even though the outcome is success.</summary>
[Fact]
public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
{
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Skipped,
outcome: AuditOutcome.Success, request: new string('f', 10 * 1024));
var result = Redactor().Apply(evt);
Assert.False(Details(result).PayloadTruncated);
}
/// <summary>Verifies that a submitted status is treated as a success and uses the default cap.</summary>
[Fact]
public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
{
// Submitted is NOT an error status (IsErrorStatus==false) → default 8 KiB cap.
var evt = NewEvent(
channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted,
outcome: AuditOutcome.Success, request: new string('g', 10 * 1024));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
Assert.True(d.PayloadTruncated);
}
/// <summary>Verifies that a large inbound API body uses the inbound cap rather than the default cap.</summary>
[Fact]
public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
{
var evt = NewEvent(
channel: AuditChannel.ApiInbound, status: AuditStatus.Delivered,
outcome: AuditOutcome.Success, request: new string('a', 100_000));
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.False(d.PayloadTruncated);
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
}
/// <summary>Verifies that a failed inbound API body above the inbound maximum is truncated to that maximum.</summary>
[Fact]
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
{
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
var evt = NewEvent(
channel: AuditChannel.ApiInbound, status: AuditStatus.Failed,
outcome: AuditOutcome.Failure, response: new string('z', 50_000));
var result = Redactor(opts).Apply(evt);
var d = Details(result);
Assert.True(d.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
}
/// <summary>Verifies that multibyte UTF-8 text is truncated at a character boundary rather than in the middle of a byte sequence.</summary>
[Fact]
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
{
var sb = new StringBuilder();
for (int i = 0; i < 2100; i++) sb.Append("😀");
var input = sb.ToString();
var evt = NewEvent(request: input);
var result = Redactor().Apply(evt);
var d = Details(result);
var bytes = Encoding.UTF8.GetByteCount(d.RequestSummary!);
Assert.True(bytes <= 8192);
Assert.Equal(0, bytes % 4);
Assert.DoesNotContain('�', d.RequestSummary!);
Assert.True(d.PayloadTruncated);
}
/// <summary>Verifies that a details payload that was already marked truncated stays truncated.</summary>
[Fact]
public void ExistingDetailsPayloadTruncated_RemainsTrue()
{
var evt = NewEvent(request: "small", detailsPayloadTruncated: true);
var result = Redactor().Apply(evt);
var d = Details(result);
Assert.Equal("small", d.RequestSummary);
Assert.True(d.PayloadTruncated);
}
// ---- Target length cap -------------------------------------------------
/// <summary>Verifies that a target over the cap is truncated at a byte boundary.</summary>
[Fact]
public void Target_OverCap_Truncated_ToByteBoundary()
{
// Target is a canonical top-level field; the redactor caps it at the
// default cap so an absurdly long target can't blow the column.
var longTarget = new string('t', 10 * 1024);
var evt = NewEvent(status: AuditStatus.Delivered, outcome: AuditOutcome.Success, target: longTarget);
var result = Redactor().Apply(evt);
Assert.NotNull(result.Target);
Assert.True(Encoding.UTF8.GetByteCount(result.Target!) <= 8192);
}
// ---- Fast-path ---------------------------------------------------------
/// <summary>Verifies that the fast path returns the same instance when the details are null and the target is short.</summary>
[Fact]
public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "tester",
Action = "ApiOutbound.ApiCall",
Outcome = AuditOutcome.Success,
Target = "short",
DetailsJson = null,
};
var result = Redactor().Apply(evt);
Assert.Same(evt, result);
}
/// <summary>Verifies that the fast path returns the same instance when the details are empty and the target is short.</summary>
[Fact]
public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "tester",
Action = "ApiOutbound.ApiCall",
Outcome = AuditOutcome.Success,
Target = null,
DetailsJson = "",
};
var result = Redactor().Apply(evt);
Assert.Same(evt, result);
}
// ---- Never-throws safety net -------------------------------------------
/// <summary>Verifies that malformed details JSON never throws and yields a safe copy.</summary>
[Fact]
public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
{
// Deserialize never throws (returns empty details), but a malformed
// DetailsJson with a long Target still flows the slow path. Assert the
// redactor returns a result without throwing.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "tester",
Action = "ApiOutbound.ApiCall",
Outcome = AuditOutcome.Success,
Target = new string('x', 10 * 1024),
DetailsJson = "{not valid json at all]",
};
var ex = Record.Exception(() => Redactor().Apply(evt));
Assert.Null(ex);
}
/// <summary>Verifies that a fault thrown by the options monitor never leaks sensitive values, because the outer catch over-redacts every sensitive field.</summary>
[Fact]
public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
{
// Force the outer try/catch → OverRedact path by injecting an
// IOptionsMonitor whose CurrentValue getter throws. This is the FIRST
// statement in the try block, so the exception escapes before any
// redaction work has run — the input event could still carry live
// sensitive values in all five free-text fields.
//
// The never-leak contract requires ALL of RequestSummary, ResponseSummary,
// ErrorDetail, ErrorMessage, and Extra to be suppressed to the
// over-redacted marker, and PayloadTruncated to be true.
var sensitiveEvent = NewEvent(
request: "SENSITIVE-REQUEST-DATA",
response: "SENSITIVE-RESPONSE-DATA",
errorDetail: "SENSITIVE-ERROR-DETAIL",
extra: "SENSITIVE-EXTRA-DATA");
// Manually inject ErrorMessage into DetailsJson (NewEvent does not expose it).
var withMsg = sensitiveEvent with
{
DetailsJson = AuditDetailsCodec.Serialize(
AuditDetailsCodec.Deserialize(sensitiveEvent.DetailsJson) with
{
ErrorMessage = "SENSITIVE-ERROR-MESSAGE",
}),
};
var redactor = new ScadaBridgeAuditRedactor(
new ThrowingMonitor(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<ScadaBridgeAuditRedactor>.Instance);
// (a) Apply must not throw even when options access itself throws.
var ex = Record.Exception(() => redactor.Apply(withMsg));
Assert.Null(ex);
// (b) Returned event must have ALL sensitive free-text fields suppressed
// (never-leak guarantee), and PayloadTruncated must be true.
var result = redactor.Apply(withMsg);
var d = Details(result);
const string marker = "<redacted: redactor error>";
Assert.Equal(marker, d.RequestSummary);
Assert.Equal(marker, d.ResponseSummary);
Assert.Equal(marker, d.ErrorDetail);
Assert.Equal(marker, d.ErrorMessage);
Assert.Equal(marker, d.Extra);
Assert.True(d.PayloadTruncated);
// Confirm no raw sensitive values survive anywhere in DetailsJson.
Assert.DoesNotContain("SENSITIVE", result.DetailsJson ?? "");
}
/// <summary>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{
private int _count;
/// <summary>Gets the number of recorded failures.</summary>
public int Count => _count;
/// <inheritdoc />
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
}
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
/// <summary>Initializes a new instance of the <see cref="StaticMonitor"/> class.</summary>
/// <param name="value">The options snapshot returned on every read.</param>
public StaticMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the options value this monitor serves.</summary>
public AuditLogOptions CurrentValue => _value;
/// <summary>Returns the options value regardless of name.</summary>
/// <param name="name">The options name (ignored).</param>
/// <returns>The options value.</returns>
public AuditLogOptions Get(string? name) => _value;
/// <summary>Ignores the listener; the options never change.</summary>
/// <param name="listener">The change listener (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
/// <summary>
/// IOptionsMonitor test double that throws from <see cref="CurrentValue"/> to
/// force the outer try/catch → OverRedact path in
/// <see cref="ScadaBridgeAuditRedactor.Apply"/>.
/// </summary>
private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
{
/// <summary>Gets the options value this monitor serves.</summary>
public AuditLogOptions CurrentValue =>
throw new InvalidOperationException("Simulated options fault for outer-catch test");
/// <summary>Returns the options value regardless of name.</summary>
/// <param name="name">The options name (ignored).</param>
/// <returns>The options value.</returns>
public AuditLogOptions Get(string? name) => new AuditLogOptions();
/// <summary>Ignores the listener; the options never change.</summary>
/// <param name="listener">The change listener (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}