4620e542e8
Review of 3d1b86b4: OpcUaPublishActor installs a new authority generation
before applying its address space, and an apply that throws keeps the old one.
A scripted alarm moved from equipment A to B therefore stayed commandable with
A's grant under B's generation (briefly before the rebuild, or indefinitely
after a failed apply).
The generation now carries ScriptedAlarmOwners (ScriptedAlarmId -> EquipmentId,
decoded by ParseDataPlaneAuthority from the artifact's ScriptedAlarms rows, the
same rows the composition materialises from). AuthorizeScriptedCondition denies
unless the node manager's live binding names the same owner as the generation:
a moved alarm is denied under the old owner's, the new owner's and a Cluster
grant until the address space re-binds it; an alarm the generation no longer
deploys is denied. Install/apply order unchanged.
Tests: window (a) generation moved / binding stale => both owners and Cluster
denied, then rebuild under the new owner => new owner allowed, old denied;
window (b) apply failed after install => denied across clock and further
generations, then a successful rebuild restores the new owner only; undeployed
alarm denied; artifact decode of the owner map.
382 lines
20 KiB
C#
382 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Gitea #797 over the wire — a real activated <c>apikey</c> session with <b>zero</b> application-wide roles,
|
|
/// holding only an <c>Equipment</c> grant, drives the Part 9 methods on a scripted condition whose id is a
|
|
/// production-valid opaque <c>ScriptedAlarmId</c>, under a production-shaped UNS tree (every folder an opaque
|
|
/// entity id, grant + owner paths resolved by <see cref="AclScopePathResolver"/> from the NAMES).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The rig's NodeIds stay exactly what production publishes: the condition is <c>s=scripted-eqa-hi</c> in
|
|
/// the UNS namespace and every routed <see cref="AlarmCommand.AlarmId"/> is that id unchanged — the
|
|
/// ScadaBridge-visible wire contract.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ServiceResultException>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ServiceResultException>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "recreated"));
|
|
|
|
var oldGrant = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
|
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), 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.
|
|
Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer());
|
|
await ConditionMethodEventTests.CallAsync(
|
|
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct);
|
|
|
|
var unknown = await Should.ThrowAsync<ServiceResultException>(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, "recreated").Count.ShouldBe(2, "the recreated 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 ─────────────────────────────
|
|
|
|
/// <summary>Area1 / Line1 / {Pump, Fan} under opaque entity ids, one scripted alarm under each equipment.</summary>
|
|
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<string, (string? ParentId, string Name)>(),
|
|
new Dictionary<string, (string? RawFolderId, string Name)>(),
|
|
new Dictionary<string, (string DriverInstanceId, string Name)>(),
|
|
new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
|
|
new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
|
|
new Dictionary<string, string> { [AreaId] = "Area1" },
|
|
new Dictionary<string, (string UnsAreaId, string Name)> { [LineId] = (AreaId, "Line1") },
|
|
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") },
|
|
[]);
|
|
|
|
private static Dictionary<string, NodeAclPath> OwnerPaths()
|
|
{
|
|
var resolver = Resolver();
|
|
return new Dictionary<string, NodeAclPath>
|
|
{
|
|
[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<string, string> deployedOwners,
|
|
ApiKey[] keys, params NodeAcl[] grants) =>
|
|
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
|
|
|
|
/// <summary>The generation's deployed scripted-alarm owners; <paramref name="alarmOwner"/> moves the Pump alarm.</summary>
|
|
private static Dictionary<string, string> Deployed(string alarmOwner = Pump) =>
|
|
new() { [Alarm] = alarmOwner, [FanAlarm] = Fan };
|
|
|
|
/// <summary>The key's ONLY alarm authority: an Equipment grant on <paramref name="equipmentId"/>.</summary>
|
|
private static NodeAcl OwnerGrant(string equipmentId) => new()
|
|
{
|
|
NodeAclId = $"owner-{equipmentId}",
|
|
ClusterId = ServerEventRig.Cluster,
|
|
Principal = ServerEventRig.KeyPrincipal,
|
|
ScopeKind = NodeAclScopeKind.Equipment,
|
|
ScopeId = equipmentId,
|
|
PermissionFlags = Commands,
|
|
};
|
|
|
|
/// <summary>The anonymous observer: sees everything, may command nothing.</summary>
|
|
private static NodeAcl Observer() => new()
|
|
{
|
|
NodeAclId = "observer",
|
|
ClusterId = ServerEventRig.Cluster,
|
|
Principal = OpcUaPrincipals.Anonymous,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = ObserveOnly,
|
|
};
|
|
|
|
private static List<AlarmCommand> Route(ServerEventRig rig)
|
|
{
|
|
var routed = new List<AlarmCommand>();
|
|
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<RoleCarryingUserIdentity>()
|
|
.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;
|
|
}
|
|
}
|