Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.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

378 lines
17 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Every script-initiated
/// <c>ExternalSystem.Call</c> emits exactly one <c>ApiOutbound</c>/<c>ApiCall</c>
/// audit event via the wrapper inside
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>. The audit emission
/// is best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original <see cref="ExternalCallResult"/>
/// (or original exception) must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCallAuditEmissionTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow.
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
// Store the decomposed row view so assertions keep
// reading the ScadaBridge domain fields as typed properties.
/// <summary>Gets the captured audit rows.</summary>
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
/// <summary>Gets or sets an exception to throw from WriteAsync.</summary>
public Exception? ThrowOnWrite { get; set; }
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (ThrowOnWrite != null)
{
return Task.FromException(ThrowOnWrite);
}
Events.Add(evt.AsRow());
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// A fixed per-execution id used by the default
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid executionId,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
executionId,
auditWriter,
SiteId,
SourceScript,
cachedForwarder: null,
parentExecutionId: parentExecutionId);
}
/// <summary>Verifies that a successful call emits one ApiOutbound ApiCall event with a Delivered status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.True(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal("ERP.GetOrder", evt.Target);
// ForwardState is no longer a field on the canonical
// record — it is a site-storage-only concern. The emitter no longer
// sets it; the site SQLite writer defaults it to Pending on insert.
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
// No call arguments → null request summary; the response body is captured.
Assert.Null(evt.RequestSummary);
Assert.Equal("{}", evt.ResponseSummary);
}
/// <summary>Verifies that the audit row captures the request arguments and the response body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
await helper.Call("Weather", "GetCurrent", args);
var evt = Assert.Single(writer.Events);
// RequestSummary is the serialized argument dictionary; ResponseSummary
// is the verbatim response body. (Cap + redaction are the writer's job.)
Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
}
/// <summary>Verifies that an HTTP 500 emits a Failed event with status 500 and an error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Transient error: HTTP 500 from ERP: Internal Server Error"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(500, evt.HttpStatus);
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
Assert.Contains("500", evt.ErrorMessage);
}
/// <summary>Verifies that an HTTP 400 emits a Failed event with status 400.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Permanent error: HTTP 400 from ERP: Bad Request"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(400, evt.HttpStatus);
}
/// <summary>Verifies that a client network exception emits a Failed event whose error message comes from the exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException()
{
var client = new Mock<IExternalSystemClient>();
var networkEx = new HttpRequestException("network down");
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(networkEx);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var thrown = await Assert.ThrowsAsync<HttpRequestException>(() => helper.Call("ERP", "GetOrder"));
Assert.Same(networkEx, thrown);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Null(evt.HttpStatus);
Assert.Equal("network down", evt.ErrorMessage);
Assert.NotNull(evt.ErrorDetail);
Assert.Contains("HttpRequestException", evt.ErrorDetail);
}
/// <summary>Verifies that a throwing audit writer leaves the script call result unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged()
{
var client = new Mock<IExternalSystemClient>();
var expected = new ExternalCallResult(true, "{\"v\":1}", null);
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.Same(expected, result);
Assert.Empty(writer.Events);
}
/// <summary>Verifies that provenance fields are populated from the context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, null, null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var beforeId = Guid.NewGuid();
await helper.Call("ERP", "GetOrder");
var evt = writer.Events[0];
Assert.NotEqual(beforeId, evt.EventId);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
// The sync ApiCall row carries the per-execution id the
// helper was constructed with in ExecutionId. CorrelationId is null —
// a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
// ParentExecutionId: null for a non-routed run — the
// default CreateHelper supplies no parentExecutionId.
Assert.Null(evt.ParentExecutionId);
}
/// <summary>Verifies that a routed run stamps the parent execution id from the context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
{
// ParentExecutionId: an inbound-API-routed run
// carries the spawning execution's id; the sync ApiCall row must stamp
// it in ParentExecutionId alongside its own fresh ExecutionId.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var parentExecutionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(parentExecutionId, evt.ParentExecutionId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
}
/// <summary>Verifies that a non-routed run has a null parent execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_NonRoutedRun_ParentExecutionIdIsNull()
{
// A normal (tag/timer) run is not routed — no parent id supplied, so
// the emitted ApiCall row's ParentExecutionId stays null.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
/// <summary>Verifies that a sync API call stamps the execution id and a null correlation id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(executionId, evt.ExecutionId);
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
Assert.Null(evt.CorrelationId);
}
/// <summary>Verifies that two calls on the same helper share the same execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
await helper.Call("ERP", "GetCustomer");
Assert.Equal(2, writer.Events.Count);
// Both sync ApiCall rows from one execution carry the same ExecutionId.
Assert.Equal(executionId, writer.Events[0].ExecutionId);
Assert.Equal(executionId, writer.Events[1].ExecutionId);
Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
// Neither sync call carries a CorrelationId.
Assert.Null(writer.Events[0].CorrelationId);
Assert.Null(writer.Events[1].CorrelationId);
}
/// <summary>Verifies that the duration is recorded as non-zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DurationMs_Recorded_NonZero()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "Slow", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.Returns(async () =>
{
await Task.Delay(20);
return new ExternalCallResult(true, null, null);
});
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
await helper.Call("ERP", "Slow");
var evt = writer.Events[0];
Assert.NotNull(evt.DurationMs);
Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
}
}