Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingProcessedFlagTests.cs
Joseph Doherty 6134050ceb fix(server): resolve Low code-review findings (Server-004,006,008,012,014,015)
- Server-004: pass the role-derived display name to UserIdentity's base
  ctor (the SDK's DisplayName has no public setter) and drop the dead
  Display property; make RoleBasedIdentity internal sealed.
- Server-006: derive a bounded CancellationToken from the SDK's
  OperationContext.OperationDeadline in OnReadValue / OnWriteValue so a
  stalled driver call can no longer pin the request thread.
- Server-008: mark handled slots via CallMethodRequest.Processed = true
  in RouteScriptedAlarmMethodCalls (the SDK skips on Processed, not on a
  Good error slot).
- Server-012: PeerHttpProbeLoop.ProbeAsync stops mutating client.Timeout
  per call; uses a per-request CancellationTokenSource linked to the
  shutdown token instead.
- Server-014: wire SealedBootstrap into Program.cs via AddSealedBootstrap
  + OpcUaServerService so the generation-sealed cache + stale-config flag
  + resilient reader actually run; /healthz now reflects cache-fallback
  state.
- Server-015: replace the stale 'PR 16 / PR 17 minimum-viable scope'
  class summaries on OtOpcUaServer and OpcUaServerOptions with the
  shipped LDAP + anonymous-role + configurable security-profile prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:24:20 -04:00

177 lines
7.3 KiB
C#

using Opc.Ua;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Regression for Server-008 — <c>RouteScriptedAlarmMethodCalls</c> must mark a handled
/// <see cref="CallMethodRequest"/> slot as <c>Processed = true</c> so the stack's
/// <c>CustomNodeManager2.Call</c> skips it. The pre-fix code relied on the slot's
/// <c>errors[i]</c> being <c>ServiceResult.Good</c>, but the SDK's actual skip predicate is
/// <see cref="CallMethodRequest.Processed"/>; without setting it, the stack's built-in
/// Part 9 Acknowledge / Confirm handler would also fire, producing a double transition.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmMethodRoutingProcessedFlagTests
{
private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
{
var upstream = new CachedTagUpstreamSource();
var logger = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(logger);
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
var defs = new List<ScriptedAlarmDefinition>
{
new(AlarmId: alarmId,
EquipmentPath: "/eq",
AlarmName: alarmId,
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.Medium,
MessageTemplate: "msg",
PredicateScriptSource: "return true;"),
};
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
return engine;
}
private static ScriptedAlarmEngine BuildEngine(string alarmId)
{
var upstream = new CachedTagUpstreamSource();
var logger = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(logger);
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
var defs = new List<ScriptedAlarmDefinition>
{
new(AlarmId: alarmId,
EquipmentPath: "/eq",
AlarmName: alarmId,
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.Medium,
MessageTemplate: "msg",
PredicateScriptSource: "return false;"),
};
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
return engine;
}
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId)
=> new()
{
ObjectId = new NodeId(conditionNodeId, 2),
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
InputArguments =
{
new Variant(new byte[] { 1, 2, 3 }),
new Variant(new LocalizedText("ack-comment")),
},
};
private static CallMethodRequest AddCommentRequest(string conditionNodeId)
=> new()
{
ObjectId = new NodeId(conditionNodeId, 2),
MethodId = MethodIds.ConditionType_AddComment,
InputArguments =
{
new Variant(new byte[] { 1, 2, 3 }),
new Variant(new LocalizedText("comment-text")),
},
};
[Fact]
public void Handled_Acknowledge_marks_Processed_true_so_baseCall_skips_the_slot()
{
using var engine = BuildActiveEngine("al-1");
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
var results = new List<CallMethodResult> { new CallMethodResult() };
var errors = new List<ServiceResult> { null! };
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-1",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
calls[0].Processed.ShouldBeTrue(
"CustomNodeManager2.Call/CallInternalAsync skips slots with Processed=true. "
+ "Without this flag, base.Call would re-dispatch the Acknowledge to the stack's "
+ "built-in Part 9 handler and the engine would observe a double transition.");
}
[Fact]
public void Handled_AddComment_marks_Processed_true()
{
using var engine = BuildEngine("al-1");
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition") };
var results = new List<CallMethodResult> { new CallMethodResult() };
var errors = new List<ServiceResult> { null! };
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-1",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
calls[0].Processed.ShouldBeTrue("AddComment handled by the engine must not re-dispatch via base.Call");
}
[Fact]
public void Engine_error_path_also_marks_Processed_so_baseCall_does_not_re_run_the_method()
{
using var engine = BuildEngine("al-1");
var calls = new List<CallMethodRequest>
{
// Index maps to an alarm id the engine doesn't know — engine throws
// ArgumentException, helper sets errors[i] = BadInvalidArgument.
AcknowledgeRequest("al-1.Condition"),
};
var results = new List<CallMethodResult> { new CallMethodResult() };
var errors = new List<ServiceResult> { null! };
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-NOT-IN-ENGINE",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
ServiceResult.IsBad(errors[0]).ShouldBeTrue("engine error path");
calls[0].Processed.ShouldBeTrue(
"even when the engine returns Bad, the slot was handled — base.Call must not "
+ "re-dispatch the method against the OPC UA built-in handler.");
}
[Fact]
public void Unhandled_slot_leaves_Processed_false_so_baseCall_drives_it()
{
using var engine = BuildActiveEngine("al-1");
var genericMethod = new CallMethodRequest
{
ObjectId = new NodeId("some-driver-method", 2),
MethodId = new NodeId("driver-method", 2),
};
var calls = new List<CallMethodRequest> { genericMethod };
var results = new List<CallMethodResult> { new CallMethodResult() };
var errors = new List<ServiceResult> { null! };
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine,
conditionIdToAlarmId: new Dictionary<string, string>());
calls[0].Processed.ShouldBeFalse("non-alarm methods must fall through to base.Call");
errors[0].ShouldBeNull("unhandled slot's error must stay null for the base implementation");
}
private sealed class NamedIdentity(string displayName) : UserIdentity(displayName, "") { }
}