Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs
T
Joseph Doherty 5e2d3dd1bc fix(secrets): malformed ${secret:} references fail closed; README sample validates (scadaproj#45)
Secrets-009: SecretReferenceExpander.ExpandAsync throws FormatException when a value holds a
${secret: marker that is not a complete reference with a non-blank name (${secret:}, an
unterminated ${secret:name, a nested marker), instead of passing the literal text through as the
credential. ExpandConfigurationAsync names the offending key (never the value) and writes nothing.
The README wiring sample drops the relative "SqlitePath": "secrets.db" (which failed validation)
for the per-user default and shows an absolute example; a test validates the sample.

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

176 lines
6.9 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"]);
}
// ---------- Secrets-009: malformed references are fail-closed ----------
[Theory]
[InlineData("pw=${secret:}")]
[InlineData("pw=${secret: }")]
[InlineData("pw=${secret:sql/foo")]
[InlineData("pw=${secret:sql/foo};x=${secret:")]
[InlineData("pw=${secret:a${secret:sql/foo}")]
public async Task ExpandAsync_rejects_a_malformed_reference_instead_of_passing_it_through(string raw)
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
var ex = await Assert.ThrowsAsync<FormatException>(() => expander.ExpandAsync(raw, CancellationToken.None));
Assert.DoesNotContain("hunter2", ex.Message);
}
[Fact]
public async Task ExpandConfigurationAsync_names_the_key_holding_a_malformed_reference_and_writes_nothing()
{
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Db:Good"] = "pw=${secret:sql/foo}",
["Db:Typo"] = "Password=${secret:}",
})
.Build();
var ex = await Assert.ThrowsAsync<FormatException>(
() => Expander(("sql/foo", "hunter2")).ExpandConfigurationAsync(config, CancellationToken.None));
Assert.Contains("Db:Typo", ex.Message);
Assert.DoesNotContain("Password", ex.Message);
Assert.Equal("pw=${secret:sql/foo}", config["Db:Good"]); // nothing half-expanded
}
[Fact]
public async Task ExpandAsync_text_without_a_marker_is_still_returned_unchanged()
{
// Positive control: the guard is about the marker, not about braces or dollar signs.
string raw = "a=${env:X};b={secret};c=$secret:";
Assert.Equal(raw, await Expander().ExpandAsync(raw, CancellationToken.None));
}
}