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