using Microsoft.Extensions.Configuration; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Configuration; namespace ZB.MOM.WW.Secrets.Tests.Configuration; public sealed class SecretReferenceExpanderTests { private sealed class FakeSecretResolver : ISecretResolver { private readonly Dictionary _values; public FakeSecretResolver(Dictionary values) => _values = values; public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(_values.TryGetValue(name.Value, out string? v) ? v : null); } private static SecretReferenceExpander Expander(params (string Name, string? Value)[] secrets) { var dict = new Dictionary(); foreach ((string name, string? value) in secrets) { dict[new SecretName(name).Value] = value; } return new SecretReferenceExpander(new FakeSecretResolver(dict)); } [Fact] public async Task ExpandAsync_SingleToken() { SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); string result = await expander.ExpandAsync("Password=${secret:sql/foo}", CancellationToken.None); Assert.Equal("Password=hunter2", result); } [Fact] public async Task ExpandAsync_MultipleTokens() { SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"), ("sql/bar", "swordfish")); string result = await expander.ExpandAsync( "u=${secret:sql/foo};p=${secret:sql/bar}", CancellationToken.None); Assert.Equal("u=hunter2;p=swordfish", result); } [Fact] public async Task ExpandAsync_NoToken_Unchanged() { SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); string result = await expander.ExpandAsync("no tokens here", CancellationToken.None); Assert.Equal("no tokens here", result); } [Fact] public async Task ExpandAsync_MissingSecret_Throws() { SecretReferenceExpander expander = Expander(); SecretNotFoundException ex = await Assert.ThrowsAsync( () => expander.ExpandAsync("Password=${secret:sql/absent}", CancellationToken.None)); Assert.Contains("sql/absent", ex.Message); Assert.Contains("${secret:sql/absent}", ex.Message); } [Fact] public async Task ExpandConfigurationAsync_RewritesMatchingKeys() { SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); IConfigurationRoot config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}", ["Plain:Value"] = "unchanged", }) .Build(); await expander.ExpandConfigurationAsync(config, CancellationToken.None); Assert.Contains("hunter2", config["ConnectionStrings:Db"]); Assert.DoesNotContain("${secret:", config["ConnectionStrings:Db"]); Assert.Equal("unchanged", config["Plain:Value"]); } [Fact] public async Task ExpandConfigurationAsync_MissingSecret_FailsClosed() { SecretReferenceExpander expander = Expander(); IConfigurationRoot config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}", }) .Build(); await Assert.ThrowsAsync( () => expander.ExpandConfigurationAsync(config, CancellationToken.None)); } [Fact] public async Task ExpandConfigurationAsync_SkipsCommentKeys() { // A "_comment" key may document the ${secret:...} syntax with a literal (even malformed or // not-yet-seeded) example token; it must be left untouched — never resolved, never throwing — // while a real sibling key still expands. Regression: an unseeded/ellipsis example in a comment // previously crashed boot (SecretName rejects '...'). SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); IConfigurationRoot config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["_secretsComment"] = "e.g. Password=${secret:...} or ${secret:sql/not-seeded}", ["Nested:_comment"] = "also skipped: ${secret:sql/also-absent}", ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}", }) .Build(); await expander.ExpandConfigurationAsync(config, CancellationToken.None); // Comment keys untouched (literal token preserved, no throw despite absent/malformed names). Assert.Equal("e.g. Password=${secret:...} or ${secret:sql/not-seeded}", config["_secretsComment"]); Assert.Contains("${secret:sql/also-absent}", config["Nested:_comment"]); // Real key still expanded. Assert.Equal("Server=.;Password=hunter2", config["ConnectionStrings:Db"]); } // ---------- Secrets-009: malformed references are fail-closed ---------- [Theory] [InlineData("pw=${secret:}")] [InlineData("pw=${secret: }")] [InlineData("pw=${secret:sql/foo")] [InlineData("pw=${secret:sql/foo};x=${secret:")] [InlineData("pw=${secret:a${secret:sql/foo}")] public async Task ExpandAsync_rejects_a_malformed_reference_instead_of_passing_it_through(string raw) { SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); var ex = await Assert.ThrowsAsync(() => expander.ExpandAsync(raw, CancellationToken.None)); Assert.DoesNotContain("hunter2", ex.Message); } [Fact] public async Task ExpandConfigurationAsync_names_the_key_holding_a_malformed_reference_and_writes_nothing() { IConfigurationRoot config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Db:Good"] = "pw=${secret:sql/foo}", ["Db:Typo"] = "Password=${secret:}", }) .Build(); var ex = await Assert.ThrowsAsync( () => Expander(("sql/foo", "hunter2")).ExpandConfigurationAsync(config, CancellationToken.None)); Assert.Contains("Db:Typo", ex.Message); Assert.DoesNotContain("Password", ex.Message); Assert.Equal("pw=${secret:sql/foo}", config["Db:Good"]); // nothing half-expanded } [Fact] public async Task ExpandAsync_text_without_a_marker_is_still_returned_unchanged() { // Positive control: the guard is about the marker, not about braces or dollar signs. string raw = "a=${env:X};b={secret};c=$secret:"; Assert.Equal(raw, await Expander().ExpandAsync(raw, CancellationToken.None)); } }