feat(mgmt): secured-write submit/reject/list handlers + Operator/Verifier gating (T14b)

This commit is contained in:
Joseph Doherty
2026-06-18 02:29:29 -04:00
parent 586d54359c
commit 25c9240415
3 changed files with 458 additions and 0 deletions
@@ -0,0 +1,273 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Handler tests for the two-person ("secured") write submit/reject/list flow
/// (M7 OPC UA / MxGateway UX, Task T14b). Submit is gated to Operator, reject to
/// Verifier; the no-self-approval rule and the MxGateway-protocol precondition
/// are enforced inside the handlers and surface as ManagementError.
/// </summary>
public class SecuredWriteHandlerTests : TestKit, IDisposable
{
private readonly ISiteRepository _siteRepo;
private readonly ISecuredWriteRepository _securedWriteRepo;
private readonly ServiceCollection _services;
public SecuredWriteHandlerTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
_services = new ServiceCollection();
_services.AddScoped(_ => _siteRepo);
_services.AddScoped(_ => _securedWriteRepo);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, string username, params string[] roles) =>
new(new AuthenticatedUser(username, username, roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
/// <summary>Wires a site whose single connection uses the given protocol.</summary>
private void SeedSiteWithConnection(int siteId, string identifier, string connectionName, string protocol)
{
_siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
.Returns(new Site($"Site{siteId}", identifier) { Id = siteId });
_siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new(connectionName, protocol, siteId) { Id = 100 }
});
}
// ------------------------------------------------------------------------
// Submit
// ------------------------------------------------------------------------
[Fact]
public void Submit_OnNonMxGatewayConnection_ReturnsError_NoRowInserted()
{
SeedSiteWithConnection(1, "SITE1", "Plc1", "OpcUa");
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Plc1", "Tag.A", "true", "Boolean", "go"),
"alice", "Operator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("MxGateway", response.Error);
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}
[Fact]
public void Submit_OnUnknownConnection_ReturnsError_NoRowInserted()
{
SeedSiteWithConnection(1, "SITE1", "Plc1", "MxGateway");
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "DoesNotExist", "Tag.A", "true", "Boolean", null),
"alice", "Operator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}
[Fact]
public void Submit_OnMxGatewayConnection_InsertsPendingRowWithOperatorUser()
{
SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
PendingSecuredWrite? inserted = null;
_securedWriteRepo
.When(r => r.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
.Do(ci => inserted = ci.Arg<PendingSecuredWrite>());
_securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
.Returns(55L);
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise setpoint"),
"alice", "Operator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(inserted);
Assert.Equal("Pending", inserted!.Status);
Assert.Equal("alice", inserted.OperatorUser);
Assert.Equal("Mx1", inserted.ConnectionName);
Assert.Equal("SITE1", inserted.SiteId);
Assert.Equal("Tag.Setpoint", inserted.TagPath);
Assert.Equal("42.5", inserted.ValueJson);
Assert.Equal("Double", inserted.ValueType);
Assert.Equal("raise setpoint", inserted.OperatorComment);
// The returned DTO carries the store-assigned id and operator.
Assert.Contains("\"id\":55", response.JsonData);
Assert.Contains("alice", response.JsonData);
}
// ------------------------------------------------------------------------
// Reject
// ------------------------------------------------------------------------
private PendingSecuredWrite SeedPendingWrite(long id, string operatorUser)
{
var row = new PendingSecuredWrite
{
Id = id,
SiteId = "SITE1",
ConnectionName = "Mx1",
TagPath = "Tag.A",
ValueJson = "true",
ValueType = "Boolean",
Status = "Pending",
OperatorUser = operatorUser,
SubmittedAtUtc = DateTime.UtcNow
};
_securedWriteRepo.GetAsync(id, Arg.Any<CancellationToken>()).Returns(row);
return row;
}
[Fact]
public void Reject_BySubmittingUser_ReturnsError_StatusUnchanged()
{
SeedPendingWrite(7, operatorUser: "alice");
var actor = CreateActor();
// Same principal that submitted attempts to reject — separation of duties.
var envelope = Envelope(new RejectSecuredWriteCommand(7, "no"), "alice", "Verifier");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
}
[Fact]
public void Reject_ByDifferentUser_FlipsStatusToRejected()
{
SeedPendingWrite(7, operatorUser: "alice");
PendingSecuredWrite? updated = null;
_securedWriteRepo
.When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<PendingSecuredWrite>());
var actor = CreateActor();
var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(updated);
Assert.Equal("Rejected", updated!.Status);
Assert.Equal("bob", updated.VerifierUser);
Assert.Equal("not authorized", updated.VerifierComment);
Assert.NotNull(updated.DecidedAtUtc);
// Operator fields are preserved (fully-populated update).
Assert.Equal("alice", updated.OperatorUser);
}
[Fact]
public void Reject_NonPendingRow_ReturnsError()
{
var row = SeedPendingWrite(7, operatorUser: "alice");
row.Status = "Approved";
var actor = CreateActor();
var envelope = Envelope(new RejectSecuredWriteCommand(7, null), "bob", "Verifier");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
}
[Fact]
public void Reject_MissingRow_ReturnsError()
{
_securedWriteRepo.GetAsync(99, Arg.Any<CancellationToken>()).Returns((PendingSecuredWrite?)null);
var actor = CreateActor();
var envelope = Envelope(new RejectSecuredWriteCommand(99, null), "bob", "Verifier");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
}
// ------------------------------------------------------------------------
// List
// ------------------------------------------------------------------------
[Fact]
public void List_FiltersByStatusAndSite()
{
_securedWriteRepo.QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>
{
new()
{
Id = 3, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow
}
});
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Viewer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Contains("\"id\":3", response.JsonData);
Assert.Contains("alice", response.JsonData);
_securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<CancellationToken>());
}
[Fact]
public void List_NoFilters_PassesNullsToRepository()
{
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>());
}
}