using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// /// Follow-up #2 + G-2a — pins the five resolution forms supported by /// : env:NAME, file:PATH, /// dev:KEY, secret:NAME (via the shared ), and /// the literal-string fallback. The secret: arm is fail-closed — an absent secret /// throws rather than leaking the ref string as the key. (The resolver was extracted from /// GalaxyDriver to the shared GalaxySecretRef in Driver.Galaxy.Contracts so the /// runtime driver and the AdminUI browser share one copy.) /// public sealed class GalaxyDriverApiKeyResolverTests { /// Name the fake resolver knows; matches the SecretName-normalized form. private const string KnownSecretName = "galaxy/inst1/apikey"; /// The value the fake resolver returns for . private const string KnownSecretValue = "key-from-secret-store"; /// A fake resolver: returns a known value for one known name, null otherwise. private static ISecretResolver FakeResolver() => new StubSecretResolver(); /// Verifies that a literal string is returned unchanged. [Fact] public async Task Literal_string_is_returned_unchanged() { (await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver())) .ShouldBe("plain-text-key"); } /// Verifies that env: prefix resolves to an environment variable. [Fact] public async Task Env_prefix_resolves_to_environment_variable() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY"; Environment.SetEnvironmentVariable(name, "key-from-env"); try { (await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver())) .ShouldBe("key-from-env"); } finally { Environment.SetEnvironmentVariable(name, null); } } /// Verifies that unset environment variables throw with a descriptive message. [Fact] public async Task Env_prefix_unset_variable_throws_with_descriptive_message() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET"; Environment.SetEnvironmentVariable(name, null); var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver())); ex.Message.ShouldContain(name); ex.Message.ShouldContain("unset"); } /// Verifies that file: prefix resolves to trimmed file contents. [Fact] public async Task File_prefix_resolves_to_trimmed_file_contents() { var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt"); File.WriteAllText(path, " key-from-file \n"); try { (await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())) .ShouldBe("key-from-file"); } finally { File.Delete(path); } } /// Verifies that file: prefix with missing path throws. [Fact] public async Task File_prefix_missing_path_throws() { var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); ex.Message.ShouldContain(path); ex.Message.ShouldContain("doesn't exist"); } // ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed ===== /// Verifies that secret: prefix resolves through the ISecretResolver. [Fact] public async Task Secret_prefix_resolves_through_secret_resolver() { (await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver())) .ShouldBe(KnownSecretValue); } /// Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string. [Fact] public async Task Secret_prefix_absent_secret_throws_fail_closed() { const string missingRef = "secret:galaxy/missing/apikey"; var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver())); // Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and // must NOT return the ref string as the key. ex.Message.ShouldContain("galaxy/missing/apikey"); ex.Message.ShouldContain("absent"); } /// Verifies that the secret: arm does not consult the logger's literal warning. [Fact] public async Task Secret_prefix_does_not_emit_literal_warning() { var logger = new CaptureLogger(); await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); } // ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path ===== /// Verifies that literal strings emit a warning when a logger is supplied. [Fact] public async Task Literal_string_emits_warning_when_logger_supplied() { // A literal API key on a production deployment means the cleartext key sits // in the DriverConfig JSON. The resolver must surface a warning so an // operator who committed one by accident sees it at startup. var logger = new CaptureLogger(); var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger); key.ShouldBe("plain-text-key"); logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("literal", StringComparison.OrdinalIgnoreCase)); } /// Verifies that dev: prefix returns literal text without emitting warnings. [Fact] public async Task Dev_prefix_returns_literal_without_warning() { // An explicit dev: prefix signals the operator knowingly opted into a literal // key (dev / parity rig). The resolver must accept it AND suppress the // warning so production logs aren't polluted on a deliberate dev choice. var logger = new CaptureLogger(); var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger); key.ShouldBe("plain-text-key"); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); } /// Verifies that env: prefix does not emit literal string warnings. [Fact] public async Task Env_prefix_does_not_emit_literal_warning() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN"; Environment.SetEnvironmentVariable(name, "v"); try { var logger = new CaptureLogger(); await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); } finally { Environment.SetEnvironmentVariable(name, null); } } /// Verifies that a null resolver is rejected (ArgumentNullException). [Fact] public async Task Null_resolver_throws() { await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!)); } /// A test logger that captures log entries for verification. private sealed class CaptureLogger : ILogger { /// Gets the list of captured log entries with their levels and messages. public List<(LogLevel Level, string Message)> Entries { get; } = new(); /// public IDisposable? BeginScope(TState state) where TState : notnull => null; /// public bool IsEnabled(LogLevel logLevel) => true; /// public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } /// A fake ISecretResolver returning one known value; null for every other name. private sealed class StubSecretResolver : ISecretResolver { /// public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult( string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal) ? KnownSecretValue : null); } /// Verifies that file: prefix with empty file throws. [Fact] public async Task File_prefix_empty_file_throws() { var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt"); File.WriteAllText(path, " \n "); try { var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); ex.Message.ShouldContain("empty"); } finally { File.Delete(path); } } }