using System.Net; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; 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; /// /// lmxopcua#751 over the wire — session activation hands the authenticator the client's remote endpoint, /// and the per-client API-key miss budget therefore applies to a real client. /// /// /// The SDK exposes no client address on any public surface; /// reads it from the TCP listener by reflection and degrades to null when that path breaks. These tests are /// what turn an SDK upgrade that breaks it into a failure instead of a silent fall-back to process-wide /// accounting. /// public sealed class ApiKeyMissBudgetPerEndpointTests { [Fact] public async Task Session_activation_hands_the_authenticator_the_clients_address() { var ct = TestContext.Current.CancellationToken; var recorder = new RecordingAuthenticator(); await using var rig = await Rig.StartAsync("RemoteEndpoint", recorder, ct); using var session = await rig.ConnectAsync("otua_k-probe_secret", ct); recorder.Seen.ShouldNotBeNull("the server must resolve the activating client's endpoint"); IPAddress.IsLoopback(recorder.Seen!.Address).ShouldBeTrue($"connected from loopback, got {recorder.Seen}"); recorder.Seen.Port.ShouldBeGreaterThan(0); } /// /// The production authenticator over the bounded store: with a per-client burst of 2 and the known-id /// set unavailable, a client's third and fourth unknown keys are refused without reaching the store. /// [Fact] public async Task A_client_past_its_miss_budget_is_refused_without_a_store_read() { var ct = TestContext.Current.CancellationToken; var inner = new UnlistingStore(); var options = Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "integration-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 0, }); var bounded = new BoundedApiKeyStore( inner, options, NullLogger.Instance, TimeProvider.System, new ApiKeyStoreGuardOptions { MissBurst = 100, MissRefillPerSecond = 0, MissBurstPerEndpoint = 2, MissRefillPerSecondPerEndpoint = 0, }); var authenticator = new ApiKeyOpcUaUserAuthenticator( bounded, options, NullLogger.Instance); await using var rig = await Rig.StartAsync("MissBudget", authenticator, ct); for (var i = 0; i < 4; i++) { var rejected = await Should.ThrowAsync( () => rig.ConnectAsync($"otua_k-guess-{i}_secret", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } inner.FindCalls.ShouldBe(2, "only the client's first two misses may reach the store"); } private sealed class Rig : IAsyncDisposable { private readonly string _pkiRoot; private OpcUaApplicationHost _host = null!; private int _port; private Rig(string pkiRoot) => _pkiRoot = pkiRoot; public static async Task StartAsync(string name, IOpcUaUserAuthenticator authenticator, 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, _) = 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.Instance, authenticator); }, () => new OtOpcUaSdkServer(), ct); return rig; } public async Task ConnectAsync(string credential, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.MissBudgetClient", ApplicationUri = $"urn:OtOpcUa.MissBudgetClient.{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: "ApiKeyMissBudgetPerEndpointTests", sessionTimeout: 60_000, identity: new UserIdentity(new UserNameIdentityToken { UserName = "apikey", DecryptedPassword = Encoding.UTF8.GetBytes(credential), }), preferredLocales: null, ct: ct); } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } } /// Admits every UserName token and records the endpoint the server handed it. private sealed class RecordingAuthenticator : IOpcUaUserAuthenticator { public IPEndPoint? Seen { get; private set; } public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => throw new InvalidOperationException("the server must call the endpoint-carrying overload"); public Task AuthenticateUserNameAsync( string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) { Seen = remoteEndpoint; return Task.FromResult(OpcUaUserAuthResult.AllowKey("probe", [], "k-probe")); } } /// Holds no keys and cannot list ids, so every lookup is a miss under the budget. private sealed class UnlistingStore : IApiKeyStore { private int _findCalls; public int FindCalls => Volatile.Read(ref _findCalls); public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) { Interlocked.Increment(ref _findCalls); return Task.FromResult(null); } public Task> FindGrantsAsync(string principal, CancellationToken ct) => Task.FromResult>([]); } }