feat(mgmt): secured-write approve relays to site MxGateway write with CAS race guard (T14b)
This commit is contained in:
@@ -2,12 +2,15 @@ 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;
|
||||
|
||||
@@ -21,16 +24,54 @@ 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()
|
||||
@@ -270,4 +311,160 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user