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;
///
/// 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 , gates on the
/// role (deny otherwise) AND on the gateway being wired
/// (BadNotWritable otherwise), then returns Good (optimistic write) and dispatches the write
/// fire-and-forget through the . 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:
/// (role + availability) and
/// (compare-and-revert).
///
public sealed class EquipmentWriteGateTests
{
/// (a) A null identity (anonymous / no role-carrying identity on the context) is denied with
/// BadUserAccessDenied — the gate fails closed.
[Fact]
public void Null_identity_is_denied()
{
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
identity: null,
gatewayWired: true);
result.ShouldNotBeNull();
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
/// (b) An identity WITHOUT the WriteOperate role is denied with
/// BadUserAccessDenied.
[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);
}
/// (c) An identity WITH the WriteOperate role and a wired gateway passes the gate
/// (returns null — proceed). The role match is case-insensitive (the role set + gate both use
/// OrdinalIgnoreCase).
[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();
}
/// (d) An identity WITH the WriteOperate role but no gateway wired maps to
/// BadNotWritable ("writes unavailable") — the gate passes but there is nowhere to route.
[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");
}
///
/// 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 bool is the outcome's Success flag.
///
[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);
}
/// Null-value edges: a failed write whose node still holds the optimistic null reverts;
/// a failed write whose node moved off null does not.
[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 ─────────────────────────────
/// (A1) A null value write to a value variable is rejected synchronously with
/// BadTypeMismatch (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).
[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);
}
/// (A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns null).
[Fact]
public void Structure_matching_type_proceeds()
{
var node = ValueNode(DataTypeIds.Int32);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
}
/// (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.
[Fact]
public void Structure_numeric_to_numeric_proceeds()
{
var node = ValueNode(DataTypeIds.Int32);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 3.5d, node).ShouldBeNull();
}
/// (A4) A confident cross-family mismatch (a String payload into a Boolean node) is rejected
/// with BadTypeMismatch via the cheap built-in-type compatibility check.
[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);
}
/// (A5) Confidence-gated defer: a node whose DataType is the abstract BaseDataType
/// 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.
[Fact]
public void Structure_unresolved_datatype_defers_to_sdk()
{
var node = ValueNode(DataTypeIds.BaseDataType);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "anything", node).ShouldBeNull();
}
/// (A6) A non-variable node (defensive) is never rejected on type — only the null-payload check
/// applies. A non-null write to a plain proceeds.
[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();
}
/// (A7) Closed-set safety boundary: an Enumeration-typed node with an Int32 payload DEFERS
/// (returns null) rather than false-rejecting — an enum write normally carries an Int32 the SDK coerces.
/// Before the closed-set gate this returned BadTypeMismatch — a false rejection of a valid write.
[Fact]
public void Structure_enumeration_datatype_defers_to_sdk()
{
var node = ValueNode(DataTypeIds.Enumeration);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 2, node).ShouldBeNull();
}
/// (A8) Closed-set safety boundary: any expected built-in type outside the materialiser's emitted
/// set (here Guid) DEFERS — only the numeric families + Boolean/String/DateTime/ByteString are ever rejected,
/// so the fail-fast can never reject a write the SDK would coerce.
[Fact]
public void Structure_noncheckable_expected_type_defers()
{
var node = ValueNode(DataTypeIds.Guid);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).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);
}