using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Configuration; namespace ZB.MOM.WW.Secrets.Tests.Configuration; /// /// scadaproj#32 (Secrets-003): a configuration reload must not revert ${secret:} expansion /// to the placeholder text. Before the fix the expander wrote into the providers' in-memory data, and /// a file provider's Load() replaces that data, so the next appsettings.json reload put /// Password=${secret:…} back into every reader's hands. /// public sealed class SecretExpansionReloadTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-reload-{Guid.NewGuid():N}"); public SecretExpansionReloadTests() => Directory.CreateDirectory(_dir); private sealed class FixedResolver(Dictionary values) : ISecretResolver { public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(values.TryGetValue(name.Value, out string? v) ? v : null); } private static SecretReferenceExpander Expander() => new(new FixedResolver(new Dictionary { ["db/pw"] = "hunter2" })); private string WriteSettings(string level) { string path = Path.Combine(_dir, "appsettings.json"); File.WriteAllText(path, $$""" { "Cs": "Server=s;Password=${secret:db/pw}", "Opts": { "Cs": "pw=${secret:db/pw}", "Level": "{{level}}" } } """); return path; } private sealed class Opts { public string? Cs { get; set; } public string? Level { get; set; } } [Fact] public async Task ConfigurationManager_keeps_the_expansion_through_a_reload() { // The shape every family host uses: builder.Configuration is a ConfigurationManager. var config = new ConfigurationManager(); config.AddJsonFile(WriteSettings("Info"), optional: false, reloadOnChange: false); await Expander().ExpandConfigurationAsync(config, CancellationToken.None); Assert.Equal("Server=s;Password=hunter2", config["Cs"]); ((IConfigurationRoot)config).Reload(); Assert.Equal("Server=s;Password=hunter2", config["Cs"]); } [Fact] public async Task An_options_monitor_rebinds_to_the_expanded_value_and_unrelated_keys_still_reload() { var config = new ConfigurationManager(); string path = WriteSettings("Info"); config.AddJsonFile(path, optional: false, reloadOnChange: false); await Expander().ExpandConfigurationAsync(config, CancellationToken.None); var services = new ServiceCollection(); services.Configure(config.GetSection("Opts")); using ServiceProvider provider = services.BuildServiceProvider(); IOptionsMonitor monitor = provider.GetRequiredService>(); Assert.Equal("pw=hunter2", monitor.CurrentValue.Cs); // The operator edits a log level on a running service. WriteSettings("Debug"); ((IConfigurationRoot)config).Reload(); Assert.Equal("Debug", monitor.CurrentValue.Level); // the edit took effect... Assert.Equal("pw=hunter2", monitor.CurrentValue.Cs); // ...and the credential survived it } [Fact] public async Task A_plain_root_reapplies_the_expansion_after_a_reload() { IConfigurationRoot config = new ConfigurationBuilder() .AddJsonFile(WriteSettings("Info"), optional: false, reloadOnChange: false) .Build(); await Expander().ExpandConfigurationAsync(config, CancellationToken.None); ((IConfigurationRoot)config).Reload(); Assert.Equal("Server=s;Password=hunter2", config["Cs"]); } [Fact] public async Task A_missing_secret_fails_closed_without_expanding_anything() { var config = new ConfigurationManager(); config.AddInMemoryCollection(new Dictionary { ["A"] = "${secret:db/pw}", ["B"] = "${secret:db/absent}", }); await Assert.ThrowsAsync( () => Expander().ExpandConfigurationAsync(config, CancellationToken.None)); Assert.Equal("${secret:db/pw}", config["A"]); } public void Dispose() => Directory.Delete(_dir, recursive: true); }