Files
UA-.NETStandard/tests/Opc.Ua.Core.Tests/Security/Crypto/CryptoProviderModelTests.cs
romanett 1fb1134974 Tests: scope MeterListeners to the meter under test (fix flaky ClientBaseTests metrics test) (#4708)
## Problem

`ClientBaseTests.ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`
failed intermittently in full-suite runs:

```
Expected: "opc.ua.client.request.duration"
But was:  "opc.ua.channel.reconnect.duration"
```

`TelemetryContextBase.CreateMeter()` returns a new `Meter` per call,
named after the calling assembly, so `ClientBase`'s meter and
`ClientChannelManagerMetrics`' meter are both named `Opc.Ua.Core`.
`TestMeterListener` enabled every instrument whose `Meter.Name` matched,
so reconnect-duration measurements from `[Parallelizable]` channel
manager fixtures were recorded and could precede the request duration.

## Fix (test-only)

- `ClientBaseTests.TestMeterListener`: enable instruments only when
`ReferenceEquals(instrument.Meter, meter)`, i.e. the meter instance the
client under test created. Assertions unchanged.
- `CryptoProviderModelTests.AuditorPublishesAProviderMetric`: same flaw
— it enabled every `opc.ua.crypto*` instrument process-wide (fixture is
`ParallelScope.All`), so another auditor's uncertified gauge could
overwrite the observed count. Now scoped to the auditor's own meter
(read via reflection, as `ClientBaseTests` already does for
`ClientBase`).

Other `MeterListener` users reviewed and left as-is: `NodeCacheTests` /
`AdapterMetricsTests` are non-parallel fixtures; channel manager
listeners only assert existence and filter by endpoint tag.

## Verification

- `ClientBaseTests` + `CryptoProviderModelTests`: 4× consecutive, 25/25.
- `FullyQualifiedName~Opc.Ua.Core.Tests.Stack` (net10.0): 2 clean
passes, 3548 passed / 80 skipped. One attempt hung in unrelated
`ClientChannelManagerManagedTests` async tests
(`ReconnectAsyncNotifiesAttachedParticipantsAsync` /
`ReleasingOneLeaseKeepsChannelAliveForOtherAsync`) and was rerun — a
separate intermittent hang.
- net48 build of `Opc.Ua.Core.Tests`: 0 warnings, 0 errors; `dotnet
format whitespace` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:07:46 +02:00

286 lines
11 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Core.Tests.Security.Crypto
{
/// <summary>
/// Covers the parts of the crypto provider model that carry no cryptography
/// but decide what gets used and what gets reported.
/// </summary>
[TestFixture]
[Category("CryptoProvider")]
[Parallelizable(ParallelScope.All)]
[SetCulture("en-us")]
public class CryptoProviderModelTests
{
[Test]
public void BuilderUseDefaultBindsAndChains()
{
var registry = new CryptoProviderRegistry();
var builder = new CryptoProviderBuilder(registry);
var house = new StubProvider("House", new CryptoCapability(CryptoPurpose.KeyAgreement));
CryptoProviderBuilder returned = builder.UseDefault(house);
Assert.Multiple(() =>
{
Assert.That(returned, Is.SameAs(builder), "the builder must chain");
Assert.That(registry.Resolve(CryptoPurpose.KeyAgreement), Is.SameAs(house));
});
}
[Test]
public void BuilderBindsAPurposeUnderOnePolicy()
{
var registry = new CryptoProviderRegistry();
var builder = new CryptoProviderBuilder(registry);
var narrow = new StubProvider(
"Narrow",
new CryptoCapability(
CryptoPurpose.CertificateIssuance, SecurityPolicies.Basic256Sha256));
builder.For(CryptoPurpose.CertificateIssuance, SecurityPolicies.Basic256Sha256)
.Use(narrow);
Assert.Multiple(() =>
{
Assert.That(
registry.Resolve(
CryptoPurpose.CertificateIssuance, SecurityPolicies.Basic256Sha256),
Is.SameAs(narrow));
Assert.That(
registry.Resolve(
CryptoPurpose.CertificateIssuance, SecurityPolicies.ECC_nistP256).Name,
Is.EqualTo("Platform"),
"a policy specific binding must not leak to other policies");
});
}
[Test]
public void BuilderRejectsANullRegistry()
{
Assert.Throws<ArgumentNullException>(() => new CryptoProviderBuilder(null!));
}
/// <summary>
/// The binding is a struct, so its equality has to behave.
/// </summary>
[Test]
public void BindingEqualityDistinguishesPurposeAndPolicy()
{
var registry = new CryptoProviderRegistry();
var builder = new CryptoProviderBuilder(registry);
CryptoPurposeBinding purposeOnly = builder.For(CryptoPurpose.UserIdentityKey);
CryptoPurposeBinding sameAgain = builder.For(CryptoPurpose.UserIdentityKey);
CryptoPurposeBinding otherPurpose = builder.For(CryptoPurpose.KeyAgreement);
CryptoPurposeBinding withPolicy = builder.For(
CryptoPurpose.UserIdentityKey, SecurityPolicies.Basic256Sha256);
// Assigned first so the operators are genuinely exercised without
// the analyzer rewriting them into constraints.
bool operatorEqual = purposeOnly == sameAgain;
bool operatorNotEqual = purposeOnly != sameAgain;
bool equalsSame = purposeOnly.Equals(sameAgain);
bool equalsOther = purposeOnly.Equals(otherPurpose);
bool equalsWithPolicy = purposeOnly.Equals(withPolicy);
bool equalsBoxed = purposeOnly.Equals((object)sameAgain);
bool equalsForeign = purposeOnly.Equals("not a binding");
Assert.Multiple(() =>
{
Assert.That(equalsSame, Is.True);
Assert.That(operatorEqual, Is.True);
Assert.That(operatorNotEqual, Is.False);
Assert.That(equalsOther, Is.False);
Assert.That(
equalsWithPolicy,
Is.False,
"the same purpose under a policy is a different binding");
Assert.That(equalsBoxed, Is.True);
Assert.That(equalsForeign, Is.False);
Assert.That(
purposeOnly.GetHashCode(), Is.EqualTo(sameAgain.GetHashCode()));
});
}
[Test]
public void AuditorRejectsNullArguments()
{
var registry = new CryptoProviderRegistry();
Assert.Multiple(() =>
{
Assert.Throws<ArgumentNullException>(
() => new CryptoProviderAuditor(null!, NUnitTelemetryContext.Create()));
Assert.Throws<ArgumentNullException>(
() => new CryptoProviderAuditor(registry, null!));
});
}
/// <summary>
/// The metrics exist so an operator can see which module is in use
/// without reading the log, so they have to actually publish.
/// </summary>
[Test]
public void AuditorPublishesAProviderMetric()
{
var registry = new CryptoProviderRegistry();
registry.RegisterFor(
CryptoPurpose.ApplicationInstanceKey,
new StubProvider(
"Token",
new CryptoCapability(CryptoPurpose.ApplicationInstanceKey),
new CryptoValidationStatus(CryptoValidationLevel.Uncertified, "token")));
using var auditor = new CryptoProviderAuditor(registry, NUnitTelemetryContext.Create());
var auditorMeter = (Meter?)typeof(CryptoProviderAuditor)
.GetField(
"m_meter",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance)?
.GetValue(auditor);
Assert.That(auditorMeter, Is.Not.Null);
var observed = new List<KeyValuePair<string, object?>>();
int uncertified = -1;
using (var listener = new MeterListener())
{
listener.InstrumentPublished = (instrument, l) =>
{
// Match the auditor's own meter instance: its name comes from
// the telemetry context and is shared, so a name match would
// also observe auditors created by tests running in parallel.
if (ReferenceEquals(instrument.Meter, auditorMeter) &&
instrument.Name.StartsWith("opc.ua.crypto", StringComparison.Ordinal))
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<int>((instrument, measurement, tags, state) =>
{
if (instrument.Name.Contains("uncertified", StringComparison.Ordinal))
{
uncertified = measurement;
return;
}
foreach (KeyValuePair<string, object?> tag in tags)
{
observed.Add(tag);
}
});
listener.Start();
listener.RecordObservableInstruments();
}
Assert.Multiple(() =>
{
Assert.That(
observed.Exists(t =>
t.Key == "opc.ua.crypto.provider" && (string?)t.Value == "Token"),
Is.True,
"the provider in use must be observable");
Assert.That(
observed.Exists(t => t.Key == "opc.ua.crypto.validation"),
Is.True,
"its validation level must be observable alongside it");
Assert.That(
uncertified,
Is.GreaterThan(0),
"an uncertified provider must be counted");
});
}
[Test]
public void CapabilityAndPurposeExposeReadableIdentities()
{
var capability = new CryptoCapability(
CryptoPurpose.KeyAgreement, SecurityPolicies.ECC_nistP256);
Assert.Multiple(() =>
{
Assert.That(capability.Purpose, Is.EqualTo(CryptoPurpose.KeyAgreement));
Assert.That(capability.ToString(), Is.Not.Empty);
Assert.That(CryptoPurpose.KeyAgreement.ToString(), Is.Not.Empty);
Assert.That(
CryptoPurpose.KeyAgreement, Is.Not.EqualTo(CryptoPurpose.UserIdentityKey));
});
}
[Test]
public void ValidationStatusDescribesItself()
{
var validated = new CryptoValidationStatus(
CryptoValidationLevel.FipsValidated, "Vendor HSM", "CMVP #1234");
var unknown = new CryptoValidationStatus(CryptoValidationLevel.Unknown);
Assert.Multiple(() =>
{
Assert.That(validated.IsAcceptableForFips, Is.True);
Assert.That(unknown.IsAcceptableForFips, Is.False);
Assert.That(validated.ToString(), Does.Contain("CMVP #1234"));
Assert.That(
CryptoValidationStatus.Platform.Level,
Is.EqualTo(CryptoValidationLevel.FipsCapablePlatform));
});
}
private sealed class StubProvider : ICryptoProvider
{
public StubProvider(
string name,
CryptoCapability capability,
CryptoValidationStatus validation = default)
{
Name = name;
Capabilities = new ArrayOf<CryptoCapability>(new[] { capability });
Validation = validation.Level == CryptoValidationLevel.Unknown
? CryptoValidationStatus.Platform
: validation;
}
public string Name { get; }
public CryptoValidationStatus Validation { get; }
public ArrayOf<CryptoCapability> Capabilities { get; }
}
}
}