feat(audit): redactor + writer helpers (Null/Truncating/NoOp/Composite/Redacting)
Code-review fixes: CompositeAuditWriter re-throws OperationCanceledException (honors cancellation) + evt null-guard; RedactingAuditWriter evt null-guard; added marker-longer-than-max and cancellation-propagation regression tests.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class CompositeAuditWriterTests
|
||||
{
|
||||
private sealed class RecordingWriter : IAuditWriter
|
||||
{
|
||||
public int Count;
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class ThrowingWriter : IAuditWriter
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
|
||||
}
|
||||
private sealed class CancellingWriter : IAuditWriter
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
|
||||
[Fact]
|
||||
public async Task Fans_out_to_all_writers()
|
||||
{
|
||||
var a = new RecordingWriter(); var b = new RecordingWriter();
|
||||
await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
|
||||
Assert.Equal(1, a.Count);
|
||||
Assert.Equal(1, b.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_failing_writer_does_not_stop_the_others()
|
||||
{
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_propagated_not_swallowed()
|
||||
{
|
||||
// OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => sut.WriteAsync(Evt()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class NoOpAuditWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAsync_completes_without_error()
|
||||
{
|
||||
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
await NoOpAuditWriter.Instance.WriteAsync(evt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class NullAuditRedactorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_returns_input_unchanged()
|
||||
{
|
||||
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
|
||||
Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class RedactingAuditWriterTests
|
||||
{
|
||||
private sealed class CapturingWriter : IAuditWriter
|
||||
{
|
||||
public AuditEvent? Last;
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class StampRedactor : IAuditRedactor
|
||||
{
|
||||
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
|
||||
}
|
||||
|
||||
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
|
||||
|
||||
[Fact]
|
||||
public async Task Inner_writer_receives_the_redacted_event()
|
||||
{
|
||||
var inner = new CapturingWriter();
|
||||
var sut = new RedactingAuditWriter(new StampRedactor(), inner);
|
||||
await sut.WriteAsync(Evt());
|
||||
Assert.Equal("redacted", inner.Last!.DetailsJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class TruncatingAuditRedactorTests
|
||||
{
|
||||
private static AuditEvent Evt(string? details, string? target = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
|
||||
DetailsJson = details, Target = target,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Short_values_pass_through_unchanged()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
|
||||
var evt = Evt("small");
|
||||
Assert.Equal("small", r.Apply(evt).DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_details_are_truncated_with_marker()
|
||||
{
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 50)));
|
||||
Assert.Equal(10, result.DetailsJson!.Length);
|
||||
Assert.EndsWith("~", result.DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_target_is_truncated()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
|
||||
var result = r.Apply(Evt(null, target: "abcdefghij"));
|
||||
Assert.Equal(5, result.Target!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_fields_are_left_null()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor();
|
||||
var result = r.Apply(Evt(null));
|
||||
Assert.Null(result.DetailsJson);
|
||||
Assert.Null(result.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_longer_than_max_clips_the_marker_itself()
|
||||
{
|
||||
// Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 20)));
|
||||
Assert.Equal(3, result.DetailsJson!.Length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user