Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/TestDoubles.cs
T
Joseph Doherty 7a2980e2f1 fix(grpc): keep the RpcException off the failover warning so no sink can render Status.Detail
Review follow-ups: the warning now carries only the two endpoint URIs and the
status code — attaching the exception object would let any real sink render
its ToString(), which for RpcException includes server-authored Status.Detail.
The leak pin test now also asserts no exception rides on the warning, via a
new WarningExceptions capture on RecordingLogger. Plus two cosmetic nits:
unified ThrowIfNull idiom, documented that FallbackEndpoints mutation after
Validate is unsupported.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:49:45 -04:00

219 lines
8.9 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>
/// The exception object attached to each Warning entry, index-aligned with
/// <see cref="Warnings"/>. Captured separately from the formatted message because real sinks
/// ALSO render an attached exception's <c>ToString()</c> — for <c>RpcException</c> that includes
/// <c>Status.Detail</c> — so a leak test that only scans the message would miss that channel.
/// </summary>
public List<Exception?> WarningExceptions { 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);
WarningExceptions.Add(exception);
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);
}