Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverNodeManagerCancellationTests.cs
Joseph Doherty 6134050ceb fix(server): resolve Low code-review findings (Server-004,006,008,012,014,015)
- Server-004: pass the role-derived display name to UserIdentity's base
  ctor (the SDK's DisplayName has no public setter) and drop the dead
  Display property; make RoleBasedIdentity internal sealed.
- Server-006: derive a bounded CancellationToken from the SDK's
  OperationContext.OperationDeadline in OnReadValue / OnWriteValue so a
  stalled driver call can no longer pin the request thread.
- Server-008: mark handled slots via CallMethodRequest.Processed = true
  in RouteScriptedAlarmMethodCalls (the SDK skips on Processed, not on a
  Good error slot).
- Server-012: PeerHttpProbeLoop.ProbeAsync stops mutating client.Timeout
  per call; uses a per-request CancellationTokenSource linked to the
  shutdown token instead.
- Server-014: wire SealedBootstrap into Program.cs via AddSealedBootstrap
  + OpcUaServerService so the generation-sealed cache + stale-config flag
  + resilient reader actually run; /healthz now reflects cache-fallback
  state.
- Server-015: replace the stale 'PR 16 / PR 17 minimum-viable scope'
  class summaries on OtOpcUaServer and OpcUaServerOptions with the
  shipped LDAP + anonymous-role + configurable security-profile prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:24:20 -04:00

117 lines
5.3 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Regression for Server-006 — synchronous OnReadValue / OnWriteValue stack hooks must
/// derive a <see cref="CancellationToken"/> from the operation deadline so a stalled
/// driver call doesn't pin a request thread for the full pipeline timeout. The shared
/// helper <see cref="DriverNodeManager.DeriveOperationCancellation"/> turns the
/// <see cref="ISystemContext"/>'s <c>OperationDeadline</c> into a linked CTS.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverNodeManagerCancellationTests
{
/// <summary>
/// Build a SystemContext bound to the supplied IOperationContext. SystemContext's
/// OperationContext setter is protected, so we use the public <c>Copy</c> method
/// which clones the context onto the supplied operation context.
/// </summary>
private static ISystemContext ContextWithDeadline(DateTime deadline)
=> new SystemContext().Copy(new StubOperationContext(deadline));
[Fact]
public void Future_deadline_produces_uncancelled_token()
{
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(30));
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
cts.Token.IsCancellationRequested.ShouldBeFalse();
}
[Fact]
public void Past_deadline_produces_already_cancelled_token()
{
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(-5));
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
cts.Token.IsCancellationRequested.ShouldBeTrue(
"an expired OperationDeadline must surface as an immediately-cancelled token so the "
+ "stalled driver call returns without burning a request thread");
}
[Fact]
public void Missing_deadline_uses_fallback_timeout()
{
// No OperationContext attached → no deadline plumbed; helper falls back to the
// supplied timeout so an OnReadValue hook into a stalled driver can't hang the
// request thread indefinitely.
var ctx = new SystemContext();
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue(
"fallback timeout must fire so a missing deadline cannot hang the request thread");
cts.Token.IsCancellationRequested.ShouldBeTrue();
}
[Fact]
public void DateTime_MinValue_deadline_uses_fallback_timeout()
{
// IOperationContext.OperationDeadline is `DateTime.MinValue` when the stack hasn't
// plumbed a deadline through. The helper treats that as "no deadline" and falls
// back to the supplied timeout, otherwise an MinValue would surface as
// already-cancelled and short-circuit every read.
var ctx = ContextWithDeadline(DateTime.MinValue);
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue();
cts.Token.IsCancellationRequested.ShouldBeTrue();
}
[Fact]
public void DateTime_MaxValue_deadline_uses_fallback_timeout_not_overflow()
{
// OperationContext sets OperationDeadline = DateTime.MaxValue when the client's
// RequestHeader.TimeoutHint is zero (the default). DateTime.MaxValue - UtcNow
// overflows CancellationTokenSource(TimeSpan)'s Int32.MaxValue-ms cap, so the
// helper must collapse it to the fallback — otherwise the read throws
// ArgumentOutOfRangeException from inside DeriveOperationCancellation and surfaces
// as BadInternalError on every read (regression that broke OpcUaServerIntegrationTests).
var ctx = ContextWithDeadline(DateTime.MaxValue);
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(30));
cts.Token.IsCancellationRequested.ShouldBeFalse("MaxValue deadline + 30 s fallback must produce a live token");
}
[Fact]
public void Null_context_returns_uncancelled_token_with_fallback()
{
// Defensive — OnReadValue receives an ISystemContext from the stack so the helper
// shouldn't NRE if a future override passes through a null context.
using var cts = DriverNodeManager.DeriveOperationCancellation(context: null!, fallback: TimeSpan.FromSeconds(30));
cts.Token.IsCancellationRequested.ShouldBeFalse();
}
/// <summary>Minimal IOperationContext for deadline testing.</summary>
private sealed class StubOperationContext(DateTime deadline) : IOperationContext
{
public DateTime OperationDeadline { get; } = deadline;
public NodeId? SessionId => null;
public IUserIdentity? UserIdentity => null;
public IList<string>? PreferredLocales => null;
public DiagnosticsMasks DiagnosticsMask => DiagnosticsMasks.None;
public StringTable StringTable { get; } = new StringTable();
public StatusCode OperationStatus => StatusCodes.Good;
public string? AuditEntryId => null;
}
}