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