471 lines
18 KiB
C#
471 lines
18 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
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.DataConnection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
|
|
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 StubCommunicationService _comms;
|
|
private readonly ServiceCollection _services;
|
|
|
|
public SecuredWriteHandlerTests()
|
|
{
|
|
_siteRepo = Substitute.For<ISiteRepository>();
|
|
_securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
|
|
_comms = new StubCommunicationService();
|
|
|
|
_services = new ServiceCollection();
|
|
_services.AddScoped(_ => _siteRepo);
|
|
_services.AddScoped(_ => _securedWriteRepo);
|
|
_services.AddSingleton<CommunicationService>(_comms);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test double for the site-write seam. <see cref="CommunicationService.WriteTagAsync"/>
|
|
/// is virtual so the approve relay can be exercised without a live actor system;
|
|
/// records the relayed request and returns a canned response.
|
|
/// </summary>
|
|
private sealed class StubCommunicationService : CommunicationService
|
|
{
|
|
public StubCommunicationService()
|
|
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
|
|
{
|
|
}
|
|
|
|
public WriteTagRequest? LastRequest { get; private set; }
|
|
|
|
public int CallCount { get; private set; }
|
|
|
|
public Func<WriteTagRequest, WriteTagResponse> Responder { get; set; } =
|
|
req => new WriteTagResponse(req.CorrelationId, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow);
|
|
|
|
public Exception? ThrowOnWrite { get; set; }
|
|
|
|
public override Task<WriteTagResponse> WriteTagAsync(
|
|
string siteId, WriteTagRequest request, CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
LastRequest = request;
|
|
if (ThrowOnWrite is not null)
|
|
{
|
|
return Task.FromException<WriteTagResponse>(ThrowOnWrite);
|
|
}
|
|
|
|
return Task.FromResult(Responder(request));
|
|
}
|
|
}
|
|
|
|
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>());
|
|
}
|
|
|
|
// ------------------------------------------------------------------------
|
|
// Approve (execute) — Task C3
|
|
// ------------------------------------------------------------------------
|
|
|
|
/// <summary>Arms the CAS to succeed (a different verifier wins the race).</summary>
|
|
private void ArmCasSuccess(long id) =>
|
|
_securedWriteRepo.TryMarkApprovedAsync(
|
|
id, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(true);
|
|
|
|
[Fact]
|
|
public void Approve_ByDifferentVerifier_RelaysWrite_FlipsStatusToExecuted()
|
|
{
|
|
SeedPendingWrite(7, operatorUser: "alice");
|
|
ArmCasSuccess(7);
|
|
|
|
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 ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
|
|
// The CAS guarded the transition (exactly once).
|
|
_securedWriteRepo.Received(1).TryMarkApprovedAsync(
|
|
7, "bob", "approved", Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
|
|
// The write was relayed to the site MxGateway connection.
|
|
Assert.Equal(1, _comms.CallCount);
|
|
Assert.NotNull(_comms.LastRequest);
|
|
Assert.Equal("Mx1", _comms.LastRequest!.ConnectionName);
|
|
Assert.Equal("Tag.A", _comms.LastRequest.TagPath);
|
|
|
|
Assert.NotNull(updated);
|
|
Assert.Equal("Executed", updated!.Status);
|
|
Assert.NotNull(updated.ExecutedAtUtc);
|
|
Assert.Null(updated.ExecutionError);
|
|
}
|
|
|
|
[Fact]
|
|
public void Approve_BySubmittingUser_ReturnsError_CasNotCalled_NoRelay()
|
|
{
|
|
SeedPendingWrite(7, operatorUser: "alice");
|
|
|
|
var actor = CreateActor();
|
|
// Same principal that submitted attempts to approve — separation of duties.
|
|
var envelope = Envelope(new ApproveSecuredWriteCommand(7, "self"), "alice", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(
|
|
default, default!, default, default, default);
|
|
Assert.Equal(0, _comms.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Approve_WhenCasLosesRace_ReturnsError_NoRelay()
|
|
{
|
|
SeedPendingWrite(7, operatorUser: "alice");
|
|
_securedWriteRepo.TryMarkApprovedAsync(
|
|
7, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(false);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ApproveSecuredWriteCommand(7, "race"), "bob", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
Assert.Contains("already decided", response.Error);
|
|
Assert.Equal(0, _comms.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Approve_WhenRelayFails_FlipsStatusToFailed_WithError()
|
|
{
|
|
SeedPendingWrite(7, operatorUser: "alice");
|
|
ArmCasSuccess(7);
|
|
_comms.Responder = req =>
|
|
new WriteTagResponse(req.CorrelationId, Success: false, ErrorMessage: "device offline", DateTimeOffset.UtcNow);
|
|
|
|
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 ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
// The approve itself succeeds (the row was decided); the relay outcome is recorded on the row.
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(1, _comms.CallCount);
|
|
Assert.NotNull(updated);
|
|
Assert.Equal("Failed", updated!.Status);
|
|
Assert.Equal("device offline", updated.ExecutionError);
|
|
Assert.NotNull(updated.ExecutedAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Approve_WhenRelayThrows_FlipsStatusToFailed_WithExceptionMessage()
|
|
{
|
|
SeedPendingWrite(7, operatorUser: "alice");
|
|
ArmCasSuccess(7);
|
|
_comms.ThrowOnWrite = new InvalidOperationException("transport down");
|
|
|
|
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 ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.NotNull(updated);
|
|
Assert.Equal("Failed", updated!.Status);
|
|
Assert.Contains("transport down", updated.ExecutionError);
|
|
}
|
|
|
|
[Fact]
|
|
public void Approve_UnknownValueType_FlipsStatusToFailed_NoRelay()
|
|
{
|
|
var row = SeedPendingWrite(7, operatorUser: "alice");
|
|
row.ValueType = "Bogus";
|
|
ArmCasSuccess(7);
|
|
|
|
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 ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(0, _comms.CallCount);
|
|
Assert.NotNull(updated);
|
|
Assert.Equal("Failed", updated!.Status);
|
|
Assert.Equal("unknown value type", updated.ExecutionError);
|
|
}
|
|
}
|