Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
T
Joseph Doherty 5bbe1e3c7a
v2-ci / unit-tests (push) Successful in 1h9m7s
fix(heidenhain): resolve the probe's secret resolver lazily — eager resolution crash-looped central nodes at boot (#803)
The Test-Connect probe set is resolved inside the AdminOperations Akka starter; resolving
ISecretResolver there walks AddZbSecrets -> IAuditWriter -> ActorAuditWriter, whose ctor needs
AuditWriterActorKey (registered later in the same builder). Found by the docker-dev live gate.
Regression test: resolving the probe set never resolves ISecretResolver (fails before the fix).
Also: TagRowProblem doc comment re-attached; Heidenhain credential help text names the
secret:heidenhain/ and env:OTOPCUA_HEIDENHAIN_ namespaces.
2026-11-11 00:27:37 -05:00

129 lines
6.2 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Guards the Test Connect wiring contract: every driver type editable in the AdminUI must have
/// a registered <see cref="IDriverProbe"/>, resolvable from the same DI container that hosts the
/// <c>admin-operations</c> cluster singleton. The singleton is role-pinned to <c>admin</c>, so on
/// a split-role deployment (the MAIN cluster's admin-only nodes) the probes must be wired by the
/// admin path — not only the driver path — or every Test Connect button returns
/// "No probe registered for driver type X".
/// </summary>
/// <remarks>
/// ⚠️ <b>The expected set is derived from <see cref="DriverTypeNames.All"/>, not hand-written.</b>
/// It used to be a literal array kept "in sync" by comment, and it drifted the moment `Sql`
/// (<c>9b5a9687</c>) and `Mqtt` (<c>f79d13e2</c>) shipped: the count assertion below went red
/// reporting <i>12 where 10 expected</i> — a true statement that named neither driver and read like
/// the probes were wrong rather than the list. Worse is the direction it could NOT catch: a new
/// driver type registered with <b>no</b> probe passed silently, because a hand-written list grows
/// only when someone remembers to grow it, and this test exists precisely for the case where nobody
/// did. Deriving from the constants makes the guard bidirectional and self-maintaining.
/// </remarks>
public sealed class DriverProbeRegistrationTests
{
[Fact]
public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_driver_type()
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var probes = sp.GetServices<IDriverProbe>().ToList();
// No duplicate DriverType — AdminOperationsActor builds a dictionary keyed by DriverType
// (case-insensitive) and would throw on a duplicate key, crashing the singleton.
var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
var missing = DriverTypeNames.All.Where(t => !byType.ContainsKey(t)).ToList();
missing.ShouldBeEmpty(
"Driver types with no IDriverProbe — Test Connect returns 'No probe registered for driver "
+ $"type X' for each: {string.Join(", ", missing)}");
}
[Fact]
public void No_probe_is_registered_for_a_driver_type_that_does_not_exist()
{
// The other direction. A probe whose DriverType matches no DriverTypeNames constant can never
// be reached — AdminOperationsActor looks probes up by the authored DriverType — so it is a
// typo or a leftover from a renamed driver, and it reads as coverage while providing none.
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var orphans = sp.GetServices<IDriverProbe>()
.Select(p => p.DriverType)
.Where(t => !DriverTypeNames.All.Contains(t, StringComparer.OrdinalIgnoreCase))
.ToList();
orphans.ShouldBeEmpty($"Probes for unknown driver types: {string.Join(", ", orphans)}");
}
/// <summary>
/// Two driver types are spelled differently by the constant and by the AdminUI's own key
/// (<c>TwinCAT</c>/<c>TwinCat</c>, <c>FOCAS</c>/<c>Focas</c>), so the lookup must fold case. The
/// derived tests above cannot cover this — they compare the constants against themselves.
/// </summary>
[Theory]
[InlineData("TwinCat", DriverTypeNames.TwinCAT)]
[InlineData("Focas", DriverTypeNames.FOCAS)]
[InlineData("modbus", DriverTypeNames.Modbus)]
public void A_probe_resolves_by_a_case_drifted_driver_type_key(string authoredKey, string canonical)
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var byType = sp.GetServices<IDriverProbe>().ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
byType.ShouldContainKey(authoredKey);
byType[authoredKey].DriverType.ShouldBe(canonical);
}
[Fact]
public void AddOtOpcUaDriverProbes_is_idempotent()
{
// A fused admin,driver node calls the registration from both the driver-factory path and the
// admin path. TryAddEnumerable must de-dup so the probe set stays unique (else the actor's
// ToDictionary throws).
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var probes = sp.GetServices<IDriverProbe>().ToList();
var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count();
probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup.");
distinctTypes.ShouldBe(DriverTypeNames.All.Count);
}
[Fact]
public void Resolving_the_probe_set_never_resolves_the_secret_resolver()
{
// #803 live gate: the probe set is resolved inside the AdminOperations Akka starter, where
// ISecretResolver -> AddZbSecrets -> IAuditWriter -> ActorAuditWriter throws (its actor key is
// registered later in the same builder). A probe that resolved the secret resolver eagerly crash-looped
// every central node at boot. Model that starter-time graph with a resolver factory that throws.
var resolved = 0;
var services = new ServiceCollection();
services.AddSingleton<ZB.MOM.WW.Secrets.Abstractions.ISecretResolver>(_ =>
{
resolved++;
throw new InvalidOperationException("ISecretResolver must not be resolved while building the probe set");
});
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var probes = sp.GetServices<IDriverProbe>().ToList();
probes.ShouldNotBeEmpty();
resolved.ShouldBe(0);
}
}