diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs index 77b3f28..51a0400 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs @@ -6,21 +6,35 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal public interface IResponseCachingPolicyProvider { /// - /// Determine wehther the response cache middleware should be executed for the incoming HTTP request. + /// Determine whether the response caching logic should be attempted for the incoming HTTP request. /// /// The . - /// true if the request is cacheable; otherwise false. - bool IsRequestCacheable(ResponseCachingContext context); + /// true if response caching logic should be attempted; otherwise false. + bool AttemptResponseCaching(ResponseCachingContext context); /// - /// Determine whether the response received by the middleware be cached for future requests. + /// Determine whether a cache lookup is allowed for the incoming HTTP request. + /// + /// The . + /// true if cache lookup for this request is allowed; otherwise false. + bool AllowCacheLookup(ResponseCachingContext context); + + /// + /// Determine whether storage of the response is allowed for the incoming HTTP request. + /// + /// The . + /// true if storage of the response for this request is allowed; otherwise false. + bool AllowCacheStorage(ResponseCachingContext context); + + /// + /// Determine whether the response received by the middleware can be cached for future requests. /// /// The . /// true if the response is cacheable; otherwise false. bool IsResponseCacheable(ResponseCachingContext context); /// - /// Determine whether the response retrieved from the response cache is fresh and be served. + /// Determine whether the response retrieved from the response cache is fresh and can be served. /// /// The . /// true if the cached entry is fresh; otherwise false. diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs index b900731..f8a0bf3 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs @@ -31,7 +31,7 @@ internal static class LoggerExtensions private static Action _logResponseWithUnsuccessfulStatusCodeNotCacheable; private static Action _logNotModifiedIfNoneMatchStar; private static Action _logNotModifiedIfNoneMatchMatched; - private static Action _logNotModifiedIfUnmodifiedSinceSatisfied; + private static Action _logNotModifiedIfModifiedSinceSatisfied; private static Action _logNotModifiedServed; private static Action _logCachedResponseServed; private static Action _logGatewayTimeoutServed; @@ -40,6 +40,7 @@ internal static class LoggerExtensions private static Action _logResponseCached; private static Action _logResponseNotCached; private static Action _logResponseContentLengthMismatchNotCached; + private static Action _logExpirationInfiniteMaxStaleSatisfied; static LoggerExtensions() { @@ -70,7 +71,7 @@ static LoggerExtensions() _logExpirationMustRevalidate = LoggerMessage.Define( logLevel: LogLevel.Debug, eventId: 7, - formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. It must be revalidated because the 'must-revalidate' cache directive is specified."); + formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. It must be revalidated because the 'must-revalidate' or 'proxy-revalidate' cache directive is specified."); _logExpirationMaxStaleSatisfied = LoggerMessage.Define( logLevel: LogLevel.Debug, eventId: 8, @@ -119,10 +120,10 @@ static LoggerExtensions() logLevel: LogLevel.Debug, eventId: 19, formatString: $"The ETag {{ETag}} in the '{HeaderNames.IfNoneMatch}' header matched the ETag of a cached entry."); - _logNotModifiedIfUnmodifiedSinceSatisfied = LoggerMessage.Define( + _logNotModifiedIfModifiedSinceSatisfied = LoggerMessage.Define( logLevel: LogLevel.Debug, eventId: 20, - formatString: $"The last modified date of {{LastModified}} is before the date {{IfUnmodifiedSince}} specified in the '{HeaderNames.IfUnmodifiedSince}' header."); + formatString: $"The last modified date of {{LastModified}} is before the date {{IfModifiedSince}} specified in the '{HeaderNames.IfModifiedSince}' header."); _logNotModifiedServed = LoggerMessage.Define( logLevel: LogLevel.Information, eventId: 21, @@ -155,6 +156,10 @@ static LoggerExtensions() logLevel: LogLevel.Warning, eventId: 28, formatString: $"The response could not be cached for this request because the '{HeaderNames.ContentLength}' did not match the body length."); + _logExpirationInfiniteMaxStaleSatisfied = LoggerMessage.Define( + logLevel: LogLevel.Debug, + eventId: 29, + formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. However, the 'max-stale' cache directive was specified without an assigned value and a stale response of any age is accepted."); } internal static void LogRequestMethodNotCacheable(this ILogger logger, string method) @@ -252,9 +257,9 @@ internal static void LogNotModifiedIfNoneMatchMatched(this ILogger logger, Entit _logNotModifiedIfNoneMatchMatched(logger, etag, null); } - internal static void LogNotModifiedIfUnmodifiedSinceSatisfied(this ILogger logger, DateTimeOffset lastModified, DateTimeOffset ifUnmodifiedSince) + internal static void LogNotModifiedIfModifiedSinceSatisfied(this ILogger logger, DateTimeOffset lastModified, DateTimeOffset ifModifiedSince) { - _logNotModifiedIfUnmodifiedSinceSatisfied(logger, lastModified, ifUnmodifiedSince, null); + _logNotModifiedIfModifiedSinceSatisfied(logger, lastModified, ifModifiedSince, null); } internal static void LogNotModifiedServed(this ILogger logger) @@ -296,5 +301,10 @@ internal static void LogResponseContentLengthMismatchNotCached(this ILogger logg { _logResponseContentLengthMismatchNotCached(logger, null); } + + internal static void LogExpirationInfiniteMaxStaleSatisfied(this ILogger logger, TimeSpan age, TimeSpan maxAge) + { + _logExpirationInfiniteMaxStaleSatisfied(logger, age, maxAge, null); + } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs index eeed0d9..f9f8e86 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs @@ -37,7 +37,7 @@ internal ResponseCachingContext(HttpContext httpContext, ILogger logger) internal ILogger Logger { get; } - internal bool ShouldCacheResponse { get; set; } + internal bool ShouldCacheResponse { get; set; } internal string BaseKey { get; set; } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs index c37fcac..2108ff3 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs @@ -10,10 +10,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal { public class ResponseCachingPolicyProvider : IResponseCachingPolicyProvider { - public virtual bool IsRequestCacheable(ResponseCachingContext context) + public virtual bool AttemptResponseCaching(ResponseCachingContext context) { - // Verify the method var request = context.HttpContext.Request; + + // Verify the method if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) { context.Logger.LogRequestMethodNotCacheable(request.Method); @@ -27,6 +28,13 @@ public virtual bool IsRequestCacheable(ResponseCachingContext context) return false; } + return true; + } + + public virtual bool AllowCacheLookup(ResponseCachingContext context) + { + var request = context.HttpContext.Request; + // Verify request cache-control parameters if (!StringValues.IsNullOrEmpty(request.Headers[HeaderNames.CacheControl])) { @@ -50,6 +58,12 @@ public virtual bool IsRequestCacheable(ResponseCachingContext context) return true; } + public virtual bool AllowCacheStorage(ResponseCachingContext context) + { + // Check request no-store + return !HeaderUtilities.ContainsCacheDirective(context.HttpContext.Request.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.NoStoreString); + } + public virtual bool IsResponseCacheable(ResponseCachingContext context) { var responseCacheControlHeader = context.HttpContext.Response.Headers[HeaderNames.CacheControl]; @@ -61,9 +75,8 @@ public virtual bool IsResponseCacheable(ResponseCachingContext context) return false; } - // Check no-store - if (HeaderUtilities.ContainsCacheDirective(context.HttpContext.Request.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.NoStoreString) - || HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.NoStoreString)) + // Check response no-store + if (HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.NoStoreString)) { context.Logger.LogResponseWithNoStoreNotCacheable(); return false; @@ -187,17 +200,26 @@ public virtual bool IsCachedEntryFresh(ResponseCachingContext context) // Validate max age if (age >= lowestMaxAge) { - // Must revalidate - if (HeaderUtilities.ContainsCacheDirective(cachedCacheControlHeaders, CacheControlHeaderValue.MustRevalidateString)) + // Must revalidate or proxy revalidate + if (HeaderUtilities.ContainsCacheDirective(cachedCacheControlHeaders, CacheControlHeaderValue.MustRevalidateString) + || HeaderUtilities.ContainsCacheDirective(cachedCacheControlHeaders, CacheControlHeaderValue.ProxyRevalidateString)) { context.Logger.LogExpirationMustRevalidate(age, lowestMaxAge.Value); return false; } TimeSpan? requestMaxStale; + var maxStaleExist = HeaderUtilities.ContainsCacheDirective(requestCacheControlHeaders, CacheControlHeaderValue.MaxStaleString); HeaderUtilities.TryParseSeconds(requestCacheControlHeaders, CacheControlHeaderValue.MaxStaleString, out requestMaxStale); - // Request allows stale values + // Request allows stale values with no age limit + if (maxStaleExist && !requestMaxStale.HasValue) + { + context.Logger.LogExpirationInfiniteMaxStaleSatisfied(age, lowestMaxAge.Value); + return true; + } + + // Request allows stale values with age limit if (requestMaxStale.HasValue && age - lowestMaxAge < requestMaxStale) { context.Logger.LogExpirationMaxStaleSatisfied(age, lowestMaxAge.Value, requestMaxStale.Value); diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs index 2ac2000..ed41f3c 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs @@ -74,39 +74,53 @@ public async Task Invoke(HttpContext httpContext) var context = new ResponseCachingContext(httpContext, _logger); // Should we attempt any caching logic? - if (_policyProvider.IsRequestCacheable(context)) + if (_policyProvider.AttemptResponseCaching(context)) { // Can this request be served from cache? - if (await TryServeFromCacheAsync(context)) + if (_policyProvider.AllowCacheLookup(context) && await TryServeFromCacheAsync(context)) { return; } - // Hook up to listen to the response stream - ShimResponseStream(context); - - try + // Should we store the response to this request? + if (_policyProvider.AllowCacheStorage(context)) { - // Subscribe to OnStarting event - httpContext.Response.OnStarting(_onStartingCallback, context); + // Hook up to listen to the response stream + ShimResponseStream(context); - await _next(httpContext); + try + { + // Subscribe to OnStarting event + httpContext.Response.OnStarting(_onStartingCallback, context); - // If there was no response body, check the response headers now. We can cache things like redirects. - await OnResponseStartingAsync(context); + await _next(httpContext); - // Finalize the cache entry - await FinalizeCacheBodyAsync(context); - } - finally - { - UnshimResponseStream(context); + // If there was no response body, check the response headers now. We can cache things like redirects. + await OnResponseStartingAsync(context); + + // Finalize the cache entry + await FinalizeCacheBodyAsync(context); + } + finally + { + UnshimResponseStream(context); + } + + return; } } - else + + // Response should not be captured but add IResponseCachingFeature which may be required when the response is generated + AddResponseCachingFeature(httpContext); + + try { await _next(httpContext); } + finally + { + RemoveResponseCachingFeature(httpContext); + } } internal async Task TryServeCachedResponseAsync(ResponseCachingContext context, IResponseCacheEntry cacheEntry) @@ -220,6 +234,12 @@ internal async Task FinalizeCacheHeadersAsync(ResponseCachingContext context) (context.ResponseExpires - context.ResponseTime.Value) ?? DefaultExpirationTimeSpan; + // Generate a base key if none exist + if (string.IsNullOrEmpty(context.BaseKey)) + { + context.BaseKey = _keyProvider.CreateBaseKey(context); + } + // Check if any vary rules exist if (!StringValues.IsNullOrEmpty(varyHeaders) || !StringValues.IsNullOrEmpty(varyQueryKeys)) { @@ -279,9 +299,9 @@ internal async Task FinalizeCacheHeadersAsync(ResponseCachingContext context) internal async Task FinalizeCacheBodyAsync(ResponseCachingContext context) { - var contentLength = context.HttpContext.Response.ContentLength; if (context.ShouldCacheResponse && context.ResponseCachingStream.BufferingEnabled) { + var contentLength = context.HttpContext.Response.ContentLength; var bufferStream = context.ResponseCachingStream.GetBufferStream(); if (!contentLength.HasValue || contentLength == bufferStream.Length) { @@ -322,6 +342,15 @@ internal Task OnResponseStartingAsync(ResponseCachingContext context) } } + internal static void AddResponseCachingFeature(HttpContext context) + { + if (context.Features.Get() != null) + { + throw new InvalidOperationException($"Another instance of {nameof(ResponseCachingFeature)} already exists. Only one instance of {nameof(ResponseCachingMiddleware)} can be configured for an application."); + } + context.Features.Set(new ResponseCachingFeature()); + } + internal void ShimResponseStream(ResponseCachingContext context) { // Shim response stream @@ -337,13 +366,12 @@ internal void ShimResponseStream(ResponseCachingContext context) } // Add IResponseCachingFeature - if (context.HttpContext.Features.Get() != null) - { - throw new InvalidOperationException($"Another instance of {nameof(ResponseCachingFeature)} already exists. Only one instance of {nameof(ResponseCachingMiddleware)} can be configured for an application."); - } - context.HttpContext.Features.Set(new ResponseCachingFeature()); + AddResponseCachingFeature(context.HttpContext); } + internal static void RemoveResponseCachingFeature(HttpContext context) => + context.Features.Set(null); + internal static void UnshimResponseStream(ResponseCachingContext context) { // Unshim response stream @@ -353,7 +381,7 @@ internal static void UnshimResponseStream(ResponseCachingContext context) context.HttpContext.Features.Set(context.OriginalSendFileFeature); // Remove IResponseCachingFeature - context.HttpContext.Features.Set(null); + RemoveResponseCachingFeature(context.HttpContext); } internal static bool ContentIsNotModified(ResponseCachingContext context) @@ -388,8 +416,8 @@ internal static bool ContentIsNotModified(ResponseCachingContext context) } else { - var ifUnmodifiedSince = context.HttpContext.Request.Headers[HeaderNames.IfUnmodifiedSince]; - if (!StringValues.IsNullOrEmpty(ifUnmodifiedSince)) + var ifModifiedSince = context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince]; + if (!StringValues.IsNullOrEmpty(ifModifiedSince)) { DateTimeOffset modified; if (!HeaderUtilities.TryParseDate(cachedResponseHeaders[HeaderNames.LastModified], out modified) && @@ -398,11 +426,11 @@ internal static bool ContentIsNotModified(ResponseCachingContext context) return false; } - DateTimeOffset unmodifiedSince; - if (HeaderUtilities.TryParseDate(ifUnmodifiedSince, out unmodifiedSince) && - modified <= unmodifiedSince) + DateTimeOffset modifiedSince; + if (HeaderUtilities.TryParseDate(ifModifiedSince, out modifiedSince) && + modified <= modifiedSince) { - context.Logger.LogNotModifiedIfUnmodifiedSinceSatisfied(modified, unmodifiedSince); + context.Logger.LogNotModifiedIfModifiedSinceSatisfied(modified, modifiedSince); return true; } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs index a31d7c3..fc66045 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs @@ -5,7 +5,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -157,14 +159,14 @@ public void ContentIsNotModified_NotConditionalRequest_False() } [Fact] - public void ContentIsNotModified_IfUnmodifiedSince_FallsbackToDateHeader() + public void ContentIsNotModified_IfModifiedSince_FallsbackToDateHeader() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.CachedResponseHeaders = new HeaderDictionary(); - context.HttpContext.Request.Headers[HeaderNames.IfUnmodifiedSince] = HeaderUtilities.FormatDate(utcNow); + context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow); // Verify modifications in the past succeeds context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); @@ -183,19 +185,19 @@ public void ContentIsNotModified_IfUnmodifiedSince_FallsbackToDateHeader() // Verify logging TestUtils.AssertLoggedMessages( sink.Writes, - LoggedMessage.NotModifiedIfUnmodifiedSinceSatisfied, - LoggedMessage.NotModifiedIfUnmodifiedSinceSatisfied); + LoggedMessage.NotModifiedIfModifiedSinceSatisfied, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied); } [Fact] - public void ContentIsNotModified_IfUnmodifiedSince_LastModifiedOverridesDateHeader() + public void ContentIsNotModified_IfModifiedSince_LastModifiedOverridesDateHeader() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.CachedResponseHeaders = new HeaderDictionary(); - context.HttpContext.Request.Headers[HeaderNames.IfUnmodifiedSince] = HeaderUtilities.FormatDate(utcNow); + context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow); // Verify modifications in the past succeeds context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); @@ -217,20 +219,20 @@ public void ContentIsNotModified_IfUnmodifiedSince_LastModifiedOverridesDateHead // Verify logging TestUtils.AssertLoggedMessages( sink.Writes, - LoggedMessage.NotModifiedIfUnmodifiedSinceSatisfied, - LoggedMessage.NotModifiedIfUnmodifiedSinceSatisfied); + LoggedMessage.NotModifiedIfModifiedSinceSatisfied, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied); } [Fact] - public void ContentIsNotModified_IfNoneMatch_Overrides_IfUnmodifiedSince_ToTrue() + public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToTrue() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.CachedResponseHeaders = new HeaderDictionary(); - // This would fail the IfUnmodifiedSince checks - context.HttpContext.Request.Headers[HeaderNames.IfUnmodifiedSince] = HeaderUtilities.FormatDate(utcNow); + // This would fail the IfModifiedSince checks + context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow); context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = EntityTagHeaderValue.Any.ToString(); @@ -241,15 +243,15 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfUnmodifiedSince_ToTrue( } [Fact] - public void ContentIsNotModified_IfNoneMatch_Overrides_IfUnmodifiedSince_ToFalse() + public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToFalse() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.CachedResponseHeaders = new HeaderDictionary(); - // This would pass the IfUnmodifiedSince checks - context.HttpContext.Request.Headers[HeaderNames.IfUnmodifiedSince] = HeaderUtilities.FormatDate(utcNow); + // This would pass the IfModifiedSince checks + context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow); context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = "\"E1\""; @@ -328,27 +330,56 @@ public void ContentIsNotModified_IfNoneMatch_MatchesAtLeastOneValue_True() } [Fact] - public async Task FinalizeCacheHeaders_DoNotUpdateShouldCacheResponse_IfResponseIsNotCacheable() + public async Task OnResponseStartingAsync_IfAllowResponseCaptureIsTrue_SetsResponseTime() { - var sink = new TestSink(); - var middleware = TestUtils.CreateTestMiddleware(testSink: sink, policyProvider: new ResponseCachingPolicyProvider()); + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var middleware = TestUtils.CreateTestMiddleware(options: new ResponseCachingOptions + { + SystemClock = clock + }); var context = TestUtils.CreateTestContext(); + context.ResponseTime = null; - Assert.False(context.ShouldCacheResponse); + await middleware.OnResponseStartingAsync(context); - middleware.ShimResponseStream(context); - await middleware.FinalizeCacheHeadersAsync(context); + Assert.Equal(clock.UtcNow, context.ResponseTime); + } - Assert.False(context.ShouldCacheResponse); - Assert.Empty(sink.Writes); + [Fact] + public async Task OnResponseStartingAsync_IfAllowResponseCaptureIsTrue_SetsResponseTimeOnlyOnce() + { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var middleware = TestUtils.CreateTestMiddleware(options: new ResponseCachingOptions + { + SystemClock = clock + }); + var context = TestUtils.CreateTestContext(); + var initialTime = clock.UtcNow; + context.ResponseTime = null; + + await middleware.OnResponseStartingAsync(context); + Assert.Equal(initialTime, context.ResponseTime); + + clock.UtcNow += TimeSpan.FromSeconds(10); + + await middleware.OnResponseStartingAsync(context); + Assert.NotEqual(clock.UtcNow, context.ResponseTime); + Assert.Equal(initialTime, context.ResponseTime); } [Fact] - public async Task FinalizeCacheHeaders_UpdateShouldCacheResponse_IfResponseIsCacheable() + public async Task FinalizeCacheHeadersAsync_UpdateShouldCacheResponse_IfResponseCacheable() { var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, policyProvider: new ResponseCachingPolicyProvider()); var context = TestUtils.CreateTestContext(); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() { Public = true @@ -363,7 +394,22 @@ public async Task FinalizeCacheHeaders_UpdateShouldCacheResponse_IfResponseIsCac } [Fact] - public async Task FinalizeCacheHeaders_DefaultResponseValidity_Is10Seconds() + public async Task FinalizeCacheHeadersAsync_DoNotUpdateShouldCacheResponse_IfResponseIsNotCacheable() + { + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, policyProvider: new ResponseCachingPolicyProvider()); + var context = TestUtils.CreateTestContext(); + + middleware.ShimResponseStream(context); + + await middleware.FinalizeCacheHeadersAsync(context); + + Assert.False(context.ShouldCacheResponse); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task FinalizeCacheHeadersAsync_DefaultResponseValidity_Is10Seconds() { var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink); @@ -376,15 +422,21 @@ public async Task FinalizeCacheHeaders_DefaultResponseValidity_Is10Seconds() } [Fact] - public async Task FinalizeCacheHeaders_ResponseValidity_UseExpiryIfAvailable() + public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseExpiryIfAvailable() { - var utcNow = DateTimeOffset.MinValue; + var clock = new TestClock + { + UtcNow = DateTimeOffset.MinValue + }; var sink = new TestSink(); - var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions + { + SystemClock = clock + }); var context = TestUtils.CreateTestContext(); - context.ResponseTime = utcNow; - context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(11)); + context.ResponseTime = clock.UtcNow; + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); await middleware.FinalizeCacheHeadersAsync(context); @@ -393,17 +445,26 @@ public async Task FinalizeCacheHeaders_ResponseValidity_UseExpiryIfAvailable() } [Fact] - public async Task FinalizeCacheHeaders_ResponseValidity_UseMaxAgeIfAvailable() + public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseMaxAgeIfAvailable() { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; var sink = new TestSink(); - var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions + { + SystemClock = clock + }); var context = TestUtils.CreateTestContext(); + + context.ResponseTime = clock.UtcNow; context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() { MaxAge = TimeSpan.FromSeconds(12) }.ToString(); - context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(context.ResponseTime.Value + TimeSpan.FromSeconds(11)); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); await middleware.FinalizeCacheHeadersAsync(context); @@ -412,18 +473,26 @@ public async Task FinalizeCacheHeaders_ResponseValidity_UseMaxAgeIfAvailable() } [Fact] - public async Task FinalizeCacheHeaders_ResponseValidity_UseSharedMaxAgeIfAvailable() + public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseSharedMaxAgeIfAvailable() { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; var sink = new TestSink(); - var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions + { + SystemClock = clock + }); var context = TestUtils.CreateTestContext(); + + context.ResponseTime = clock.UtcNow; context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() { MaxAge = TimeSpan.FromSeconds(12), SharedMaxAge = TimeSpan.FromSeconds(13) }.ToString(); - - context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(context.ResponseTime.Value + TimeSpan.FromSeconds(11)); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); await middleware.FinalizeCacheHeadersAsync(context); @@ -432,7 +501,7 @@ public async Task FinalizeCacheHeaders_ResponseValidity_UseSharedMaxAgeIfAvailab } [Fact] - public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_IfNotEquivalentToPrevious() + public async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_IfNotEquivalentToPrevious() { var cache = new TestResponseCache(); var sink = new TestSink(); @@ -451,19 +520,17 @@ public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_IfNotEquivalentTo }; context.CachedVaryByRules = cachedVaryByRules; - await middleware.TryServeFromCacheAsync(context); await middleware.FinalizeCacheHeadersAsync(context); Assert.Equal(1, cache.SetCount); Assert.NotSame(cachedVaryByRules, context.CachedVaryByRules); TestUtils.AssertLoggedMessages( sink.Writes, - LoggedMessage.NoResponseServed, LoggedMessage.VaryByRulesUpdated); } [Fact] - public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_IfEquivalentToPrevious() + public async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_IfEquivalentToPrevious() { var cache = new TestResponseCache(); var sink = new TestSink(); @@ -483,7 +550,6 @@ public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_IfEquivalentToPre }; context.CachedVaryByRules = cachedVaryByRules; - await middleware.TryServeFromCacheAsync(context); await middleware.FinalizeCacheHeadersAsync(context); // An update to the cache is always made but the entry should be the same @@ -491,7 +557,6 @@ public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_IfEquivalentToPre Assert.Same(cachedVaryByRules, context.CachedVaryByRules); TestUtils.AssertLoggedMessages( sink.Writes, - LoggedMessage.NoResponseServed, LoggedMessage.VaryByRulesUpdated); } @@ -515,7 +580,7 @@ public static TheoryData NullOrEmptyVaryRules [Theory] [MemberData(nameof(NullOrEmptyVaryRules))] - public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_NullOrEmptyRules(StringValues vary) + public async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_NullOrEmptyRules(StringValues vary) { var cache = new TestResponseCache(); var sink = new TestSink(); @@ -528,40 +593,43 @@ public async Task FinalizeCacheHeaders_UpdateCachedVaryByRules_NullOrEmptyRules( VaryByQueryKeys = vary }); - await middleware.TryServeFromCacheAsync(context); await middleware.FinalizeCacheHeadersAsync(context); // Vary rules should not be updated Assert.Equal(0, cache.SetCount); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.NoResponseServed); + Assert.Empty(sink.Writes); } [Fact] - public async Task FinalizeCacheHeaders_DoNotAddDate_IfSpecified() + public async Task FinalizeCacheHeadersAsync_AddsDate_IfNoneSpecified() { - var utcNow = DateTimeOffset.MinValue; + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; var sink = new TestSink(); - var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions + { + SystemClock = clock + }); var context = TestUtils.CreateTestContext(); - context.ResponseTime = utcNow; Assert.True(StringValues.IsNullOrEmpty(context.HttpContext.Response.Headers[HeaderNames.Date])); await middleware.FinalizeCacheHeadersAsync(context); - Assert.Equal(HeaderUtilities.FormatDate(utcNow), context.HttpContext.Response.Headers[HeaderNames.Date]); + Assert.Equal(HeaderUtilities.FormatDate(clock.UtcNow), context.HttpContext.Response.Headers[HeaderNames.Date]); Assert.Empty(sink.Writes); } [Fact] - public async Task FinalizeCacheHeaders_AddsDate_IfNoneSpecified() + public async Task FinalizeCacheHeadersAsync_DoNotAddDate_IfSpecified() { var utcNow = DateTimeOffset.MinValue; var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink); var context = TestUtils.CreateTestContext(); + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); context.ResponseTime = utcNow + TimeSpan.FromSeconds(10); @@ -574,7 +642,7 @@ public async Task FinalizeCacheHeaders_AddsDate_IfNoneSpecified() } [Fact] - public async Task FinalizeCacheHeaders_StoresCachedResponse_InState() + public async Task FinalizeCacheHeadersAsync_StoresCachedResponse_InState() { var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink); @@ -589,20 +657,19 @@ public async Task FinalizeCacheHeaders_StoresCachedResponse_InState() } [Fact] - public async Task FinalizeCacheHeaders_SplitsVaryHeaderByCommas() + public async Task FinalizeCacheHeadersAsync_SplitsVaryHeaderByCommas() { var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink); var context = TestUtils.CreateTestContext(); + context.HttpContext.Response.Headers[HeaderNames.Vary] = "HeaderB, heaDera"; - await middleware.TryServeFromCacheAsync(context); await middleware.FinalizeCacheHeadersAsync(context); Assert.Equal(new StringValues(new[] { "HEADERA", "HEADERB" }), context.CachedVaryByRules.Headers); TestUtils.AssertLoggedMessages( sink.Writes, - LoggedMessage.NoResponseServed, LoggedMessage.VaryByRulesUpdated); } @@ -614,11 +681,12 @@ public async Task FinalizeCacheBody_Cache_IfContentLengthMatches() var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); var context = TestUtils.CreateTestContext(); + context.ShouldCacheResponse = true; middleware.ShimResponseStream(context); context.HttpContext.Response.ContentLength = 20; + await context.HttpContext.Response.WriteAsync(new string('0', 20)); - context.ShouldCacheResponse = true; context.CachedResponse = new CachedResponse(); context.BaseKey = "BaseKey"; context.CachedResponseValidFor = TimeSpan.FromSeconds(10); @@ -639,11 +707,12 @@ public async Task FinalizeCacheBody_DoNotCache_IfContentLengthMismatches() var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); var context = TestUtils.CreateTestContext(); + context.ShouldCacheResponse = true; middleware.ShimResponseStream(context); context.HttpContext.Response.ContentLength = 9; + await context.HttpContext.Response.WriteAsync(new string('0', 10)); - context.ShouldCacheResponse = true; context.CachedResponse = new CachedResponse(); context.BaseKey = "BaseKey"; context.CachedResponseValidFor = TimeSpan.FromSeconds(10); @@ -664,10 +733,11 @@ public async Task FinalizeCacheBody_Cache_IfContentLengthAbsent() var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); var context = TestUtils.CreateTestContext(); + context.ShouldCacheResponse = true; middleware.ShimResponseStream(context); + await context.HttpContext.Response.WriteAsync(new string('0', 10)); - context.ShouldCacheResponse = true; context.CachedResponse = new CachedResponse() { Headers = new HeaderDictionary() @@ -691,10 +761,10 @@ public async Task FinalizeCacheBody_DoNotCache_IfShouldCacheResponseFalse() var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); var context = TestUtils.CreateTestContext(); + context.ShouldCacheResponse = false; middleware.ShimResponseStream(context); await context.HttpContext.Response.WriteAsync(new string('0', 10)); - context.ShouldCacheResponse = false; await middleware.FinalizeCacheBodyAsync(context); @@ -712,10 +782,10 @@ public async Task FinalizeCacheBody_DoNotCache_IfBufferingDisabled() var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); var context = TestUtils.CreateTestContext(); + context.ShouldCacheResponse = true; middleware.ShimResponseStream(context); await context.HttpContext.Response.WriteAsync(new string('0', 10)); - context.ShouldCacheResponse = true; context.ResponseCachingStream.DisableBuffering(); await middleware.FinalizeCacheBodyAsync(context); @@ -727,16 +797,52 @@ public async Task FinalizeCacheBody_DoNotCache_IfBufferingDisabled() } [Fact] - public void ShimResponseStream_SecondInvocation_Throws() + public void AddResponseCachingFeature_SecondInvocation_Throws() { - var middleware = TestUtils.CreateTestMiddleware(); - var context = TestUtils.CreateTestContext(); + var httpContext = new DefaultHttpContext(); // Should not throw - middleware.ShimResponseStream(context); + ResponseCachingMiddleware.AddResponseCachingFeature(httpContext); // Should throw - Assert.ThrowsAny(() => middleware.ShimResponseStream(context)); + Assert.ThrowsAny(() => ResponseCachingMiddleware.AddResponseCachingFeature(httpContext)); + } + + private class FakeResponseFeature : HttpResponseFeature + { + public override void OnStarting(Func callback, object state) { } + } + + [Theory] + // If allowResponseCaching is false, other settings will not matter but are included for completeness + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public async Task Invoke_AddsResponseCachingFeature_Always(bool allowResponseCaching, bool allowCacheLookup, bool allowCacheStorage) + { + var responseCachingFeatureAdded = false; + var middleware = TestUtils.CreateTestMiddleware(next: httpContext => + { + responseCachingFeatureAdded = httpContext.Features.Get() != null; + return TaskCache.CompletedTask; + }, + policyProvider: new TestResponseCachingPolicyProvider + { + AttemptResponseCachingValue = allowResponseCaching, + AllowCacheLookupValue = allowCacheLookup, + AllowCacheStorageValue = allowCacheStorage + }); + + var context = new DefaultHttpContext(); + context.Features.Set(new FakeResponseFeature()); + await middleware.Invoke(context); + + Assert.True(responseCachingFeatureAdded); } [Fact] diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs index 02a068e..4f1307b 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs @@ -3,7 +3,6 @@ using System; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.ResponseCaching.Internal; using Microsoft.Extensions.Logging.Testing; using Microsoft.Net.Http.Headers; @@ -27,13 +26,13 @@ public static TheoryData CacheableMethods [Theory] [MemberData(nameof(CacheableMethods))] - public void IsRequestCacheable_CacheableMethods_Allowed(string method) + public void AttemptResponseCaching_CacheableMethods_Allowed(string method) { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = method; - Assert.True(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); + Assert.True(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); Assert.Empty(sink.Writes); } public static TheoryData NonCacheableMethods @@ -56,80 +55,80 @@ public static TheoryData NonCacheableMethods [Theory] [MemberData(nameof(NonCacheableMethods))] - public void IsRequestCacheable_UncacheableMethods_NotAllowed(string method) + public void AttemptResponseCaching_UncacheableMethods_NotAllowed(string method) { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = method; - Assert.False(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); + Assert.False(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.RequestMethodNotCacheable); } [Fact] - public void IsRequestCacheable_AuthorizationHeaders_NotAllowed() + public void AttemptResponseCaching_AuthorizationHeaders_NotAllowed() { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW"; - Assert.False(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); + Assert.False(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.RequestWithAuthorizationNotCacheable); } [Fact] - public void IsRequestCacheable_NoCache_NotAllowed() + public void AllowCacheStorage_NoStore_Allowed() { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() { - NoCache = true + NoStore = true }.ToString(); - Assert.False(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.RequestWithNoCacheNotCacheable); + Assert.True(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + Assert.Empty(sink.Writes); } [Fact] - public void IsRequestCacheable_NoStore_Allowed() + public void AllowCacheLookup_NoCache_NotAllowed() { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() { - NoStore = true + NoCache = true }.ToString(); - Assert.True(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); - Assert.Empty(sink.Writes); + Assert.False(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestWithNoCacheNotCacheable); } [Fact] - public void IsRequestCacheable_LegacyDirectives_NotAllowed() + public void AllowCacheLookup_LegacyDirectives_NotAllowed() { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; - Assert.False(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); + Assert.False(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.RequestWithPragmaNoCacheNotCacheable); } [Fact] - public void IsRequestCacheable_LegacyDirectives_OverridenByCacheControl() + public void AllowCacheLookup_LegacyDirectives_OverridenByCacheControl() { var sink = new TestSink(); var context = TestUtils.CreateTestContext(sink); @@ -137,7 +136,22 @@ public void IsRequestCacheable_LegacyDirectives_OverridenByCacheControl() context.HttpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; context.HttpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10"; - Assert.True(new ResponseCachingPolicyProvider().IsRequestCacheable(context)); + Assert.True(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void AllowCacheStorage_NoStore_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + NoStore = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().AllowCacheStorage(context)); Assert.Empty(sink.Writes); } @@ -184,26 +198,6 @@ public void IsResponseCacheable_NoCache_NotAllowed() LoggedMessage.ResponseWithNoCacheNotCacheable); } - [Fact] - public void IsResponseCacheable_RequestNoStore_NotAllowed() - { - var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); - context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() - { - NoStore = true - }.ToString(); - context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() - { - Public = true - }.ToString(); - - Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.ResponseWithNoStoreNotCacheable); - } - [Fact] public void IsResponseCacheable_ResponseNoStore_NotAllowed() { @@ -289,6 +283,9 @@ public void IsResponseCacheable_SuccessStatusCodes_Allowed(int statusCode) } [Theory] + [InlineData(StatusCodes.Status100Continue)] + [InlineData(StatusCodes.Status101SwitchingProtocols)] + [InlineData(StatusCodes.Status102Processing)] [InlineData(StatusCodes.Status201Created)] [InlineData(StatusCodes.Status202Accepted)] [InlineData(StatusCodes.Status203NonAuthoritative)] @@ -296,6 +293,8 @@ public void IsResponseCacheable_SuccessStatusCodes_Allowed(int statusCode) [InlineData(StatusCodes.Status205ResetContent)] [InlineData(StatusCodes.Status206PartialContent)] [InlineData(StatusCodes.Status207MultiStatus)] + [InlineData(StatusCodes.Status208AlreadyReported)] + [InlineData(StatusCodes.Status226IMUsed)] [InlineData(StatusCodes.Status300MultipleChoices)] [InlineData(StatusCodes.Status301MovedPermanently)] [InlineData(StatusCodes.Status302Found)] @@ -325,9 +324,14 @@ public void IsResponseCacheable_SuccessStatusCodes_Allowed(int statusCode) [InlineData(StatusCodes.Status417ExpectationFailed)] [InlineData(StatusCodes.Status418ImATeapot)] [InlineData(StatusCodes.Status419AuthenticationTimeout)] + [InlineData(StatusCodes.Status421MisdirectedRequest)] [InlineData(StatusCodes.Status422UnprocessableEntity)] [InlineData(StatusCodes.Status423Locked)] [InlineData(StatusCodes.Status424FailedDependency)] + [InlineData(StatusCodes.Status426UpgradeRequired)] + [InlineData(StatusCodes.Status428PreconditionRequired)] + [InlineData(StatusCodes.Status429TooManyRequests)] + [InlineData(StatusCodes.Status431RequestHeaderFieldsTooLarge)] [InlineData(StatusCodes.Status451UnavailableForLegalReasons)] [InlineData(StatusCodes.Status500InternalServerError)] [InlineData(StatusCodes.Status501NotImplemented)] @@ -337,6 +341,9 @@ public void IsResponseCacheable_SuccessStatusCodes_Allowed(int statusCode) [InlineData(StatusCodes.Status505HttpVersionNotsupported)] [InlineData(StatusCodes.Status506VariantAlsoNegotiates)] [InlineData(StatusCodes.Status507InsufficientStorage)] + [InlineData(StatusCodes.Status508LoopDetected)] + [InlineData(StatusCodes.Status510NotExtended)] + [InlineData(StatusCodes.Status511NetworkAuthenticationRequired)] public void IsResponseCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) { var sink = new TestSink(); @@ -687,6 +694,29 @@ public void IsCachedEntryFresh_MaxStaleOverridesFreshness_ToFresh() LoggedMessage.ExpirationMaxStaleSatisfied); } + [Fact] + public void IsCachedEntryFresh_MaxStaleInfiniteOverridesFreshness_ToFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true // No value specified means a MaxStaleLimit of infinity + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationInfiniteMaxStaleSatisfied); + } + [Fact] public void IsCachedEntryFresh_MaxStaleOverridesFreshness_ButStillNotFresh() { @@ -735,5 +765,30 @@ public void IsCachedEntryFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh sink.Writes, LoggedMessage.ExpirationMustRevalidate); } + + [Fact] + public void IsCachedEntryFresh_ProxyRevalidateOverridesRequestMaxStale_ToNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(2) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MustRevalidate = true + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMustRevalidate); + } } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs index f2a014b..e8347bf 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs @@ -123,10 +123,16 @@ public async void ServesFreshContent_If_CacheControlNoCache(string method) using (var server = new TestServer(builder)) { var client = server.CreateClient(); - client.DefaultRequestHeaders.CacheControl = - new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response no longer served + client.DefaultRequestHeaders.CacheControl = + new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); await AssertFreshResponseAsync(initialResponse, subsequentResponse); @@ -146,10 +152,16 @@ public async void ServesFreshContent_If_PragmaNoCache(string method) using (var server = new TestServer(builder)) { var client = server.CreateClient(); - client.DefaultRequestHeaders.Pragma.Clear(); - client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("no-cache")); var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response no longer served + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("no-cache")); var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); await AssertFreshResponseAsync(initialResponse, subsequentResponse); @@ -565,7 +577,7 @@ public async void ServesFreshContent_IfIHttpSendFileFeature_Used() } [Fact] - public async void ServesCachedContent_IfSubsequentRequest_ContainsNoStore() + public async void ServesCachedContent_IfSubsequentRequestContainsNoStore() { var builders = TestUtils.CreateBuildersWithResponseCaching(); @@ -587,7 +599,7 @@ public async void ServesCachedContent_IfSubsequentRequest_ContainsNoStore() } [Fact] - public async void ServesFreshContent_IfInitialRequestContains_NoStore() + public async void ServesFreshContent_IfInitialRequestContainsNoStore() { var builders = TestUtils.CreateBuildersWithResponseCaching(); @@ -608,6 +620,31 @@ public async void ServesFreshContent_IfInitialRequestContains_NoStore() } } + [Fact] + public async void ServesFreshContent_IfInitialResponseContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(requestDelegate: async (context) => + { + var headers = context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + NoStore = true + }; + await TestUtils.TestRequestDelegate(context); + }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + [Fact] public async void Serves304_IfIfModifiedSince_Satisfied() { @@ -619,7 +656,7 @@ public async void Serves304_IfIfModifiedSince_Satisfied() { var client = server.CreateClient(); var initialResponse = await client.GetAsync(""); - client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MaxValue; + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MaxValue; var subsequentResponse = await client.GetAsync(""); initialResponse.EnsureSuccessStatusCode(); @@ -639,7 +676,7 @@ public async void ServesCachedContent_IfIfModifiedSince_NotSatisfied() { var client = server.CreateClient(); var initialResponse = await client.GetAsync(""); - client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MinValue; var subsequentResponse = await client.GetAsync(""); await AssertCachedResponseAsync(initialResponse, subsequentResponse); diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs index b50be5b..88d1511 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Xunit; +using ISystemClock = Microsoft.AspNetCore.ResponseCaching.Internal.ISystemClock; namespace Microsoft.AspNetCore.ResponseCaching.Tests { @@ -42,11 +43,19 @@ static TestUtils() } var uniqueId = Guid.NewGuid().ToString(); - headers.CacheControl = new CacheControlHeaderValue + if (headers.CacheControl == null) { - Public = true, - MaxAge = string.IsNullOrEmpty(expires) ? TimeSpan.FromSeconds(10) : (TimeSpan?)null - }; + headers.CacheControl = new CacheControlHeaderValue + { + Public = true, + MaxAge = string.IsNullOrEmpty(expires) ? TimeSpan.FromSeconds(10) : (TimeSpan?)null + }; + } + else + { + headers.CacheControl.Public = true; + headers.CacheControl.MaxAge = string.IsNullOrEmpty(expires) ? TimeSpan.FromSeconds(10) : (TimeSpan?)null; + } headers.Date = DateTimeOffset.UtcNow; headers.Headers["X-Value"] = uniqueId; @@ -103,12 +112,17 @@ internal static IEnumerable CreateBuildersWithResponseCaching( } internal static ResponseCachingMiddleware CreateTestMiddleware( + RequestDelegate next = null, IResponseCache cache = null, ResponseCachingOptions options = null, TestSink testSink = null, IResponseCachingKeyProvider keyProvider = null, IResponseCachingPolicyProvider policyProvider = null) { + if (next == null) + { + next = httpContext => TaskCache.CompletedTask; + } if (cache == null) { cache = new TestResponseCache(); @@ -127,7 +141,7 @@ internal static ResponseCachingMiddleware CreateTestMiddleware( } return new ResponseCachingMiddleware( - httpContext => TaskCache.CompletedTask, + next, Options.Create(options), testSink == null ? (ILoggerFactory)NullLoggerFactory.Instance : new TestLoggerFactory(testSink, true), policyProvider, @@ -188,7 +202,7 @@ internal class LoggedMessage internal static LoggedMessage ResponseWithUnsuccessfulStatusCodeNotCacheable => new LoggedMessage(17, LogLevel.Debug); internal static LoggedMessage NotModifiedIfNoneMatchStar => new LoggedMessage(18, LogLevel.Debug); internal static LoggedMessage NotModifiedIfNoneMatchMatched => new LoggedMessage(19, LogLevel.Debug); - internal static LoggedMessage NotModifiedIfUnmodifiedSinceSatisfied => new LoggedMessage(20, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfModifiedSinceSatisfied => new LoggedMessage(20, LogLevel.Debug); internal static LoggedMessage NotModifiedServed => new LoggedMessage(21, LogLevel.Information); internal static LoggedMessage CachedResponseServed => new LoggedMessage(22, LogLevel.Information); internal static LoggedMessage GatewayTimeoutServed => new LoggedMessage(23, LogLevel.Information); @@ -197,6 +211,7 @@ internal class LoggedMessage internal static LoggedMessage ResponseCached => new LoggedMessage(26, LogLevel.Information); internal static LoggedMessage ResponseNotCached => new LoggedMessage(27, LogLevel.Information); internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(28, LogLevel.Warning); + internal static LoggedMessage ExpirationInfiniteMaxStaleSatisfied => new LoggedMessage(29, LogLevel.Debug); private LoggedMessage(int evenId, LogLevel logLevel) { @@ -218,11 +233,21 @@ public Task SendFileAsync(string path, long offset, long? count, CancellationTok internal class TestResponseCachingPolicyProvider : IResponseCachingPolicyProvider { - public bool IsCachedEntryFresh(ResponseCachingContext context) => true; + public bool AllowCacheLookupValue { get; set; } = false; + public bool AllowCacheStorageValue { get; set; } = false; + public bool AttemptResponseCachingValue { get; set; } = false; + public bool IsCachedEntryFreshValue { get; set; } = true; + public bool IsResponseCacheableValue { get; set; } = true; - public bool IsRequestCacheable(ResponseCachingContext context) => true; + public bool AllowCacheLookup(ResponseCachingContext context) => AllowCacheLookupValue; - public bool IsResponseCacheable(ResponseCachingContext context) => true; + public bool AllowCacheStorage(ResponseCachingContext context) => AllowCacheStorageValue; + + public bool AttemptResponseCaching(ResponseCachingContext context) => AttemptResponseCachingValue; + + public bool IsCachedEntryFresh(ResponseCachingContext context) => IsCachedEntryFreshValue; + + public bool IsResponseCacheable(ResponseCachingContext context) => IsResponseCacheableValue; } internal class TestResponseCachingKeyProvider : IResponseCachingKeyProvider @@ -284,4 +309,9 @@ public Task SetAsync(string key, IResponseCacheEntry entry, TimeSpan validFor) return TaskCache.CompletedTask; } } + + internal class TestClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } + } }