diff --git a/src/Microsoft.AspNet.Server.WebListener/FeatureContext.cs b/src/Microsoft.AspNet.Server.WebListener/FeatureContext.cs index 10401145c6..0d57f9c9ab 100644 --- a/src/Microsoft.AspNet.Server.WebListener/FeatureContext.cs +++ b/src/Microsoft.AspNet.Server.WebListener/FeatureContext.cs @@ -27,6 +27,7 @@ using System.Threading.Tasks; using Microsoft.AspNet.FeatureModel; using Microsoft.AspNet.Http.Features; using Microsoft.AspNet.Http.Features.Authentication; +using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Server; using Microsoft.Net.WebSockets; @@ -46,8 +47,11 @@ namespace Microsoft.AspNet.Server.WebListener IHttpUpgradeFeature, IRequestIdentifierFeature { + private static Action OnStartDelegate = OnStart; + private RequestContext _requestContext; private FeatureCollection _features; + private bool _enableResponseCaching; private Stream _requestBody; private IDictionary _requestHeaders; @@ -69,11 +73,13 @@ namespace Microsoft.AspNet.Server.WebListener private Stream _responseStream; private IDictionary _responseHeaders; - internal FeatureContext(RequestContext requestContext) + internal FeatureContext(RequestContext requestContext, bool enableResponseCaching) { _requestContext = requestContext; _features = new FeatureCollection(); _authHandler = new AuthenticationHandler(requestContext); + _enableResponseCaching = enableResponseCaching; + requestContext.Response.OnSendingHeaders(OnStartDelegate, this); PopulateFeatures(); } @@ -471,5 +477,69 @@ namespace Microsoft.AspNet.Server.WebListener return _requestContext.TraceIdentifier; } } + + private static void OnStart(object obj) + { + var featureContext = (FeatureContext)obj; + + ConsiderEnablingResponseCache(featureContext); + } + + private static void ConsiderEnablingResponseCache(FeatureContext featureContext) + { + if (featureContext._enableResponseCaching) + { + // We don't have to worry too much about what Http.Sys supports, caching is a best-effort feature. + // If there's something about the request or response that prevents it from caching then the response + // will complete normally without caching. + featureContext._requestContext.Response.CacheTtl = GetCacheTtl(featureContext._requestContext); + } + } + + private static TimeSpan? GetCacheTtl(RequestContext requestContext) + { + var response = requestContext.Response; + // Only consider kernel-mode caching if the Cache-Control response header is present. + var cacheControlHeader = response.Headers[HeaderNames.CacheControl]; + if (string.IsNullOrEmpty(cacheControlHeader)) + { + return null; + } + + // Before we check the header value, check for the existence of other headers which would + // make us *not* want to cache the response. + if (response.Headers.ContainsKey(HeaderNames.SetCookie) + || response.Headers.ContainsKey(HeaderNames.Vary) + || response.Headers.ContainsKey(HeaderNames.Pragma)) + { + return null; + } + + // We require 'public' and 's-max-age' or 'max-age' or the Expires header. + CacheControlHeaderValue cacheControl; + if (CacheControlHeaderValue.TryParse(cacheControlHeader, out cacheControl) && cacheControl.Public) + { + if (cacheControl.SharedMaxAge.HasValue) + { + return cacheControl.SharedMaxAge; + } + else if (cacheControl.MaxAge.HasValue) + { + return cacheControl.MaxAge; + } + + DateTimeOffset expirationDate; + if (HeaderUtilities.TryParseDate(response.Headers[HeaderNames.Expires], out expirationDate)) + { + var expiresOffset = expirationDate - DateTimeOffset.UtcNow; + if (expiresOffset > TimeSpan.Zero) + { + return expiresOffset; + } + } + } + + return null; + } } } diff --git a/src/Microsoft.AspNet.Server.WebListener/MessagePump.cs b/src/Microsoft.AspNet.Server.WebListener/MessagePump.cs index 7bfdaead54..9400c3fe48 100644 --- a/src/Microsoft.AspNet.Server.WebListener/MessagePump.cs +++ b/src/Microsoft.AspNet.Server.WebListener/MessagePump.cs @@ -78,6 +78,8 @@ namespace Microsoft.AspNet.Server.WebListener } } + internal bool EnableResponseCaching { get; set; } = true; + internal void Start(AppFunc app) { // Can't call Start twice @@ -160,7 +162,7 @@ namespace Microsoft.AspNet.Server.WebListener try { Interlocked.Increment(ref _outstandingRequests); - FeatureContext featureContext = new FeatureContext(requestContext); + FeatureContext featureContext = new FeatureContext(requestContext, EnableResponseCaching); await _appFunc(featureContext.Features).SupressContext(); requestContext.Dispose(); } diff --git a/src/Microsoft.AspNet.Server.WebListener/ServerInformation.cs b/src/Microsoft.AspNet.Server.WebListener/ServerInformation.cs index 3ad2fbb2f4..9c50b466f8 100644 --- a/src/Microsoft.AspNet.Server.WebListener/ServerInformation.cs +++ b/src/Microsoft.AspNet.Server.WebListener/ServerInformation.cs @@ -50,5 +50,11 @@ namespace Microsoft.AspNet.Server.WebListener get { return _messagePump.MaxAccepts; } set { _messagePump.MaxAccepts = value; } } + + public bool EnableResponseCaching + { + get { return _messagePump.EnableResponseCaching; } + set { _messagePump.EnableResponseCaching = value; } + } } } diff --git a/src/Microsoft.AspNet.Server.WebListener/project.json b/src/Microsoft.AspNet.Server.WebListener/project.json index d035af3107..0e52987ecb 100644 --- a/src/Microsoft.AspNet.Server.WebListener/project.json +++ b/src/Microsoft.AspNet.Server.WebListener/project.json @@ -5,6 +5,7 @@ "Microsoft.AspNet.Http.Features": "1.0.0-*", "Microsoft.AspNet.Hosting.Server.Abstractions": "1.0.0-*", "Microsoft.Framework.Logging.Abstractions": "1.0.0-*", + "Microsoft.Net.Http.Headers": "1.0.0-*", "Microsoft.Net.Http.Server": "1.0.0-*" }, "compilationOptions": { diff --git a/src/Microsoft.Net.Http.Server/NativeInterop/UnsafeNativeMethods.cs b/src/Microsoft.Net.Http.Server/NativeInterop/UnsafeNativeMethods.cs index 7e48142209..2b2c05b6a3 100644 --- a/src/Microsoft.Net.Http.Server/NativeInterop/UnsafeNativeMethods.cs +++ b/src/Microsoft.Net.Http.Server/NativeInterop/UnsafeNativeMethods.cs @@ -212,7 +212,7 @@ namespace Microsoft.Net.Http.Server internal static extern uint HttpReceiveHttpRequest(SafeHandle requestQueueHandle, ulong requestId, uint flags, HTTP_REQUEST* pRequestBuffer, uint requestBufferLength, uint* pBytesReturned, SafeNativeOverlapped pOverlapped); [DllImport(HTTPAPI, ExactSpelling = true, CallingConvention = CallingConvention.StdCall, SetLastError = true)] - internal static extern uint HttpSendHttpResponse(SafeHandle requestQueueHandle, ulong requestId, uint flags, HTTP_RESPONSE_V2* pHttpResponse, void* pCachePolicy, uint* pBytesSent, SafeLocalFree pRequestBuffer, uint requestBufferLength, SafeNativeOverlapped pOverlapped, IntPtr pLogData); + internal static extern uint HttpSendHttpResponse(SafeHandle requestQueueHandle, ulong requestId, uint flags, HTTP_RESPONSE_V2* pHttpResponse, HTTP_CACHE_POLICY* pCachePolicy, uint* pBytesSent, SafeLocalFree pRequestBuffer, uint requestBufferLength, SafeNativeOverlapped pOverlapped, IntPtr pLogData); [DllImport(HTTPAPI, ExactSpelling = true, CallingConvention = CallingConvention.StdCall, SetLastError = true)] internal static extern uint HttpSendResponseEntityBody(SafeHandle requestQueueHandle, ulong requestId, uint flags, ushort entityChunkCount, HTTP_DATA_CHUNK* pEntityChunks, uint* pBytesSent, SafeLocalFree pRequestBuffer, uint requestBufferLength, SafeNativeOverlapped pOverlapped, IntPtr pLogData); @@ -369,6 +369,21 @@ namespace Microsoft.Net.Http.Server internal ushort* pQueryString; } + // Only cache unauthorized GETs + HEADs. + [StructLayout(LayoutKind.Sequential)] + internal struct HTTP_CACHE_POLICY + { + internal HTTP_CACHE_POLICY_TYPE Policy; + internal uint SecondsToLive; + } + + internal enum HTTP_CACHE_POLICY_TYPE : int + { + HttpCachePolicyNocache = 0, + HttpCachePolicyUserInvalidates = 1, + HttpCachePolicyTimeToLive = 2, + } + [StructLayout(LayoutKind.Sequential)] internal struct SOCKADDR { diff --git a/src/Microsoft.Net.Http.Server/RequestProcessing/Response.cs b/src/Microsoft.Net.Http.Server/RequestProcessing/Response.cs index e33a031e5f..18da46bcbe 100644 --- a/src/Microsoft.Net.Http.Server/RequestProcessing/Response.cs +++ b/src/Microsoft.Net.Http.Server/RequestProcessing/Response.cs @@ -85,6 +85,7 @@ namespace Microsoft.Net.Http.Server _bufferingEnabled = _requestContext.Server.BufferResponses; _expectedBodyLength = 0; _nativeStream = null; + CacheTtl = null; } private enum ResponseState @@ -306,6 +307,8 @@ namespace Microsoft.Net.Http.Server get { return _responseState >= ResponseState.StartedSending; } } + public TimeSpan? CacheTtl { get; set; } + private void EnsureResponseStream() { if (_nativeStream == null) @@ -391,6 +394,14 @@ namespace Microsoft.Net.Http.Server _nativeResponse.Response_V1.pEntityChunks = null; } + var cachePolicy = new HttpApi.HTTP_CACHE_POLICY(); + var cacheTtl = CacheTtl; + if (cacheTtl.HasValue && cacheTtl.Value > TimeSpan.Zero) + { + cachePolicy.Policy = HttpApi.HTTP_CACHE_POLICY_TYPE.HttpCachePolicyTimeToLive; + cachePolicy.SecondsToLive = (uint)Math.Min(cacheTtl.Value.Ticks / TimeSpan.TicksPerSecond, Int32.MaxValue); + } + byte[] reasonPhraseBytes = new byte[HeaderEncoding.GetByteCount(reasonPhrase)]; fixed (byte* pReasonPhrase = reasonPhraseBytes) { @@ -405,7 +416,7 @@ namespace Microsoft.Net.Http.Server Request.RequestId, (uint)flags, pResponse, - null, + &cachePolicy, &bytesSent, SafeLocalFree.Zero, 0, diff --git a/src/Microsoft.Net.Http.Server/RequestProcessing/ResponseStream.cs b/src/Microsoft.Net.Http.Server/RequestProcessing/ResponseStream.cs index c00c8af45f..6cc412dba5 100644 --- a/src/Microsoft.Net.Http.Server/RequestProcessing/ResponseStream.cs +++ b/src/Microsoft.Net.Http.Server/RequestProcessing/ResponseStream.cs @@ -583,7 +583,6 @@ namespace Microsoft.Net.Http.Server uint statusCode; uint bytesSent = 0; - flags |= _leftToWrite == count ? HttpApi.HTTP_FLAGS.NONE : HttpApi.HTTP_FLAGS.HTTP_SEND_RESPONSE_FLAG_MORE_DATA; bool startedSending = _requestContext.Response.HasStartedSending; var chunked = _requestContext.Response.BoundaryType == BoundaryType.Chunked; ResponseStreamAsyncResult asyncResult = new ResponseStreamAsyncResult(this, fileName, offset, count, chunked, cancellationRegistration); @@ -602,6 +601,7 @@ namespace Microsoft.Net.Http.Server bytesWritten = asyncResult.FileLength - offset; } // Update _leftToWrite now so we can queue up additional calls to SendFileAsync. + flags |= _leftToWrite == bytesWritten ? HttpApi.HTTP_FLAGS.NONE : HttpApi.HTTP_FLAGS.HTTP_SEND_RESPONSE_FLAG_MORE_DATA; UpdateWritenCount((uint)bytesWritten); try diff --git a/test/Microsoft.AspNet.Server.WebListener.FunctionalTests/ResponseCachingTests.cs b/test/Microsoft.AspNet.Server.WebListener.FunctionalTests/ResponseCachingTests.cs new file mode 100644 index 0000000000..3e44988bc6 --- /dev/null +++ b/test/Microsoft.AspNet.Server.WebListener.FunctionalTests/ResponseCachingTests.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNet.FeatureModel; +using Microsoft.AspNet.Http.Internal; +using Xunit; + +namespace Microsoft.AspNet.Server.WebListener.FunctionalTests +{ + public class ResponseCachingTests + { + [Fact] + public async Task Caching_NoCacheControl_NotCached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("2", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_JustPublic_NotCached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public"; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("2", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_MaxAge_Cached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public, max-age=10"; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("1", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_SMaxAge_Cached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public, s-maxage=10"; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("1", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_SMaxAgeAndMaxAge_SMaxAgePreferredCached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public, max-age=0, s-maxage=10"; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("1", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_Expires_Cached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public"; + httpContext.Response.Headers["Expires"] = (DateTime.UtcNow + TimeSpan.FromSeconds(10)).ToString("r"); + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("1", await SendRequestAsync(address)); + } + } + + [Theory] + [InlineData("Set-cookie")] + [InlineData("vary")] + [InlineData("pragma")] + public async Task Caching_DisallowedResponseHeaders_NotCached(string headerName) + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public, max-age=10"; + httpContext.Response.Headers[headerName] = "headerValue"; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("2", await SendRequestAsync(address)); + } + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + public async Task Caching_InvalidExpires_NotCached(string expiresValue) + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public"; + httpContext.Response.Headers["Expires"] = expiresValue; + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("2", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_ExpiresWithoutPublic_NotCached() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Expires"] = (DateTime.UtcNow + TimeSpan.FromSeconds(10)).ToString("r"); + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("2", await SendRequestAsync(address)); + } + } + + [Fact] + public async Task Caching_MaxAgeAndExpires_MaxAgePreferred() + { + var requestCount = 1; + string address; + using (Utilities.CreateHttpServer(out address, env => + { + var httpContext = new DefaultHttpContext((IFeatureCollection)env); + httpContext.Response.ContentType = "some/thing"; // Http.Sys requires content-type for caching + httpContext.Response.Headers["x-request-count"] = (requestCount++).ToString(); + httpContext.Response.Headers["Cache-Control"] = "public, max-age=10"; + httpContext.Response.Headers["Expires"] = (DateTime.UtcNow - TimeSpan.FromSeconds(10)).ToString("r"); // In the past + return httpContext.Response.Body.WriteAsync(new byte[10], 0, 10); + })) + { + Assert.Equal("1", await SendRequestAsync(address)); + Assert.Equal("1", await SendRequestAsync(address)); + } + } + + private async Task SendRequestAsync(string uri) + { + using (var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(10) }) + { + var response = await client.GetAsync(uri); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal(10, response.Content.Headers.ContentLength); + Assert.Equal(new byte[10], await response.Content.ReadAsByteArrayAsync()); + return response.Headers.GetValues("x-request-count").FirstOrDefault(); + } + } + } +} diff --git a/test/Microsoft.Net.Http.Server.FunctionalTests/Microsoft.Net.Http.Server.FunctionalTests.xproj b/test/Microsoft.Net.Http.Server.FunctionalTests/Microsoft.Net.Http.Server.FunctionalTests.xproj index 010e267757..185d583692 100644 --- a/test/Microsoft.Net.Http.Server.FunctionalTests/Microsoft.Net.Http.Server.FunctionalTests.xproj +++ b/test/Microsoft.Net.Http.Server.FunctionalTests/Microsoft.Net.Http.Server.FunctionalTests.xproj @@ -13,5 +13,8 @@ 2.0 + + + \ No newline at end of file diff --git a/test/Microsoft.Net.Http.Server.FunctionalTests/ResponseCachingTests.cs b/test/Microsoft.Net.Http.Server.FunctionalTests/ResponseCachingTests.cs new file mode 100644 index 0000000000..fca4fd66fc --- /dev/null +++ b/test/Microsoft.Net.Http.Server.FunctionalTests/ResponseCachingTests.cs @@ -0,0 +1,1072 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Net.Http.Server.FunctionalTests +{ + public class ResponseCachingTests + { + private readonly string _absoluteFilePath; + private readonly long _fileLength; + + public ResponseCachingTests() + { + _absoluteFilePath = Directory.GetFiles(Environment.CurrentDirectory).First(); + _fileLength = new FileInfo(_absoluteFilePath).Length; + } + + [Fact] + public async Task Caching_SetTtlWithoutContentType_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SetTtlWithContentType_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + // Http.Sys does not set the optional Age header for cached content. + // http://tools.ietf.org/html/rfc7234#section-5.1 + public async Task Caching_CheckAge_NotSentWithCachedContent() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + Assert.False(response.Headers.Age.HasValue); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + Assert.False(response.Headers.Age.HasValue); + } + } + + [Fact] + // Http.Sys does not update the optional Age header for cached content. + // http://tools.ietf.org/html/rfc7234#section-5.1 + public async Task Caching_SetAge_AgeHeaderCachedAndNotUpdated() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.Headers["age"] = "12345"; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + Assert.True(response.Headers.Age.HasValue); + Assert.Equal(TimeSpan.FromSeconds(12345), response.Headers.Age.Value); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + Assert.True(response.Headers.Age.HasValue); + Assert.Equal(TimeSpan.FromSeconds(12345), response.Headers.Age.Value); + } + } + + [Fact] + public async Task Caching_SetTtlZeroSeconds_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(0); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SetTtlMiliseconds_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromMilliseconds(900); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SetTtlNegative_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(-10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SetTtlHuge_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.MaxValue; + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SetTtlAndWriteBody_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.ShouldBuffer = true; + context.Response.Body.Write(new byte[10], 0, 10); + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[20], await response.Content.ReadAsByteArrayAsync()); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[20], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_Flush_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Flush(); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_WriteFlush_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[10], 0, 10); + context.Response.Body.Flush(); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[10], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_WriteFullContentLength_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength = 10; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[10], 0, 10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(10, response.Content.Headers.ContentLength); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(10, response.Content.Headers.ContentLength); + } + } + + [Fact] + public async Task Caching_SendFileNoContentLength_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + await context.Response.SendFileAsync(_absoluteFilePath, 0, null, CancellationToken.None); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(_fileLength, response.Content.Headers.ContentLength); + + responseTask = SendRequestAsync(address); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_SendFileWithFullContentLength_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength =_fileLength; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + await context.Response.SendFileAsync(_absoluteFilePath, 0, null, CancellationToken.None); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(_fileLength, response.Content.Headers.ContentLength); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(_fileLength, response.Content.Headers.ContentLength); + } + } + + [Fact] + public async Task Caching_SetTtlAndStatusCode_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + // Http.Sys will cache any status code. + for (int status = 200; status < 600; status++) + { + var responseTask = SendRequestAsync(address + status); + + var context = await server.GetContextAsync(); + context.Response.StatusCode = status; + context.Response.Headers["x-request-count"] = status.ToString(); + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(status, (int)response.StatusCode); + Assert.Equal(status.ToString(), response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address + status); + Assert.Equal(status, (int)response.StatusCode); + Assert.Equal(status.ToString(), response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + } + + // Only GET requests can have cached responses. + [Theory] + // See HTTP_VERB for known verbs + [InlineData("HEAD")] + [InlineData("UNKNOWN")] + [InlineData("INVALID")] + [InlineData("OPTIONS")] + [InlineData("DELETE")] + [InlineData("TRACE")] + [InlineData("TRACK")] + [InlineData("MOVE")] + [InlineData("COPY")] + [InlineData("PROPFIND")] + [InlineData("PROPPATCH")] + [InlineData("MKCOL")] + [InlineData("LOCK")] + [InlineData("UNLOCK")] + [InlineData("SEARCH")] + [InlineData("CUSTOMVERB")] + [InlineData("PATCH")] + [InlineData("POST")] + [InlineData("PUT")] + // [InlineData("CONNECT", null)] 400 bad request if it's not a WebSocket handshake. + public async Task Caching_VariousUnsupportedRequestMethods_NotCached(string method) + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, method); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = context.Request.Method + "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal(method + "1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address, method); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = context.Request.Method + "2"; + // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal(method + "2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation. http://tools.ietf.org/html/rfc7234#section-4.4 + // "A cache MUST invalidate the effective Request URI ... when a non-error status code + // is received in response to an unsafe request method." + [Theory] + // See HTTP_VERB for known verbs + [InlineData("HEAD")] + [InlineData("UNKNOWN")] + [InlineData("INVALID")] + [InlineData("OPTIONS")] + [InlineData("DELETE")] + [InlineData("TRACE")] + [InlineData("TRACK")] + [InlineData("MOVE")] + [InlineData("COPY")] + [InlineData("PROPFIND")] + [InlineData("PROPPATCH")] + [InlineData("MKCOL")] + [InlineData("LOCK")] + [InlineData("UNLOCK")] + [InlineData("SEARCH")] + [InlineData("CUSTOMVERB")] + [InlineData("PATCH")] + [InlineData("POST")] + [InlineData("PUT")] + // [InlineData("CONNECT", null)] 400 bad request if it's not a WebSocket handshake. + public async Task Caching_UnsupportedRequestMethods_BypassCacheAndLeaveItIntact(string method) + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + // Cache the first response + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = context.Request.Method + "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("GET1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Try to clear the cache with a second request + responseTask = SendRequestAsync(address, method); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = context.Request.Method + "2"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Dispose(); + + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal(method + "2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Send a third request to check the cache. + responseTask = SendRequestAsync(address); + + // The cache wasn't cleared when it should have been + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("GET1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation / implementation limiation, Vary is not respected. + // http://tools.ietf.org/html/rfc7234#section-4.1 + [Fact] + public async Task Caching_SetVary_NotRespected() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, "GET", "x-vary", "vary1"); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.Headers["vary"] = "x-vary"; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal("x-vary", response.Headers.GetValues("vary").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await SendRequestAsync(address, "GET", "x-vary", "vary2"); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal("x-vary", response.Headers.GetValues("vary").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // http://tools.ietf.org/html/rfc7234#section-3.2 + [Fact] + public async Task Caching_RequestAuthorization_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, "GET", "Authorization", "Basic abc123"); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address, "GET", "Authorization", "Basic abc123"); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Dispose(); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + [Fact] + public async Task Caching_RequestAuthorization_NotServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address, "GET", "Authorization", "Basic abc123"); + + context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "2"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Dispose(); + + // Send a second request and make sure we get the same response (without listening for one on the server). + response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // Responses can be cached for requests with Pragma: no-cache. + // http://tools.ietf.org/html/rfc7234#section-5.2.1.4 + [Fact] + public async Task Caching_RequestPragmaNoCache_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, "GET", "Pragma", "no-cache"); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation, Requests with Pragma: no-cache should not be served from cache. + // http://tools.ietf.org/html/rfc7234#section-5.4 + // http://tools.ietf.org/html/rfc7234#section-5.2.1.4 + [Fact] + public async Task Caching_RequestPragmaNoCache_NotRespectedAndServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Pragma", "no-cache"); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // Responses can be cached for requests with cache-control: no-cache. + // http://tools.ietf.org/html/rfc7234#section-5.2.1.4 + [Fact] + public async Task Caching_RequestCacheControlNoCache_Cached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, "GET", "Cache-Control", "no-cache"); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation, Requests with Cache-Control: no-cache should not be served from cache. + // http://tools.ietf.org/html/rfc7234#section-5.2.1.4 + [Fact] + public async Task Caching_RequestCacheControlNoCache_NotRespectedAndServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Cache-Control", "no-cache"); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation + // http://tools.ietf.org/html/rfc7234#section-5.2.1.1 + [Fact] + public async Task Caching_RequestCacheControlMaxAgeZero_NotRespectedAndServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Cache-Control", "min-fresh=0"); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // RFC violation + // http://tools.ietf.org/html/rfc7234#section-5.2.1.3 + [Fact] + public async Task Caching_RequestCacheControlMinFreshOutOfRange_NotRespectedAndServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Cache-Control", "min-fresh=20"); + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[0], await response.Content.ReadAsByteArrayAsync()); + } + } + + // Http.Sys limitation, partial responses are not cached. + [Fact] + public async Task Caching_CacheRange_NotCached() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address, "GET", "Range", "bytes=0-10"); + + var context = await server.GetContextAsync(); + context.Response.StatusCode = 206; + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.Headers["content-range"] = "bytes 0-10/100"; + context.Response.ContentLength = 11; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[100], 0, 11); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[11], await response.Content.ReadAsByteArrayAsync()); + + responseTask = SendRequestAsync(address, "GET", "Range", "bytes=0-10"); + + context = await server.GetContextAsync(); + context.Response.StatusCode = 206; + context.Response.Headers["x-request-count"] = "2"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.Headers["content-range"] = "bytes 0-10/100"; + context.Response.ContentLength = 11; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[100], 0, 11); + context.Dispose(); + + response = await responseTask; + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("2", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal("bytes 0-10/100", response.Content.Headers.GetValues("content-range").FirstOrDefault()); + Assert.Equal(new byte[11], await response.Content.ReadAsByteArrayAsync()); + } + } + + // http://tools.ietf.org/html/rfc7233#section-4.1 + [Fact] + public async Task Caching_RequestRangeFromCache_RangeServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength = 100; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[100], 0, 100); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[100], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Range", "bytes=0-10"); + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal("bytes 0-10/100", response.Content.Headers.GetValues("content-range").FirstOrDefault()); + Assert.Equal(new byte[11], await response.Content.ReadAsByteArrayAsync()); + } + } + + // http://tools.ietf.org/html/rfc7233#section-4.1 + [Fact] + public async Task Caching_RequestMultipleRangesFromCache_RangesServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength = 100; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + context.Response.Body.Write(new byte[100], 0, 100); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(new byte[100], await response.Content.ReadAsByteArrayAsync()); + + response = await SendRequestAsync(address, "GET", "Range", "bytes=0-10,15-20"); + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.True(response.Content.Headers.GetValues("content-type").First().StartsWith("multipart/byteranges;")); + } + } + + [Fact] + public async Task Caching_RequestRangeFromCachedFile_ServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseLength = _fileLength / 2; // Make sure it handles partial files. + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength = responseLength; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + await context.Response.SendFileAsync(_absoluteFilePath, 0, responseLength, CancellationToken.None); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(responseLength, response.Content.Headers.ContentLength); + + // Send a second request and make sure we get the same response (without listening for one on the server). + var rangeLength = responseLength / 2; + response = await SendRequestAsync(address, "GET", "Range", "bytes=0-" + (rangeLength - 1)); + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(rangeLength, response.Content.Headers.ContentLength); + Assert.Equal("bytes 0-" + (rangeLength - 1) + "/" + responseLength, response.Content.Headers.GetValues("content-range").FirstOrDefault()); + } + } + + [Fact] + public async Task Caching_RequestMultipleRangesFromCachedFile_ServedFromCache() + { + string address; + using (var server = Utilities.CreateHttpServer(out address)) + { + var responseLength = _fileLength / 2; // Make sure it handles partial files. + var responseTask = SendRequestAsync(address); + + var context = await server.GetContextAsync(); + context.Response.Headers["x-request-count"] = "1"; + context.Response.Headers["content-type"] = "some/thing"; // Http.sys requires a content-type to cache + context.Response.ContentLength = responseLength; + context.Response.CacheTtl = TimeSpan.FromSeconds(10); + await context.Response.SendFileAsync(_absoluteFilePath, 0, responseLength, CancellationToken.None); + context.Dispose(); + + var response = await responseTask; + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.Equal(responseLength, response.Content.Headers.ContentLength); + + // Send a second request and make sure we get the same response (without listening for one on the server). + var rangeLength = responseLength / 4; + response = await SendRequestAsync(address, "GET", "Range", "bytes=0-" + (rangeLength - 1) + "," + rangeLength + "-" + (rangeLength + rangeLength - 1)); + Assert.Equal(206, (int)response.StatusCode); + Assert.Equal("1", response.Headers.GetValues("x-request-count").FirstOrDefault()); + Assert.True(response.Content.Headers.GetValues("content-type").First().StartsWith("multipart/byteranges;")); + } + } + + private async Task SendRequestAsync(string uri, string method = "GET", string extraHeader = null, string extraHeaderValue = null) + { + using (var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) }) + { + var request = new HttpRequestMessage(new HttpMethod(method), uri); + if (!string.IsNullOrEmpty(extraHeader)) + { + request.Headers.Add(extraHeader, extraHeaderValue); + } + return await client.SendAsync(request); + } + } + } +}