Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs
T
Joseph Doherty 9bd3c76d96 fix(secrets): ${secret:} expander skips comment keys (_-prefixed leaf) — 0.1.2
Live HistorianGateway boot caught that the expander scanned a '_secretsComment' appsettings
value documenting the ${secret:...} syntax, tried to resolve the literal example token, and
crashed boot. The family uses _*Comment keys pervasively, so skip any config key whose leaf
segment starts with '_' (the _comment convention). Functional keys never lead with '_'.
2026-07-16 04:16:25 -04:00

133 lines
5.1 KiB
C#

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<string, string?> _values;
public FakeSecretResolver(Dictionary<string, string?> values) => _values = values;
public Task<string?> 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<string, string?>();
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<SecretNotFoundException>(
() => 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<string, string?>
{
["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<string, string?>
{
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}",
})
.Build();
await Assert.ThrowsAsync<SecretNotFoundException>(
() => 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<string, string?>
{
["_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"]);
}
}