Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs
T
Joseph Doherty 64e6ba70e8 fix(management): site-scope ListDataConnections + hoist scope checks above resolve/status oracles (arch-review 2026-08-19 P6-4 + P6-5)
P6-4: HandleListDataConnections took no user at all and returned EVERY
site's connections to any authenticated caller, nullifying the row-98
fail-closed gate on HandleGetDataConnection — Get denied exactly what List
handed over. DataConnectionPublicShape already scrubs secrets, so the leak
was site topology plus a connection-id existence oracle, but it still
defeated scoping. List now takes the AuthenticatedUser: an explicit SiteId
goes through EnforceSiteScope (the ListAreas shape), and an unfiltered list
from a site-scoped non-Administrator caller is narrowed to their permitted
sites (the ListSites shape). GetRequiredRoles is untouched — scoping is not
a role gate, so the frozen 138-command authz matrix is unaffected.

The Central UI Design > Connections page bypasses the actor entirely
(ISiteRepository.GetAllDataConnectionsAsync direct) and IS reachable by a
scoped user: it is gated RequireDesign, and SiteScopeService explicitly does
NOT exempt Designer (register row 86). It now filters sites through
SiteScope.FilterSitesAsync and keeps only their connections, mirroring
EventLogs/AlarmSummary.

P6-5: four handlers resolved their target — and on approve/reject reported
its status — BEFORE the scope check, so an out-of-scope caller could tell a
real id from a fake one, and read a secured write's lifecycle status, from
the error shape alone. The scope check is hoisted above the not-found and
status checks in HandleSubmitSecuredWrite, HandleApproveSecuredWrite,
HandleRejectSecuredWrite and HandleDebugSnapshot, with the unresolvable case
routed through the existing DenyUnresolvedTargetIfScoped so the denial is
identical whether or not the target exists. System-wide callers keep their
ordinary not-found/status answers, and the P6-1 CAS semantics on
approve/reject are unchanged (only the checks above the CAS moved).

Tests: 8 new ManagementService tests + 2 CentralUI bUnit tests, all verified
to FAIL against the unfixed handlers (src reverted, tests re-run) and pass
after — including the unscoped/Administrator control cases proving behaviour
for system-wide callers is unchanged. Full ManagementService.Tests 545 green,
CentralUI.Tests 1073 green.
2026-08-19 14:49:24 -04:00

1272 lines
57 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.AuditLog.Central;
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.Interfaces.Services;
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.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
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
{
/// <summary>
/// The central node the test writer stamps onto every emitted row. #206 routes
/// SecuredWrite audit through <see cref="ICentralAuditWriter"/>, whose
/// <see cref="INodeIdentityProvider"/> stamps <c>SourceNode</c>; the handler tests
/// assert each lifecycle row carries this value (proving the stamping actually runs).
/// </summary>
private const string ExpectedSourceNode = "central-a";
private readonly ISiteRepository _siteRepo;
private readonly ISecuredWriteRepository _securedWriteRepo;
private readonly IAuditLogRepository _auditRepo;
private readonly StubCommunicationService _comms;
private readonly ServiceCollection _services;
public SecuredWriteHandlerTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
_auditRepo = Substitute.For<IAuditLogRepository>();
_comms = new StubCommunicationService();
_services = new ServiceCollection();
_services.AddScoped(_ => _siteRepo);
_services.AddScoped(_ => _securedWriteRepo);
_services.AddScoped(_ => _auditRepo);
_services.AddSingleton<CommunicationService>(_comms);
// Server-side secured-write TTL (arch-review S2): the expiry helper reads
// SecuredWritePendingTtl off IOptions<ManagementServiceOptions>. Register the
// defaults (24 h TTL) so the TTL tests exercise the real threshold.
_services.AddSingleton<IOptions<ManagementServiceOptions>>(
Options.Create(new ManagementServiceOptions()));
// #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same
// central-direct-write path Notification Outbox dispatch + Inbound API use) so
// these tests exercise SourceNode stamping end-to-end — actor → writer → repo.
// The writer stamps SourceNode from the INodeIdentityProvider (central-a/central-b)
// and persists via the captured IAuditLogRepository, so the captured rows now
// carry a non-null SourceNode. Mirrors AuditLog.Tests CentralAuditWriterTests'
// wiring (real writer + substituted repo + node identity).
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns(ExpectedSourceNode);
_services.AddSingleton<INodeIdentityProvider>(nodeIdentity);
_services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp,
NullLogger<CentralAuditWriter>.Instance,
nodeIdentity: sp.GetRequiredService<INodeIdentityProvider>()));
}
/// <summary>
/// Captures every <see cref="AuditEvent"/> that lands at the substituted
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — i.e. the row AFTER the
/// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c> (#206), so the
/// captured events carry the production node-of-origin value. Audit emission is
/// best-effort and asynchronous off the actor thread, so a short await-condition
/// poll lets the captured list settle before assertions.
/// </summary>
private List<AuditEvent> CaptureAuditEvents()
{
var captured = new List<AuditEvent>();
_auditRepo
.When(r => r.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()))
.Do(ci => captured.Add(ci.Arg<AuditEvent>()));
return captured;
}
/// <summary>Spins briefly until the captured audit list reaches <paramref name="count"/> rows.</summary>
private static void WaitForAuditRows(List<AuditEvent> captured, int count)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (captured.Count < count && DateTime.UtcNow < deadline)
{
Thread.Sleep(10);
}
}
private static AuditEvent SingleOfKind(List<AuditEvent> captured, AuditKind kind) =>
Assert.Single(captured, e => e.Action == $"{AuditChannel.SecuredWrite}.{kind}");
/// <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"));
private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) =>
new(new AuthenticatedUser(username, username, roles, permittedSiteIds), 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);
// The approve handler re-asserts the MxGateway protocol AT EXECUTE (D2 / T14
// TOCTOU guard), so the connection named on the row must still resolve to an
// MxGateway connection at approve-time for the relay to proceed. Seed it here
// so every approve test that should relay sees a valid MxGateway target; tests
// exercising the reconfigured/missing-connection case re-stub this lookup.
SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
return row;
}
/// <summary>
/// Arms the reject compare-and-swap to succeed (this verifier wins the
/// Pending-&gt;Rejected transition). NSubstitute's unarmed default for the
/// bool-returning CAS is <c>false</c>, i.e. "lost the race", so every reject test
/// that expects the decision to land must arm it explicitly (arch-review
/// 2026-08-19 P6-1).
/// </summary>
private void ArmRejectCasSuccess(long id) =>
_securedWriteRepo.TryMarkRejectedAsync(
id, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
/// <summary>
/// Lets the best-effort audit path settle, then asserts NOTHING was written. Audit
/// emission is awaited inline before the handler replies, so the reply itself is the
/// happens-before edge; the short sleep is belt-and-braces against a late writer.
/// </summary>
private static void AssertNoAuditRows(List<AuditEvent> captured)
{
Thread.Sleep(250);
Assert.Empty(captured);
}
[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);
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkRejectedAsync(
default, default!, default, default, default);
}
[Fact]
public void Reject_ByDifferentUser_CasFlipsStatusToRejected_NoBlindUpdate()
{
SeedPendingWrite(7, operatorUser: "alice");
ArmRejectCasSuccess(7);
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);
// The decision goes through the conditional Pending->Rejected CAS, exactly once,
// carrying the verifier identity + comment (arch-review 2026-08-19 P6-1).
_securedWriteRepo.Received(1).TryMarkRejectedAsync(
7, "bob", "not authorized", Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
// The blind full-row overwrite is gone — it could land mid-relay on a row a
// concurrent approve already owned.
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
// The projected row reflects the committed decision.
Assert.Contains("Rejected", response.JsonData);
Assert.Contains("bob", response.JsonData);
Assert.Contains("not authorized", response.JsonData);
// Operator fields are preserved.
Assert.Contains("alice", response.JsonData);
}
[Fact]
public void Reject_NonPendingRow_ReturnsError()
{
var row = SeedPendingWrite(7, operatorUser: "alice");
row.Status = "Approved";
var captured = CaptureAuditEvents();
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);
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkRejectedAsync(
default, default!, default, default, default);
// Nothing was decided, so no Reject audit row may exist.
AssertNoAuditRows(captured);
}
[Fact]
public void Reject_WhenCasLosesRace_ReturnsConflict_WritesNothing_NoRejectAudit()
{
// The P6-1 race, from the reject side: the status read at the top of the handler
// saw Pending, but a concurrent approve won the Pending->Approved CAS and entered
// its multi-second WriteTagAsync relay before this reject reached the store. The
// reject must lose (CAS returns false), record NOTHING — no status write, no
// audit row — and report the status that actually won, so the two-person-control
// record can never say Rejected for a write that physically executed.
SeedPendingWrite(7, operatorUser: "alice");
_securedWriteRepo.TryMarkRejectedAsync(
7, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(false);
// First read (the handler's stale-read guard) sees Pending; the post-CAS re-read
// observes the status the concurrent approve committed.
var stalePending = new PendingSecuredWrite
{
Id = 7, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow
};
var approvedByRival = new PendingSecuredWrite
{
Id = 7, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Approved",
OperatorUser = "alice", VerifierUser = "carol",
SubmittedAtUtc = DateTime.UtcNow, DecidedAtUtc = DateTime.UtcNow
};
_securedWriteRepo.GetAsync(7, Arg.Any<CancellationToken>())
.Returns(stalePending, approvedByRival);
var captured = CaptureAuditEvents();
var actor = CreateActor();
actor.Tell(Envelope(new RejectSecuredWriteCommand(7, "no"), "bob", "Verifier"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// The conflict names the status that won the race.
Assert.Contains("Approved", response.Error);
Assert.Contains("cannot be rejected", response.Error);
_securedWriteRepo.Received(1).TryMarkRejectedAsync(
7, "bob", "no", Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
AssertNoAuditRows(captured);
}
[Fact]
public void Approve_OnAlreadyRejectedRow_ReturnsError_NoCas_NoRelay_NoAudit()
{
// The mirror case: a rejected row can never be approved/executed afterwards.
var row = SeedPendingWrite(7, operatorUser: "alice");
row.Status = "Rejected";
row.VerifierUser = "carol";
var captured = CaptureAuditEvents();
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(7, "ok"), "bob", "Verifier"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Rejected", response.Error);
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(
default, default!, default, default, default);
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
AssertNoAuditRows(captured);
}
[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<IReadOnlyCollection<string>?>(), 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();
// arch-review UA1: ListSecuredWrites is gated to the Secured Write
// roles + Administrator, so use Operator here (this test asserts filter
// forwarding, not the role gate — see ListSecuredWrites_* auth tests).
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Operator");
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<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void List_NoFilters_PassesNullsToRepository()
{
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
// arch-review UA1: gated read — Operator satisfies the any-of gate.
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, "SITE1"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
{
new("Site1", "SITE1") { Id = 1 },
new("Site3", "SITE3") { Id = 3 },
});
_securedWriteRepo.QueryAsync(null, null, 0, 200,
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
Arg.Is<IReadOnlyCollection<string>?>(s => s != null && s.Single() == "SITE3"),
Arg.Any<CancellationToken>());
}
[Fact]
public void List_SystemWideReader_PassesNullPermittedFilter()
{
_securedWriteRepo.QueryAsync(null, null, 0, 200,
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
Arg.Is<IReadOnlyCollection<string>?>(s => s == null),
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 captured = CaptureAuditEvents();
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);
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
// A lost approve decides nothing, so it may leave no Approve audit row behind.
AssertNoAuditRows(captured);
}
[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);
}
[Fact]
public void Approve_WhenConnectionNoLongerMxGateway_FlipsStatusToFailed_NoRelay()
{
// D2 / T14 TOCTOU guard: the MxGateway protocol is re-asserted AT EXECUTE, not
// only at submit. The named connection was reconfigured/recreated as a
// non-MxGateway (OPC UA) connection between submit and approval; relaying then
// would execute the secured write against a non-MxGateway adapter, violating the
// feature's core safety invariant. The row must fail deterministically with the
// protocol error and NO relay — even though the CAS already won.
SeedPendingWrite(7, operatorUser: "alice");
ArmCasSuccess(7);
// Re-stub the approve-time connection lookup to a non-MxGateway protocol,
// overriding the MxGateway seed inside SeedPendingWrite.
SeedSiteWithConnection(1, "SITE1", "Mx1", "OpcUa");
var captured = CaptureAuditEvents();
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));
// No relay — the connection is no longer an MxGateway target.
Assert.Equal(0, _comms.CallCount);
Assert.NotNull(updated);
Assert.Equal("Failed", updated!.Status);
Assert.NotNull(updated.ExecutedAtUtc);
Assert.Contains("no longer an MxGateway connection", updated.ExecutionError);
// The Execute audit row records the failure (canonical Failure outcome).
WaitForAuditRows(captured, 2);
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
}
[Fact]
public void Approve_WhenConnectionDeleted_FlipsStatusToFailed_NotFound_NoRelay()
{
// TOCTOU companion: the connection named on the row was deleted between submit
// and approval. The re-load at execute returns no match → fail with "not found"
// and no relay.
SeedPendingWrite(7, operatorUser: "alice");
ArmCasSuccess(7);
// Re-stub the approve-time lookup so the site resolves but exposes no connections.
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>());
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.Contains("not found", updated.ExecutionError);
}
// ------------------------------------------------------------------------
// List paging — Task 17 (arch-review P3)
// ------------------------------------------------------------------------
[Fact]
public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount()
{
_securedWriteRepo.QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
_securedWriteRepo.CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>()).Returns(87);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null, Skip: 10, Take: 25), "carol", "Operator"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
Assert.Contains("\"totalCount\":87", resp.JsonData);
}
// ------------------------------------------------------------------------
// Opportunistic expiry sweep on list — Task 16 (arch-review S2)
// ------------------------------------------------------------------------
[Fact]
public void List_SweepsStalePendingRows_MarksExpired_CallsTryMarkExpiredOnceForStaleOnly()
{
var fresh = new PendingSecuredWrite
{
Id = 1, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-1)
};
var stale = new PendingSecuredWrite
{
Id = 2, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.B",
ValueJson = "false", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-25) // TTL default 24h
};
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite> { fresh, stale });
_securedWriteRepo.TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The stale row surfaces as Expired in the projection; the fresh row stays Pending.
Assert.Contains("\"id\":2", resp.JsonData);
Assert.Contains("Expired", resp.JsonData);
// Exactly one CAS — only the overdue row.
_securedWriteRepo.Received(1).TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
_securedWriteRepo.DidNotReceive().TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
// ------------------------------------------------------------------------
// Server-side TTL expiry — Task 15 (arch-review S2)
// ------------------------------------------------------------------------
[Fact]
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
{
var row = SeedPendingWrite(1, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25); // TTL default 24h
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "bob", "Verifier"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
_securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
// The stale setpoint is NEVER relayed to the site.
Assert.Equal(0, _comms.CallCount);
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting()
{
var row = SeedPendingWrite(1, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25);
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new RejectSecuredWriteCommand(1, null), "bob", "Verifier"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
// The reject path never writes the row (the CAS expiry owns the transition).
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkRejectedAsync(
default, default!, default, default, default);
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Approve_FreshPendingRow_StillExecutes()
{
// Submitted 1h ago → within the 24h TTL, so the existing approve path is unchanged.
var row = SeedPendingWrite(7, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-1);
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();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkExpiredAsync(default, default, default);
Assert.Equal(1, _comms.CallCount);
Assert.NotNull(updated);
Assert.Equal("Executed", updated!.Status);
}
// ------------------------------------------------------------------------
// Audit emission (T14b — SecuredWrite AuditLog rows)
// ------------------------------------------------------------------------
/// <summary>
/// The correlation id stamped on a secured-write audit row encodes the row's long Id
/// as a big-endian value in the final 8 bytes of an otherwise-zero Guid — mirrors the
/// production encoding in <c>ManagementActor.SecuredWriteCorrelation</c>.
/// </summary>
private static Guid CorrelationFor(long id)
{
Span<byte> bytes = stackalloc byte[16];
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
return new Guid(bytes);
}
[Fact]
public void Submit_EmitsExactlyOneSubmitAuditRow_WithOperatorAndCorrelation()
{
SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
_securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
.Returns(55L);
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise"),
"alice", "Operator");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 1);
var evt = SingleOfKind(captured, AuditKind.SecuredWriteSubmit);
Assert.Equal("alice", evt.Actor);
Assert.Equal(CorrelationFor(55L), evt.CorrelationId);
Assert.Equal("SecuredWrite", evt.Category);
Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target);
// #206: the Submit row is stamped with the writing central node's identifier
// (it routed through the real CentralAuditWriter), not the legacy null SourceNode.
Assert.Equal(ExpectedSourceNode, evt.SourceNode);
// Exactly one row — no stray duplicates.
Assert.Single(captured);
}
[Fact]
public void Reject_EmitsOneRejectAuditRow_WithVerifier()
{
SeedPendingWrite(7, operatorUser: "alice");
ArmRejectCasSuccess(7);
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 1);
var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject);
Assert.Equal("bob", evt.Actor);
Assert.Equal(CorrelationFor(7L), evt.CorrelationId);
// #206: Reject row carries the writing central node's identifier.
Assert.Equal(ExpectedSourceNode, evt.SourceNode);
Assert.Single(captured);
}
[Fact]
public void Approve_Success_EmitsApproveThenExecuteDeliveredAuditRows()
{
SeedPendingWrite(7, operatorUser: "alice");
ArmCasSuccess(7);
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 2);
var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove);
Assert.Equal("bob", approve.Actor);
Assert.Equal(CorrelationFor(7L), approve.CorrelationId);
// #206: Approve + Execute rows both carry the writing central node's identifier.
Assert.Equal(ExpectedSourceNode, approve.SourceNode);
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal("bob", execute.Actor);
Assert.Equal(CorrelationFor(7L), execute.CorrelationId);
Assert.Equal(ExpectedSourceNode, execute.SourceNode);
// Delivered outcome → canonical Success.
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, execute.Outcome);
Assert.Equal(2, captured.Count);
}
[Fact]
public void Submit_AuditInsertThrows_StillSucceeds_RowStillPersisted()
{
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);
// Best-effort: a thrown audit insert must NOT abort the secured-write action.
// #206: the throw now happens behind the real CentralAuditWriter (which swallows
// it), so this still exercises the standing "audit failure never aborts the
// action" invariant — now through the central-direct-write path.
_auditRepo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("audit db down")));
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.A", "true", "Boolean", null),
"alice", "Operator");
actor.Tell(envelope);
// The action still succeeds and the row is still persisted despite the audit failure.
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);
}
[Fact]
public void Approve_CorruptListValueJson_FlipsStatusToFailed_DecodeError_NoRelay()
{
// C3 robustness fix: a List-typed value with corrupt JSON must not throw
// out of the handler and leave the row stuck Approved — it is contained,
// the row flips to Failed with a "value decode error", and no relay occurs.
var row = SeedPendingWrite(7, operatorUser: "alice");
row.ValueType = "List";
row.ValueJson = "{not valid json";
ArmCasSuccess(7);
var captured = CaptureAuditEvents();
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));
// No relay — the value never decoded.
Assert.Equal(0, _comms.CallCount);
Assert.NotNull(updated);
Assert.Equal("Failed", updated!.Status);
Assert.NotNull(updated.ExecutedAtUtc);
Assert.StartsWith("value decode error:", updated.ExecutionError);
// The Execute audit row records the failure (canonical Failure outcome).
WaitForAuditRows(captured, 2);
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
}
// ------------------------------------------------------------------------
// Site scope on submit / approve / reject (arch-review R2 N3)
// ------------------------------------------------------------------------
[Fact]
public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(
new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", ["3"], "Operator"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}
[Fact]
public void Submit_SiteScopedOperator_InScopeSite_Succeeds()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(
new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", ["1"], "Operator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(default, default!, default, default, default);
Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
}
[Fact]
public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new RejectSecuredWriteCommand(42, "no"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkRejectedAsync(
default, default!, default, default, default);
}
[Fact]
public void Approve_ScopedAdministrator_BypassesSiteScope()
{
// A scoped Verifier who is ALSO an Administrator bypasses site scope
// (EnforceSiteScope admin bypass, ManagementActor.cs:499) — the approve
// proceeds past the scope gate rather than returning ManagementUnauthorized.
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
_securedWriteRepo.TryMarkApprovedAsync(
42, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "carol", ["3"], "Verifier", "Administrator"));
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
// ------------------------------------------------------------------------
// Resolve-before-scope oracles (arch-review 2026-08-19 P6-5)
//
// Submit/approve/reject used to answer "not found" (and, on approve/reject,
// "is 'Executed', not Pending") BEFORE the scope gate ran, so an out-of-scope
// caller could enumerate row ids and read their lifecycle status by the error
// shape alone. Every out-of-scope caller must now get the SAME scope denial
// whether or not the target exists, and whatever its status.
// ------------------------------------------------------------------------
/// <summary>Seeds a non-Pending (already executed) row for the status-oracle tests.</summary>
private void SeedExecutedWrite(long id) =>
_securedWriteRepo.GetAsync(id, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = id, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Executed",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
[Fact]
public void Approve_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound()
{
_securedWriteRepo.GetAsync(999, Arg.Any<CancellationToken>())
.Returns((PendingSecuredWrite?)null);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(999, "ok"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void Approve_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
SeedExecutedWrite(42);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier"));
// A ManagementError naming 'Executed' would tell the caller the row exists
// and what happened to it — the oracle this hoist closes.
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(0, _comms.CallCount);
}
[Fact]
public void Approve_SystemWideVerifier_NonexistentId_StillReportsNotFound()
{
// The fail-closed arm must leave the unscoped caller's answers untouched.
_securedWriteRepo.GetAsync(999, Arg.Any<CancellationToken>())
.Returns((PendingSecuredWrite?)null);
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(999, "ok"), "bob", "Verifier"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("not found", response.Error);
}
[Fact]
public void Reject_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound()
{
_securedWriteRepo.GetAsync(999, Arg.Any<CancellationToken>())
.Returns((PendingSecuredWrite?)null);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new RejectSecuredWriteCommand(999, "no"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void Reject_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
SeedExecutedWrite(42);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new RejectSecuredWriteCommand(42, "no"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkRejectedAsync(
default, default!, default, default, default);
}
[Fact]
public void Reject_SystemWideVerifier_ExecutedRow_StillReportsStatusConflict()
{
// In-scope/unscoped behavior is unchanged: the status error still names
// the status that blocked the transition (the P6-1 CAS shape is intact).
SeedExecutedWrite(42);
var actor = CreateActor();
actor.Tell(Envelope(new RejectSecuredWriteCommand(42, "no"), "bob", "Verifier"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Executed", response.Error);
}
[Fact]
public void Submit_SiteScopedOperator_NonexistentSite_Unauthorized_NotNotFound()
{
_siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any<CancellationToken>())
.Returns((Site?)null);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(
new SubmitSecuredWriteCommand("GHOST", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", ["3"], "Operator"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}
[Fact]
public void Submit_SystemWideOperator_NonexistentSite_StillReportsNotFound()
{
_siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any<CancellationToken>())
.Returns((Site?)null);
var actor = CreateActor();
actor.Tell(Envelope(
new SubmitSecuredWriteCommand("GHOST", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", "Operator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("not found", response.Error);
}
}