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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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, "") { }
|
||||
}
|
||||
Reference in New Issue
Block a user