Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/TestDoubles.cs
T
Joseph Doherty 3e3748b985 test(secrets): Grpc hub end-to-end integration suite + pull-only contract pin
Adds the end-to-end layer the unit suites structurally cannot reach: a real
in-process ASP.NET Core host serving SecretsHubService through
AddZbSecretsGrpcHub/MapZbSecretsHub, a follower built by the shipped
AddZbSecretsGrpcHubClient, a real gRPC call between them, and real SQLite
stores on both sides. The class of defect this catches is pieces that are each
individually correct and are never connected to one another — for instance the
interceptor's warn-once, which only holds if the container really hands gRPC
the singleton it registered.

Covered: byte-identical convergence over the wire (every ciphertext/nonce/tag/
wrapped-DEK byte, kek_id, revision, both timestamps INCLUDING their offsets,
across a tombstone, a nulls-everywhere row and an empty-string-description row);
delete propagation; a hub unreachable at follower boot leaving the last-known-
good store intact and converging once it answers; the auth negatives fail-closed
across the wire with one uniform denial detail; the per-request name cap as
ResourceExhausted; and an RpcException from the call invoker escaping the hub
client unchanged, so a transport fault stays distinguishable from a parse fault.

The credential-hygiene assertions are made against logs captured at Trace on
BOTH hosts, so "no token material ever reaches a sink" is asserted at the most
verbose setting a deployment could switch on.

SecretsHubContractPinTests reads the generated descriptor — not the .proto text
— and pins the RPC set to exactly {GetManifest, GetSecrets}, plus unary-only and
the package-qualified service name. The hub is pull-only by construction:
updates originate at central only, and the absence of a write RPC is what
enforces that, not authorization a mis-wired follower may hold more of than
intended. A third RPC silently retires the guarantee, so the failure message
says so.

Microsoft.AspNetCore.TestHost pinned at 10.0.7 — the version ZB.MOM.WW.LocalDb,
OtOpcUa and ScadaBridge already pin, matching the Microsoft.Extensions.* line.

Full suite: 436 passed / 0 failed / 15 skipped, no new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:44:13 -04:00

210 lines
8.4 KiB
C#

using Google.Protobuf;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// A <see cref="CallInvoker"/> that answers the two hub RPCs from canned responses and records what
/// the client sent.
/// </summary>
/// <remarks>
/// This is what lets the tests drive the <b>real</b> <c>GrpcSecretsHubClient</c> — the generated
/// client accepts a <see cref="CallInvoker"/>, so substituting one exercises the header, the
/// deadline and the wire-to-domain conversion without a live server. A fake of the
/// <c>ISecretsHubReader</c> seam would bypass every one of those.
/// </remarks>
internal sealed class FakeSecretsHubCallInvoker : CallInvoker
{
/// <summary>Response returned for <c>GetManifest</c>.</summary>
public GetManifestResponse ManifestResponse { get; set; } = new();
/// <summary>Response returned for <c>GetSecrets</c>.</summary>
public GetSecretsResponse SecretsResponse { get; set; } = new();
/// <summary>The options every call was invoked with, in call order.</summary>
public List<CallOptions> Calls { get; } = [];
/// <summary>The RPC names invoked, in call order.</summary>
public List<string> Methods { get; } = [];
/// <summary>The most recent <c>GetSecrets</c> request, so tests can assert what was asked for.</summary>
public GetSecretsRequest? LastSecretsRequest { get; private set; }
/// <summary>
/// When set, every call fails with this exception instead of returning a canned response —
/// the transport half of the client's two failure kinds.
/// </summary>
/// <remarks>
/// A parse failure is contained per item; a transport failure must escape untouched, or an
/// unreachable hub becomes indistinguishable from an empty one. Modelled as a factory so a test
/// can hand back the same instance and assert identity, or a fresh one per call.
/// </remarks>
public Func<Exception>? FaultWith { get; set; }
/// <summary>Returns the <c>Authorization</c> header value seen on the call at the given index.</summary>
/// <param name="index">Zero-based call index.</param>
/// <returns>The header value, or <see langword="null"/> when the header was not sent.</returns>
public string? AuthorizationOn(int index) =>
Calls[index].Headers?.GetValue("authorization");
/// <inheritdoc />
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
Method<TRequest, TResponse> method, string? host, CallOptions options, TRequest request)
{
ArgumentNullException.ThrowIfNull(method);
Calls.Add(options);
Methods.Add(method.Name);
if (request is GetSecretsRequest secretsRequest)
{
LastSecretsRequest = secretsRequest;
}
if (FaultWith is not null)
{
// Faulted on the response task rather than thrown synchronously, because that is how a
// real call reports a transport failure — the client has to let it out of an await.
return new AsyncUnaryCall<TResponse>(
Task.FromException<TResponse>(FaultWith()),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => new Metadata(),
() => { });
}
object response = method.Name switch
{
"GetManifest" => ManifestResponse,
"GetSecrets" => SecretsResponse,
_ => throw new InvalidOperationException($"unexpected RPC '{method.Name}'."),
};
return new AsyncUnaryCall<TResponse>(
Task.FromResult((TResponse)response),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => new Metadata(),
() => { });
}
/// <inheritdoc />
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
Method<TRequest, TResponse> method, string? host, CallOptions options, TRequest request) =>
throw new NotSupportedException("The hub client only makes async unary calls.");
/// <inheritdoc />
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(
Method<TRequest, TResponse> method, string? host, CallOptions options, TRequest request) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
/// <inheritdoc />
public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(
Method<TRequest, TResponse> method, string? host, CallOptions options) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
/// <inheritdoc />
public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(
Method<TRequest, TResponse> method, string? host, CallOptions options) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
}
/// <summary>A clock frozen at a caller-chosen instant, so a deadline assertion can be exact.</summary>
/// <param name="now">The instant every read returns.</param>
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => now;
}
/// <summary>Captures level and formatted message for every log entry.</summary>
/// <typeparam name="TCategory">The logger category.</typeparam>
internal sealed class RecordingLogger<TCategory> : ILogger<TCategory>
{
/// <summary>Formatted messages logged at <see cref="LogLevel.Information"/>.</summary>
public List<string> Information { get; } = [];
/// <summary>Formatted messages logged at <see cref="LogLevel.Warning"/>.</summary>
public List<string> Warnings { get; } = [];
/// <summary>Count of information entries.</summary>
public int InformationCount => Information.Count;
/// <summary>Count of warning entries.</summary>
public int WarningCount => Warnings.Count;
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
string message = formatter(state, exception);
switch (logLevel)
{
case LogLevel.Information:
Information.Add(message);
break;
case LogLevel.Warning:
Warnings.Add(message);
break;
default:
break;
}
}
}
/// <summary>Builders for well-formed wire messages that individual tests then damage.</summary>
internal static class TestWire
{
/// <summary>A well-formed wire row.</summary>
/// <param name="name">The secret name.</param>
/// <param name="marker">A byte written into the ciphertext so the row is identifiable.</param>
/// <returns>The wire row.</returns>
public static SecretRow Row(string name, byte marker) => new()
{
Name = name,
ContentType = nameof(SecretContentType.Text),
Ciphertext = ByteString.CopyFrom(marker),
Nonce = ByteString.CopyFrom((byte)1),
Tag = ByteString.CopyFrom((byte)2),
WrappedDek = ByteString.CopyFrom((byte)3),
WrapNonce = ByteString.CopyFrom((byte)4),
WrapTag = ByteString.CopyFrom((byte)5),
KekId = "sha256:test",
Revision = 0,
IsDeleted = false,
CreatedUtc = FixedInstant.ToString("O"),
UpdatedUtc = FixedInstant.ToString("O"),
};
/// <summary>A well-formed manifest entry matching <see cref="Row"/>.</summary>
/// <param name="name">The secret name.</param>
/// <returns>The wire manifest entry.</returns>
public static ManifestEntry Entry(string name) => new()
{
Name = name,
Revision = 0,
UpdatedUtc = FixedInstant.ToString("O"),
IsDeleted = false,
};
/// <summary>The timestamp every builder stamps, so rows differ only where a test makes them differ.</summary>
public static readonly DateTimeOffset FixedInstant =
new(2026, 8, 7, 1, 2, 3, TimeSpan.Zero);
}