Open analogrelay opened 5 years ago
Bear in mind that WebHostBuilder will be deprecated in future releases.
Yes, we're aware :). The focus is on better integration with TestServer, not necessarily using WebHostBuilder.
Please, these characteristics are very important to be able to test SignalR with TestServer in integration or unit tests.
👀
There is now an API on HttpConnectionOptions to allow replacing the websocket so an extension method on the HubConnectionBuilder can be done now.
@BrennanConroy which .Net version? .Net 5 or 6? Thanks.
6, we don't add or change APIs in already released versions.
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
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();
@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();
Awesome, thank you for the help, that worked 🙇
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();
Where is IntegrationTestConstants
defined?
I think that's something they defined in their app and unrelated to what is being shown in the sample code.
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?
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!
Is there anything more clearly defined for testing SignalR with WebApplicationFactory yet? Can we also get some testing documentation in the official docs?
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?
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>
.
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();
})
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();
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 aWebSocket
"factory" so that we can integrate properly with TestServer.It might be most appropriate here to add an alternative to
WithUrl
onHubConnectionBuilder
. Consider this possible test code: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.