using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.Secrets; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; /// /// lmxopcua#765 item 3 — the host wiring of the cached API-key → Galaxy ack identity set: the driver registry /// hands every Galaxy driver the node's directory (so the synchronous pre-check can refuse a keyed session /// without an identity), a refresh loop runs on driver nodes, secret writes signal it through the decorated /// invalidator, and the refresh interval is configurable. /// [Trait("Category", "Unit")] public sealed class GalaxyAckIdentityCacheWiringTests { private const string WithIdentity = "0a1b2c3d4e5f6071"; private const string WithoutIdentity = "ffeeddccbbaa0099"; private const string GalaxyConfig = """ { "Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "dev:key" }, "MxAccess": { "ClientName": "OtOpcUa-A" } } """; [Fact] public async Task A_registry_built_Galaxy_driver_refuses_a_keyed_session_without_an_identity() { var store = new ListOnlySecretStore(GalaxyAckIdentitySecrets.UserSecretName(WithIdentity)); using var sp = DriverServices(store).BuildServiceProvider(); var directory = sp.GetRequiredService(); (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue(); var driver = sp.GetRequiredService() .TryGet(GalaxyDriverFactoryExtensions.DriverTypeName)!.Invoke("galaxy-1", GalaxyConfig); var preflight = driver.ShouldBeAssignableTo()!; preflight.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull(); preflight.GetAcknowledgeRefusal(WithIdentity).ShouldBeNull(); (driver as IDisposable)?.Dispose(); } [Fact] public void Driver_nodes_run_the_refresh_loop_and_share_one_directory() { var services = DriverServices(new ListOnlySecretStore()); services.AddOtOpcUaDriverFactories(); // a fused admin,driver node may reach it twice // By descriptor: resolving every IHostedService would need the resilience publisher's whole graph. services.Count(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(GalaxyAckIdentityRefreshService)).ShouldBe(1); using var sp = services.BuildServiceProvider(); sp.GetRequiredService() .ShouldBeSameAs(sp.GetRequiredService()); } [Fact] public void The_refresh_interval_and_staleness_are_configurable() { var services = DriverServices(new ListOnlySecretStore()); services.AddGalaxyAckIdentityCache(Config(new() { ["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = "7", ["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = "50", })); using var sp = services.BuildServiceProvider(); var directory = sp.GetRequiredService(); directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(7)); directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(50)); } [Fact] public void Defaults_are_30s_and_three_intervals() { using var sp = DriverServices(new ListOnlySecretStore()).BuildServiceProvider(); var directory = sp.GetRequiredService(); directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(30)); directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(90)); } [Theory] [InlineData("0", "0")] [InlineData("3601", "0")] [InlineData("30", "30")] public void Bad_cache_settings_fail_validation(string interval, string staleAfter) { var services = new ServiceCollection(); services.AddGalaxyAckIdentityCache(Config(new() { ["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = interval, ["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = staleAfter, })); using var sp = services.BuildServiceProvider(); Should.Throw(() => sp.GetRequiredService>().Value); } [Fact] public async Task AddOtOpcUaSecrets_decorates_the_invalidator_so_an_identity_write_untrusts_the_directory() { // Registered before AddOtOpcUaSecrets, so the library's TryAdd keeps this store for the directory's listing. var services = DriverServices(new ListOnlySecretStore()); services.AddLogging(); services.AddOtOpcUaSecrets(Config(new() { // Resolving the invalidator never opens the store; the path only has to pass validation. ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"), ["Secrets:MasterKey:Source"] = "Environment", ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY", ["Secrets:RunMigrationsOnStartup"] = "false", })); using var sp = services.BuildServiceProvider(); var invalidator = sp.GetRequiredService(); var decorated = invalidator.ShouldBeOfType(); decorated.Inner.ShouldBeOfType(); decorated.Inner.ShouldBeSameAs(sp.GetRequiredService(), "the resolver cache is still invalidated"); var directory = sp.GetRequiredService(); (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue(); directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent); // What ApiKeyGalaxyAckIdentityService.SetAsync does after writing the user secret. invalidator.Invalidate(new SecretName(GalaxyAckIdentitySecrets.UserSecretName(WithoutIdentity))); directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown); } [Fact] public void Without_a_directory_the_decorated_invalidator_is_a_pass_through() { var inner = new RecordingInvalidator(); var services = new ServiceCollection(); services.AddSingleton(_ => inner); SecretsRegistration.DecorateCacheInvalidator(services); using var sp = services.BuildServiceProvider(); var name = new SecretName("otopcua/apikeys/abc/galaxy-ack-user"); sp.GetRequiredService().Invalidate(name); inner.Names.ShouldBe([name]); } // ------------------------------------------------------------------ helpers private static IServiceCollection DriverServices(ISecretStore store) { var services = new ServiceCollection(); services.AddSingleton(new NullResolver()); services.AddSingleton(store); services.AddOtOpcUaDriverFactories(); return services; } private static IConfiguration Config(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); private sealed class NullResolver : ISecretResolver { public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); } private sealed class RecordingInvalidator : ISecretCacheInvalidator { public List Names { get; } = []; public void Invalidate(SecretName name) => Names.Add(name); } private sealed class ListOnlySecretStore(params string[] names) : ISecretStore { public Task> ListAsync(bool includeDeleted, CancellationToken ct) => Task.FromResult>(names.Select(n => new SecretMetadata { Name = new SecretName(n), ContentType = SecretContentType.Text, KekId = "k", Revision = 1, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch, }).ToList()); public Task GetAsync(SecretName name, CancellationToken ct) => throw new NotSupportedException(); public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException(); public Task> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException(); public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException(); } }