using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// Guards the role-conditional data-plane authentication wiring (arch-review 03/S18). A decorator /// registered in one branch of a role conditional is the "built but never wired" shape, so the branch /// each node role lands in is asserted rather than assumed. /// public sealed class ApiKeyStoreRegistrationTests { /// /// The authorizer's clock and the registered clock must be the SAME instance. /// /// /// already documents that a divergence between two clocks denies /// every request with a verdict indistinguishable from "no grant matched" — found once by a test /// clock two hours ahead. One registered singleton threaded into every consumer makes the /// divergence unrepresentable instead of merely unlikely, so the registration is what is pinned. /// [Fact] public void The_authorizer_and_the_key_store_share_one_TimeProvider() { using var sp = BuildNode(hasAdmin: true, hasDriver: true); var registered = sp.GetRequiredService(); var authorizer = sp.GetRequiredService(); registered.ShouldBeSameAs(Probe.Instance); authorizer.Clock.ShouldBeSameAs(registered); } /// /// An admin (or fused admin+driver) node's key store is the /// decorator, wrapping the ConfigDb store. /// /// /// The decorator lives in ONE branch of a role conditional, which is the "built but never wired" /// shape OVERALL theme 3 names. Unwired, every unit test in BoundedApiKeyStoreTests still /// passes and the deadline protects nothing. /// [Fact] public void An_admin_node_wraps_the_ConfigDb_store_in_the_bound() { using var sp = BuildNode(hasAdmin: true, hasDriver: true); var store = sp.GetRequiredService().ShouldBeOfType(); store.Inner.ShouldBeOfType(); store.Clock.ShouldBeSameAs(sp.GetRequiredService()); } /// /// A driver-only node's key store is the bare artifact store — deliberately NOT wrapped. /// /// /// It is two dictionary probes on an in-memory snapshot, so it can neither hang nor be worth /// caching; wrapping it would add a deadline to a call that cannot miss one and a second copy of /// data the snapshot already holds. /// [Fact] public void A_driver_only_node_gets_the_bare_artifact_store() { using var sp = BuildNode(hasAdmin: false, hasDriver: true); sp.GetRequiredService().ShouldBeOfType(); } /// /// A node carrying neither role registers the deny-all authenticator explicitly, and no key /// store at all. /// /// /// Explicitly, not by absence: OtOpcUaServerHostedService takes /// as a required constructor parameter, and the built-in /// container does not fall back to a default for an unregistered service — it throws, and the node /// does not start. The assertion is on the singleton INSTANCE, so a future registration that /// happened to resolve some other deny-all type still surfaces here. /// [Fact] public void A_node_with_neither_role_registers_the_deny_all_authenticator() { using var sp = BuildNode(hasAdmin: false, hasDriver: false); sp.GetRequiredService().ShouldBeSameAs(NullOpcUaUserAuthenticator.Instance); sp.GetService().ShouldBeNull("a node that serves no data plane has no keys to read"); } /// Builds the data-plane authentication wiring for one node role combination. /// Whether the node carries the admin role. /// Whether the node carries the driver role. /// The built provider; the caller disposes it. internal static ServiceProvider BuildNode(bool hasAdmin, bool hasDriver) { var services = new ServiceCollection(); services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); services.AddSingleton(Probe.Instance); services.Configure(_ => { }); services.Configure(_ => { }); services.Configure(o => o.Roles = ["admin", "driver", "cluster-MAIN"]); services.AddSingleton(EmptyDataPlaneAuthoritySource.Instance); services.AddSingleton(); services.AddSingleton(); if (hasAdmin) { services.AddDbContextFactory( o => o.UseInMemoryDatabase($"apikey-reg-{Guid.NewGuid():N}")); } services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver); return services.BuildServiceProvider(); } /// A clock distinguishable from by reference. private sealed class Probe : TimeProvider { public static readonly Probe Instance = new(); /// public override DateTimeOffset GetUtcNow() => new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero); } }