1c31e6bcb8
A single malformed row or manifest entry poisoned the whole sweep, permanently. WireToDomain.ToRows/ToManifest materialized eagerly, so one unparseable element threw out of the fetch callback, past SecretReplicationReconciler's per-batch handling, into SweepSafelyAsync's whole-sweep catch. The follower's pull set is derived from the hub's manifest, so the identical bad element arrived in the identical batch on every interval and nothing ever converged — logged as "sync failed", indistinguishable from a hub outage. Isolation now matches the Akka sibling's split exactly: WireToDomain stays a strict per-item parser and loses its two batch entry points (their absence is pinned by a reflection test), while GrpcSecretsHubClient converts each item inside its own try — the same shape as SecretReplicationActor.Materialize. A dropped item costs one warning naming the secret and a reason category, and every well-formed sibling still converges. Only parse-shaped exceptions are caught (ArgumentException, FormatException); a defect still propagates. A row whose own name is unparseable is logged under a marker rather than echoing the untrusted string, and no ciphertext, DEK or credential reaches a log line. GrpcSecretsHubClient had zero coverage — the existing fake implements the ISecretsHubReader seam directly, bypassing the header, the deadline and the conversion, which are the only things that class does. It is now driven for real through a fake CallInvoker handed to the generated client. The stale doc comment claiming the reconciler isolated the failure is replaced by one describing the mechanism that actually exists, and Endpoint gains a note that a plain-http hub sends the bearer token cleartext (doc only, no validation change). Tests 52 -> 67 in the package suite; solution green, no new warnings. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
187 lines
7.3 KiB
C#
187 lines
7.3 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>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;
|
|
}
|
|
|
|
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);
|
|
}
|