bb59fd4e75
Three deferred 'surface the failed write' enhancements on the write-outcome self-correction path in OtOpcUaNodeManager: - Item A: synchronous structural fail-fast. EvaluateEquipmentWriteStructure (pure static) rejects a structurally-invalid write INLINE (Bad sync) after the authz gate but before the optimistic dispatch, so the SDK never applies it. Null payload -> BadTypeMismatch; plus a confidence-gated cheap built-in type compatibility check (numeric widening + BaseDataType wildcard tolerated; uncertain cases defer to the SDK's own coercion). - Item B: Bad-quality blip on device-write failure. On a revert, RevertOptimisticWriteIfNeeded first publishes the still-applied optimistic value with StatusCode BadDeviceFailure, then restores the prior value/status (both under the existing Lock). Documents the queue-coalescing caveat (a slow subscriber may see only the restored value -> the audit event is the reliable signal). - Item C: Part 8 AuditWriteUpdateEvent on device-write failure. Builds an AuditWriteUpdateEventState (SourceNode=node, AttributeId=Value, OldValue=prior, NewValue=attempted, ClientUserId from the threaded identity, Message carries outcome.Reason) under Lock and reports it via Server.ReportEvent OUTSIDE Lock. Guarded so auditing-disabled / report failure never breaks the revert. Threads the writing identity's user-id + node into the continuation. Adds 6 unit tests for EvaluateEquipmentWriteStructure. Build clean (0 warnings); 158/158 OpcUaServer.Tests green.
184 lines
8.7 KiB
C#
184 lines
8.7 KiB
C#
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 + 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> — the gate fails closed.</summary>
|
|
[Fact]
|
|
public void Null_identity_is_denied()
|
|
{
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
|
identity: null,
|
|
gatewayWired: true);
|
|
|
|
result.ShouldNotBeNull();
|
|
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
/// <summary>(b) An identity WITHOUT the <c>WriteOperate</c> role is denied with
|
|
/// <c>BadUserAccessDenied</c>.</summary>
|
|
[Fact]
|
|
public void Identity_without_WriteOperate_is_denied()
|
|
{
|
|
var identity = IdentityWith("ReadOnly", OpcUaDataPlaneRoles.AlarmAck); // no WriteOperate
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
|
identity,
|
|
gatewayWired: true);
|
|
|
|
result.ShouldNotBeNull();
|
|
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
/// <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_and_gateway_passes()
|
|
{
|
|
var identity = IdentityWith("readonly", "writeoperate"); // lower-cased: case-insensitive match
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
|
identity,
|
|
gatewayWired: true);
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
/// <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_no_gateway_maps_to_bad_not_writable()
|
|
{
|
|
var identity = IdentityWith(OpcUaDataPlaneRoles.WriteOperate);
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
|
|
identity,
|
|
gatewayWired: false);
|
|
|
|
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();
|
|
}
|
|
|
|
// ───────────────────────────── Item A — EvaluateEquipmentWriteStructure ─────────────────────────────
|
|
|
|
/// <summary>(A1) A <c>null</c> value write to a value variable is rejected synchronously with
|
|
/// <c>BadTypeMismatch</c> (the minimum sensible structural check — a value node always holds a typed
|
|
/// payload, so a null write is never valid and fails fast inline rather than optimistic-then-revert).</summary>
|
|
[Fact]
|
|
public void Structure_null_value_is_rejected_with_type_mismatch()
|
|
{
|
|
var node = ValueNode(DataTypeIds.Int32);
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: null, node);
|
|
|
|
result.ShouldNotBeNull();
|
|
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
|
|
}
|
|
|
|
/// <summary>(A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns <c>null</c>).</summary>
|
|
[Fact]
|
|
public void Structure_matching_type_proceeds()
|
|
{
|
|
var node = ValueNode(DataTypeIds.Int32);
|
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>(A3) Numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics) — a
|
|
/// Double payload into an Int32 node proceeds rather than being rejected here.</summary>
|
|
[Fact]
|
|
public void Structure_numeric_to_numeric_proceeds()
|
|
{
|
|
var node = ValueNode(DataTypeIds.Int32);
|
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 3.5d, node).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>(A4) A confident cross-family mismatch (a String payload into a Boolean node) is rejected
|
|
/// with <c>BadTypeMismatch</c> via the cheap built-in-type compatibility check.</summary>
|
|
[Fact]
|
|
public void Structure_cross_family_mismatch_is_rejected()
|
|
{
|
|
var node = ValueNode(DataTypeIds.Boolean);
|
|
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "not-a-bool", node);
|
|
|
|
result.ShouldNotBeNull();
|
|
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
|
|
}
|
|
|
|
/// <summary>(A5) Confidence-gated defer: a node whose DataType is the abstract <c>BaseDataType</c>
|
|
/// wildcard (unresolved built-in type) proceeds for ANY non-null payload — the SDK's own coercion stays
|
|
/// authoritative. A "fallback" equipment tag (unknown DataType string) materialises as exactly this.</summary>
|
|
[Fact]
|
|
public void Structure_unresolved_datatype_defers_to_sdk()
|
|
{
|
|
var node = ValueNode(DataTypeIds.BaseDataType);
|
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "anything", node).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>(A6) A non-variable node (defensive) is never rejected on type — only the null-payload check
|
|
/// applies. A non-null write to a plain <see cref="NodeState"/> proceeds.</summary>
|
|
[Fact]
|
|
public void Structure_non_variable_node_defers()
|
|
{
|
|
var folder = new FolderState(null) { NodeId = new NodeId("f", 2), BrowseName = new QualifiedName("f", 2) };
|
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 1, folder).ShouldBeNull();
|
|
}
|
|
|
|
private static BaseDataVariableState ValueNode(NodeId dataType) => new(null)
|
|
{
|
|
NodeId = new NodeId("eq/x", 2),
|
|
BrowseName = new QualifiedName("x", 2),
|
|
DisplayName = "x",
|
|
DataType = dataType,
|
|
ValueRank = ValueRanks.Scalar,
|
|
};
|
|
|
|
private static RoleCarryingUserIdentity IdentityWith(params string[] roles) =>
|
|
new(new UserNameIdentityToken { UserName = "op" }, roles);
|
|
}
|