using System.Collections.Concurrent; using System.Net; using Grpc.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Client.Tests; /// /// Hosts the real mxaccess_gateway.v1.MxAccessGateway service on a loopback /// Kestrel endpoint so client tests exercise genuine HTTP/2 framing, protobuf /// serialization, call metadata, and gRPC status propagation. /// /// /// /// This is the counterpart of : that fake replaces /// IMxGatewayClientTransport, so nothing below the client wrapper runs. This one /// replaces only the gateway's behaviour — every byte between the client and /// the service is the real wire format. Contract breaks that a transport fake cannot /// see (a field the client never decodes, metadata it does not actually send, a status /// code it maps differently once it arrives as a real ) fail /// here. /// /// /// Plaintext h2c is used deliberately: TLS is covered by /// MxGatewayClientTlsHandlerTests, and h2c keeps the harness certificate-free so /// it runs identically on every CI host. See docs/GatewayTesting.md /// (Client Wire Tests) for the shared pattern and its Python counterpart. /// /// internal sealed class WireFakeGatewayServer : IAsyncDisposable { private readonly WebApplication _app; private WireFakeGatewayServer(WebApplication app, FakeGatewayService service, int port) { _app = app; Service = service; Endpoint = new Uri($"http://127.0.0.1:{port}"); } /// /// Gets the canned service backing the endpoint; tests read its recorded requests. /// public FakeGatewayService Service { get; } /// /// Gets the h2c endpoint to point at. /// public Uri Endpoint { get; } /// /// Starts a server on an ephemeral loopback port. /// /// Optional configuration of the canned service. /// The started server. public static async Task StartAsync(Action? configure = null) { FakeGatewayService service = new(); configure?.Invoke(service); WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.ConfigureKestrel(options => // Port 0 lets the OS pick; HTTP/2 without TLS (h2c) is what the client's // plain http:// endpoint negotiates via RequestVersionExact. options.Listen(IPAddress.Loopback, 0, listen => listen.Protocols = HttpProtocols.Http2)); builder.Services.AddGrpc(); builder.Services.AddSingleton(service); WebApplication app = builder.Build(); app.MapGrpcService(); await app.StartAsync().ConfigureAwait(false); return new WireFakeGatewayServer(app, service, ResolvePort(app)); } /// /// Creates a client bound to this server's endpoint. /// /// API key the client should present. /// A client that talks to this server over h2c. public MxGatewayClient CreateClient(string apiKey) => MxGatewayClient.Create(new MxGatewayClientOptions { Endpoint = Endpoint, ApiKey = apiKey, UseTls = false, DefaultCallTimeout = TimeSpan.FromSeconds(30), }); /// public async ValueTask DisposeAsync() { await _app.StopAsync().ConfigureAwait(false); await _app.DisposeAsync().ConfigureAwait(false); } private static int ResolvePort(WebApplication app) { IServerAddressesFeature? addresses = app.Services .GetRequiredService() .Features .Get(); string address = addresses?.Addresses.FirstOrDefault() ?? throw new InvalidOperationException("Kestrel did not report a bound address."); return new Uri(address).Port; } /// /// Canned gateway answering the four session RPCs with gateway-shaped replies. /// internal sealed class FakeGatewayService : MxAccessGateway.MxAccessGatewayBase { /// The session id every reply carries. public const string SessionId = "wire-session-1"; /// The server handle the canned Register reply returns. public const int ServerHandle = 4242; /// The item handle the canned data-change event carries. public const int ItemHandle = 77; /// /// Gets the authorization header value observed per RPC name. /// public ConcurrentDictionary AuthorizationByMethod { get; } = new(); /// /// Gets or sets a value indicating whether Invoke fails with /// instead of replying. /// public bool DenyInvoke { get; set; } /// /// Gets or sets the replay-gap sentinel emitted at the head of the event stream. /// public ReplayGap? ReplayGap { get; set; } /// /// Gets the last Invoke request the client sent, as decoded from the wire. /// public MxCommandRequest? InvokeRequest { get; private set; } /// /// Gets the last StreamEvents request the client sent. /// public StreamEventsRequest? StreamEventsRequest { get; private set; } /// /// Gets the last CloseSession request the client sent. /// public CloseSessionRequest? CloseSessionRequest { get; private set; } /// public override Task OpenSession( OpenSessionRequest request, ServerCallContext context) { Record(context); return Task.FromResult(new OpenSessionReply { SessionId = SessionId, BackendName = "fake-backend", WorkerProcessId = 1234, WorkerProtocolVersion = 1, GatewayProtocolVersion = 3, Capabilities = { "events", "invoke" }, ProtocolStatus = Ok(), }); } /// public override Task Invoke(MxCommandRequest request, ServerCallContext context) { Record(context); InvokeRequest = request; if (DenyInvoke) { throw new RpcException(new Status(StatusCode.PermissionDenied, "invoke scope required")); } return Task.FromResult(new MxCommandReply { SessionId = request.SessionId, CorrelationId = request.ClientCorrelationId, Kind = request.Command.Kind, ProtocolStatus = Ok(), Hresult = 0, Register = new RegisterReply { ServerHandle = ServerHandle }, }); } /// public override async Task StreamEvents( StreamEventsRequest request, IServerStreamWriter responseStream, ServerCallContext context) { Record(context); StreamEventsRequest = request; if (ReplayGap is not null) { // The sentinel shape the gateway emits: family unspecified, body unset, // only replay_gap populated. await responseStream.WriteAsync(new MxEvent { SessionId = request.SessionId, ReplayGap = ReplayGap, }).ConfigureAwait(false); } await responseStream.WriteAsync(new MxEvent { SessionId = request.SessionId, Family = MxEventFamily.OnDataChange, ServerHandle = ServerHandle, ItemHandle = ItemHandle, Value = new MxValue { Int32Value = 17 }, Quality = 192, WorkerSequence = 9, OnDataChange = new OnDataChangeEvent(), }).ConfigureAwait(false); } /// public override Task CloseSession( CloseSessionRequest request, ServerCallContext context) { Record(context); CloseSessionRequest = request; return Task.FromResult(new CloseSessionReply { SessionId = request.SessionId, FinalState = SessionState.Closed, ProtocolStatus = Ok(), }); } private static ProtocolStatus Ok() => new() { Code = ProtocolStatusCode.Ok }; private void Record(ServerCallContext context) { string? authorization = context.RequestHeaders.GetValue("authorization"); if (authorization is not null) { // context.Method is the fully-qualified "/package.Service/Method"; // key on the bare method name so assertions stay readable. AuthorizationByMethod[context.Method[(context.Method.LastIndexOf('/') + 1)..]] = authorization; } } } }