@@ -28,6 +28,31 @@ public sealed class DeleteConfirmationWiringTests
|
||||
Regex.Matches(src, "btn-outline-danger").Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #832: the shared confirm used to sit INSIDE the Alarms tab branch, so it was on the page only while that
|
||||
/// tab was showing — the Tags tab's Remove and the Virtual Tags tab's Delete set the dialog visible and
|
||||
/// nothing appeared. Pinned by brace depth: the dialog must be at the depth of the tab <c>@if</c> chain,
|
||||
/// not inside any branch of it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equipment_page_confirm_renders_outside_every_tab_branch()
|
||||
{
|
||||
var src = AdminUiSource.Read("Components", "Pages", "Uns", "EquipmentPage.razor");
|
||||
var markup = src[..src.IndexOf("@code", StringComparison.Ordinal)];
|
||||
|
||||
int DepthAt(int index) =>
|
||||
markup[..index].Count(c => c == '{') - markup[..index].Count(c => c == '}');
|
||||
|
||||
var tabChain = markup.IndexOf("@if (_activeTab == \"details\")", StringComparison.Ordinal);
|
||||
var dialog = markup.IndexOf("<RawConfirmDialog", StringComparison.Ordinal);
|
||||
tabChain.ShouldBeGreaterThan(0);
|
||||
dialog.ShouldBeGreaterThan(tabChain, "the confirm follows the tab chain");
|
||||
DepthAt(dialog).ShouldBe(DepthAt(tabChain), "the confirm must not be nested inside a tab branch");
|
||||
|
||||
// Positive control: a tab-scoped modal really is one level deeper, so the depth probe can tell them apart.
|
||||
DepthAt(markup.IndexOf("<ScriptedAlarmModal", StringComparison.Ordinal)).ShouldBe(DepthAt(tabChain) + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Script_editor_delete_confirms()
|
||||
{
|
||||
|
||||
+36
@@ -56,4 +56,40 @@ public sealed class OpcUaApplicationHostOptionsValidatorTests
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)");
|
||||
}
|
||||
|
||||
/// <summary>#833 review: an enabled lockout with a non-positive duration fails; a disabled one is not checked.</summary>
|
||||
[Theory]
|
||||
[InlineData(5, 0, 60, false)]
|
||||
[InlineData(5, 300, 0, false)]
|
||||
[InlineData(5, -1, -1, false)]
|
||||
[InlineData(5, 30, 600, true)]
|
||||
[InlineData(0, 0, 0, true)]
|
||||
[InlineData(-1, 0, 0, true)]
|
||||
public void Credential_lockout_durations_must_be_positive_while_enabled(
|
||||
int maxFailedAttempts, int lockoutSeconds, int windowSeconds, bool valid)
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
||||
{
|
||||
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions
|
||||
{
|
||||
MaxFailedAttempts = maxFailedAttempts, LockoutSeconds = lockoutSeconds, FailureWindowSeconds = windowSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
result.Succeeded.ShouldBe(valid);
|
||||
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:LockoutSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>#833 review: the slot wait must be positive while the lockout is enabled.</summary>
|
||||
[Fact]
|
||||
public void Credential_lockout_slot_wait_must_be_positive_while_enabled()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
||||
{
|
||||
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions { SlotWaitSeconds = 0 },
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:SlotWaitSeconds"));
|
||||
}
|
||||
}
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#833 over the wire. A few failed UserName activations used to trip the OPC Foundation SDK's
|
||||
/// per-client lockout, which then refused EVERY activation from that client — Anonymous included — with
|
||||
/// <c>BadUserAccessDenied</c> for 5 minutes. The host now disables the SDK lockout and enforces a
|
||||
/// UserName-only replacement (<see cref="FailedCredentialLockout"/>).
|
||||
/// </summary>
|
||||
public sealed class CredentialLockoutWireTests
|
||||
{
|
||||
private const string GoodKey = "otua_k-good_secret";
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_credentials_never_lock_the_client_out_of_anonymous_activation()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct);
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
||||
rejected.StatusCode.ShouldBeOneOf(StatusCodes.BadIdentityTokenRejected, StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
using var anonymous = await rig.ConnectAsync(credential: null, ct);
|
||||
var ns = await anonymous.ReadValueAsync(VariableIds.Server_NamespaceArray, ct);
|
||||
StatusCode.IsGood(ns.StatusCode).ShouldBeTrue();
|
||||
auth.AnonymousCalls.ShouldBe(1, "the anonymous activation must reach the authenticator, not be refused before it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_credentials_still_lock_out_further_UserName_activations_from_that_client()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
}
|
||||
|
||||
var callsBefore = auth.UserNameCalls;
|
||||
var locked = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(GoodKey, ct));
|
||||
locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #833 review — one client opening more keyed sessions at once than MaxFailedAttempts (a ScadaBridge site
|
||||
/// node behind one NAT IP) is never refused for parallelism: the extra activations wait for a slot.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Parallel_valid_activations_beyond_the_threshold_all_succeed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct);
|
||||
|
||||
var sessions = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => rig.ConnectAsync(GoodKey, ct)));
|
||||
try
|
||||
{
|
||||
sessions.ShouldAllBe(s => s.Connected);
|
||||
auth.UserNameCalls.ShouldBe(8);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in sessions) s.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_disabled_lockout_admits_a_valid_key_after_any_number_of_failures()
|
||||
{
|
||||
// Also proves the SDK's own lockout (default 5) is switched off: with ours disabled nothing may refuse.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, ct);
|
||||
|
||||
for (var i = 0; i < 7; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
}
|
||||
|
||||
using var session = await rig.ConnectAsync(GoodKey, ct);
|
||||
session.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
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<Rig> StartAsync(
|
||||
IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct)
|
||||
{
|
||||
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}"));
|
||||
var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}";
|
||||
(rig._host, _) = await OpcUaHostStart.StartAsync(
|
||||
port =>
|
||||
{
|
||||
rig._port = port;
|
||||
return new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Lockout",
|
||||
ApplicationUri = applicationUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
CredentialLockout = lockout,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance,
|
||||
authenticator);
|
||||
},
|
||||
() => new OtOpcUaSdkServer(), ct);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/// <summary>Opens a session; a null credential activates anonymously.</summary>
|
||||
public async Task<ISession> ConnectAsync(string? credential, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.LockoutClient",
|
||||
ApplicationUri = "urn:OtOpcUa.LockoutClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
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 ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "CredentialLockoutWireTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: credential is null
|
||||
? new UserIdentity()
|
||||
: new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)),
|
||||
preferredLocales: default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Admits <see cref="GoodKey"/> and anonymous; denies everything else; counts calls.</summary>
|
||||
private sealed class CountingAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
private int _userNameCalls;
|
||||
private int _anonymousCalls;
|
||||
|
||||
public int UserNameCalls => Volatile.Read(ref _userNameCalls);
|
||||
public int AnonymousCalls => Volatile.Read(ref _anonymousCalls);
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _userNameCalls);
|
||||
return Task.FromResult(password == GoodKey
|
||||
? OpcUaUserAuthResult.AllowKey("good", [], "k-good")
|
||||
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||
}
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
||||
string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) =>
|
||||
AuthenticateUserNameAsync(username, password, ct);
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _anonymousCalls);
|
||||
return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, []));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.</summary>
|
||||
public sealed class FailedCredentialLockoutTests
|
||||
{
|
||||
private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5"));
|
||||
|
||||
[Fact]
|
||||
public void Locks_at_the_threshold_and_releases_after_the_lockout()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks");
|
||||
lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue();
|
||||
remaining.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(299));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
||||
clock.Advance(TimeSpan.FromSeconds(1));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_refusal_during_the_lockout_does_not_extend_it()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock);
|
||||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||||
clock.Advance(TimeSpan.FromSeconds(200));
|
||||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
clock.Advance(TimeSpan.FromSeconds(100));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failures_spaced_wider_than_the_window_never_accumulate()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
}
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_clears_the_count()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client);
|
||||
lockout.RecordSuccess(Client);
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||||
var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99"));
|
||||
var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5"));
|
||||
var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99"));
|
||||
|
||||
lockout.RecordFailure(attacker).ShouldBeTrue();
|
||||
lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address");
|
||||
lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_attempts_disables_it()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock());
|
||||
for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.TrackedCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracking_is_bounded_and_prunes_expired_clients_first()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
|
||||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
|
||||
|
||||
lockout.RecordFailure("overflow");
|
||||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked");
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
lockout.RecordFailure("overflow");
|
||||
lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room");
|
||||
}
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// #833 review — a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind one
|
||||
/// NAT IP) must never be refused for parallelism: at most MaxFailedAttempts verify concurrently, the rest
|
||||
/// WAIT for a slot, and all 20 succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_all_valid_parallel_burst_queues_for_slots_and_every_activation_succeeds()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
|
||||
var concurrent = 0;
|
||||
var maxConcurrent = 0;
|
||||
var proceeded = 0;
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
|
||||
{
|
||||
var decision = await lockout.BeginAsync(Client, Ct);
|
||||
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
Interlocked.Increment(ref proceeded);
|
||||
var now = Interlocked.Increment(ref concurrent);
|
||||
InterlockedMax(ref maxConcurrent, now);
|
||||
await Task.Delay(20, Ct); // the authenticate call
|
||||
Interlocked.Decrement(ref concurrent);
|
||||
lockout.Complete(Client, succeeded: true);
|
||||
}, Ct)));
|
||||
|
||||
proceeded.ShouldBe(20);
|
||||
maxConcurrent.ShouldBe(5, "exactly MaxFailedAttempts verify at once — no more, and the cap is actually reached");
|
||||
lockout.TrackedCount.ShouldBe(0, "nothing left behind once the burst drains");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #833 review — the security half: a parallel burst of guesses that all arrive before any failure is
|
||||
/// recorded still reaches the authenticator only MaxFailedAttempts times; once those fail, every waiter is
|
||||
/// refused as locked out without being authenticated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_all_bad_parallel_burst_authenticates_only_max_attempts_and_refuses_the_waiters_as_locked_out()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var authenticated = 0;
|
||||
var lockedOut = 0;
|
||||
|
||||
var attempts = Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
|
||||
{
|
||||
var decision = await lockout.BeginAsync(Client, Ct);
|
||||
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
|
||||
{
|
||||
Interlocked.Increment(ref lockedOut);
|
||||
return;
|
||||
}
|
||||
|
||||
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
Interlocked.Increment(ref authenticated);
|
||||
await release.Task; // hold the slots until all 15 others are queued behind them
|
||||
lockout.Complete(Client, succeeded: false);
|
||||
}, Ct)).ToArray();
|
||||
|
||||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15);
|
||||
authenticated.ShouldBe(5);
|
||||
release.SetResult();
|
||||
await Task.WhenAll(attempts);
|
||||
|
||||
authenticated.ShouldBe(5, "no waiter may reach the authenticator once the key locks out");
|
||||
lockedOut.ShouldBe(15);
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
||||
lockout.WaitingCount(Client).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>After a recorded failure the client has one slot fewer: capacity is MaxFailedAttempts − failures.</summary>
|
||||
[Fact]
|
||||
public async Task Recorded_failures_shrink_the_number_of_concurrent_verifications()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock());
|
||||
lockout.RecordFailure(Client);
|
||||
lockout.RecordFailure(Client);
|
||||
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
var second = lockout.BeginAsync(Client, Ct).AsTask();
|
||||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
|
||||
second.IsCompleted.ShouldBeFalse("one failure short of the threshold leaves exactly one slot");
|
||||
|
||||
lockout.Complete(Client, succeeded: true); // a success clears the count and hands the slot on
|
||||
(await second).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
lockout.Complete(Client, succeeded: true);
|
||||
lockout.TrackedCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A cancelled waiter is dequeued and leaves nothing behind (no leaked slot, entry prunable).</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_waiter_is_removed_and_leaks_nothing()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||||
var waiting = lockout.BeginAsync(Client, cts.Token).AsTask();
|
||||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
|
||||
cts.Cancel();
|
||||
await Should.ThrowAsync<OperationCanceledException>(waiting);
|
||||
lockout.WaitingCount(Client).ShouldBe(0);
|
||||
|
||||
lockout.Complete(Client, succeeded: true);
|
||||
lockout.TrackedCount.ShouldBe(0, "the held slot and the cancelled waiter are both gone");
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
lockout.Abandon(Client);
|
||||
lockout.TrackedCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A waiter that never gets a slot times out, uncounted — the client is not one failure closer.</summary>
|
||||
[Fact]
|
||||
public async Task A_slot_wait_timeout_fails_that_attempt_without_counting_it()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(
|
||||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, SlotWaitSeconds = 1 }, new ManualClock());
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.SlotWaitTimedOut);
|
||||
lockout.WaitingCount(Client).ShouldBe(0);
|
||||
|
||||
lockout.Complete(Client, succeeded: true);
|
||||
lockout.TrackedCount.ShouldBe(0, "the timed-out attempt recorded no failure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_the_first_refusal_of_a_lockout_episode_is_flagged_for_Information_logging()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, LockoutSeconds = 10 }, clock);
|
||||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||||
|
||||
var r1 = await lockout.BeginAsync(Client, Ct);
|
||||
r1.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut);
|
||||
r1.FirstRefusal.ShouldBeTrue();
|
||||
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeFalse();
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(11));
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
lockout.Complete(Client, succeeded: false).ShouldBeTrue("a new episode");
|
||||
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeTrue("each lockout episode logs its first refusal once");
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int seen;
|
||||
while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (!condition())
|
||||
{
|
||||
DateTime.UtcNow.ShouldBeLessThan(deadline, "condition not reached in time");
|
||||
await Task.Delay(5, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #833 review — with LockoutSeconds shorter than FailureWindowSeconds, the count survived the expired lockout
|
||||
/// (the last failure was still inside the window), so the very next failure relocked the client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_expired_lockout_starts_a_fresh_count_even_inside_the_failure_window()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(
|
||||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, FailureWindowSeconds = 600, LockoutSeconds = 10 }, clock);
|
||||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(11));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeFalse("one failure after the lockout is the first of a new run");
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_host_switches_the_sdk_lockout_off()
|
||||
{
|
||||
var config = new Opc.Ua.ServerConfiguration();
|
||||
config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against");
|
||||
OpcUaApplicationHost.DisableSdkAuthenticationLockout(config);
|
||||
config.MaxFailedAuthenticationAttempts.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user