fix(site-secrets): one shared secret: reference parser for scanner and MxGateway adapter
Final review M3: SecretReferenceScanner.TryParseSecretReference (trimmed, case-insensitive
prefix, trimmed name, empty = not a reference, ${secret:} never matches) is used by the scanner
and by MxGatewayDataConnection's ApiKey resolution, so a padded or case-variant reference
resolves the name central ships. A bare 'secret:' ApiKey now fails closed. M7: unit test pins
stamped hash == ComputeCurrentArtifactHashesAsync with a non-empty Secrets list.
This commit is contained in:
+36
@@ -46,4 +46,40 @@ public class SecretReferenceScannerTests
|
||||
Assert.Equal(["mxgw-key"], refs.Select(r => r.Name));
|
||||
Assert.Equal(["GW1"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy);
|
||||
}
|
||||
|
||||
// ── Shared parser (final review M3) ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:mxgw-key", "mxgw-key")]
|
||||
[InlineData("SECRET:mxgw-key", "mxgw-key")]
|
||||
[InlineData(" Secret: mxgw-key ", "mxgw-key")]
|
||||
[InlineData("secret:a/b/c", "a/b/c")]
|
||||
public void TryParseSecretReference_ParsesTrimmedCaseInsensitive(string value, string expected)
|
||||
{
|
||||
Assert.True(SecretReferenceScanner.TryParseSecretReference(value, out var name));
|
||||
Assert.Equal(expected, name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("secret:")]
|
||||
[InlineData("secret: ")]
|
||||
[InlineData("${secret:node-local}")]
|
||||
[InlineData("literal-api-key")]
|
||||
[InlineData("see secret:x in docs")]
|
||||
public void TryParseSecretReference_RejectsNonReferences(string? value)
|
||||
{
|
||||
Assert.False(SecretReferenceScanner.TryParseSecretReference(value, out var name));
|
||||
Assert.Equal(string.Empty, name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_AndParser_AgreeOnThePaddedCaseVariantName()
|
||||
{
|
||||
const string value = " SECRET: mxgw-key ";
|
||||
var shipped = Assert.Single(SecretReferenceScanner.Scan([Dc("GW", $$"""{"ApiKey":"{{value}}"}""")])).Name;
|
||||
Assert.True(SecretReferenceScanner.TryParseSecretReference(value, out var parsed));
|
||||
Assert.Equal(shipped, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -52,6 +52,43 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.Contains("mxgateway/testconn/api-key", resolver.Requested);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:mxgw-key ")]
|
||||
[InlineData(" SECRET: mxgw-key")]
|
||||
[InlineData("Secret:mxgw-key")]
|
||||
public async Task ConnectAsync_resolves_padded_or_case_variant_ref_to_the_name_the_scanner_ships(string apiKey)
|
||||
{
|
||||
// Final review M3: the adapter and central's SecretReferenceScanner share one parser,
|
||||
// so the name looked up is exactly the name that was deployed.
|
||||
var shipped = Assert.Single(ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets.SecretReferenceScanner.Scan([
|
||||
new ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DataConnectionArtifact(
|
||||
"GW", "MxGateway", System.Text.Json.JsonSerializer.Serialize(new { ApiKey = apiKey }), null, 3),
|
||||
])).Name;
|
||||
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var resolver = new FakeSecretResolver("resolved-plaintext-key");
|
||||
var adapter = new MxGatewayDataConnection(fake, NullLogger<MxGatewayDataConnection>.Instance, resolver);
|
||||
|
||||
await adapter.ConnectAsync(Details(apiKey));
|
||||
|
||||
Assert.Equal("mxgw-key", shipped);
|
||||
Assert.Equal([shipped], resolver.Requested);
|
||||
Assert.Equal("resolved-plaintext-key", fake.ConnectedWith!.ApiKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_fails_closed_on_a_secret_ref_with_no_name()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var resolver = new FakeSecretResolver("SHOULD-NOT-BE-USED");
|
||||
var adapter = new MxGatewayDataConnection(fake, NullLogger<MxGatewayDataConnection>.Instance, resolver);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => adapter.ConnectAsync(Details("secret: ")));
|
||||
|
||||
Assert.Null(fake.ConnectedWith);
|
||||
Assert.Empty(resolver.Requested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_passes_literal_apikey_unchanged_and_does_not_call_resolver()
|
||||
{
|
||||
|
||||
@@ -470,6 +470,46 @@ public class ArtifactDeploymentServiceTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps_WithSecrets()
|
||||
{
|
||||
// Final review M7: the parity above runs with no data connections. Pin it for a site
|
||||
// whose connections reference secrets, so the stamped hash (built from a command that
|
||||
// carries a non-empty Secrets list) equals the "what would it be now" hash — a
|
||||
// successful secrets deploy must read Current, not Out of date.
|
||||
var site = new Site("Site One", "site-1") { Id = 1 };
|
||||
var sites = new List<Site> { site };
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection>
|
||||
{
|
||||
new("GW1", "MxGateway", 1)
|
||||
{
|
||||
PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}"""
|
||||
}
|
||||
});
|
||||
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key1"), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key1", 3)));
|
||||
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key2"), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key2", 7)));
|
||||
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(
|
||||
recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var deployResult = await service.DeployToAllSitesAsync("admin");
|
||||
Assert.True(deployResult.IsSuccess);
|
||||
Assert.Equal(1, deployResult.Value.SuccessCount);
|
||||
var shipped = Assert.Single(recorder.Received);
|
||||
Assert.Equal(2, shipped.Secrets!.Count);
|
||||
Assert.NotNull(site.ArtifactRevisionHash);
|
||||
|
||||
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
||||
|
||||
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user