using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
///
/// Gitea #797 over the wire — a real activated apikey session with zero application-wide roles,
/// holding only an Equipment grant, drives the Part 9 methods on a scripted condition whose id is a
/// production-valid opaque ScriptedAlarmId, under a production-shaped UNS tree (every folder an opaque
/// entity id, grant + owner paths resolved by from the NAMES).
///
///
///
/// Observation is through a SEPARATE anonymous session that holds a Cluster grant carrying only
/// Browse/Read/Subscribe/AlarmRead — no alarm-command flag — so it sees every condition event while its
/// own presence can never be what authorizes a command. Every count is read after a later sentinel
/// transition on the same item, so a late duplicate would already be queued ahead of it.
///
///
/// The rig's NodeIds stay exactly what production publishes: the condition is s=scripted-eqa-hi in
/// the UNS namespace and every routed is that id unchanged — the
/// ScadaBridge-visible wire contract.
///
///
public sealed class ScriptedAlarmOwnerScopeWireTests
{
private const string AreaId = "UA-0000000001";
private const string LineId = "UL-0000000001";
private const string Pump = "EQ-00000000a1b2";
private const string Fan = "EQ-00000000c3d4";
private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi"
private const string FanAlarm = "scripted-fan-hi";
private const NodePermissions Commands =
NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm
| NodePermissions.AlarmShelve | NodePermissions.MethodCall;
private const NodePermissions ObserveOnly =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead;
///
/// Every gated condition method, authorized by the narrow owner grant alone: routes exactly once with the
/// opaque id unchanged and reports exactly one condition event — while the same method on the SIBLING
/// equipment's scripted alarm, on the same session, is denied, routes nothing and reports nothing.
///
[Theory]
[InlineData("Acknowledge")]
[InlineData("Confirm")]
[InlineData("AddComment")]
[InlineData("OneShotShelve")]
[InlineData("TimedShelve")]
[InlineData("Unshelve")]
[InlineData("Disable")]
[InlineData("Enable")]
public async Task A_narrow_equipment_grant_authorizes_each_method_once_and_its_sibling_is_denied(string method)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Owner797{method}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = ConditionMethodEventTests.PreState(method, native: false);
Project(rig, Alarm, pre with { Message = "base" });
Project(rig, FanAlarm, pre with { Message = "fan-base" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base") && events.Has(item, "fan-base"));
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), method, events.LatestEventId(item, "base"), ct);
var denied = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync(
session, Uns(session, FanAlarm), method, events.LatestEventId(item, "fan-base"), ct));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Project(rig, Alarm, pre with { Message = "sentinel" });
Project(rig, FanAlarm, pre with { Message = "fan-sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel") && events.Has(item, "fan-sentinel"));
var mine = events.ConditionEvents(item, "base");
mine.Count.ShouldBe(2, $"the base transition plus exactly one {method} event");
ConditionMethodEventTests.AssertNewState(method, native: false, mine[1]);
events.ConditionEvents(item, "fan-base").Count.ShouldBe(1, "the denied sibling call reports nothing");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, method)]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
///
/// Initial and repeated Acknowledge / Confirm on the narrow grant: every call is authorized and routed (the
/// engine owns the retry decision), and the condition events are exactly the documented contract — the ack,
/// the COMMENTED re-ack (its comment changed), the confirm, and nothing for the re-confirm (no delta).
///
[Fact]
public async Task Initial_and_repeated_ack_and_confirm_route_every_call_and_report_exact_events()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Owner797Repeat", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = Uns(session, Alarm);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "ack");
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "again");
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 4);
await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
Project(rig, Alarm, pre with { Acknowledged = true, Confirmed = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "base").Count.ShouldBe(4, "base + ack + commented re-ack + confirm; the re-confirm is silent");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe(
[(Alarm, "Acknowledge"), (Alarm, "Acknowledge"), (Alarm, "Confirm"), (Alarm, "Confirm")]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
///
/// The grant, the key and the clock are re-read at every call on the open session: revoking any of them
/// denies the next command (no route, no event, no state change); restoring the authority restores the
/// allow, on the same session, without reconnecting.
///
[Theory]
[InlineData("grant")]
[InlineData("key")]
[InlineData("expiry")]
public async Task A_revoked_or_expired_authority_denies_the_next_command_on_the_open_session(string change)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Owner797Revoke{change}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
var clock = new Clock();
var key = ServerEventRig.Key();
key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10);
var state = Install(rig, clock, [key], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = Uns(session, Alarm);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
switch (change)
{
case "grant": Reapply(state, "rev-2", [key], Observer()); break;
case "key":
var disabled = ServerEventRig.Key();
disabled.Enabled = false;
Reapply(state, "rev-2", [disabled], OwnerGrant(Pump), Observer());
break;
case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break;
}
var condState = rig.Nm.TryGetAlarmCondition(Alarm)!;
var commentBefore = condState.Comment!.Value;
foreach (var method in new[] { "AddComment", "Confirm", "OneShotShelve" })
{
var error = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync(
session, condition, method, events.LatestEventId(item, "base"), ct));
error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, method);
}
condState.Comment!.Value.ShouldBe(commentBefore);
routed.Count.ShouldBe(1);
clock.Reset();
Reapply(state, "rev-3", [key], OwnerGrant(Pump), Observer());
await ConditionMethodEventTests.CallAsync(session, condition, "AddComment", events.LatestEventId(item, "base"), ct);
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "base").Count.ShouldBe(3, "base + ack + the restored AddComment; the denied calls report nothing");
routed.Select(c => c.Operation).ShouldBe(["Acknowledge", "AddComment"]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
///
/// Delete + recreate of the SAME opaque id under ANOTHER equipment: the old equipment's grant is denied on
/// the recreated condition and the new equipment's grant authorizes it; the routed id never changes. An id
/// with no live condition is refused by the server before any gate, and routes nothing.
///
[Fact]
public async Task A_recreated_alarm_follows_its_new_owner_and_an_unknown_id_routes_nothing()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Owner797Recreate", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
var state = Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
await ConditionMethodEventTests.CallAsync(session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "base"), ct);
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
rig.Nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan);
Project(rig, Alarm, pre with { Message = "recreated" });
// Gitea #808 — deliberate change: while the live binding (Fan) disagrees with the generation (Pump), the
// condition is unplaceable for EVERY grant, so even the Cluster-granted observer does not receive its event
// (it used to: the opaque id split to [uns, scripted-eqa-hi], which only a Cluster grant reached). Positive
// control: a later event on the correctly placed sibling arrives, so "recreated" would have arrived first.
Project(rig, FanAlarm, pre with { Message = "fan-control" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "fan-control"));
events.Has(item, "recreated").ShouldBeFalse("a moved alarm is invisible until the generation agrees");
var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray();
var oldGrant = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", staleEventId, ct));
oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.Count.ShouldBe(1);
// The old grant was refused above with the generation STILL deploying the alarm under Pump (binding ≠
// generation); now the deploy that moved it lands, granting the new owner — and placing it again for the
// observer, which sees its next event.
Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer());
Project(rig, Alarm, pre with { Message = "moved" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "moved"));
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "moved"), ct);
var unknown = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync(
session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct));
unknown.StatusCode.ShouldNotBe(StatusCodes.Good);
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "moved").Count.ShouldBe(2, "the re-placed base plus the new owner's one ack");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
// ───────────────────────────── helpers ─────────────────────────────
/// Area1 / Line1 / {Pump, Fan} under opaque entity ids, one scripted alarm under each equipment.
private static void Topology(ServerEventRig rig)
{
rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
}
private static AclScopePathResolver Resolver() => new(
new Dictionary(),
new Dictionary(),
new Dictionary(),
new Dictionary(),
new Dictionary(),
new Dictionary { [AreaId] = "Area1" },
new Dictionary { [LineId] = (AreaId, "Line1") },
new Dictionary { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") },
[]);
///
/// The topology's UNS folder placements (Gitea #808), from the same resolver: a scripted condition is placed
/// only while its owner's live folder ancestry matches the generation's.
///
private static Dictionary FolderScopes()
{
var resolver = Resolver();
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
return new Dictionary
{
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
};
}
private static Dictionary OwnerPaths()
{
var resolver = Resolver();
return new Dictionary
{
[Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(),
[Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(),
};
}
private static DataPlaneAuthorityState Install(ServerEventRig rig, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
Reapply(state, "rev-1", keys, grants);
rig.Nm.DataPlaneAuthority = state;
rig.Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock);
return state;
}
private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) =>
Reapply(state, revision, Deployed(), keys, grants);
private static void Reapply(
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary deployedOwners,
ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
/// The generation's deployed scripted-alarm owners; moves the Pump alarm.
private static Dictionary Deployed(string alarmOwner = Pump) =>
new() { [Alarm] = alarmOwner, [FanAlarm] = Fan };
/// The key's ONLY alarm authority: an Equipment grant on .
private static NodeAcl OwnerGrant(string equipmentId) => new()
{
NodeAclId = $"owner-{equipmentId}",
ClusterId = ServerEventRig.Cluster,
Principal = ServerEventRig.KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = equipmentId,
PermissionFlags = Commands,
};
/// The anonymous observer: sees everything, may command nothing.
private static NodeAcl Observer() => new()
{
NodeAclId = "observer",
ClusterId = ServerEventRig.Cluster,
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = ObserveOnly,
};
private static List Route(ServerEventRig rig)
{
var routed = new List();
rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); };
return routed;
}
private static void AssertNoRoles(ServerEventRig rig) =>
rig.Server.CurrentInstance.SessionManager.GetSessions()
.Select(s => s.EffectiveIdentity).OfType()
.Single(i => i.ApiKeyId == ServerEventRig.KeyId)
.Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role");
private static async Task<(RecordedEvents Events, ISession Observer, MonitoredItem Item)> ObserveAsync(
ServerEventRig rig, CancellationToken ct)
{
var observer = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
item.Status.Error.ShouldBeNull();
return (events, observer, item);
}
private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) =>
rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns);
private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id);
private sealed class Clock : TimeProvider
{
private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow;
private TimeSpan _offset;
public override DateTimeOffset GetUtcNow() => _initial + _offset;
public void Advance(TimeSpan by) => _offset += by;
public void Reset() => _offset = TimeSpan.Zero;
}
}