- 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>
177 lines
7.3 KiB
C#
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, "") { }
|
|
}
|