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;
///
/// A that answers the two hub RPCs from canned responses and records what
/// the client sent.
///
///
/// This is what lets the tests drive the real GrpcSecretsHubClient — the generated
/// client accepts a , so substituting one exercises the header, the
/// deadline and the wire-to-domain conversion without a live server. A fake of the
/// ISecretsHubReader seam would bypass every one of those.
///
internal sealed class FakeSecretsHubCallInvoker : CallInvoker
{
/// Response returned for GetManifest.
public GetManifestResponse ManifestResponse { get; set; } = new();
/// Response returned for GetSecrets.
public GetSecretsResponse SecretsResponse { get; set; } = new();
/// The options every call was invoked with, in call order.
public List Calls { get; } = [];
/// The RPC names invoked, in call order.
public List Methods { get; } = [];
/// The most recent GetSecrets request, so tests can assert what was asked for.
public GetSecretsRequest? LastSecretsRequest { get; private set; }
/// Returns the Authorization header value seen on the call at the given index.
/// Zero-based call index.
/// The header value, or when the header was not sent.
public string? AuthorizationOn(int index) =>
Calls[index].Headers?.GetValue("authorization");
///
public override AsyncUnaryCall AsyncUnaryCall(
Method 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(
Task.FromResult((TResponse)response),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => new Metadata(),
() => { });
}
///
public override TResponse BlockingUnaryCall(
Method method, string? host, CallOptions options, TRequest request) =>
throw new NotSupportedException("The hub client only makes async unary calls.");
///
public override AsyncServerStreamingCall AsyncServerStreamingCall(
Method method, string? host, CallOptions options, TRequest request) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
///
public override AsyncClientStreamingCall AsyncClientStreamingCall(
Method method, string? host, CallOptions options) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
///
public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall(
Method method, string? host, CallOptions options) =>
throw new NotSupportedException("The hub contract has no streaming RPC.");
}
/// A clock frozen at a caller-chosen instant, so a deadline assertion can be exact.
/// The instant every read returns.
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
///
public override DateTimeOffset GetUtcNow() => now;
}
/// Captures level and formatted message for every log entry.
/// The logger category.
internal sealed class RecordingLogger : ILogger
{
/// Formatted messages logged at .
public List Information { get; } = [];
/// Formatted messages logged at .
public List Warnings { get; } = [];
/// Count of information entries.
public int InformationCount => Information.Count;
/// Count of warning entries.
public int WarningCount => Warnings.Count;
///
public IDisposable? BeginScope(TState state) where TState : notnull => null;
///
public bool IsEnabled(LogLevel logLevel) => true;
///
public void Log(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func 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;
}
}
}
/// Builders for well-formed wire messages that individual tests then damage.
internal static class TestWire
{
/// A well-formed wire row.
/// The secret name.
/// A byte written into the ciphertext so the row is identifiable.
/// The wire row.
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"),
};
/// A well-formed manifest entry matching .
/// The secret name.
/// The wire manifest entry.
public static ManifestEntry Entry(string name) => new()
{
Name = name,
Revision = 0,
UpdatedUtc = FixedInstant.ToString("O"),
IsDeleted = false,
};
/// The timestamp every builder stamps, so rows differ only where a test makes them differ.
public static readonly DateTimeOffset FixedInstant =
new(2026, 8, 7, 1, 2, 3, TimeSpan.Zero);
}