feat(audit): ScadaBridge C2 — ScadaBridgeAuditRedactor/SafeDefaultAuditRedactor : IAuditRedactor on canonical record (Task 2.5)
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
|
||||
/// <see cref="SafeDefaultAuditRedactor"/> — the canonical-record analogue of
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.SafeDefaultAuditPayloadFilter"/>.
|
||||
/// Header-only scrub of the always-sensitive default headers inside
|
||||
/// <c>DetailsJson</c>'s RequestSummary / ResponseSummary; never throws, never
|
||||
/// performs body / SQL / truncation work.
|
||||
/// </summary>
|
||||
public class SafeDefaultAuditRedactorTests
|
||||
{
|
||||
private static AuditEvent EventWith(string? request = null, string? response = null)
|
||||
{
|
||||
var details = new AuditDetails
|
||||
{
|
||||
RequestSummary = request,
|
||||
ResponseSummary = response,
|
||||
};
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "tester",
|
||||
Action = "ApiOutbound.ApiCall",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||
};
|
||||
}
|
||||
|
||||
private static AuditDetails Details(AuditEvent evt) =>
|
||||
AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||
|
||||
[Fact]
|
||||
public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
|
||||
{
|
||||
var evt = EventWith(request: "Authorization: Bearer secret-token\nContent-Type: application/json");
|
||||
|
||||
var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
|
||||
|
||||
var d = Details(result);
|
||||
Assert.Contains("Authorization: [REDACTED]", d.RequestSummary!);
|
||||
Assert.DoesNotContain("secret-token", d.RequestSummary!);
|
||||
Assert.Contains("Content-Type: application/json", d.RequestSummary!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
|
||||
{
|
||||
var evt = EventWith(response: "Set-Cookie: sessionid=abc123\nX-Other: ok");
|
||||
|
||||
var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
|
||||
|
||||
var d = Details(result);
|
||||
Assert.Contains("Set-Cookie: [REDACTED]", d.ResponseSummary!);
|
||||
Assert.DoesNotContain("abc123", d.ResponseSummary!);
|
||||
Assert.Contains("X-Other: ok", d.ResponseSummary!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseInsensitive_HeaderName_Redacted()
|
||||
{
|
||||
var evt = EventWith(request: "authorization: Bearer x-y-z");
|
||||
|
||||
var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
|
||||
|
||||
Assert.Contains("[REDACTED]", Details(result).RequestSummary!);
|
||||
Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonSensitiveHeader_Preserved()
|
||||
{
|
||||
var evt = EventWith(request: "X-Request-Id: abc-123\nAccept: application/json");
|
||||
|
||||
var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
|
||||
|
||||
var d = Details(result);
|
||||
Assert.Contains("X-Request-Id: abc-123", d.RequestSummary!);
|
||||
Assert.Contains("Accept: application/json", d.RequestSummary!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullDetails_FastPath_ReturnsSameInstance()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "tester",
|
||||
Action = "ApiOutbound.ApiCall",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = null,
|
||||
};
|
||||
|
||||
var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
|
||||
|
||||
Assert.Same(evt, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedDetailsJson_NeverThrows()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "tester",
|
||||
Action = "ApiOutbound.ApiCall",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = "{not valid json]",
|
||||
};
|
||||
|
||||
var ex = Record.Exception(() => SafeDefaultAuditRedactor.Instance.Apply(evt));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
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 (Task 2.5) tests for
|
||||
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
|
||||
/// <see cref="IAuditRedactor"/> implementation that ports the
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> redaction + truncation behaviour onto
|
||||
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> record and its
|
||||
/// <see cref="AuditEvent.DetailsJson"/> payload bag. These mirror the legacy
|
||||
/// Payload fixtures (HeaderRedaction / BodyRegex / SqlParam / RedactionSafetyNet
|
||||
/// / Truncation) but operate on canonical events built via
|
||||
/// <see cref="AuditDetailsCodec"/>.
|
||||
/// </summary>
|
||||
public class ScadaBridgeAuditRedactorTests
|
||||
{
|
||||
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) ---------------------
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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!);
|
||||
}
|
||||
|
||||
[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!);
|
||||
}
|
||||
|
||||
[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) --------------
|
||||
|
||||
[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!);
|
||||
}
|
||||
|
||||
[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!);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoRegexConfigured_FieldUnchanged()
|
||||
{
|
||||
var evt = NewEvent(request: "{\"password\":\"hunter2\"}");
|
||||
|
||||
var result = Redactor().Apply(evt);
|
||||
|
||||
Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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!);
|
||||
}
|
||||
|
||||
[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) ----------------
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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!));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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 -------------------------------------------------
|
||||
|
||||
[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 ---------------------------------------------------------
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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 -------------------------------------------
|
||||
|
||||
[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>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
|
||||
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
public int Count => _count;
|
||||
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;
|
||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user