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();
}
}