feat(opcua): write-outcome self-correction — capture prior + compare-and-revert on failure
This commit is contained in:
@@ -1,83 +1,111 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 11 — the inbound operator-write authz gate + fire-and-forget dispatch. The OnWriteValue handler
|
||||
/// on a writable equipment-tag node extracts the caller's <see cref="RoleCarryingUserIdentity"/>, gates
|
||||
/// on the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role (deny otherwise), and on pass kicks off the
|
||||
/// fire-and-forget route through the <see cref="OtOpcUaNodeManager.NodeWriteRouter"/> and returns
|
||||
/// <c>Good</c> (optimistic write). The pure decision is extracted into
|
||||
/// <see cref="OtOpcUaNodeManager.EvaluateEquipmentWrite"/> so the gate + dispatch are unit-testable
|
||||
/// without booting an SDK server: the handler just supplies the extracted identity and a thunk that
|
||||
/// starts the router (or null when no router is wired).
|
||||
/// Task 11 + write-outcome self-correction — the inbound operator-write authz/availability gate and the
|
||||
/// compare-and-revert decision. The OnWriteValue handler on a writable equipment-tag node extracts the
|
||||
/// caller's <see cref="RoleCarryingUserIdentity"/>, gates on the
|
||||
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role (deny otherwise) AND on the gateway being wired
|
||||
/// (BadNotWritable otherwise), then returns <c>Good</c> (optimistic write) and dispatches the write
|
||||
/// fire-and-forget through the <see cref="OtOpcUaNodeManager.NodeWriteGateway"/>. When the async device
|
||||
/// outcome comes back FAILED, the node self-corrects back to its real pre-write value — but only while the
|
||||
/// node still holds the optimistic value (so a fresh poll isn't clobbered).
|
||||
///
|
||||
/// Both decisions are extracted as pure statics so they're unit-testable without booting an SDK server:
|
||||
/// <see cref="OtOpcUaNodeManager.EvaluateEquipmentWriteGate"/> (role + availability) and
|
||||
/// <see cref="OtOpcUaNodeManager.ShouldRevert"/> (compare-and-revert).
|
||||
/// </summary>
|
||||
public sealed class EquipmentWriteGateTests
|
||||
{
|
||||
/// <summary>(a) A null identity (anonymous / no role-carrying identity on the context) is denied with
|
||||
/// <c>BadUserAccessDenied</c> and the route thunk is NEVER invoked — the gate fails closed.</summary>
|
||||
/// <c>BadUserAccessDenied</c> — the gate fails closed.</summary>
|
||||
[Fact]
|
||||
public void Null_identity_is_denied_and_does_not_route()
|
||||
public void Null_identity_is_denied()
|
||||
{
|
||||
var routed = false;
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWrite(
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
||||
identity: null,
|
||||
route: () => routed = true);
|
||||
gatewayWired: true);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBeFalse();
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>(b) An identity WITHOUT the <c>WriteOperate</c> role is denied with
|
||||
/// <c>BadUserAccessDenied</c> and the route thunk is NEVER invoked.</summary>
|
||||
/// <c>BadUserAccessDenied</c>.</summary>
|
||||
[Fact]
|
||||
public void Identity_without_WriteOperate_is_denied_and_does_not_route()
|
||||
public void Identity_without_WriteOperate_is_denied()
|
||||
{
|
||||
var routed = false;
|
||||
var identity = IdentityWith("ReadOnly", OpcUaDataPlaneRoles.AlarmAck); // no WriteOperate
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWrite(
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
||||
identity,
|
||||
route: () => routed = true);
|
||||
gatewayWired: true);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBeFalse();
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>(c) An identity WITH the <c>WriteOperate</c> role and a non-null route invokes the route
|
||||
/// thunk (fire-and-forget) and returns <c>ServiceResult.Good</c> so the SDK applies the value
|
||||
/// optimistically. The role match is case-insensitive (the role set + gate both use
|
||||
/// <summary>(c) An identity WITH the <c>WriteOperate</c> role and a wired gateway passes the gate
|
||||
/// (returns <c>null</c> — proceed). The role match is case-insensitive (the role set + gate both use
|
||||
/// <c>OrdinalIgnoreCase</c>).</summary>
|
||||
[Fact]
|
||||
public void Identity_with_WriteOperate_routes_and_returns_good()
|
||||
public void Identity_with_WriteOperate_and_gateway_passes()
|
||||
{
|
||||
var routed = false;
|
||||
var identity = IdentityWith("readonly", "writeoperate"); // lower-cased: case-insensitive match
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWrite(
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
||||
identity,
|
||||
route: () => routed = true);
|
||||
gatewayWired: true);
|
||||
|
||||
routed.ShouldBeTrue();
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>(d) An identity WITH the <c>WriteOperate</c> role but a null route (no router wired — e.g.
|
||||
/// admin-only nodes) maps to <c>BadNotWritable</c> ("writes unavailable") — the gate passes but there is
|
||||
/// nowhere to route the write.</summary>
|
||||
/// <summary>(d) An identity WITH the <c>WriteOperate</c> role but no gateway wired maps to
|
||||
/// <c>BadNotWritable</c> ("writes unavailable") — the gate passes but there is nowhere to route.</summary>
|
||||
[Fact]
|
||||
public void Identity_with_WriteOperate_and_null_route_maps_to_bad_not_writable()
|
||||
public void Identity_with_WriteOperate_and_no_gateway_maps_to_bad_not_writable()
|
||||
{
|
||||
var identity = IdentityWith(OpcUaDataPlaneRoles.WriteOperate);
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWrite(
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
||||
identity,
|
||||
route: null);
|
||||
gatewayWired: false);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable);
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable);
|
||||
result.LocalizedText.Text.ShouldContain("writes unavailable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="OtOpcUaNodeManager.ShouldRevert"/> decision table. Revert ONLY on a failed outcome AND
|
||||
/// only while the node still holds the optimistic value (a fresh poll that moved the node on must not
|
||||
/// be clobbered). The first <c>bool</c> is the outcome's Success flag.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, "v", "v", false)] // success → never revert (even though still-optimistic)
|
||||
[InlineData(false, "v", "v", true)] // fail + node still holds optimistic → revert
|
||||
[InlineData(false, "w", "v", false)] // fail + a poll moved the node on → no revert
|
||||
[InlineData(true, "w", "v", false)] // success + moved on → no revert
|
||||
public void ShouldRevert_decision_table(bool success, object currentNodeValue, object optimisticValue, bool expected)
|
||||
{
|
||||
var outcome = new NodeWriteOutcome(success, success ? null : "device rejected");
|
||||
|
||||
OtOpcUaNodeManager.ShouldRevert(outcome, currentNodeValue, optimisticValue).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Null-value edges: a failed write whose node still holds the optimistic <c>null</c> reverts;
|
||||
/// a failed write whose node moved off <c>null</c> does not.</summary>
|
||||
[Fact]
|
||||
public void ShouldRevert_null_value_edges()
|
||||
{
|
||||
var fail = new NodeWriteOutcome(false, "device rejected");
|
||||
|
||||
OtOpcUaNodeManager.ShouldRevert(fail, currentNodeValue: null, optimisticValue: null).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldRevert(fail, currentNodeValue: null, optimisticValue: "v").ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static RoleCarryingUserIdentity IdentityWith(params string[] roles) =>
|
||||
new(new UserNameIdentityToken { UserName = "op" }, roles);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user