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 */ }
}
}
}