dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.22k stars 9.95k forks source link

[SignalR] Better integration with TestServer #11888

Open analogrelay opened 5 years ago

analogrelay commented 5 years ago

Right now, it's only possible to use TestServer with the non-WebSockets transports because we only provide a way to replace the HttpMessageHandler. We should consider providing a way to provide a WebSocket "factory" so that we can integrate properly with TestServer.

It might be most appropriate here to add an alternative to WithUrl on HubConnectionBuilder. Consider this possible test code:

var server = new TestServer(webHostBuilder);
var connection = new HubConnectionBuilder()
    .WithTestServer(server, HttpTransportType.WebSockets, o => {
        // Assorted other HttpConnectionOptions
    })
    .Build();

It would be relatively simple to add this API once we have a way to swap out the WebSocket, though we'd probably need to do so in a new assembly to avoid layering issues.

khteh commented 4 years ago

Bear in mind that WebHostBuilder will be deprecated in future releases.

analogrelay commented 4 years ago

Yes, we're aware :). The focus is on better integration with TestServer, not necessarily using WebHostBuilder.

Suriman commented 4 years ago

Please, these characteristics are very important to be able to test SignalR with TestServer in integration or unit tests.

suadev commented 3 years ago

👀

BrennanConroy commented 3 years ago

There is now an API on HttpConnectionOptions to allow replacing the websocket so an extension method on the HubConnectionBuilder can be done now.

khteh commented 3 years ago

@BrennanConroy which .Net version? .Net 5 or 6? Thanks.

BrennanConroy commented 3 years ago

6, we don't add or change APIs in already released versions.

wegylexy commented 3 years ago

Not sure if it helps, but here is an example to use named pipe as the underlying transport for testing: https://github.com/wegylexy/SignalRTunnel/blob/0afb58b11f88afcae8962a797f1eace14ce05096/src/Test/Test.cs#L71

dayadimova commented 2 years ago

Hey @BrennanConroy. I have migrated my integration tests to .NET 6 and I've replaced the WebSocket in HttpConnectionOptions with one created from TestServer. Currently having some issues with authentication - the access token is not sent with the request that establishes a connection to the server. Can you advise on how to add it to either the headers or query string?

SignalR client code:

var connection = new HubConnectionBuilder()
                .WithUrl($"{_baseUrl}/hubs", options =>
                {
                    options.AccessTokenProvider = () => Task.FromResult(token);
                    options.SkipNegotiation = true;
                    options.Transports = HttpTransportType.WebSockets;
                    options.WebSocketFactory = (context, cancellationToken) =>
                    {
                        var webSocketClient = _server.CreateWebSocketClient();
                        var webSocket = webSocketClient.ConnectAsync(context.Uri, cancellationToken).GetAwaiter().GetResult();
                        return ValueTask.FromResult(webSocket);
                    };
                    options.Headers.Add(IntegrationTestConstants.CorrTokenHeaderKey, IntegrationTestConstants.CorrTokenHeaderValue);
                })
                .Build();
sergey-litvinov commented 2 years ago

@dayadimova in case of browser's websocket implementation access token can be passed only with uri. And as you are creating WebSocket client manually, you need to add it to your uri as well.

You can check default WebSocketFactory implementation here - https://github.com/dotnet/aspnetcore/blob/a0b950fc51c43289ab3c6dbea15926d32f3556cc/src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs#L48

The needed part is at the bottom

            if (_httpConnectionOptions.AccessTokenProvider != null)
            {
                var accessToken = await _httpConnectionOptions.AccessTokenProvider();
                if (!string.IsNullOrWhiteSpace(accessToken))
                {
                    // We can't use request headers in the browser, so instead append the token as a query string in that case
                    if (OperatingSystem.IsBrowser())
                    {
                        var accessTokenEncoded = UrlEncoder.Default.Encode(accessToken);
                        accessTokenEncoded = "access_token=" + accessTokenEncoded;
                        url = Utils.AppendQueryString(url, accessTokenEncoded);
                    }
                    else
                    {
#pragma warning disable CA1416 // Analyzer bug
                        webSocket.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
#pragma warning restore CA1416 // Analyzer bug
                    }
                }
            }

so you would need to update your code to something like

var connection = new HubConnectionBuilder()
                .WithUrl($"{_baseUrl}/hubs", options =>
                {
                    options.AccessTokenProvider = () => Task.FromResult(token);
                    options.SkipNegotiation = true;
                    options.Transports = HttpTransportType.WebSockets;
                    options.WebSocketFactory = (context, cancellationToken) =>
                    {
                        var webSocketClient = _server.CreateWebSocketClient();
+                       var url = $"{context.Uri}?access_token={token}";
+                       var webSocket = webSocketClient.ConnectAsync(url, cancellationToken).GetAwaiter().GetResult();
                        return ValueTask.FromResult(webSocket);
                    };
                    options.Headers.Add(IntegrationTestConstants.CorrTokenHeaderKey, IntegrationTestConstants.CorrTokenHeaderValue);
                })
                .Build();
dayadimova commented 2 years ago

Awesome, thank you for the help, that worked 🙇

wegylexy commented 2 years ago

You want to return the connection task instead of blocking a thread.

var connection = new HubConnectionBuilder()
    .WithUrl($"{_baseUrl}/hubs", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(token);
        options.SkipNegotiation = true;
        options.Transports = HttpTransportType.WebSockets;
        options.WebSocketFactory = (context, cancellationToken) =>
        {
            var webSocketClient = _server.CreateWebSocketClient();
            var url = $"{context.Uri}?access_token={token}";
-           var webSocket = webSocketClient.ConnectAsync(url, cancellationToken).GetAwaiter().GetResult();
+           var webSocketTask = webSocketClient.ConnectAsync(url, cancellationToken);
-           return ValueTask.FromResult(webSocket);
+           return new(webSocketTask);
        };
        options.Headers.Add(IntegrationTestConstants.CorrTokenHeaderKey, IntegrationTestConstants.CorrTokenHeaderValue);
    })
    .Build();
khteh commented 2 years ago

Where is IntegrationTestConstants defined?

BrennanConroy commented 2 years ago

I think that's something they defined in their app and unrelated to what is being shown in the sample code.

khteh commented 2 years ago

The following code snippet works:

            HubConnection connection = new HubConnectionBuilder()
                            .WithUrl("https://localhost/chatHub", o => {
                                o.Transports = HttpTransportType.WebSockets;
                                o.AccessTokenProvider = async () => await AccessTokenProvider();
                                o.SkipNegotiation = true;
                                o.HttpMessageHandlerFactory = _ => _testServer.CreateHandler();
                                o.WebSocketFactory = async (context, cancellationToken) =>
                                {
                                    var wsClient = _testServer.CreateWebSocketClient();
                                    var url = $"{context.Uri}?access_token={await AccessTokenProvider()}";
                                    return await wsClient.ConnectAsync(new Uri(url), cancellationToken);
                                };
                            }).Build();

How to use the AccessTokenProvider option? Is it redundant for this use case?

deathcat05 commented 1 year ago

I seem to be running into an issue with something similar to this. I am using .NET 6, and have created my TestServer and SignalR connection as follows in my integration test function:

await using var application = new WebApplicationFactory<Program>();
            using var client = application.CreateClient();

            string? accessToken = await GetAcessTokenAsync(client, agent.Id, agent.Secret);
            var connection = new HubConnectionBuilder()
                .WithUrl("https://localhost/hub", options =>
                {
                    options.Transports = HttpTransportType.WebSockets;
                    options.AccessTokenProvider = () => Task.FromResult(accessToken);
                    options.SkipNegotiation = true;
                    options.HttpMessageHandlerFactory = _ => application.Server.CreateHandler();
                    options.WebSocketFactory = async (context, cancellationToken) =>
                    {
                        var wsClient = application.Server.CreateWebSocketClient();
                        var url = $"{context.Uri}?access_token={accessToken}";
                        return await wsClient.ConnectAsync(new Uri(url), cancellationToken);
                    };
                })
                .Build();

            await connection.StartAsync();

However, my test fails with the following Error information:

Message:  System.InvalidOperationException : Incomplete handshake, status code: 401

Stack Trace: 

WebSocketClient.ConnectAsync(Uri uri, CancellationToken cancellationToken)
<<TestShouldReturnTrue>b__3>d.MoveNext() line 42
--- End of stack trace from previous location ---
WebSocketsTransport.StartAsync(Uri url, TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.StartTransport(Uri connectUrl, HttpTransportType transportType, TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.SelectAndStartTransport(TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.StartAsyncCore(TransferFormat transferFormat, CancellationToken cancellationToken)
ForceAsyncAwaiter.GetResult()
HttpConnection.StartAsync(TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnectionFactory.ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
HttpConnectionFactory.ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
HubConnection.StartAsyncCore(CancellationToken cancellationToken)
HubConnection.StartAsyncInner(CancellationToken cancellationToken)
ForceAsyncAwaiter.GetResult()
HubConnection.StartAsync(CancellationToken cancellationToken)
PingPongTests.TestShouldReturnTrue(AgentModel agent) line 47
PingPongTests.TestShouldReturnTrue(AgentModel agent) line 50
--- End of stack trace from previous location ---

My token is issued successfully. I noticed that if I remove the options.SkipNegotiations = true in my code, I get the following exception in my test function:

Message: 

System.AggregateException : Unable to connect to the server with any of the available transports. (WebSockets failed: Incomplete handshake, status code: 401)
---- Microsoft.AspNetCore.Http.Connections.Client.TransportFailedException : WebSockets failed: Incomplete handshake, status code: 401
-------- System.InvalidOperationException : Incomplete handshake, status code: 401

Stack Trace: 

HttpConnection.SelectAndStartTransport(TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.StartAsyncCore(TransferFormat transferFormat, CancellationToken cancellationToken)
ForceAsyncAwaiter.GetResult()
HttpConnection.StartAsync(TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnectionFactory.ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
HttpConnectionFactory.ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
HubConnection.StartAsyncCore(CancellationToken cancellationToken)
HubConnection.StartAsyncInner(CancellationToken cancellationToken)
ForceAsyncAwaiter.GetResult()
HubConnection.StartAsync(CancellationToken cancellationToken)
PingPongTests.TestShouldReturnTrue(AgentModel agent) line 47
PingPongTests.TestShouldReturnTrue(AgentModel agent) line 50
--- End of stack trace from previous location ---
----- Inner Stack Trace -----
----- Inner Stack Trace -----
WebSocketClient.ConnectAsync(Uri uri, CancellationToken cancellationToken)
<<TestShouldReturnTrue>b__3>d.MoveNext() line 42
--- End of stack trace from previous location ---
WebSocketsTransport.StartAsync(Uri url, TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.StartTransport(Uri connectUrl, HttpTransportType transportType, TransferFormat transferFormat, CancellationToken cancellationToken)
HttpConnection.SelectAndStartTransport(TransferFormat transferFormat, CancellationToken cancellationToken)

I have used public partial class Program in Program.cs for the WebApplicationFactory. Am I missing something important somewhere?

Thanks in advance!

EddyHaigh commented 1 year ago

Is there anything more clearly defined for testing SignalR with WebApplicationFactory yet? Can we also get some testing documentation in the official docs?

aleoyakas commented 1 year ago

We're using cookie authentication for our SignalR endpoints which is achieved via a REST endpoint. Currently, there doesn't seem to be a way to share a handler (and therefore the cookies) with TestServer.

Is there plans to include this functionality/is there a workaround that I'm unaware of?

BrennanConroy commented 1 year ago

It doesn't look like TestServer has first class support for cookies, which means using it with SignalR won't have cookies either.

There is enough extensibility to implement it manually though. If you wrap the HttpMessageHandler from TestServer.CreateHandler you can intercept the Set-Cookie header and add it to a CookieContainer instance that you share with HttpConnectionOptions in SignalR. You'd also need to add cookies from the CookieContainer to requests via the TestServer.CreateHandler overload that has an Action<HttpContext>.

HakamFostok commented 11 months ago

I am using .NET 7 and in my case, the following code was more than enough

string accessToken = // get it from someplace
builder.WithUrl("https://localhost/hub", o =>
{
    o.AccessTokenProvider = () => Task.FromResult<string?>(accessToken);
    o.HttpMessageHandlerFactory = _ => testServer.CreateHandler();
})
DomenPigeon commented 3 months ago

Is there are new progress on this issue?

Also just wanted to point out that the StartAsync method takes more than 4s to complete, which is really long to have all tests by default take 4s. I have tested it without the WebApplicationFactory on localhost and it took like 100ms.

var retryPolicy = new RetryPolicy();
var hubConnection = new HubConnectionBuilder()
    .WithUrl("http://localhost/hubPath", opt => opt.HttpMessageHandlerFactory = HttpMessageHandlerFactory)
    .Build();

await hubConnection.StartAsync();