Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretExpansionReloadTests.cs
T
Joseph Doherty f60f641069 fix(secrets): ${secret:} expansion survives configuration reload (scadaproj#32)
ExpandConfigurationAsync wrote through config[key] = ..., which only sets the
providers' in-memory Data; a file provider's reload replaces it, so editing
appsettings.json on a running service reverted every expanded key to its
${secret:...} placeholder.

- ConfigurationManager: expanded values are pinned in an internal overlay
  source appended last; its provider's Load() is a no-op and Build always
  returns the same provider, so reloads (and source rebuilds) keep the pins
- any other IConfigurationRoot: in-place write as before, re-applied from the
  root's reload token
- all references resolve before anything is written (a fail-closed miss no
  longer leaves the config half expanded)
- tests: SecretExpansionReloadTests (real JSON file + Reload(), options
  monitor, plain root); three fail without the fix
- docs: README reload note, CHANGELOG
- code-reviews: Secrets-003 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00

114 lines
4.4 KiB
C#

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;
/// <summary>
/// scadaproj#32 (Secrets-003): a configuration reload must not revert <c>${secret:}</c> expansion
/// to the placeholder text. Before the fix the expander wrote into the providers' in-memory data, and
/// a file provider's <c>Load()</c> replaces that data, so the next <c>appsettings.json</c> reload put
/// <c>Password=${secret:…}</c> back into every reader's hands.
/// </summary>
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<string, string> values) : ISecretResolver
{
public Task<string?> 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<string, string> { ["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<Opts>(config.GetSection("Opts"));
using ServiceProvider provider = services.BuildServiceProvider();
IOptionsMonitor<Opts> monitor = provider.GetRequiredService<IOptionsMonitor<Opts>>();
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<string, string?>
{
["A"] = "${secret:db/pw}",
["B"] = "${secret:db/absent}",
});
await Assert.ThrowsAsync<SecretNotFoundException>(
() => Expander().ExpandConfigurationAsync(config, CancellationToken.None));
Assert.Equal("${secret:db/pw}", config["A"]);
}
public void Dispose() => Directory.Delete(_dir, recursive: true);
}