fix(opcua): shelve declaration ids from a fixed set, not an address-space lookup; pin instance-id + state-machine-id shelve calls (#629)

New() ran FindNodeInAddressSpace under Lock, taking other node managers' locks (lock-order
risk); the three AlarmConditionType_ShelvingState_* ids are now a static set. Adds wire tests:
TimedShelve/Unshelve by the condition's own instance method ids (object = condition or its
ShelvingState) succeed and report, and a ShelvedStateMachineType_* id call is pinned as
BadMethodInvalid with a positive control.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 15:01:54 -04:00
parent 1de8357d13
commit 66c47c3ddd
3 changed files with 111 additions and 12 deletions
@@ -246,8 +246,96 @@ public sealed class ConditionChildNodeIdTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The shelve methods resolve by the condition's OWN child method ids
/// (<c>{condition}/ShelvingState/TimedShelve</c>, <c>…/Unshelve</c>) — the ids a browse of the condition
/// returns — whether the call names the condition or its ShelvingState as the object, and each accepted
/// call reports its one condition event.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Shelve_methods_called_by_their_instance_ids_succeed_and_report(bool objectIsShelvingState)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"CondChildMethodIds{objectIsShelvingState}", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
rig.MaterialiseScripted();
var current = ConditionMethodEventTests.PreState("TimedShelve", native: false) with { Message = "base" };
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = ConditionNodeId(session, native: false);
var ns = condition.NamespaceIndex;
var shelvingState = new NodeId($"{ScriptedAlarmId}/ShelvingState", ns);
var target = objectIsShelvingState ? shelvingState : condition;
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/TimedShelve", ns), ct, 60_000d);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].Value
.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/Unshelve", ns), ct);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].Value
.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Pins the #629 trade-off: the shelve methods resolve by the condition's instance ids and by the
/// <c>AlarmConditionType_ShelvingState_*</c> ids every client uses (the positive control), but NOT by
/// the <c>ShelvedStateMachineType_*</c> ids — those were the methods' SDK declarations, replaced by the
/// AlarmConditionType ones when the methods moved off their ns=0 NodeIds. Change this deliberately.
/// </summary>
[Fact]
public async Task A_shelve_call_by_the_state_machine_types_method_id_is_not_supported()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondChildMethodStateMachineId", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
rig.MaterialiseScripted();
var current = ConditionMethodEventTests.PreState("OneShotShelve", native: false);
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
rig.Project(false, current);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var condition = ConditionNodeId(session, native: false);
var refused = await Should.ThrowAsync<ServiceResultException>(
session.CallAsync(condition, MethodIds.ShelvedStateMachineType_OneShotShelve, ct));
refused.StatusCode.ShouldBe(StatusCodes.BadMethodInvalid);
// Positive control: the AlarmConditionType declaration id resolves on the same condition.
await session.CallAsync(condition, MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, ct);
}
finally { await rig.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private const NodePermissions AllMethods =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
| NodePermissions.MethodCall;
/// <summary>
/// Native: active, unacked, "native-distinct". Scripted: active, unacked, "scripted-distinct", timed
/// shelved for 60 s — none of which the condition type's instance declarations can hold.