using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; // G-3 / Task 9: the MxGateway per-endpoint ApiKey may hold a `secret:` reference // resolved via ISecretResolver at connect time. Literal keys pass through unchanged // (back-compat); an unresolvable / missing secret, or a secret ref with no resolver // available, fails closed (no connection established with an empty/null key). [Collection("DataConnectionManagerActor")] public class MxGatewayDataConnectionSecretsTests { private static Dictionary Details(string apiKey) => new() { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = apiKey, ["ClientName"] = "client-a", ["WriteUserId"] = "0", ["ReadTimeoutMs"] = "2000", }; private sealed class FakeSecretResolver : ISecretResolver { private readonly string? _value; public FakeSecretResolver(string? value) => _value = value; public List Requested { get; } = new(); public Task GetAsync(SecretName name, CancellationToken ct) { Requested.Add(name.Value); return Task.FromResult(_value); } } [Fact] public async Task ConnectAsync_resolves_secret_ref_apikey() { var fake = new FakeMxGatewayClient(); var resolver = new FakeSecretResolver("resolved-plaintext-key"); var adapter = new MxGatewayDataConnection(fake, NullLogger.Instance, resolver); await adapter.ConnectAsync(Details("secret:mxgateway/testconn/api-key")); Assert.Equal(ConnectionHealth.Connected, adapter.Status); Assert.NotNull(fake.ConnectedWith); Assert.Equal("resolved-plaintext-key", fake.ConnectedWith!.ApiKey); Assert.Contains("mxgateway/testconn/api-key", resolver.Requested); } [Fact] public async Task ConnectAsync_passes_literal_apikey_unchanged_and_does_not_call_resolver() { var fake = new FakeMxGatewayClient(); var resolver = new FakeSecretResolver("SHOULD-NOT-BE-USED"); var adapter = new MxGatewayDataConnection(fake, NullLogger.Instance, resolver); await adapter.ConnectAsync(Details("literal-api-key")); Assert.Equal("literal-api-key", fake.ConnectedWith!.ApiKey); Assert.Empty(resolver.Requested); } [Fact] public async Task ConnectAsync_fails_closed_when_secret_unresolvable() { var fake = new FakeMxGatewayClient(); var resolver = new FakeSecretResolver(null); // secret not found / tombstoned var adapter = new MxGatewayDataConnection(fake, NullLogger.Instance, resolver); await Assert.ThrowsAsync( () => adapter.ConnectAsync(Details("secret:mxgateway/testconn/missing"))); // Fail-closed: the underlying client was never connected and status is not Connected. Assert.Null(fake.ConnectedWith); Assert.NotEqual(ConnectionHealth.Connected, adapter.Status); } [Fact] public async Task ConnectAsync_fails_closed_on_secret_ref_with_no_resolver() { var fake = new FakeMxGatewayClient(); var adapter = new MxGatewayDataConnection(fake, NullLogger.Instance, secretResolver: null); await Assert.ThrowsAsync( () => adapter.ConnectAsync(Details("secret:mxgateway/testconn/api-key"))); Assert.Null(fake.ConnectedWith); Assert.NotEqual(ConnectionHealth.Connected, adapter.Status); } // DI-threading seam (code-review follow-up): the four tests above construct the adapter // directly with an explicit resolver, so they don't prove the factory→adapter DI wiring // G-3 actually relies on. This test resolves IDataConnectionFactory from a real container // (AddDataConnectionLayer + a registered fake ISecretResolver, no AddZbSecrets), creates a // "MxGateway" adapter through it, and drives a secret:-ref ConnectAsync. The real client // then fails to reach a non-existent gateway (expected, ignored) — but resolution happens // BEFORE any connect attempt, so the fake resolver being invoked proves the greediest // 3-arg DataConnectionFactory ctor picked up the DI-registered resolver and threaded it in. // Had it been null, ConnectAsync would have thrown the "no ISecretResolver" fail-closed // path instead and the resolver would never have been called. [Fact] public async Task Factory_from_DI_threads_registered_ISecretResolver_into_MxGateway_adapter() { var fake = new FakeSecretResolver("resolved-from-di"); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new ConfigurationBuilder().Build()); services.AddDataConnectionLayer(); services.AddSingleton(fake); using var provider = services.BuildServiceProvider(); var factory = provider.GetRequiredService(); // Point at a fast-refusing loopback port so the real client's connect fails quickly. var details = Details("secret:mxgateway/testconn/api-key"); details["Endpoint"] = "http://127.0.0.1:1"; await using var adapter = factory.Create("MxGateway", details); Assert.IsType(adapter); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { await adapter.ConnectAsync(details, cts.Token); } catch { // The real RealMxGatewayClient cannot reach 127.0.0.1:1 — irrelevant to this test. } // Resolution runs before the connect attempt: the resolver being invoked proves the // factory threaded the DI-registered ISecretResolver into the adapter. Assert.Contains("mxgateway/testconn/api-key", fake.Requested); } }