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.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
/// <summary>
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
/// Verifies <see cref="AuditWriteMiddleware"/> emits exactly one
/// <see cref="AuditChannel.ApiInbound"/> row per request via
/// <see cref="ICentralAuditWriter"/> covering all outcome shapes:
/// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed),
@@ -25,6 +25,10 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
/// </summary>
public class AuditWriteMiddlewareTests
{
/// <summary>Verifies that only the peer that serves a relayed request writes the local failure audit row.</summary>
/// <param name="scenario">The scenario selector.</param>
/// <param name="localRows">The expected number of locally written audit rows.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(0, 1)]
[InlineData(1, 1)]
@@ -58,19 +62,35 @@ public class AuditWriteMiddlewareTests
if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId);
}
private sealed class StandbyGate : IActiveNodeGate { public bool IsActiveNode => false; }
private sealed class StandbyGate : IActiveNodeGate
{
/// <inheritdoc />
public bool IsActiveNode => false;
}
private sealed class ResultForwarder(IResult result) : IStandbyRequestForwarder
{
/// <inheritdoc />
public bool IsConfigured => true;
/// <inheritdoc />
public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) => Task.FromResult(result);
}
private sealed class InboundOptions(InboundApiOptions value) : IOptionsMonitor<InboundApiOptions>
{
/// <summary>Gets the fixed options instance.</summary>
public InboundApiOptions CurrentValue => value;
/// <summary>Returns the fixed options instance regardless of name.</summary>
/// <param name="name">The named-options key, ignored.</param>
/// <returns>The fixed options instance.</returns>
public InboundApiOptions Get(string? name) => value;
/// <summary>Does nothing; the fake never raises change notifications.</summary>
/// <param name="listener">The change listener, ignored.</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<InboundApiOptions, string?> listener) => null;
}
/// <summary>Verifies that skipping body capture suppresses the exception text in the audit row.</summary>
/// <param name="escaped">Whether the exception text contains characters that need escaping.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -100,11 +120,14 @@ public class AuditWriteMiddlewareTests
/// </summary>
private sealed class RecordingAuditWriter : ICentralAuditWriter
{
// C3 (Task 2.5): store the decomposed row view so assertions keep
// Store the decomposed row view so assertions keep
// reading the ScadaBridge domain fields as typed properties.
/// <summary>Gets the recorded audit rows.</summary>
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
/// <summary>Gets or sets an optional callback invoked on each write.</summary>
public Func<AuditEvent, Task>? OnWrite { get; set; }
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
@@ -170,8 +193,11 @@ public class AuditWriteMiddlewareTests
/// </summary>
private sealed class StubAuditActorAccessor : IAuditActorAccessor
{
/// <summary>Initializes the accessor with a fixed actor.</summary>
/// <param name="currentActor">The actor string to return, or null for none.</param>
public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor;
/// <inheritdoc />
public string? CurrentActor { get; }
}
@@ -185,12 +211,21 @@ public class AuditWriteMiddlewareTests
{
private readonly AuditLogOptions _value;
/// <summary>Initializes the monitor with a fixed options value.</summary>
/// <param name="value">The options value returned on every read.</param>
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the fixed options instance.</summary>
public AuditLogOptions CurrentValue => _value;
/// <summary>Returns the fixed options instance regardless of name.</summary>
/// <param name="name">The named-options key, ignored.</param>
/// <returns>The fixed options instance.</returns>
public AuditLogOptions Get(string? name) => _value;
/// <summary>Does nothing; the fake never raises change notifications.</summary>
/// <param name="listener">The change listener, ignored.</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
@@ -198,6 +233,8 @@ public class AuditWriteMiddlewareTests
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
// ---------------------------------------------------------------------
/// <summary>Verifies that a successful request emits one inbound-request event with Delivered status and HTTP 200.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
{
@@ -226,6 +263,8 @@ public class AuditWriteMiddlewareTests
// 2. 400 — script/validation failure path
// ---------------------------------------------------------------------
/// <summary>Verifies that a 400 response emits an event with Failed status and HTTP 400.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
{
@@ -251,6 +290,8 @@ public class AuditWriteMiddlewareTests
// 3. 401 — auth failure path
// ---------------------------------------------------------------------
/// <summary>Verifies that a 401 response emits an inbound-auth-failure event with Failed status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
{
@@ -273,6 +314,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.Actor);
}
/// <summary>Verifies that a 403 response emits an inbound-auth-failure event with Failed status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
{
@@ -296,6 +339,8 @@ public class AuditWriteMiddlewareTests
// 4. 500 — handler threw OR returned 500
// ---------------------------------------------------------------------
/// <summary>Verifies that a 500 response emits an event with Failed status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_500_EmitsEvent_Status_Failed()
{
@@ -315,6 +360,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(500, evt.HttpStatus);
}
/// <summary>Verifies that a throwing pipeline emits a Failed event and rethrows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
{
@@ -340,6 +387,8 @@ public class AuditWriteMiddlewareTests
// message), and the middleware lifts it onto the row.
// ---------------------------------------------------------------------
/// <summary>Verifies that a script error detail stashed on the items lands on the audit row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptErrorDetail_StashedOnItems_LandsOnTheRow()
{
@@ -360,6 +409,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(detail, evt.ErrorDetail);
}
/// <summary>Verifies that no stashed error detail leaves the row error detail null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoErrorDetailStashed_LeavesErrorDetailNull()
{
@@ -377,6 +428,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.ErrorDetail);
}
/// <summary>Verifies that the error detail of a thrown exception wins over the stashed item.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ThrownException_ErrorDetail_WinsOverTheStashedItem()
{
@@ -408,6 +461,8 @@ public class AuditWriteMiddlewareTests
// HttpContext.Items.
// ---------------------------------------------------------------------
/// <summary>Verifies that the API key name resolved from the HTTP context is recorded as the actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
{
@@ -437,6 +492,8 @@ public class AuditWriteMiddlewareTests
// null — never echo an unauthenticated principal back.
// ---------------------------------------------------------------------
/// <summary>Verifies that the authenticated user from the accessor is recorded as the actor when there is no API key stash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash()
{
@@ -459,6 +516,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("alice", evt.Actor);
}
/// <summary>Verifies that the API key stash takes precedence over the authenticated principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal()
{
@@ -483,6 +542,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("integration-svc", evt.Actor);
}
/// <summary>Verifies that an auth failure keeps the actor null even when the accessor resolves a principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal()
{
@@ -506,6 +567,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.Actor);
}
/// <summary>Verifies that a request with no API key and no authenticated principal leaves the actor null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull()
{
@@ -532,6 +595,8 @@ public class AuditWriteMiddlewareTests
// 6. Writer failure must NEVER alter the HTTP response
// ---------------------------------------------------------------------
/// <summary>Verifies that a throwing audit writer leaves a successful HTTP response unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
{
@@ -553,6 +618,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(200, ctx.Response.StatusCode);
}
/// <summary>Verifies that a throwing audit writer leaves a failed HTTP response unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
{
@@ -576,6 +643,8 @@ public class AuditWriteMiddlewareTests
// 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
// ---------------------------------------------------------------------
/// <summary>Verifies that the remote IP and user agent appear in the Extra JSON.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemoteIp_And_UserAgent_AppearInExtra()
{
@@ -605,6 +674,8 @@ public class AuditWriteMiddlewareTests
// JsonDocument.Parse) still see the full payload.
// ---------------------------------------------------------------------
/// <summary>Verifies that the request body is buffered and stashed on the request summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
{
@@ -631,12 +702,14 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// Execution id — Audit Log #23: each inbound row carries a fresh
// Execution id — each inbound row carries a fresh
// per-request execution id so inbound rows are correlatable. The inbound
// row's CorrelationId stays null — CorrelationId is purely the
// per-operation-lifecycle id and an inbound request is a one-shot.
// ---------------------------------------------------------------------
/// <summary>Verifies that an inbound row carries a non-null execution id and a null correlation id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
{
@@ -658,6 +731,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.CorrelationId);
}
/// <summary>Verifies that separate requests get distinct execution ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SeparateRequests_GetDistinct_ExecutionIds()
{
@@ -676,7 +751,7 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
// ParentExecutionId — the
// inbound request's ExecutionId is minted ONCE, early, and stashed on
// HttpContext.Items so the endpoint handler can carry it onto the routed
// RouteToCallRequest as ParentExecutionId. The inbound row that the
@@ -684,6 +759,8 @@ public class AuditWriteMiddlewareTests
// NEVER set.
// ---------------------------------------------------------------------
/// <summary>Verifies that the inbound execution id is stashed on the HTTP items before the endpoint runs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
{
@@ -705,6 +782,8 @@ public class AuditWriteMiddlewareTests
Assert.NotEqual(Guid.Empty, stashed);
}
/// <summary>Verifies that the inbound row execution id equals the early-minted stashed id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
{
@@ -727,6 +806,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
}
/// <summary>Verifies that the inbound row's own parent execution id stays null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InboundRow_OwnParentExecutionId_StaysNull()
{
@@ -747,6 +828,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.ParentExecutionId);
}
/// <summary>Verifies that the duration in milliseconds is recorded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DurationMs_IsRecorded()
{
@@ -769,13 +852,15 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// Response body capture — Audit Log #23 (inbound full-response feature).
// Until the M5-deferred work landed, ResponseSummary was always null.
// Response body capture (inbound full-response feature).
// Until response capture was implemented, ResponseSummary was always null.
// These tests pin the new contract: the middleware wraps Response.Body,
// runs the pipeline, copies the buffered bytes back to the real stream,
// and stashes a UTF-8 string copy on ResponseSummary.
// ---------------------------------------------------------------------
/// <summary>Verifies that the response body is captured on the response summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_IsCaptured_OnResponseSummary()
{
@@ -795,6 +880,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(responseJson, evt.ResponseSummary);
}
/// <summary>Verifies that the response body is forwarded to the original stream so downstream readers see it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
{
@@ -817,6 +904,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
}
/// <summary>Verifies that an empty response body leaves the response summary null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
{
@@ -837,6 +926,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(204, evt.HttpStatus);
}
/// <summary>Verifies that when the handler throws, the body written up to the throw is captured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
{
@@ -870,6 +961,8 @@ public class AuditWriteMiddlewareTests
// real client still see every byte.
// ---------------------------------------------------------------------
/// <summary>Verifies that a request body above the inbound maximum is truncated to the cap and flagged as payload truncated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
{
@@ -909,6 +1002,8 @@ public class AuditWriteMiddlewareTests
Assert.True(evt.PayloadTruncated);
}
/// <summary>Verifies that a response body above the inbound maximum is truncated to the cap, the client still receives all bytes, and the payload is flagged as truncated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
{
@@ -945,7 +1040,7 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// InboundAPI-018: asynchronously faulted audit-write tasks must be
// Asynchronously faulted audit-write tasks must be
// observed (logged) rather than silently dropped — but must still NOT
// alter the user-facing HTTP response (alog.md §13).
// ---------------------------------------------------------------------
@@ -954,10 +1049,11 @@ public class AuditWriteMiddlewareTests
/// Test-only writer whose <see cref="WriteAsync"/> returns a Task that
/// faults AFTER an asynchronous boundary, so the throw happens after
/// <see cref="AuditWriteMiddleware"/>'s synchronous try/catch can see it —
/// exactly the fire-and-forget bug InboundAPI-018 closes.
/// exactly the fire-and-forget bug this test guards against.
/// </summary>
private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter
{
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
return FaultAsync();
@@ -980,12 +1076,27 @@ public class AuditWriteMiddlewareTests
/// </summary>
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware>
{
/// <summary>Gets the captured log entries.</summary>
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state, ignored.</param>
/// <returns>Always null or a no-op scope.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(
Microsoft.Extensions.Logging.LogLevel logLevel,
Microsoft.Extensions.Logging.EventId eventId,
@@ -1002,10 +1113,13 @@ public class AuditWriteMiddlewareTests
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() { }
}
}
/// <summary>Verifies that an asynchronous audit writer fault is observed as a warning and does not alter the response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse()
{
@@ -1056,12 +1170,15 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// InboundAPI-019 — bodyless requests skip EnableBuffering so the
// Bodyless requests skip EnableBuffering so the
// FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE
// and any request whose Content-Length is 0. The audit row still emits
// with a null RequestSummary, mirroring the bodyless-POST contract.
// ---------------------------------------------------------------------
/// <summary>Verifies that a bodyless HTTP method skips enabling buffering so the request stream is not replaced.</summary>
/// <param name="method">The bodyless HTTP method under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
@@ -1102,6 +1219,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.RequestSummary);
}
/// <summary>Verifies that a bodyless POST with content length zero skips enabling buffering.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
{
@@ -1135,6 +1254,8 @@ public class AuditWriteMiddlewareTests
Assert.Null(evt.RequestSummary);
}
/// <summary>Verifies that a POST with a body still enables buffering and captures the request summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
{
@@ -1161,13 +1282,15 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 1: Request headers in Extra JSON
// Increment 1: Request headers in Extra JSON
// Request headers are captured into the Extra JSON object alongside the
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
// HeaderRedactList as ScadaBridgeAuditRedactor.
// ---------------------------------------------------------------------
/// <summary>Verifies that request headers appear in the Extra JSON under the request headers key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
{
@@ -1197,6 +1320,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("custom-value", customVal.GetString());
}
/// <summary>Verifies that the Authorization header is redacted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
{
@@ -1224,6 +1349,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("<redacted>", authVal.GetString());
}
/// <summary>Verifies that the X-API-Key header is redacted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
{
@@ -1249,6 +1376,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("<redacted>", keyVal.GetString());
}
/// <summary>Verifies that an entry on the custom redact list is redacted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
{
@@ -1284,6 +1413,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
}
/// <summary>Verifies that header redaction is case-insensitive.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
{
@@ -1312,7 +1443,7 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
// Increment 2: AuditInboundCeilingHits counter
// When request OR response exceeds InboundMaxBytes, the middleware
// increments IAuditInboundCeilingHitsCounter once per request.
// ---------------------------------------------------------------------
@@ -1324,7 +1455,9 @@ public class AuditWriteMiddlewareTests
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
{
private int _count;
/// <summary>Gets the number of recorded increments.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Increment() => Interlocked.Increment(ref _count);
}
@@ -1341,6 +1474,8 @@ public class AuditWriteMiddlewareTests
actorAccessor: null,
ceilingHitsCounter: counter);
/// <summary>Verifies that a request body above the inbound maximum increments the ceiling-hits counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
{
@@ -1367,6 +1502,8 @@ public class AuditWriteMiddlewareTests
Assert.True(evt.PayloadTruncated);
}
/// <summary>Verifies that a response body above the inbound maximum increments the ceiling-hits counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
{
@@ -1394,6 +1531,8 @@ public class AuditWriteMiddlewareTests
Assert.True(evt.PayloadTruncated);
}
/// <summary>Verifies that a normal request within the cap does not increment the ceiling-hits counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
{
@@ -1418,7 +1557,7 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
// Increment 3: SkipBodyCapture per-method opt-out
// A target with SkipBodyCapture=true produces an audit row with
// headers/metadata but empty/omitted body. A normal target still captures.
// ---------------------------------------------------------------------
@@ -1444,6 +1583,8 @@ public class AuditWriteMiddlewareTests
return ctx;
}
/// <summary>Verifies that with body capture skipped, an audit row is still emitted but its body is null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
{
@@ -1489,6 +1630,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(200, evt.HttpStatus);
}
/// <summary>Verifies that with body capture skipped, the ceiling-hits counter is not incremented.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
{
@@ -1526,6 +1669,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(0, counter.Count);
}
/// <summary>Verifies that with body capture not skipped, a normal target still captures the body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
{
@@ -1561,6 +1706,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
}
/// <summary>Verifies that with no override, the default target still captures the body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
{
@@ -18,7 +18,7 @@ using System.Security.Claims;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
/// <summary>
/// M4 Bundle D (D2) — verifies the production pipeline order from
/// Verifies the production pipeline order from
/// <c>ZB.MOM.WW.ScadaBridge.Host.Program.cs</c> for the inbound API:
/// <c>UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoint</c>.
///
@@ -42,7 +42,10 @@ public class MiddlewareOrderTests
/// </summary>
private sealed class OrderingRecorder
{
/// <summary>Gets the stages recorded so far, in order.</summary>
public List<string> Stages { get; } = new();
/// <summary>Appends a stage name to the recorded list.</summary>
/// <param name="stage">The name of the stage that ran.</param>
public void Record(string stage)
{
lock (Stages) { Stages.Add(stage); }
@@ -51,9 +54,11 @@ public class MiddlewareOrderTests
private sealed class RecordingAuditWriter : ICentralAuditWriter
{
// C3 (Task 2.5): store the decomposed row view so assertions keep
// Store the decomposed row view so assertions keep
// reading the ScadaBridge domain fields as typed properties.
/// <summary>Gets the audit events written, as decomposed row views.</summary>
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events) { Events.Add(evt.AsRow()); }
@@ -61,6 +66,8 @@ public class MiddlewareOrderTests
}
}
/// <summary>Verifies that the audit-write middleware runs after authentication and before script execution.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Middleware_Pipeline_PlacesAuditWriteAfterAuth_BeforeScriptExecution()
{
@@ -99,6 +106,8 @@ public class MiddlewareOrderTests
Assert.Equal("test-actor", evt.Actor);
}
/// <summary>Verifies that the pipeline records an authentication failure without reaching the endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Middleware_Pipeline_Records_AuthFailure_Beyond_Endpoint()
{
@@ -225,12 +234,17 @@ public class MiddlewareOrderTests
/// </summary>
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
/// <summary>Initializes the handler with the standard authentication-handler dependencies.</summary>
/// <param name="options">The authentication scheme options monitor.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public AlwaysAuthenticatedHandler(
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger,
System.Text.Encodings.Web.UrlEncoder encoder)
: base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
@@ -249,12 +263,21 @@ public class MiddlewareOrderTests
{
private readonly AuditLogOptions _value;
/// <summary>Initializes the monitor with a fixed options value.</summary>
/// <param name="value">The options value to return.</param>
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the fixed options value.</summary>
public AuditLogOptions CurrentValue => _value;
/// <summary>Returns the fixed options value regardless of name.</summary>
/// <param name="name">The named-options name, ignored.</param>
/// <returns>The fixed options value.</returns>
public AuditLogOptions Get(string? name) => _value;
/// <summary>Ignores the listener because the value never changes.</summary>
/// <param name="listener">The change listener, ignored.</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}