feat(opcua): surface failed inbound writes to clients (fail-fast, Bad blip, audit event)

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.
This commit is contained in:
Joseph Doherty
2026-06-15 02:38:57 -04:00
parent dcb0be650e
commit bb59fd4e75
2 changed files with 317 additions and 15 deletions
@@ -106,6 +106,78 @@ public sealed class EquipmentWriteGateTests
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);
}