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); }