using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; 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.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Gitea #563 (arch-review 03/S19 residual R2) — with Security:Acl:Enabled off, the two /// server-wide role gates (WriteOperate on Value writes, AlarmAck on the Part 9 methods) /// re-check that the session's API key is still usable on every call. /// /// /// /// A session's roles are resolved once, at activation, from the key's grants. Before #563 nothing /// with the flag off ever looked at the key again, so a key whose ExpiresUtc passed — or that /// a deployment disabled — kept WriteOperate / AlarmAck until the client chose to /// disconnect, which on a machine-to-machine data plane can be never. /// /// /// Every test here is an allow-then-deny on the same session: the first call proves the /// session genuinely holds the role, so the later denial can only have come from the key check. A /// deny-only test would pass against a gate that denied everything. /// /// public sealed class NodeManagerKeyUsabilityGateTests : IDisposable { private const string Cluster = "SITE-A"; private const string Principal = "scadabridge"; private const string KeyId = "k-563"; private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc); private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-keygate-{Guid.NewGuid():N}"); /// /// THE #563 test for the write gate: ACL off, a key-carrying session holding WriteOperate /// writes; only the clock moves past ExpiresUtc; the next write is refused and never reaches /// the device. /// [Fact] public async Task An_expired_key_loses_its_write_role_on_the_clock_with_acl_off() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; var clock = new FakeClock(Start); var (state, authorizer) = Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10))); authorizer.Enabled.ShouldBeFalse(); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate); Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); clock.Advance(TimeSpan.FromMinutes(20)); Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); gateway.Writes.Count.ShouldBe(1); state.Current.GenerationId.ShouldBe(1); // no deployment moved anything — only the clock did } finally { await host.DisposeAsync(); } } /// /// The deployment axis of the same gate: a deployment that disables the key strips the write role /// from the session that is already open. /// [Fact] public async Task A_key_disabled_by_a_deployment_loses_its_write_role_with_acl_off() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; var (state, _) = Wire(nm, new FakeClock(Start), Key(expiresUtc: null)); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate); Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var disabled = Key(expiresUtc: null); disabled.Enabled = false; state.Apply(Cluster, "rev-2", [disabled], [], NoScopes); Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); gateway.Writes.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } } /// /// Anonymous carries no key, so nothing can expire: an anonymous session granted /// WriteOperate keeps writing however far the clock moves, against a snapshot holding no keys /// at all. Pins that the new check keys on the presence of a key id, not on "is this session in the /// key table". /// [Fact] public async Task An_anonymous_session_is_unaffected_by_the_key_check() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; var clock = new FakeClock(Start); Wire(nm, clock); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var anonymous = new RoleCarryingUserIdentity( new AnonymousIdentityToken(), [OpcUaDataPlaneRoles.WriteOperate], OpcUaPrincipals.Anonymous, apiKeyId: null); Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); clock.Advance(TimeSpan.FromDays(400)); Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); gateway.Writes.Count.ShouldBe(2); } finally { await host.DisposeAsync(); } } /// /// The alarm half: ACL off, a key-carrying session holding AlarmAck acknowledges; the key /// expires by the clock; the next acknowledge is refused and routes nothing. /// [Fact] public async Task An_expired_key_loses_its_alarm_ack_role_on_the_clock_with_acl_off() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = new List(); nm.AlarmCommandRouter = routed.Add; var clock = new FakeClock(Start); Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10))); nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition( "alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-1")!; var ctx = new ServerSystemContext(server.CurrentInstance) { UserIdentity = KeyIdentity(OpcUaDataPlaneRoles.AlarmAck), }; condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("1")) .ShouldBe(ServiceResult.Good); clock.Advance(TimeSpan.FromMinutes(20)); condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("2")) .StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); routed.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private static readonly IReadOnlyDictionary> NoScopes = new Dictionary>(); private static ApiKey Key(DateTime? expiresUtc) => new() { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = Principal, Enabled = true, CreatedUtc = Start.AddDays(-1), ExpiresUtc = expiresUtc, }; /// A key-carrying session whose activation-time roles are . private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) => new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId); /// /// Wires a production-shaped, flag-OFF authorizer over one applied snapshot. The node manager's /// authority source and the authorizer's are the same instance, as the host wires them. /// private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, params ApiKey[] keys) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, [], NoScopes); var authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: false, NullLogger.Instance, clock); nm.DataPlaneAuthority = state; nm.Authorizer = authorizer; return (state, authorizer); } private static NodeId Writable(OtOpcUaNodeManager nm, string rawPath) { nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float", writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw); return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; } private static List Write( OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.Write, identity); var writes = new List { new() { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good }, }, }; var errors = Enumerable.Repeat(null!, writes.Count).ToList(); nm.Write(context, writes, errors); return errors; } /// Records what actually reached the device. private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway { public List Writes { get; } = []; public Task WriteAsync( string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { lock (Writes) Writes.Add(nodeId); return Task.FromResult(new NodeWriteOutcome(true, null)); } } /// A hand-advanced clock; the authorizer reads it on every decision. private sealed class FakeClock(DateTime utcNow) : TimeProvider { private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); /// public override DateTimeOffset GetUtcNow() => _now; /// Moves the clock forward. /// How far forward. public void Advance(TimeSpan by) => _now = _now.Add(by); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var applicationUri = $"urn:OtOpcUa.KeyGateTest:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.KeyGateTest", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } }