From 82ccf4f06e749d2044f3adb88e870b431341274f Mon Sep 17 00:00:00 2001 From: "Chris Ross (ASP.NET)" Date: Fri, 27 Oct 2017 12:05:52 -0700 Subject: [PATCH] #816 Allow directly constructing an HttpContext for TestServer --- .../ClientHandler.cs | 200 +++--------- .../HttpContextBuilder.cs | 124 +++++++ .../TestServer.cs | 32 ++ .../WebSocketClient.cs | 168 +++------- .../ClientHandlerTests.cs | 30 +- .../HttpContextBuilderTests.cs | 302 ++++++++++++++++++ .../RequestBuilderTests.cs | 5 +- .../TestClientTests.cs | 12 +- 8 files changed, 562 insertions(+), 311 deletions(-) create mode 100644 src/Microsoft.AspNetCore.TestHost/HttpContextBuilder.cs create mode 100644 test/Microsoft.AspNetCore.TestHost.Tests/HttpContextBuilderTests.cs diff --git a/src/Microsoft.AspNetCore.TestHost/ClientHandler.cs b/src/Microsoft.AspNetCore.TestHost/ClientHandler.cs index f74b774785..2109809d1a 100644 --- a/src/Microsoft.AspNetCore.TestHost/ClientHandler.cs +++ b/src/Microsoft.AspNetCore.TestHost/ClientHandler.cs @@ -33,12 +33,7 @@ namespace Microsoft.AspNetCore.TestHost /// The . public ClientHandler(PathString pathBase, IHttpApplication application) { - if (application == null) - { - throw new ArgumentNullException(nameof(application)); - } - - _application = application; + _application = application ?? throw new ArgumentNullException(nameof(application)); // PathString.StartsWithSegments that we use below requires the base path to not end in a slash. if (pathBase.HasValue && pathBase.Value.EndsWith("/")) @@ -64,187 +59,74 @@ namespace Microsoft.AspNetCore.TestHost throw new ArgumentNullException(nameof(request)); } - var state = new RequestState(request, _pathBase, _application); + var contextBuilder = new HttpContextBuilder(_application); + + Stream responseBody = null; var requestContent = request.Content ?? new StreamContent(Stream.Null); var body = await requestContent.ReadAsStreamAsync(); - if (body.CanSeek) + contextBuilder.Configure(context => { - // This body may have been consumed before, rewind it. - body.Seek(0, SeekOrigin.Begin); - } - state.Context.HttpContext.Request.Body = body; - var registration = cancellationToken.Register(state.AbortRequest); + var req = context.Request; - // Async offload, don't let the test code block the caller. - var offload = Task.Factory.StartNew(async () => - { - try - { - await _application.ProcessRequestAsync(state.Context); - await state.CompleteResponseAsync(); - state.ServerCleanup(exception: null); - } - catch (Exception ex) - { - state.Abort(ex); - state.ServerCleanup(ex); - } - finally - { - registration.Dispose(); - } - }); - - return await state.ResponseTask.ConfigureAwait(false); - } - - private class RequestState - { - private readonly HttpRequestMessage _request; - private readonly IHttpApplication _application; - private TaskCompletionSource _responseTcs; - private ResponseStream _responseStream; - private ResponseFeature _responseFeature; - private CancellationTokenSource _requestAbortedSource; - private bool _pipelineFinished; - - internal RequestState(HttpRequestMessage request, PathString pathBase, IHttpApplication application) - { - _request = request; - _application = application; - _responseTcs = new TaskCompletionSource(); - _requestAbortedSource = new CancellationTokenSource(); - _pipelineFinished = false; + req.Protocol = "HTTP/" + request.Version.ToString(fieldCount: 2); + req.Method = request.Method.ToString(); + req.Scheme = request.RequestUri.Scheme; + req.Host = HostString.FromUriComponent(request.RequestUri); if (request.RequestUri.IsDefaultPort) { - request.Headers.Host = request.RequestUri.Host; + req.Host = new HostString(req.Host.Host); } - else + + req.Path = PathString.FromUriComponent(request.RequestUri); + req.PathBase = PathString.Empty; + if (req.Path.StartsWithSegments(_pathBase, out var remainder)) { - request.Headers.Host = request.RequestUri.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + req.Path = remainder; + req.PathBase = _pathBase; } - - var contextFeatures = new FeatureCollection(); - var requestFeature = new RequestFeature(); - contextFeatures.Set(requestFeature); - _responseFeature = new ResponseFeature(); - contextFeatures.Set(_responseFeature); - var requestLifetimeFeature = new HttpRequestLifetimeFeature(); - contextFeatures.Set(requestLifetimeFeature); - - requestFeature.Protocol = "HTTP/" + request.Version.ToString(fieldCount: 2); - requestFeature.Scheme = request.RequestUri.Scheme; - requestFeature.Method = request.Method.ToString(); - - var fullPath = PathString.FromUriComponent(request.RequestUri); - PathString remainder; - if (fullPath.StartsWithSegments(pathBase, out remainder)) - { - requestFeature.PathBase = pathBase.Value; - requestFeature.Path = remainder.Value; - } - else - { - requestFeature.PathBase = string.Empty; - requestFeature.Path = fullPath.Value; - } - - requestFeature.QueryString = QueryString.FromUriComponent(request.RequestUri).Value; + req.QueryString = QueryString.FromUriComponent(request.RequestUri); foreach (var header in request.Headers) { - requestFeature.Headers.Append(header.Key, header.Value.ToArray()); + req.Headers.Append(header.Key, header.Value.ToArray()); } - var requestContent = request.Content; if (requestContent != null) { - foreach (var header in request.Content.Headers) + foreach (var header in requestContent.Headers) { - requestFeature.Headers.Append(header.Key, header.Value.ToArray()); + req.Headers.Append(header.Key, header.Value.ToArray()); } } - _responseStream = new ResponseStream(ReturnResponseMessageAsync, AbortRequest); - _responseFeature.Body = _responseStream; - _responseFeature.StatusCode = 200; - requestLifetimeFeature.RequestAborted = _requestAbortedSource.Token; - - Context = application.CreateContext(contextFeatures); - } - - public Context Context { get; private set; } - - public Task ResponseTask - { - get { return _responseTcs.Task; } - } - - internal void AbortRequest() - { - if (!_pipelineFinished) + if (body.CanSeek) { - _requestAbortedSource.Cancel(); + // This body may have been consumed before, rewind it. + body.Seek(0, SeekOrigin.Begin); } - _responseStream.Complete(); - } + req.Body = body; - internal async Task CompleteResponseAsync() - { - _pipelineFinished = true; - await ReturnResponseMessageAsync(); - _responseStream.Complete(); - await _responseFeature.FireOnResponseCompletedAsync(); - } + responseBody = context.Response.Body; + }); - internal async Task ReturnResponseMessageAsync() + var httpContext = await contextBuilder.SendAsync(cancellationToken); + + var response = new HttpResponseMessage(); + response.StatusCode = (HttpStatusCode)httpContext.Response.StatusCode; + response.ReasonPhrase = httpContext.Features.Get().ReasonPhrase; + response.RequestMessage = request; + + response.Content = new StreamContent(responseBody); + + foreach (var header in httpContext.Response.Headers) { - // Check if the response has already started because the TrySetResult below could happen a bit late - // (as it happens on a different thread) by which point the CompleteResponseAsync could run and calls this - // method again. - if (!Context.HttpContext.Response.HasStarted) + if (!response.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value)) { - var response = await GenerateResponseAsync(); - // Dispatch, as TrySetResult will synchronously execute the waiters callback and block our Write. - var setResult = Task.Factory.StartNew(() => _responseTcs.TrySetResult(response)); + bool success = response.Content.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value); + Contract.Assert(success, "Bad header"); } } - - private async Task GenerateResponseAsync() - { - await _responseFeature.FireOnSendingHeadersAsync(); - var httpContext = Context.HttpContext; - - var response = new HttpResponseMessage(); - response.StatusCode = (HttpStatusCode)httpContext.Response.StatusCode; - response.ReasonPhrase = httpContext.Features.Get().ReasonPhrase; - response.RequestMessage = _request; - // response.Version = owinResponse.Protocol; - - response.Content = new StreamContent(_responseStream); - - foreach (var header in httpContext.Response.Headers) - { - if (!response.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value)) - { - bool success = response.Content.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value); - Contract.Assert(success, "Bad header"); - } - } - return response; - } - - internal void Abort(Exception exception) - { - _pipelineFinished = true; - _responseStream.Abort(exception); - _responseTcs.TrySetException(exception); - } - - internal void ServerCleanup(Exception exception) - { - _application.DisposeContext(Context, exception); - } + return response; } } } diff --git a/src/Microsoft.AspNetCore.TestHost/HttpContextBuilder.cs b/src/Microsoft.AspNetCore.TestHost/HttpContextBuilder.cs new file mode 100644 index 0000000000..8c69ddd0e9 --- /dev/null +++ b/src/Microsoft.AspNetCore.TestHost/HttpContextBuilder.cs @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using static Microsoft.AspNetCore.Hosting.Internal.HostingApplication; + +namespace Microsoft.AspNetCore.TestHost +{ + internal class HttpContextBuilder + { + private readonly IHttpApplication _application; + private readonly HttpContext _httpContext; + + private TaskCompletionSource _responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private ResponseStream _responseStream; + private ResponseFeature _responseFeature = new ResponseFeature(); + private CancellationTokenSource _requestAbortedSource = new CancellationTokenSource(); + private bool _pipelineFinished; + private Context _testContext; + + internal HttpContextBuilder(IHttpApplication application) + { + _application = application ?? throw new ArgumentNullException(nameof(application)); + _httpContext = new DefaultHttpContext(); + + var request = _httpContext.Request; + request.Protocol = "HTTP/1.1"; + request.Method = HttpMethods.Get; + + _httpContext.Features.Set(_responseFeature); + var requestLifetimeFeature = new HttpRequestLifetimeFeature(); + requestLifetimeFeature.RequestAborted = _requestAbortedSource.Token; + _httpContext.Features.Set(requestLifetimeFeature); + + _responseStream = new ResponseStream(ReturnResponseMessageAsync, AbortRequest); + _responseFeature.Body = _responseStream; + } + + internal void Configure(Action configureContext) + { + if (configureContext == null) + { + throw new ArgumentNullException(nameof(configureContext)); + } + + configureContext(_httpContext); + } + + /// + /// Start processing the request. + /// + /// + internal Task SendAsync(CancellationToken cancellationToken) + { + var registration = cancellationToken.Register(AbortRequest); + + _testContext = _application.CreateContext(_httpContext.Features); + + // Async offload, don't let the test code block the caller. + _ = Task.Factory.StartNew(async () => + { + try + { + await _application.ProcessRequestAsync(_testContext); + await CompleteResponseAsync(); + _application.DisposeContext(_testContext, exception: null); + } + catch (Exception ex) + { + Abort(ex); + _application.DisposeContext(_testContext, ex); + } + finally + { + registration.Dispose(); + } + }); + + return _responseTcs.Task; + } + + internal void AbortRequest() + { + if (!_pipelineFinished) + { + _requestAbortedSource.Cancel(); + } + _responseStream.Complete(); + } + + internal async Task CompleteResponseAsync() + { + _pipelineFinished = true; + await ReturnResponseMessageAsync(); + _responseStream.Complete(); + await _responseFeature.FireOnResponseCompletedAsync(); + } + + internal async Task ReturnResponseMessageAsync() + { + // Check if the response has already started because the TrySetResult below could happen a bit late + // (as it happens on a different thread) by which point the CompleteResponseAsync could run and calls this + // method again. + if (!_responseFeature.HasStarted) + { + // Sets HasStarted + await _responseFeature.FireOnSendingHeadersAsync(); + _responseTcs.TrySetResult(_httpContext); + } + } + + internal void Abort(Exception exception) + { + _pipelineFinished = true; + _responseStream.Abort(exception); + _responseTcs.TrySetException(exception); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.TestHost/TestServer.cs b/src/Microsoft.AspNetCore.TestHost/TestServer.cs index af65de3556..398a575d9d 100644 --- a/src/Microsoft.AspNetCore.TestHost/TestServer.cs +++ b/src/Microsoft.AspNetCore.TestHost/TestServer.cs @@ -82,6 +82,38 @@ namespace Microsoft.AspNetCore.TestHost return new RequestBuilder(this, path); } + /// + /// Creates, configures, sends, and returns a . This completes as soon as the response is started. + /// + /// + public async Task SendAsync(Action configureContext, CancellationToken cancellationToken = default) + { + if (configureContext == null) + { + throw new ArgumentNullException(nameof(configureContext)); + } + + var builder = new HttpContextBuilder(_application); + builder.Configure(context => + { + var request = context.Request; + request.Scheme = BaseAddress.Scheme; + request.Host = HostString.FromUriComponent(BaseAddress); + if (BaseAddress.IsDefaultPort) + { + request.Host = new HostString(request.Host.Host); + } + var pathBase = PathString.FromUriComponent(BaseAddress); + if (pathBase.HasValue && pathBase.Value.EndsWith("/")) + { + pathBase = new PathString(pathBase.Value.Substring(0, pathBase.Value.Length - 1)); + } + request.PathBase = pathBase; + }); + builder.Configure(configureContext); + return await builder.SendAsync(cancellationToken).ConfigureAwait(false); + } + public void Dispose() { if (!_disposed) diff --git a/src/Microsoft.AspNetCore.TestHost/WebSocketClient.cs b/src/Microsoft.AspNetCore.TestHost/WebSocketClient.cs index 3d267f3a1b..e3deb670a5 100644 --- a/src/Microsoft.AspNetCore.TestHost/WebSocketClient.cs +++ b/src/Microsoft.AspNetCore.TestHost/WebSocketClient.cs @@ -22,12 +22,7 @@ namespace Microsoft.AspNetCore.TestHost internal WebSocketClient(PathString pathBase, IHttpApplication application) { - if (application == null) - { - throw new ArgumentNullException(nameof(application)); - } - - _application = application; + _application = application ?? throw new ArgumentNullException(nameof(application)); // PathString.StartsWithSegments that we use below requires the base path to not end in a slash. if (pathBase.HasValue && pathBase.Value.EndsWith("/")) @@ -53,79 +48,21 @@ namespace Microsoft.AspNetCore.TestHost public async Task ConnectAsync(Uri uri, CancellationToken cancellationToken) { - var state = new RequestState(uri, _pathBase, cancellationToken, _application); - - if (ConfigureRequest != null) + WebSocketFeature webSocketFeature = null; + var contextBuilder = new HttpContextBuilder(_application); + contextBuilder.Configure(context => { - ConfigureRequest(state.Context.HttpContext.Request); - } - - // Async offload, don't let the test code block the caller. - var offload = Task.Factory.StartNew(async () => - { - try - { - await _application.ProcessRequestAsync(state.Context); - state.PipelineComplete(); - state.ServerCleanup(exception: null); - } - catch (Exception ex) - { - state.PipelineFailed(ex); - state.ServerCleanup(ex); - } - finally - { - state.Dispose(); - } - }); - - return await state.WebSocketTask; - } - - private class RequestState : IDisposable, IHttpWebSocketFeature - { - private readonly IHttpApplication _application; - private TaskCompletionSource _clientWebSocketTcs; - private CancellationTokenRegistration _cancellationTokenRegistration; - private WebSocket _serverWebSocket; - - public Context Context { get; private set; } - public Task WebSocketTask { get { return _clientWebSocketTcs.Task; } } - - public RequestState(Uri uri, PathString pathBase, CancellationToken cancellationToken, IHttpApplication application) - { - _clientWebSocketTcs = new TaskCompletionSource(); - _cancellationTokenRegistration = cancellationToken.Register( - () => _clientWebSocketTcs.TrySetCanceled(cancellationToken)); - _application = application; - - // HttpContext - var contextFeatures = new FeatureCollection(); - contextFeatures.Set(new RequestFeature()); - contextFeatures.Set(new ResponseFeature()); - Context = _application.CreateContext(contextFeatures); - var httpContext = Context.HttpContext; - - // Request - var request = httpContext.Request; - request.Protocol = "HTTP/1.1"; + var request = context.Request; var scheme = uri.Scheme; scheme = (scheme == "ws") ? "http" : scheme; scheme = (scheme == "wss") ? "https" : scheme; request.Scheme = scheme; - request.Method = "GET"; - var fullPath = PathString.FromUriComponent(uri); - PathString remainder; - if (fullPath.StartsWithSegments(pathBase, out remainder)) + request.Path = PathString.FromUriComponent(uri); + request.PathBase = PathString.Empty; + if (request.Path.StartsWithSegments(_pathBase, out var remainder)) { - request.PathBase = pathBase; request.Path = remainder; - } - else - { - request.PathBase = PathString.Empty; - request.Path = fullPath; + request.PathBase = _pathBase; } request.QueryString = QueryString.FromUriComponent(uri); request.Headers.Add("Connection", new string[] { "Upgrade" }); @@ -134,70 +71,63 @@ namespace Microsoft.AspNetCore.TestHost request.Headers.Add("Sec-WebSocket-Key", new string[] { CreateRequestKey() }); request.Body = Stream.Null; - // Response - var response = httpContext.Response; - response.Body = Stream.Null; - response.StatusCode = 200; - // WebSocket - httpContext.Features.Set(this); - } + webSocketFeature = new WebSocketFeature(context); + context.Features.Set(webSocketFeature); - public void PipelineComplete() + ConfigureRequest?.Invoke(context.Request); + }); + + var httpContext = await contextBuilder.SendAsync(cancellationToken); + + if (httpContext.Response.StatusCode != StatusCodes.Status101SwitchingProtocols) { - PipelineFailed(new InvalidOperationException("Incomplete handshake, status code: " + Context.HttpContext.Response.StatusCode)); + throw new InvalidOperationException("Incomplete handshake, status code: " + httpContext.Response.StatusCode); } - - public void PipelineFailed(Exception ex) + if (webSocketFeature.ClientWebSocket == null) { - _clientWebSocketTcs.TrySetException(new InvalidOperationException("The websocket was not accepted.", ex)); + throw new InvalidOperationException("Incomplete handshake"); } - public void Dispose() + return webSocketFeature.ClientWebSocket; + } + + private string CreateRequestKey() + { + byte[] data = new byte[16]; + var rng = RandomNumberGenerator.Create(); + rng.GetBytes(data); + return Convert.ToBase64String(data); + } + + private class WebSocketFeature : IHttpWebSocketFeature + { + private readonly HttpContext _httpContext; + + public WebSocketFeature(HttpContext context) { - if (_serverWebSocket != null) - { - _serverWebSocket.Dispose(); - } + _httpContext = context; } - internal void ServerCleanup(Exception exception) - { - _application.DisposeContext(Context, exception); - } + bool IHttpWebSocketFeature.IsWebSocketRequest => true; - private string CreateRequestKey() - { - byte[] data = new byte[16]; - var rng = RandomNumberGenerator.Create(); - rng.GetBytes(data); - return Convert.ToBase64String(data); - } + public WebSocket ClientWebSocket { get; private set; } - bool IHttpWebSocketFeature.IsWebSocketRequest - { - get - { - return true; - } - } + public WebSocket ServerWebSocket { get; private set; } - Task IHttpWebSocketFeature.AcceptAsync(WebSocketAcceptContext context) + async Task IHttpWebSocketFeature.AcceptAsync(WebSocketAcceptContext context) { var websockets = TestWebSocket.CreatePair(context.SubProtocol); - if (_clientWebSocketTcs.TrySetResult(websockets.Item1)) + if (_httpContext.Response.HasStarted) { - Context.HttpContext.Response.StatusCode = StatusCodes.Status101SwitchingProtocols; - _serverWebSocket = websockets.Item2; - return Task.FromResult(_serverWebSocket); - } - else - { - Context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; - websockets.Item1.Dispose(); - websockets.Item2.Dispose(); - return _clientWebSocketTcs.Task; // Canceled or Faulted - no result + throw new InvalidOperationException("The response has already started"); } + + _httpContext.Response.StatusCode = StatusCodes.Status101SwitchingProtocols; + ClientWebSocket = websockets.Item1; + ServerWebSocket = websockets.Item2; + await _httpContext.Response.Body.FlushAsync(_httpContext.RequestAborted); // Send headers to the client + return ServerWebSocket; } } } diff --git a/test/Microsoft.AspNetCore.TestHost.Tests/ClientHandlerTests.cs b/test/Microsoft.AspNetCore.TestHost.Tests/ClientHandlerTests.cs index 6f57a5fdb7..21da6919b5 100644 --- a/test/Microsoft.AspNetCore.TestHost.Tests/ClientHandlerTests.cs +++ b/test/Microsoft.AspNetCore.TestHost.Tests/ClientHandlerTests.cs @@ -87,8 +87,7 @@ namespace Microsoft.AspNetCore.TestHost return httpClient.GetAsync("https://example.com/"); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task ResubmitRequestWorks() { int requestCount = 1; @@ -112,8 +111,7 @@ namespace Microsoft.AspNetCore.TestHost Assert.Equal("TestValue:2", response.Headers.GetValues("TestHeader").First()); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task MiddlewareOnlySetsHeaders() { var handler = new ClientHandler(PathString.Empty, new DummyApplication(context => @@ -126,8 +124,7 @@ namespace Microsoft.AspNetCore.TestHost Assert.Equal("TestValue", response.Headers.GetValues("TestHeader").First()); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task BlockingMiddlewareShouldNotBlockClient() { ManualResetEvent block = new ManualResetEvent(false); @@ -144,8 +141,7 @@ namespace Microsoft.AspNetCore.TestHost HttpResponseMessage response = await task; } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task HeadersAvailableBeforeBodyFinished() { ManualResetEvent block = new ManualResetEvent(false); @@ -164,8 +160,7 @@ namespace Microsoft.AspNetCore.TestHost Assert.Equal("BodyStarted,BodyFinished", await response.Content.ReadAsStringAsync()); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task FlushSendsHeaders() { ManualResetEvent block = new ManualResetEvent(false); @@ -184,8 +179,7 @@ namespace Microsoft.AspNetCore.TestHost Assert.Equal("BodyFinished", await response.Content.ReadAsStringAsync()); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task ClientDisposalCloses() { ManualResetEvent block = new ManualResetEvent(false); @@ -210,8 +204,7 @@ namespace Microsoft.AspNetCore.TestHost block.Set(); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task ClientCancellationAborts() { ManualResetEvent block = new ManualResetEvent(false); @@ -249,8 +242,7 @@ namespace Microsoft.AspNetCore.TestHost HttpCompletionOption.ResponseHeadersRead)); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + [Fact] public async Task ExceptionAfterFirstWriteIsReported() { ManualResetEvent block = new ManualResetEvent(false); @@ -326,10 +318,8 @@ namespace Microsoft.AspNetCore.TestHost return Task.FromResult(0); } } - - - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Hangs randomly (issue #507)")] + + [Fact] public async Task ClientHandlerCreateContextWithDefaultRequestParameters() { // This logger will attempt to access information from HttpRequest once the HttpContext is created diff --git a/test/Microsoft.AspNetCore.TestHost.Tests/HttpContextBuilderTests.cs b/test/Microsoft.AspNetCore.TestHost.Tests/HttpContextBuilderTests.cs new file mode 100644 index 0000000000..e7744ef449 --- /dev/null +++ b/test/Microsoft.AspNetCore.TestHost.Tests/HttpContextBuilderTests.cs @@ -0,0 +1,302 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.AspNetCore.TestHost +{ + public class HttpContextBuilderTests + { + [Fact] + public async Task ExpectedValuesAreAvailable() + { + var builder = new WebHostBuilder().Configure(app => { }); + var server = new TestServer(builder); + server.BaseAddress = new Uri("https://example.com/A/Path/"); + var context = await server.SendAsync(c => + { + c.Request.Method = HttpMethods.Post; + c.Request.Path = "/and/file.txt"; + c.Request.QueryString = new QueryString("?and=query"); + }); + + Assert.True(context.RequestAborted.CanBeCanceled); + Assert.Equal("HTTP/1.1", context.Request.Protocol); + Assert.Equal("POST", context.Request.Method); + Assert.Equal("https", context.Request.Scheme); + Assert.Equal("example.com", context.Request.Host.Value); + Assert.Equal("/A/Path", context.Request.PathBase.Value); + Assert.Equal("/and/file.txt", context.Request.Path.Value); + Assert.Equal("?and=query", context.Request.QueryString.Value); + Assert.NotNull(context.Request.Body); + Assert.NotNull(context.Request.Headers); + Assert.NotNull(context.Response.Headers); + Assert.NotNull(context.Response.Body); + Assert.Equal(404, context.Response.StatusCode); + Assert.Null(context.Features.Get().ReasonPhrase); + } + + [Fact] + public async Task SingleSlashNotMovedToPathBase() + { + var builder = new WebHostBuilder().Configure(app => { }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => + { + c.Request.Path = "/"; + }); + + Assert.Equal("", context.Request.PathBase.Value); + Assert.Equal("/", context.Request.Path.Value); + } + + [Fact] + public async Task MiddlewareOnlySetsHeaders() + { + var builder = new WebHostBuilder().Configure(app => + { + app.Run(c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + } + + [Fact] + public async Task BlockingMiddlewareShouldNotBlockClient() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(c => + { + block.WaitOne(); + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + var task = server.SendAsync(c => { }); + + Assert.False(task.IsCompleted); + Assert.False(task.Wait(50)); + block.Set(); + var context = await task; + } + + [Fact] + public async Task HeadersAvailableBeforeBodyFinished() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(async c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + await c.Response.WriteAsync("BodyStarted,"); + block.WaitOne(); + await c.Response.WriteAsync("BodyFinished"); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + block.Set(); + Assert.Equal("BodyStarted,BodyFinished", new StreamReader(context.Response.Body).ReadToEnd()); + } + + [Fact] + public async Task FlushSendsHeaders() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(async c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + c.Response.Body.Flush(); + block.WaitOne(); + await c.Response.WriteAsync("BodyFinished"); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + block.Set(); + Assert.Equal("BodyFinished", new StreamReader(context.Response.Body).ReadToEnd()); + } + + [Fact] + public async Task ClientDisposalCloses() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(async c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + c.Response.Body.Flush(); + block.WaitOne(); + await c.Response.WriteAsync("BodyFinished"); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + var responseStream = context.Response.Body; + Task readTask = responseStream.ReadAsync(new byte[100], 0, 100); + Assert.False(readTask.IsCompleted); + responseStream.Dispose(); + Assert.True(readTask.Wait(TimeSpan.FromSeconds(10))); + Assert.Equal(0, readTask.Result); + block.Set(); + } + + [Fact] + public void ClientCancellationAborts() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(c => + { + block.Set(); + Assert.True(c.RequestAborted.WaitHandle.WaitOne(TimeSpan.FromSeconds(10))); + c.RequestAborted.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }); + }); + var server = new TestServer(builder); + var cts = new CancellationTokenSource(); + var contextTask = server.SendAsync(c => { }, cts.Token); + block.WaitOne(); + cts.Cancel(); + + var ex = Assert.Throws(() => contextTask.Wait(TimeSpan.FromSeconds(10))); + Assert.IsAssignableFrom(ex.GetBaseException()); + } + + [Fact] + public async Task ClientCancellationAbortsReadAsync() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(async c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + c.Response.Body.Flush(); + block.WaitOne(); + await c.Response.WriteAsync("BodyFinished"); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + var responseStream = context.Response.Body; + var cts = new CancellationTokenSource(); + var readTask = responseStream.ReadAsync(new byte[100], 0, 100, cts.Token); + Assert.False(readTask.IsCompleted); + cts.Cancel(); + var ex = Assert.Throws(() => readTask.Wait(TimeSpan.FromSeconds(10))); + Assert.IsAssignableFrom(ex.GetBaseException().InnerException); + block.Set(); + } + + [Fact] + public Task ExceptionBeforeFirstWriteIsReported() + { + var builder = new WebHostBuilder().Configure(app => + { + app.Run(c => + { + throw new InvalidOperationException("Test Exception"); + }); + }); + var server = new TestServer(builder); + return Assert.ThrowsAsync(() => server.SendAsync(c => { })); + } + + [Fact] + public async Task ExceptionAfterFirstWriteIsReported() + { + var block = new ManualResetEvent(false); + var builder = new WebHostBuilder().Configure(app => + { + app.Run(async c => + { + c.Response.Headers["TestHeader"] = "TestValue"; + await c.Response.WriteAsync("BodyStarted"); + block.WaitOne(); + throw new InvalidOperationException("Test Exception"); + }); + }); + var server = new TestServer(builder); + var context = await server.SendAsync(c => { }); + + Assert.Equal("TestValue", context.Response.Headers["TestHeader"]); + Assert.Equal(11, context.Response.Body.Read(new byte[100], 0, 100)); + block.Set(); + var ex = Assert.Throws(() => context.Response.Body.Read(new byte[100], 0, 100)); + Assert.IsAssignableFrom(ex.InnerException); + } + + [Fact] + public async Task ClientHandlerCreateContextWithDefaultRequestParameters() + { + // This logger will attempt to access information from HttpRequest once the HttpContext is created + var logger = new VerifierLogger(); + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton>(logger); + }) + .Configure(app => + { + app.Run(context => + { + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + // The HttpContext will be created and the logger will make sure that the HttpRequest exists and contains reasonable values + var ctx = await server.SendAsync(c => { }); + } + + private class VerifierLogger : ILogger + { + public IDisposable BeginScope(TState state) => new NoopDispoasble(); + + public bool IsEnabled(LogLevel logLevel) => true; + + // This call verifies that fields of HttpRequest are accessed and valid + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) => formatter(state, exception); + + class NoopDispoasble : IDisposable + { + public void Dispose() + { + } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.TestHost.Tests/RequestBuilderTests.cs b/test/Microsoft.AspNetCore.TestHost.Tests/RequestBuilderTests.cs index b8329ea26f..2a37015aae 100644 --- a/test/Microsoft.AspNetCore.TestHost.Tests/RequestBuilderTests.cs +++ b/test/Microsoft.AspNetCore.TestHost.Tests/RequestBuilderTests.cs @@ -2,16 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Testing.xunit; using Xunit; namespace Microsoft.AspNetCore.TestHost { public class RequestBuilderTests { - // c.f. https://github.com/mono/mono/pull/1832 - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + [Fact] public void AddRequestHeader() { var builder = new WebHostBuilder().Configure(app => { }); diff --git a/test/Microsoft.AspNetCore.TestHost.Tests/TestClientTests.cs b/test/Microsoft.AspNetCore.TestHost.Tests/TestClientTests.cs index 748e79c841..0bb8880823 100644 --- a/test/Microsoft.AspNetCore.TestHost.Tests/TestClientTests.cs +++ b/test/Microsoft.AspNetCore.TestHost.Tests/TestClientTests.cs @@ -267,14 +267,8 @@ namespace Microsoft.AspNetCore.TestHost } }; var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddSingleton>(logger); - }) - .Configure(app => - { - app.Run(appDelegate); - }); + .ConfigureServices(services => services.AddSingleton>(logger)) + .Configure(app => app.Run(appDelegate)); var server = new TestServer(builder); // Act @@ -283,7 +277,7 @@ namespace Microsoft.AspNetCore.TestHost tokenSource.Cancel(); // Assert - await Assert.ThrowsAnyAsync(async () => await client.ConnectAsync(new System.Uri("http://localhost"), tokenSource.Token)); + await Assert.ThrowsAnyAsync(async () => await client.ConnectAsync(new Uri("http://localhost"), tokenSource.Token)); } private class VerifierLogger : ILogger