Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs
T
Joseph Doherty 69feb40e65 feat(inbound-api): audit central method-script Database.* calls (DbOutbound) and feed the flow-trace sink
InboundDatabaseHelper gains trailing optional auditWriter / nodeIdentity /
executionId / parentExecutionId / methodName / traceSink parameters; every
QuerySingle/Query/Execute writes one DbOutbound/DbWrite row (Delivered/Failed,
Target = connection name, Actor = 'api:{method}', DurationMs, SQL + parameters
as RequestSummary, Extra in the site's {op, rowsAffected|rowsReturned}
vocabulary, SourceNode) via ICentralAuditWriter — best-effort, never into the
script; the same instance goes to the RouteHelper trace sink first. Closes the
audit-scope gap Component-AuditLog.md already claimed (plan D7). Redaction and
the 8 KB cap apply at write time in the shared redactor, as for site rows.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:52:50 -04:00

241 lines
11 KiB
C#

using System.Data.Common;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
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;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// Flow Debugger outbound calls (plan 2026-09-06, Task 4 / D7): every
/// <see cref="InboundDatabaseHelper"/> call made by a central method script writes ONE
/// <c>DbOutbound.DbWrite</c> row through <see cref="ICentralAuditWriter"/> — best-effort,
/// never into the script — using the site's <c>AuditingDbCommand</c> vocabulary
/// (<c>{"op":"write","rowsAffected":N}</c> / <c>{"op":"read","rowsReturned":N}</c>), and
/// hands the same instance to the flow-trace sink first when one is present.
/// </summary>
public class InboundDatabaseHelperAuditTests
{
// One shared-cache in-memory database per gateway instance. Such a database lives only
// while a connection to it is open, so the gateway ROOTS its keep-alive connection for
// its own lifetime — an unrooted local would be GC-finalized mid-test under load (the
// executor test's Roslyn compile is long enough) and the table would silently vanish.
private sealed class SqliteGateway : IDatabaseGateway
{
private readonly string _cs = $"DataSource=file:dbaudit-{Guid.NewGuid():N}?mode=memory&cache=shared";
private readonly SqliteConnection _keepAlive;
public SqliteGateway()
{
_keepAlive = new SqliteConnection(_cs);
_keepAlive.Open();
using var cmd = _keepAlive.CreateCommand();
cmd.CommandText = "CREATE TABLE Machine(Code TEXT, SAPID TEXT); INSERT INTO Machine VALUES('Z28061A','131453');";
cmd.ExecuteNonQuery();
}
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
=> throw new NotImplementedException();
}
private sealed class ThrowingGateway : IDatabaseGateway
{
public Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
=> throw new InvalidOperationException("no such connection: " + name);
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
=> throw new NotImplementedException();
}
private static SqliteGateway SeededGateway() => new();
private readonly List<AuditEvent> _written = new();
private readonly ICentralAuditWriter _writer = Substitute.For<ICentralAuditWriter>();
private readonly INodeIdentityProvider _node = Substitute.For<INodeIdentityProvider>();
private readonly Guid _exec = Guid.NewGuid();
private readonly Guid _parent = Guid.NewGuid();
public InboundDatabaseHelperAuditTests()
{
_writer.WriteAsync(Arg.Do<AuditEvent>(e => _written.Add(e)), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
_node.NodeName.Returns("central-a");
}
private InboundDatabaseHelper Helper(IDatabaseGateway? gateway = null, Action<AuditEvent>? sink = null,
ICentralAuditWriter? writer = null) =>
new(gateway ?? SeededGateway(), CancellationToken.None, TimeSpan.Zero,
auditWriter: writer ?? _writer, nodeIdentity: _node,
executionId: _exec, parentExecutionId: _parent, methodName: "movein", traceSink: sink);
[Fact]
public async Task ExecuteAsync_WritesDbWriteDeliveredRow_WithRowsAffected()
{
var helper = Helper();
var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=@c WHERE SAPID=@s", new { c = "Z28061A", s = "131453" });
Assert.Equal(1, n);
var evt = Assert.Single(_written);
var row = evt.AsRow();
Assert.Equal(AuditChannel.DbOutbound, row.Channel);
Assert.Equal(AuditKind.DbWrite, row.Kind);
Assert.Equal(AuditStatus.Delivered, row.Status);
Assert.Equal("MesDb", evt.Target);
Assert.Equal("api:movein", evt.Actor);
Assert.Equal("central-a", evt.SourceNode);
Assert.Equal(_exec, row.ExecutionId);
Assert.Equal(_parent, row.ParentExecutionId);
Assert.Null(row.CorrelationId);
Assert.NotNull(row.DurationMs);
Assert.Null(row.ErrorMessage);
Assert.Equal("{\"op\":\"write\",\"rowsAffected\":1}", row.Extra);
Assert.Contains("\"sql\":\"UPDATE Machine SET Code=@c WHERE SAPID=@s\"", row.RequestSummary);
Assert.Contains("\"@s\":\"131453\"", row.RequestSummary);
Assert.Contains("\"@c\":\"Z28061A\"", row.RequestSummary);
}
[Fact]
public async Task QueryAsync_WritesReadRow_WithRowsReturned()
{
var helper = Helper();
var rows = await helper.QueryAsync("MesDb", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Single(rows);
var row = Assert.Single(_written).AsRow();
Assert.Equal(AuditKind.DbWrite, row.Kind);
Assert.Equal(AuditStatus.Delivered, row.Status);
Assert.Equal("{\"op\":\"read\",\"rowsReturned\":1}", row.Extra);
}
[Fact]
public async Task QuerySingleAsync_WritesReadRow_ZeroRowsWhenNoMatch()
{
var helper = Helper();
var code = await helper.QuerySingleAsync<string>("MesDb", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "nope" });
Assert.Null(code);
var row = Assert.Single(_written).AsRow();
Assert.Equal("{\"op\":\"read\",\"rowsReturned\":0}", row.Extra);
}
[Fact]
public async Task ThrowingGateway_WritesFailedRow_AndRethrowsToScript()
{
var helper = Helper(new ThrowingGateway());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => helper.ExecuteAsync("Missing", "DELETE FROM Machine", null));
Assert.Contains("no such connection", ex.Message);
var evt = Assert.Single(_written);
var row = evt.AsRow();
Assert.Equal(AuditStatus.Failed, row.Status);
Assert.Equal("Missing", evt.Target);
Assert.Equal(ex.Message, row.ErrorMessage);
Assert.Equal("{\"op\":\"write\",\"rowsAffected\":0}", row.Extra);
Assert.Contains("\"sql\":\"DELETE FROM Machine\"", row.RequestSummary);
}
[Fact]
public async Task AuditWriterFaulting_DoesNotFailTheCall()
{
var syncThrow = Substitute.For<ICentralAuditWriter>();
syncThrow.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("audit down"));
var faultedTask = Substitute.For<ICentralAuditWriter>();
faultedTask.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("audit down")));
var n1 = await Helper(writer: syncThrow).ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null);
var n2 = await Helper(writer: faultedTask).ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null);
Assert.Equal(1, n1);
Assert.Equal(1, n2);
}
[Fact]
public async Task TraceSink_ReceivesTheSameInstance_BeforeTheWriter()
{
var order = new List<(string Who, AuditEvent Evt)>();
_writer.WriteAsync(Arg.Do<AuditEvent>(e => order.Add(("writer", e))), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var helper = Helper(sink: e => order.Add(("sink", e)));
await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null);
Assert.Equal(new[] { "sink", "writer" }, order.Select(o => o.Who));
Assert.Same(order[0].Evt, order[1].Evt);
}
[Fact]
public async Task ThrowingSink_DoesNotFailTheCall_AndWriterStillGetsTheRow()
{
var helper = Helper(sink: _ => throw new InvalidOperationException("bad sink"));
var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null);
Assert.Equal(1, n);
Assert.Single(_written);
}
[Fact]
public async Task NoWriterNoSink_EmitsNothing_AndStillWorks()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null);
Assert.Equal(1, n);
Assert.Empty(_written);
}
[Fact]
public async Task Executor_WiresWriterNodeIdentityExecutionIdAndRouteSink_IntoTheHelper()
{
// End-to-end through InboundScriptExecutor: the DbOutbound row carries the inbound
// request's ExecutionId, "api:{method}" as actor, SourceNode from DI, and the same
// instance reaches the RouteHelper's trace sink (the Flow Debugger's Live path).
var services = new ServiceCollection();
services.AddSingleton<IDatabaseGateway>(SeededGateway());
services.AddSingleton(_writer);
services.AddSingleton(_node);
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var sunk = new List<AuditEvent>();
var route = new RouteHelper(Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>())
.WithTraceSink(sunk.Add);
var method = new ApiMethod(
"movein",
"return await Database.ExecuteAsync(\"MesDb\", \"UPDATE Machine SET Code=@c WHERE SAPID=@s\", new { c = \"Z28061A\", s = \"131453\" });")
{ Id = 1, TimeoutSeconds = 10, ReturnDefinition = null };
var inboundExecution = Guid.NewGuid();
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: inboundExecution, trace: true);
Assert.True(result.Success, result.ErrorMessage);
var evt = Assert.Single(_written);
var row = evt.AsRow();
Assert.Equal(AuditChannel.DbOutbound, row.Channel);
Assert.Equal(inboundExecution, row.ExecutionId);
Assert.Null(row.ParentExecutionId);
Assert.Equal("api:movein", evt.Actor);
Assert.Equal("central-a", evt.SourceNode);
Assert.Same(evt, Assert.Single(sunk));
}
}