7a2980e2f1
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
219 lines
8.9 KiB
C#
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);
|
|
}
|