Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSigningPolicyAdvisoryTests.cs
T
Joseph Doherty 559e02f5c8 feat(transport): production-boundary signing advisory, secret-key proof, runbook (#110)
Owner decisions on #110: one signing key per environment; TrustedKeys stay hand-edited;
Signing:ProductionBoundary (new, default off) logs a startup warning and reports a Degraded
bundle-signing-policy /health/ready entry while RequireSignedBundles is off; the private key
is read via ${secret:} (proved end to end, never logged); cluster pull decided not to be
built; differential deletions stay deferred. Adds docs/operations/bundle-signing-runbook.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:50:18 -05:00

88 lines
4.0 KiB
C#

using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing;
/// <summary>ScadaBridge#110: production-boundary advisory, startup warning, and key-never-logged guarantees.</summary>
public sealed class BundleSigningPolicyAdvisoryTests
{
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception) + (exception?.ToString() ?? string.Empty)));
}
/// <summary>Advisory fires only for ProductionBoundary without RequireSignedBundles.</summary>
[Theory]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
public void Evaluate_flags_only_a_production_boundary_that_accepts_unsigned(
bool boundary, bool require, bool expectAdvisory)
{
var signing = new TransportSigningOptions { ProductionBoundary = boundary, RequireSignedBundles = require };
Assert.Equal(expectAdvisory, BundleSigningPolicyAdvisory.Evaluate(signing) is not null);
}
/// <summary>The startup service logs a Warning for the unmet expectation and stays silent otherwise.</summary>
[Theory]
[InlineData(true, false, 1)]
[InlineData(true, true, 0)]
[InlineData(false, false, 0)]
public async Task Startup_service_warns_exactly_when_advisory_applies(bool boundary, bool require, int warnings)
{
var opts = new TransportOptions();
opts.Signing.ProductionBoundary = boundary;
opts.Signing.RequireSignedBundles = require;
var logger = new CapturingLogger<BundleSigningPolicyStartupWarning>();
await new BundleSigningPolicyStartupWarning(Options.Create(opts), logger).StartAsync(default);
Assert.Equal(warnings, logger.Entries.Count(e => e.Level == LogLevel.Warning));
}
/// <summary>The ProductionBoundary flag is bound from configuration and defaults off.</summary>
[Fact]
public void ProductionBoundary_defaults_off()
{
Assert.False(new TransportOptions().Signing.ProductionBoundary);
Assert.False(new TransportOptions().Signing.RequireSignedBundles);
}
/// <summary>A malformed private key must fail validation without echoing the key text into the failure message.</summary>
[Fact]
public void Validation_failure_for_bad_private_key_does_not_echo_the_key()
{
const string marker = "SUPER-SECRET-KEY-MATERIAL-12345";
var opts = new TransportOptions { SourceEnvironment = "dev" };
opts.Signing.Enabled = true;
opts.Signing.PrivateKey = "-----BEGIN PRIVATE KEY-----\n" + marker + "\n-----END PRIVATE KEY-----";
var result = new TransportOptionsValidator().Validate(null, opts);
Assert.True(result.Failed);
Assert.DoesNotContain(marker, string.Join("|", result.Failures));
}
/// <summary>Signing never writes the private key (or its base64) into the produced signature entry.</summary>
[Fact]
public void Signature_entry_does_not_contain_private_key_material()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var pem = key.ExportPkcs8PrivateKeyPem();
var der = Convert.ToBase64String(key.ExportPkcs8PrivateKey());
var sig = BundleSignerTests.Signer(BundleSignerTests.SigningOptions(pem)).Sign(new byte[] { 1, 2, 3 })!;
var text = System.Text.Encoding.UTF8.GetString(sig);
Assert.DoesNotContain(der, text);
Assert.DoesNotContain("PRIVATE KEY", text);
}
}