refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+344
@@ -0,0 +1,344 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M2 Bundle F (Task F1): 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
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnWrite != null)
|
||||
{
|
||||
return Task.FromException(ThrowOnWrite);
|
||||
}
|
||||
|
||||
Events.Add(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private const string SiteId = "site-77";
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: 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);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
// Audit Log #23: 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);
|
||||
// Audit Log #23 (ParentExecutionId): null for a non-routed run — the
|
||||
// default CreateHelper supplies no parentExecutionId.
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): 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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user