f02067d295
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
365 lines
16 KiB
C#
365 lines
16 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
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.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// arch-review 03/S19 over the wire — a key that expires by the CLOCK stops a live subscription, with
|
|
/// no deployment anywhere in the run.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This cannot be reasoned into existence for the same reason
|
|
/// <c>AclSubscriptionEnforcementTests</c> gives: whether a monitored item can recover the identity
|
|
/// of the session that created it is an assumption about SDK behaviour. Here it must recover an
|
|
/// identity carrying an <b>API key id</b> — the field the per-call expiry re-check keys on — and
|
|
/// the existing rig has never exercised one, because it connects anonymously.
|
|
/// </para>
|
|
/// <para>
|
|
/// The tick is driven through the real <see cref="AclRevalidationTimer"/> rather than by calling
|
|
/// the node manager's pass directly, so what is asserted here is the whole T8 path: revert the
|
|
/// host's timer and the denial below cannot be produced by anything except a deployment.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class AclKeyExpiryEnforcementTests
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Device = "Plant/Modbus/dev1";
|
|
private const string Tag = "Plant/Modbus/dev1/Speed";
|
|
private const string Principal = "expiry-probe";
|
|
private const string KeyId = "k-expiry";
|
|
|
|
private static readonly DateTime Start = new(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>
|
|
/// THE test this file exists for: the key's <c>ExpiresUtc</c> passes, a revalidation tick runs, and
|
|
/// the client is told <c>BadUserAccessDenied</c> — with <c>DataPlaneAuthorityState.Apply</c> called
|
|
/// exactly once, in setup.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_expired_key_stops_a_live_subscription_with_no_deploy()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclKeyExpiry", ct);
|
|
try
|
|
{
|
|
rig.Enforce(expiresUtc: Start.AddMinutes(10));
|
|
rig.Materialise();
|
|
rig.Write(1);
|
|
|
|
using var session = await rig.ConnectAsync(ct);
|
|
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
|
|
|
// Baseline: the key-carrying session's feed is live.
|
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
|
received.Clear();
|
|
|
|
// Only the clock moves. No deployment, no new generation, no grant change.
|
|
rig.Advance(TimeSpan.FromMinutes(20));
|
|
rig.TickRevalidation();
|
|
|
|
await WaitUntilAsync(
|
|
() => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied),
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
// And the data really has stopped.
|
|
received.Clear();
|
|
rig.Write(2);
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
received.ShouldNotContain(v => Equals(v.Value, 2f));
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The negative control: identical run, clock advanced but not past <c>ExpiresUtc</c> ⇒ values keep
|
|
/// arriving.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Without it the test above also passes on a build whose tick detaches every item it sees, which
|
|
/// would silence every subscription in the fleet once a minute.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task An_unexpired_key_keeps_streaming_across_a_tick()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await Rig.StartAsync("AclKeyLive", ct);
|
|
try
|
|
{
|
|
rig.Enforce(expiresUtc: Start.AddHours(4));
|
|
rig.Materialise();
|
|
rig.Write(1);
|
|
|
|
using var session = await rig.ConnectAsync(ct);
|
|
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
|
|
|
received.Clear();
|
|
rig.Advance(TimeSpan.FromMinutes(20));
|
|
rig.TickRevalidation();
|
|
|
|
rig.Write(2);
|
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
|
|
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (condition()) return;
|
|
await Task.Delay(50);
|
|
}
|
|
condition().ShouldBeTrue("condition not met within timeout");
|
|
}
|
|
|
|
/// <summary>A booted server, a key-carrying client session, and a hand-driven revalidation tick.</summary>
|
|
private sealed class Rig : IAsyncDisposable
|
|
{
|
|
private readonly string _pkiRoot;
|
|
private readonly FakeClock _clock = new(Start);
|
|
private OpcUaApplicationHost _host = null!;
|
|
private OtOpcUaSdkServer _server = null!;
|
|
private AclRevalidationTimer? _timer;
|
|
private int _port;
|
|
|
|
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
|
|
|
|
private OtOpcUaNodeManager Nm => _server.NodeManager!;
|
|
|
|
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
|
|
{
|
|
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
|
var applicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}";
|
|
(rig._host, rig._server) = await OpcUaHostStart.StartAsync(
|
|
port =>
|
|
{
|
|
rig._port = port;
|
|
return new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = $"OtOpcUa.{name}",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = rig._pkiRoot,
|
|
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance,
|
|
new KeyPresentingAuthenticator());
|
|
},
|
|
() => new OtOpcUaSdkServer(), ct);
|
|
return rig;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Switches enforcement on with one cluster-wide grant and one key expiring at
|
|
/// <paramref name="expiresUtc"/>, and arms the revalidation timer over the same clock.
|
|
/// </summary>
|
|
/// <param name="expiresUtc">When the key stops being usable.</param>
|
|
public void Enforce(DateTime expiresUtc)
|
|
{
|
|
var key = new ApiKey
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = KeyId,
|
|
SecretHash = "hash",
|
|
Principal = Principal,
|
|
Enabled = true,
|
|
CreatedUtc = Start.AddDays(-1),
|
|
ExpiresUtc = expiresUtc,
|
|
};
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = "acl-cluster",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
|
};
|
|
|
|
var state = new DataPlaneAuthorityState();
|
|
|
|
// The ONLY Apply in the whole test. Everything after this moves the clock.
|
|
state.Apply(Cluster, "rev-1", [key], [grant], NoScopes);
|
|
|
|
Nm.DataPlaneAuthority = state;
|
|
Nm.Authorizer = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
|
|
|
|
// A long interval: the tick is driven by hand, so a real timer firing underneath would only
|
|
// make the test non-deterministic. What matters is that the pass is reached through the same
|
|
// component the hosted service arms.
|
|
_timer = new AclRevalidationTimer(
|
|
() => _server.NodeManager?.RevalidateMonitoredItems(),
|
|
TimeSpan.FromHours(1),
|
|
TimeProvider.System,
|
|
NullLogger.Instance);
|
|
}
|
|
|
|
/// <summary>Moves the authorizer's clock forward — the only thing that changes mid-test.</summary>
|
|
/// <param name="by">How far forward.</param>
|
|
public void Advance(TimeSpan by) => _clock.Advance(by);
|
|
|
|
/// <summary>Runs one revalidation tick through the host's own timer component.</summary>
|
|
public void TickRevalidation() => _timer!.Tick();
|
|
|
|
public void Materialise()
|
|
{
|
|
Nm.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
|
|
Nm.EnsureFolder("Plant/Modbus", "Plant", "Modbus", AddressSpaceRealm.Raw);
|
|
Nm.EnsureFolder(Device, "Plant/Modbus", "dev1", AddressSpaceRealm.Raw);
|
|
Nm.EnsureVariable(Tag, Device, "Speed", "Float", writable: false, AddressSpaceRealm.Raw);
|
|
}
|
|
|
|
public void Write(float value) =>
|
|
Nm.WriteValue(Tag, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
public ushort RawNamespace(ISession session)
|
|
{
|
|
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
ns.ShouldBeGreaterThan((ushort)0);
|
|
return ns;
|
|
}
|
|
|
|
public NodeId TagNodeId(ISession session) => new(Tag, RawNamespace(session));
|
|
|
|
public async Task<(Subscription Subscription, List<DataValue> Received)> SubscribeAsync(
|
|
ISession session, CancellationToken ct)
|
|
{
|
|
var received = new List<DataValue>();
|
|
var gate = new object();
|
|
|
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
|
session.AddSubscription(subscription);
|
|
await subscription.CreateAsync(ct);
|
|
|
|
var item = new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = TagNodeId(session),
|
|
AttributeId = Attributes.Value,
|
|
SamplingInterval = 50,
|
|
QueueSize = 10,
|
|
};
|
|
item.Notification += (mi, _) =>
|
|
{
|
|
foreach (var v in mi.DequeueValues()) lock (gate) received.Add(v);
|
|
};
|
|
subscription.AddItem(item);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
item.Status.Error.ShouldBeNull();
|
|
return (subscription, received);
|
|
}
|
|
|
|
/// <summary>Connects presenting the fixed <c>apikey</c> username with an <c>otua_</c> credential.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The connected session.</returns>
|
|
public async Task<ISession> ConnectAsync(CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa.AclKeyClient",
|
|
ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
|
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
|
|
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
appConfig,
|
|
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
|
updateBeforeConnect: false,
|
|
checkDomain: false,
|
|
sessionName: "AclKeyExpiryEnforcementTests",
|
|
sessionTimeout: 60_000,
|
|
identity: new UserIdentity(new UserNameIdentityToken
|
|
{
|
|
UserName = "apikey",
|
|
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
|
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
|
}),
|
|
preferredLocales: null,
|
|
ct: ct);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_timer?.Dispose();
|
|
await _host.DisposeAsync();
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Admits the fixed <c>apikey</c> username as <see cref="Principal"/>, carrying
|
|
/// <see cref="KeyId"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately not the production <c>ApiKeyOpcUaUserAuthenticator</c> over a fake store: the
|
|
/// credential-verification path (parse, peppered HMAC, store lookup, deny shapes) is covered by
|
|
/// <c>ApiKeyAuthenticationTests</c> and <c>BoundedApiKeyStoreTests</c> in the Host suite, and
|
|
/// none of it changes what this file is about — whether the SDK hands a monitored item back an
|
|
/// identity that still carries the key id. Grants come from the deployed snapshot, so this
|
|
/// returns no roles.
|
|
/// </remarks>
|
|
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
|
string username, string password, CancellationToken ct) =>
|
|
Task.FromResult(
|
|
string.Equals(username, "apikey", StringComparison.Ordinal)
|
|
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
|
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId)
|
|
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|