Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerKeyUsabilityGateTests.cs
T
Joseph Doherty f02067d295 test: retry OPC UA host start on a port bind race (TOCTOU in AllocateFreePort)
The tests probe a free port (bind 0, release) and start the SDK server later; under a
highly parallel run another test can take the port in between and the SDK fails with
'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync
builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a
bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and
Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory
because a changed URI against a reused PKI store fails with 'Cannot access private key'.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 22:30:55 -04:00

291 lines
12 KiB
C#

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;
/// <summary>
/// Gitea #563 (arch-review 03/S19 residual R2) — with <c>Security:Acl:Enabled</c> <b>off</b>, the two
/// server-wide role gates (<c>WriteOperate</c> on Value writes, <c>AlarmAck</c> on the Part 9 methods)
/// re-check that the session's API key is still usable on every call.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>ExpiresUtc</c> passed — or that
/// a deployment disabled — kept <c>WriteOperate</c> / <c>AlarmAck</c> until the client chose to
/// disconnect, which on a machine-to-machine data plane can be never.
/// </para>
/// <para>
/// Every test here is an <b>allow-then-deny</b> 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.
/// </para>
/// </remarks>
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}");
/// <summary>
/// THE #563 test for the write gate: ACL off, a key-carrying session holding <c>WriteOperate</c>
/// writes; only the clock moves past <c>ExpiresUtc</c>; the next write is refused and never reaches
/// the device.
/// </summary>
[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(); }
}
/// <summary>
/// The deployment axis of the same gate: a deployment that disables the key strips the write role
/// from the session that is already open.
/// </summary>
[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(); }
}
/// <summary>
/// Anonymous carries no key, so nothing can expire: an anonymous session granted
/// <c>WriteOperate</c> 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".
/// </summary>
[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(); }
}
/// <summary>
/// The alarm half: ACL off, a key-carrying session holding <c>AlarmAck</c> acknowledges; the key
/// expires by the clock; the next acknowledge is refused and routes nothing.
/// </summary>
[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<AlarmCommand>();
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<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private static ApiKey Key(DateTime? expiresUtc) => new()
{
ApiKeyId = KeyId,
Name = KeyId,
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = Start.AddDays(-1),
ExpiresUtc = expiresUtc,
};
/// <summary>A key-carrying session whose activation-time roles are <paramref name="roles"/>.</summary>
private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) =>
new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId);
/// <summary>
/// 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.
/// </summary>
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<ServiceResult> Write(
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
var writes = new List<WriteValue>
{
new()
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
},
};
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
nm.Write(context, writes, errors);
return errors;
}
/// <summary>Records what actually reached the device.</summary>
private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway
{
public List<string> Writes { get; } = [];
public Task<NodeWriteOutcome> WriteAsync(
string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
lock (Writes) Writes.Add(nodeId);
return Task.FromResult(new NodeWriteOutcome(true, null));
}
}
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
private sealed class FakeClock(DateTime utcNow) : TimeProvider
{
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward.</summary>
/// <param name="by">How far forward.</param>
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<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
var host = started.Host;
var server = started.Server;
return (host, server);
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}