Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.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

177 lines
10 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
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.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
public class RelayProvenanceTests
{
/// <summary>Verifies that a genuine relay preserves the caller IP only when the signed request is intact.</summary>
/// <param name="tamper">The kind of tampering applied to the relayed request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("none")]
[InlineData("body")]
[InlineData("path")]
[InlineData("credential")]
[InlineData("expired")]
[InlineData("wrongkey")]
public async Task EI5_GenuineRelayPreservesCallerIp_OnlyForIntactSignedRequest(string tamper)
{
var options = new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer"] });
var writer = new Writer();
var handler = new Handler(async request =>
{
var active = Context(tamper == "body" ? "changed" : await request.Content!.ReadAsStringAsync(), "10.0.0.2");
foreach (var header in request.Headers) active.Request.Headers[header.Key] = header.Value.ToArray();
active.Request.ContentType = request.Content!.Headers.ContentType?.ToString();
if (tamper == "path") active.Request.Path = "/api/another";
if (tamper == "credential") active.Request.Headers["X-API-Key"] = "changed-key";
if (tamper is "expired" or "wrongkey")
{
var envelope = active.Request.Headers["X-ScadaBridge-Relay"].ToString().Split('.');
var node = System.Text.Json.Nodes.JsonNode.Parse(Convert.FromBase64String(envelope[0]))!;
if (tamper == "expired") node["Time"] = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var bytes = JsonSerializer.SerializeToUtf8Bytes(node);
var pepper = Encoding.UTF8.GetBytes(new string(tamper == "wrongkey" ? 'x' : 'p', 32));
var key = System.Security.Cryptography.HMACSHA256.HashData(pepper, "ScadaBridge.InboundRelay.v1"u8);
var mac = System.Security.Cryptography.HMACSHA256.HashData(key, bytes);
active.Request.Headers["X-ScadaBridge-Relay"] = Convert.ToBase64String(bytes) + "." + Convert.ToHexString(mac);
}
await AuditAsync(active, writer, options);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
});
var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger<HttpStandbyRequestForwarder>.Instance);
var original = Context("{}", "10.4.5.6");
original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey] = Guid.NewGuid();
original.Request.Headers["X-ScadaBridge-Relay"] = "caller-forged-proof";
original.Request.Headers["X-Forwarded-For"] = "6.6.6.6";
await forwarder.ForwardAsync(original, CancellationToken.None);
var row = Assert.Single(writer.Events).AsRow();
using var extra = JsonDocument.Parse(row.Extra!);
Assert.Equal(tamper != "none" ? "10.0.0.2" : "10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString());
if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
}
/// <summary>Verifies that directly forged forwarding headers cannot change the audited caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EI5_DirectForgedForwardingHeadersCannotChangeAuditedCaller()
{
var context = Context("{}", "10.4.5.6");
context.Request.Headers[HttpStandbyRequestForwarder.ForwardedRequestHeader] = "trusted-peer";
context.Request.Headers["X-ScadaBridge-Relay"] = "forged";
context.Request.Headers["X-Forwarded-For"] = "6.6.6.6";
var writer = new Writer();
await AuditAsync(context, writer, new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32) }));
using var extra = JsonDocument.Parse(Assert.Single(writer.Events).AsRow().Extra!);
Assert.Equal("10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString());
}
/// <summary>Verifies that when every authenticated peer is standby, only the origin node emits the failure audit.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EI1_AllAuthenticatedPeersStandby_OnlyOriginEmitsFailureAudit()
{
var options = new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer-a", "http://peer-b"] });
var writer = new Writer();
var attempts = 0;
var handler = new Handler(async request =>
{
attempts++;
var peer = Context(await request.Content!.ReadAsStringAsync(), "10.0.0.2");
foreach (var header in request.Headers) peer.Request.Headers[header.Key] = header.Value.ToArray();
peer.Request.ContentType = request.Content!.Headers.ContentType?.ToString();
await AuditAsync(peer, writer, options, new Standby());
var response = new HttpResponseMessage((HttpStatusCode)peer.Response.StatusCode) { Content = new StringContent("{}") };
response.Headers.Add(HttpStandbyRequestForwarder.StandbyResponseHeader, "1");
return response;
});
var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger<HttpStandbyRequestForwarder>.Instance);
var original = Context("{}", "10.4.5.6");
await AuditAsync(original, writer, options, new Standby(), forwarder);
Assert.Equal(2, attempts);
Assert.Equal(503, original.Response.StatusCode);
var row = Assert.Single(writer.Events).AsRow();
Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
}
private sealed class Standby : IActiveNodeGate
{
/// <inheritdoc />
public bool IsActiveNode => false;
}
private static async Task AuditAsync(HttpContext context, Writer writer, IOptionsMonitor<InboundApiOptions> options, IActiveNodeGate? gate = null, IStandbyRequestForwarder? forwarder = null)
{
var services = new ServiceCollection().AddLogging();
services.AddSingleton<ICentralAuditWriter>(writer);
services.AddSingleton(options);
if (gate is not null) services.AddSingleton(gate);
if (forwarder is not null) services.AddSingleton(forwarder);
services.AddSingleton<IOptionsMonitor<AuditLogOptions>>(new Monitor<AuditLogOptions>(new()));
using var provider = services.BuildServiceProvider();
context.RequestServices = provider;
var middleware = ActivatorUtilities.CreateInstance<AuditWriteMiddleware>(provider, (RequestDelegate)(async ctx =>
{
if (gate is null) { ctx.Response.StatusCode = 200; return; }
var filter = new InboundApiEndpointFilter(NullLogger<InboundApiEndpointFilter>.Instance, options);
var result = await filter.InvokeAsync(EndpointFilterInvocationContext.Create(ctx), _ => ValueTask.FromResult<object?>(Results.Ok()));
await ((IResult)result!).ExecuteAsync(ctx);
}));
await middleware.InvokeAsync(context);
}
private static DefaultHttpContext Context(string body, string ip)
{
var context = new DefaultHttpContext();
context.Request.Method = "POST"; context.Request.Path = "/api/echo";
context.Request.RouteValues["methodName"] = "echo";
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
context.Request.ContentType = "application/json";
context.Response.Body = new MemoryStream();
context.Connection.RemoteIpAddress = IPAddress.Parse(ip);
return context;
}
private sealed class Writer : ICentralAuditWriter
{
/// <summary>Gets the audit events captured by this writer.</summary>
public List<AuditEvent> Events { get; } = [];
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
}
private sealed class Monitor<T>(T value) : IOptionsMonitor<T>
{
/// <summary>Gets the fixed options value.</summary>
public T CurrentValue => value;
/// <summary>Returns the fixed options value regardless of name.</summary>
/// <param name="name">The options name, ignored.</param>
/// <returns>The fixed options value.</returns>
public T Get(string? name) => value;
/// <summary>Ignores the change listener; the fake never raises change notifications.</summary>
/// <param name="listener">The change listener, ignored.</param>
/// <returns>Always <see langword="null"/>.</returns>
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class Handler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request);
}
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
{
/// <summary>Creates an HTTP client over the fake message handler.</summary>
/// <param name="name">The logical client name.</param>
/// <returns>An HTTP client backed by the fake handler.</returns>
public HttpClient CreateClient(string name) => new(handler, false);
}
}