f60f641069
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
114 lines
4.4 KiB
C#
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);
|
|
}
|