diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs new file mode 100644 index 0000000000..3abaea6627 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs @@ -0,0 +1,324 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache +{ + /// + /// An instance of represents the state of + /// or keys. + /// + public class CacheTagKey : IEquatable + { + private static readonly char[] AttributeSeparator = new[] { ',' }; + private static readonly Func CookieAcccessor = (c, key) => c[key]; + private static readonly Func HeaderAccessor = (c, key) => c[key]; + private static readonly Func QueryAccessor = (c, key) => c[key]; + private static readonly Func RouteValueAccessor = (c, key) => c[key]?.ToString(); + + private const string CacheKeyTokenSeparator = "||"; + private const string VaryByName = "VaryBy"; + private const string VaryByHeaderName = "VaryByHeader"; + private const string VaryByQueryName = "VaryByQuery"; + private const string VaryByRouteName = "VaryByRoute"; + private const string VaryByCookieName = "VaryByCookie"; + private const string VaryByUserName = "VaryByUser"; + + private readonly string _key; + private readonly string _prefix; + private readonly string _varyBy; + private readonly DateTimeOffset? _expiresOn; + private readonly TimeSpan? _expiresAfter; + private readonly TimeSpan? _expiresSliding; + private readonly IList> _headers; + private readonly IList> _queries; + private readonly IList> _routeValues; + private readonly IList> _cookies; + private readonly bool _varyByUser; + private readonly string _username; + + private string _generatedKey; + private int? _hashcode; + + /// + /// Creates an instance of for a specific . + /// + /// The . + /// The . + /// A new . + public CacheTagKey(CacheTagHelper tagHelper, TagHelperContext context) + : this(tagHelper) + { + _key = context.UniqueId; + _prefix = nameof(CacheTagHelper); + } + + /// + /// Creates an instance of for a specific . + /// + /// The . + /// A new . + public CacheTagKey(DistributedCacheTagHelper tagHelper) + : this((CacheTagHelperBase)tagHelper) + { + _key = tagHelper.Name; + _prefix = nameof(DistributedCacheTagHelper); + } + + private CacheTagKey(CacheTagHelperBase tagHelper) + { + var httpContext = tagHelper.ViewContext.HttpContext; + var request = httpContext.Request; + + _expiresAfter = tagHelper.ExpiresAfter; + _expiresOn = tagHelper.ExpiresOn; + _expiresSliding = tagHelper.ExpiresSliding; + _varyBy = tagHelper.VaryBy; + _cookies = ExtractCollection(tagHelper.VaryByCookie, request.Cookies, CookieAcccessor); + _headers = ExtractCollection(tagHelper.VaryByHeader, request.Headers, HeaderAccessor); + _queries = ExtractCollection(tagHelper.VaryByQuery, request.Query, QueryAccessor); + _routeValues = ExtractCollection(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values, RouteValueAccessor); + _varyByUser = tagHelper.VaryByUser; + + if (_varyByUser) + { + _username = httpContext.User?.Identity?.Name; + } + } + + /// + /// Creates a representation of the key. + /// + /// A uniquely representing the key. + public string GenerateKey() + { + // Caching as the key is immutable and it can be called multiple times during a request. + if (_generatedKey != null) + { + return _generatedKey; + } + + var builder = new StringBuilder(_prefix); + builder + .Append(CacheKeyTokenSeparator) + .Append(_key); + + if (!string.IsNullOrEmpty(_varyBy)) + { + builder + .Append(CacheKeyTokenSeparator) + .Append(VaryByName) + .Append(CacheKeyTokenSeparator) + .Append(_varyBy); + } + + AddStringCollection(builder, VaryByCookieName, _cookies); + AddStringCollection(builder, VaryByHeaderName, _headers); + AddStringCollection(builder, VaryByQueryName, _queries); + AddStringCollection(builder, VaryByRouteName, _routeValues); + + if (_varyByUser) + { + builder + .Append(CacheKeyTokenSeparator) + .Append(VaryByUserName) + .Append(CacheKeyTokenSeparator) + .Append(_username); + } + + _generatedKey = builder.ToString(); + + return _generatedKey; + } + + /// + /// Creates a hashed value of the key. + /// + /// A cryptographic hash of the key. + public string GenerateHashedKey() + { + var key = GenerateKey(); + + // The key is typically too long to be useful, so we use a cryptographic hash + // as the actual key (better randomization and key distribution, so small vary + // values will generate dramatically different keys). + using (var sha = SHA256.Create()) + { + var contentBytes = Encoding.UTF8.GetBytes(key); + var hashedBytes = sha.ComputeHash(contentBytes); + return Convert.ToBase64String(hashedBytes); + } + } + + /// + public override bool Equals(object obj) + { + var other = obj as CacheTagKey; + if (other == null) + { + return false; + } + + return Equals(other); + } + + /// + public bool Equals(CacheTagKey other) + { + return string.Equals(other._key, _key, StringComparison.Ordinal) && + other._expiresAfter == _expiresAfter && + other._expiresOn == _expiresOn && + other._expiresSliding == _expiresSliding && + string.Equals(other._varyBy, _varyBy, StringComparison.Ordinal) && + AreSame(_cookies, other._cookies) && + AreSame(_headers, other._headers) && + AreSame(_queries, other._queries) && + AreSame(_routeValues, other._routeValues) && + _varyByUser == other._varyByUser && + (!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal)); + } + + /// + public override int GetHashCode() + { + // The hashcode is intentionally not using the computed + // stringified key in order to prevent string allocations + // in the common case where it's not explicitly required. + + // Caching as the key is immutable and it can be called + // multiple times during a request. + if (_hashcode.HasValue) + { + return _hashcode.Value; + } + + var hashCodeCombiner = new HashCodeCombiner(); + + hashCodeCombiner.Add(_key, StringComparer.Ordinal); + hashCodeCombiner.Add(_expiresAfter); + hashCodeCombiner.Add(_expiresOn); + hashCodeCombiner.Add(_expiresSliding); + hashCodeCombiner.Add(_varyBy, StringComparer.Ordinal); + hashCodeCombiner.Add(_username, StringComparer.Ordinal); + + CombineCollectionHashCode(hashCodeCombiner, VaryByCookieName, _cookies); + CombineCollectionHashCode(hashCodeCombiner, VaryByHeaderName, _headers); + CombineCollectionHashCode(hashCodeCombiner, VaryByQueryName, _queries); + CombineCollectionHashCode(hashCodeCombiner, VaryByRouteName, _routeValues); + + _hashcode = hashCodeCombiner.CombinedHash; + + return _hashcode.Value; + } + + private static IList> ExtractCollection(string keys, TSourceCollection collection, Func accessor) + { + if (string.IsNullOrEmpty(keys)) + { + return null; + } + + var tokenizer = new StringTokenizer(keys, AttributeSeparator); + + var result = new List>(); + + foreach (var item in tokenizer) + { + var trimmedValue = item.Trim(); + + if (trimmedValue.Length != 0) + { + var value = accessor(collection, trimmedValue.Value); + result.Add(new KeyValuePair(trimmedValue.Value, value ?? string.Empty)); + } + } + + return result; + } + + private static void AddStringCollection( + StringBuilder builder, + string collectionName, + IList> values) + { + if (values == null || values.Count == 0) + { + return; + } + + // keyName(param1=value1|param2=value2) + builder + .Append(CacheKeyTokenSeparator) + .Append(collectionName) + .Append("("); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + + if (i > 0) + { + builder.Append(CacheKeyTokenSeparator); + } + + builder + .Append(item.Key) + .Append(CacheKeyTokenSeparator) + .Append(item.Value); + } + + builder.Append(")"); + } + + private static void CombineCollectionHashCode( + HashCodeCombiner hashCodeCombiner, + string collectionName, + IList> values) + { + if (values != null) + { + hashCodeCombiner.Add(collectionName, StringComparer.Ordinal); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + hashCodeCombiner.Add(item.Key); + hashCodeCombiner.Add(item.Value); + } + } + } + + private static bool AreSame(IList> values1, IList> values2) + { + if (values1 == values2) + { + return true; + } + + if (values1 == null || values2 == null || values1.Count != values2.Count) + { + return false; + } + + for (var i = 0; i < values1.Count; i++) + { + if (!string.Equals(values1[i].Key, values2[i].Key, StringComparison.Ordinal) || + !string.Equals(values1[i].Value, values2[i].Value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs index 91b420c248..94653073e9 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs @@ -1,44 +1,81 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Concurrent; using System.IO; +using System.Linq; using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache { /// - /// Implements and ensure + /// Implements and ensures /// multiple concurrent requests are gated. + /// The entries are stored like this: + /// + /// + /// Int32 representing the hashed cache key size. + /// + /// + /// The UTF8 encoded hashed cache key. + /// + /// + /// The UTF8 encoded cached content. + /// + /// /// public class DistributedCacheTagHelperService : IDistributedCacheTagHelperService { private readonly IDistributedCacheTagHelperStorage _storage; private readonly IDistributedCacheTagHelperFormatter _formatter; private readonly HtmlEncoder _htmlEncoder; - private readonly ConcurrentDictionary> _workers; + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _workers; public DistributedCacheTagHelperService( IDistributedCacheTagHelperStorage storage, IDistributedCacheTagHelperFormatter formatter, - HtmlEncoder HtmlEncoder - ) + HtmlEncoder HtmlEncoder, + ILoggerFactory loggerFactory) { + if (storage == null) + { + throw new ArgumentNullException(nameof(storage)); + } + + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + + if (HtmlEncoder == null) + { + throw new ArgumentNullException(nameof(HtmlEncoder)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + _formatter = formatter; _storage = storage; _htmlEncoder = HtmlEncoder; - - _workers = new ConcurrentDictionary>(); + _logger = loggerFactory.CreateLogger(); + _workers = new ConcurrentDictionary>(); } /// - public async Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options) + public async Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options) { IHtmlContent content = null; @@ -55,10 +92,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache try { - var value = await _storage.GetAsync(key); - + var serializedKey = Encoding.UTF8.GetBytes(key.GenerateKey()); + var storageKey = key.GenerateHashedKey(); + var value = await _storage.GetAsync(storageKey); + if (value == null) { + // The value is not cached, we need to render the tag helper output var processedContent = await output.GetChildContentAsync(); var stringBuilder = new StringBuilder(); @@ -72,28 +112,48 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache Html = new HtmlString(stringBuilder.ToString()) }; + // Then cache the result value = await _formatter.SerializeAsync(formattingContext); - await _storage.SetAsync(key, value, options); + var encodeValue = Encode(value, serializedKey); + + await _storage.SetAsync(storageKey, encodeValue, options); content = formattingContext.Html; } else { - content = await _formatter.DeserializeAsync(value); - - // If the deserialization fails, it can return null, for instance when the - // value is not in the expected format. - if (content == null) + // The value was found in the storage, decode and ensure + // there is no cache key hash collision + byte[] decodedValue = Decode(value, serializedKey); + + try { - content = await output.GetChildContentAsync(); + if (decodedValue != null) + { + content = await _formatter.DeserializeAsync(decodedValue); + } + } + catch (Exception e) + { + _logger.DistributedFormatterDeserializationException(storageKey, e); + } + finally + { + // If the deserialization fails the content is rendered + if (content == null) + { + content = await output.GetChildContentAsync(); + } } } + // Notify all other awaiters of the final content tcs.TrySetResult(content); } catch { + // Notify all other awaiters to render the content tcs.TrySetResult(null); throw; } @@ -112,5 +172,43 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache return content; } + + private byte[] Encode(byte[] value, byte[] serializedKey) + { + using (var buffer = new MemoryStream()) + { + var keyLength = BitConverter.GetBytes(serializedKey.Length); + + buffer.Write(keyLength, 0, keyLength.Length); + buffer.Write(serializedKey, 0, serializedKey.Length); + buffer.Write(value, 0, value.Length); + + return buffer.ToArray(); + } + } + + private byte[] Decode(byte[] value, byte[] expectedKey) + { + byte[] decoded = null; + + using (var buffer = new MemoryStream(value)) + { + var keyLengthBuffer = new byte[sizeof(int)]; + buffer.Read(keyLengthBuffer, 0, keyLengthBuffer.Length); + + var keyLength = BitConverter.ToInt32(keyLengthBuffer, 0); + var serializedKeyBuffer = new byte[keyLength]; + buffer.Read(serializedKeyBuffer, 0, serializedKeyBuffer.Length); + + // Ensure we are reading the expected key before continuing + if (serializedKeyBuffer.SequenceEqual(expectedKey)) + { + decoded = new byte[value.Length - keyLengthBuffer.Length - serializedKeyBuffer.Length]; + buffer.Read(decoded, 0, decoded.Length); + } + } + + return decoded; + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs index 5843dbd38c..15abaab386 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs @@ -21,6 +21,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache /// The key in the storage. /// The . /// A cached or new content for the cache tag helper. - Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options); + Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options); } } diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs index 9fbf0c1ea6..3f23690c25 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs @@ -8,6 +8,7 @@ using System.Text.Encodings.Web; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.TagHelpers.Cache; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Primitives; @@ -23,6 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// Prefix used by instances when creating entries in . /// public static readonly string CacheKeyPrefix = nameof(CacheTagHelper); + private const string CachePriorityAttributeName = "priority"; /// @@ -63,14 +65,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers if (Enabled) { - var key = GenerateKey(context); + var cacheKey = new CacheTagKey(this, context); + MemoryCacheEntryOptions options; while (content == null) { Task result = null; - if (!MemoryCache.TryGetValue(key, out result)) + if (!MemoryCache.TryGetValue(cacheKey, out result)) { var tokenSource = new CancellationTokenSource(); @@ -85,7 +88,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // The returned value is ignored, we only do this so that // the compiler doesn't complain about the returned task // not being awaited - var localTcs = MemoryCache.Set(key, tcs.Task, options); + var localTcs = MemoryCache.Set(cacheKey, tcs.Task, options); try { @@ -93,7 +96,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // task so that the expiration options are are not impacted // by the time it took to compute it. - using (var entry = MemoryCache.CreateEntry(key)) + using (var entry = MemoryCache.CreateEntry(cacheKey)) { // The result is processed inside an entry // such that the tokens are inherited. @@ -167,16 +170,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return options; } - protected override string GetUniqueId(TagHelperContext context) - { - return context.UniqueId; - } - - protected override string GetKeyPrefix(TagHelperContext context) - { - return CacheKeyPrefix; - } - private async Task ProcessContentAsync(TagHelperOutput output) { var content = await output.GetChildContentAsync(); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs index 929f257e56..86573abcb3 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs @@ -2,14 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Security.Cryptography; -using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.TagHelpers { @@ -68,7 +64,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers public string VaryBy { get; set; } /// - /// Gets or sets the name of a HTTP request header to vary the cached result by. + /// Gets or sets a comma-delimited set of HTTP request headers to vary the cached result by. /// [HtmlAttributeName(VaryByHeaderAttributeName)] public string VaryByHeader { get; set; } @@ -122,191 +118,5 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(EnabledAttributeName)] public bool Enabled { get; set; } = true; - // Internal for unit testing - protected internal string GenerateKey(TagHelperContext context) - { - var builder = new StringBuilder(GetKeyPrefix(context)); - builder - .Append(CacheKeyTokenSeparator) - .Append(GetUniqueId(context)); - - var request = ViewContext.HttpContext.Request; - - if (!string.IsNullOrEmpty(VaryBy)) - { - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryBy)) - .Append(CacheKeyTokenSeparator) - .Append(VaryBy); - } - - AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies, (c, key) => c[key]); - AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]); - AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]); - AddVaryByRouteKey(builder); - - if (VaryByUser) - { - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryByUser)) - .Append(CacheKeyTokenSeparator) - .Append(ViewContext.HttpContext.User?.Identity?.Name); - } - - // The key is typically too long to be useful, so we use a cryptographic hash - // as the actual key (better randomization and key distribution, so small vary - // values will generate dramatically different keys). - using (var sha = SHA256.Create()) - { - var contentBytes = Encoding.UTF8.GetBytes(builder.ToString()); - var hashedBytes = sha.ComputeHash(contentBytes); - return Convert.ToBase64String(hashedBytes); - } - } - - protected abstract string GetUniqueId(TagHelperContext context); - - protected abstract string GetKeyPrefix(TagHelperContext context); - - protected static void AddStringCollectionKey( - StringBuilder builder, - string keyName, - string value, - IDictionary sourceCollection) - { - if (string.IsNullOrEmpty(value)) - { - return; - } - - // keyName(param1=value1|param2=value2) - builder - .Append(CacheKeyTokenSeparator) - .Append(keyName) - .Append("("); - - var values = Tokenize(value); - - // Perf: Avoid allocating enumerator - for (var i = 0; i < values.Count; i++) - { - var item = values[i]; - builder - .Append(item) - .Append(CacheKeyTokenSeparator) - .Append(sourceCollection[item]) - .Append(CacheKeyTokenSeparator); - } - - if (values.Count > 0) - { - // Remove the trailing separator - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - - protected static void AddStringCollectionKey( - StringBuilder builder, - string keyName, - string value, - TSourceCollection sourceCollection, - Func accessor) - { - if (string.IsNullOrEmpty(value)) - { - return; - } - - // keyName(param1=value1|param2=value2) - builder - .Append(CacheKeyTokenSeparator) - .Append(keyName) - .Append("("); - - var values = Tokenize(value); - - // Perf: Avoid allocating enumerator - for (var i = 0; i < values.Count; i++) - { - var item = values[i]; - - builder - .Append(item) - .Append(CacheKeyTokenSeparator) - .Append(accessor(sourceCollection, item)) - .Append(CacheKeyTokenSeparator); - } - - if (values.Count > 0) - { - // Remove the trailing separator - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - - protected static IList Tokenize(string value) - { - var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries); - if (values.Length == 0) - { - return values; - } - - var trimmedValues = new List(); - - for (var i = 0; i < values.Length; i++) - { - var trimmedValue = values[i].Trim(); - - if (trimmedValue.Length > 0) - { - trimmedValues.Add(trimmedValue); - } - } - - return trimmedValues; - } - - protected void AddVaryByRouteKey(StringBuilder builder) - { - var tokenFound = false; - - if (string.IsNullOrEmpty(VaryByRoute)) - { - return; - } - - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryByRoute)) - .Append("("); - - var varyByRoutes = Tokenize(VaryByRoute); - for (var i = 0; i < varyByRoutes.Count; i++) - { - var route = varyByRoutes[i]; - tokenFound = true; - - builder - .Append(route) - .Append(CacheKeyTokenSeparator) - .Append(ViewContext.RouteData.Values[route]) - .Append(CacheKeyTokenSeparator); - } - - if (tokenFound) - { - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs index fde61ed662..04edc01b1e 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs @@ -73,9 +73,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers if (Enabled) { - var key = GenerateKey(context); + var cacheKey = new CacheTagKey(this); - content = await _distributedCacheService.ProcessContentAsync(output, key, GetDistributedCacheEntryOptions()); + content = await _distributedCacheService.ProcessContentAsync(output, cacheKey, GetDistributedCacheEntryOptions()); } else { @@ -109,15 +109,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return options; } - - protected override string GetUniqueId(TagHelperContext context) - { - return Name; - } - - protected override string GetKeyPrefix(TagHelperContext context) - { - return CacheKeyPrefix; - } + } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs new file mode 100644 index 0000000000..1943e1bcb8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal +{ + internal static class MvcTagHelperLoggerExtensions + { + private static readonly Action _distributedFormatterDeserializedFailed; + + static MvcTagHelperLoggerExtensions() + { + _distributedFormatterDeserializedFailed = LoggerMessage.Define( + LogLevel.Error, + 1, + "Couldn't deserialize cached value for key {Key}."); + } + + public static void DistributedFormatterDeserializationException(this ILogger logger, string key, Exception exception) + { + _distributedFormatterDeserializedFailed(logger, key, exception); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs index 76f4fcd290..9f1ba25f35 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs @@ -4,10 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Text.Encodings.Web; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; @@ -15,6 +11,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers.Cache; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -30,218 +27,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { public class CacheTagHelperTest { - [Fact] - public void GenerateKey_ReturnsKeyBasedOnTagHelperUniqueId() - { - // Arrange - var id = Guid.NewGuid().ToString(); - var tagHelperContext = GetTagHelperContext(id); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext() - }; - var expected = GetHashedBytes("CacheTagHelper||" + id); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - - [Theory] - [InlineData("Vary-By-Value")] - [InlineData("Vary with spaces")] - [InlineData(" Vary with more spaces ")] - public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy) - { - // Arrange - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryBy = varyBy - }; - var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||" + varyBy); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - - [Theory] - [InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")] - [InlineData("Cookie0,Cookie1", - "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData("Cookie0, Cookie1", - "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData(" Cookie0, , Cookie1 ", - "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData(",Cookie0,,Cookie1,", - "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected) - { - // Arrange - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByCookie = varyByCookie - }; - cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] = - "Cookie0=Cookie0Value;Cookie1=Cookie1Value"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("Accept-Language", "CacheTagHelper||testid||VaryByHeader(Accept-Language||en-us;charset=utf8)")] - [InlineData("X-CustomHeader,Accept-Encoding, NotAvailable", - "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] - [InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable", - "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] - public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected) - { - // Arrange - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByHeader = varyByHeader - }; - var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers; - headers["Accept-Language"] = "en-us;charset=utf8"; - headers["Accept-Encoding"] = "utf8"; - headers["X-CustomHeader"] = "Header-Value"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("category", "CacheTagHelper||testid||VaryByQuery(category||cats)")] - [InlineData("Category,SortOrder,SortOption", - "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] - [InlineData("Category, SortOrder, SortOption, ", - "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] - public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected) - { - // Arrange - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByQuery = varyByQuery - }; - cacheTagHelper.ViewContext.HttpContext.Request.QueryString = - new QueryString("?sortoption=Adorability&Category=cats&sortOrder="); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("id", "CacheTagHelper||testid||VaryByRoute(id||4)")] - [InlineData("Category,,Id,OptionRouteValue", - "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] - [InlineData(" Category, , Id, OptionRouteValue, ", - "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] - public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected) - { - // Arrange - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByRoute = varyByRoute - }; - cacheTagHelper.ViewContext.RouteData.Values["id"] = 4; - cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated() - { - // Arrange - var expected = "CacheTagHelper||testid||VaryByUser||"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true - }; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName() - { - // Arrange - var expected = "CacheTagHelper||testid||VaryByUser||test_name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true - }; - var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") }); - cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey() - { - // Arrange - var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||custom-value||" + - "VaryByHeader(content-type||text/html)||VaryByUser||someuser"); - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true, - VaryByHeader = "content-type", - VaryBy = "custom-value" - }; - cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html"; - var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") }); - cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - [Fact] public async Task ProcessAsync_DoesNotCache_IfDisabled() { @@ -284,21 +69,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var id = "unique-id"; var childContent = "original-child-content"; - var cache = new Mock(); - var value = new Mock(); - value.Setup(c => c.Value).Returns(new DefaultTagHelperContent().SetContent("ok")); - value.Setup(c => c.ExpirationTokens).Returns(new List()); - cache.Setup(c => c.CreateEntry( - /*key*/ It.IsAny())) - .Returns((object key) => value.Object); - object cacheResult; - cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheResult)) - .Returns(false); + var cache = new MemoryCache(new MemoryCacheOptions()); var tagHelperContext = GetTagHelperContext(id); var tagHelperOutput = GetTagHelperOutput( attributes: new TagHelperAttributeList(), childContent: childContent); - var cacheTagHelper = new CacheTagHelper(cache.Object, new HtmlTestEncoder()) + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) { ViewContext = GetViewContext(), Enabled = true @@ -312,11 +88,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Empty(tagHelperOutput.PostContent.GetContent()); Assert.True(tagHelperOutput.IsContentModified); Assert.Equal(childContent, tagHelperOutput.Content.GetContent()); - - // There are two calls to set (for the TCS and the processed value) - cache.Verify(c => c.CreateEntry( - /*key*/ It.IsAny()), - Times.Exactly(2)); } [Fact] @@ -686,12 +457,14 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { ViewContext = GetViewContext(), }; - var key = cacheTagHelper.GenerateKey(tagHelperContext); + + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); // Act - 1 await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); Task cachedValue; - var result = cache.TryGetValue(key, out cachedValue); + var result = cache.TryGetValue(cacheTagKey, out cachedValue); // Assert - 1 Assert.Equal("HtmlEncode[[some-content]]", tagHelperOutput.Content.GetContent()); @@ -699,7 +472,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Act - 2 tokenSource.Cancel(); - result = cache.TryGetValue(key, out cachedValue); + result = cache.TryGetValue(cacheTagKey, out cachedValue); // Assert - 2 Assert.False(result); @@ -910,26 +683,5 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return Task.FromResult(tagHelperContent); }); } - - private static TagHelperOutput GetTagHelperOutput( - Func> processAsync) - { - var attributes = new TagHelperAttributeList { { "attr", "value" } }; - - return new TagHelperOutput( - "cache", - attributes, - getChildContentAsync: processAsync); - } - - private static string GetHashedBytes(string input) - { - using (var sha = SHA256.Create()) - { - var contentBytes = Encoding.UTF8.GetBytes(input); - var hashedBytes = sha.ComputeHash(contentBytes); - return Convert.ToBase64String(hashedBytes); - } - } - } + } } diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig new file mode 100644 index 0000000000..caa1c09ddc --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig @@ -0,0 +1,701 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +<<<<<<< HEAD +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +======= +>>>>>>> [Fixes #4246] Introducing CacheTagKey +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Primitives; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + public class CacheTagHelperTest + { + [Fact] + public async Task ProcessAsync_DoesNotCache_IfDisabled() + { + // Arrange + var id = "unique-id"; + var childContent = "original-child-content"; + var cache = new Mock(); + var value = new Mock(); + value.Setup(c => c.Value).Returns(new DefaultTagHelperContent().SetContent("ok")); + cache.Setup(c => c.CreateEntry( + /*key*/ It.IsAny())) + .Returns((object key) => value.Object) + .Verifiable(); + object cacheResult; + cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheResult)) + .Returns(false); + var tagHelperContext = GetTagHelperContext(id); + var tagHelperOutput = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var cacheTagHelper = new CacheTagHelper(cache.Object, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = false + }; + + // Act + await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); + + // Assert + Assert.Equal(childContent, tagHelperOutput.Content.GetContent()); + cache.Verify(c => c.CreateEntry( + /*key*/ It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ProcessAsync_ReturnsCachedValue_IfEnabled() + { + // Arrange + var id = "unique-id"; + var childContent = "original-child-content"; + var cache = new MemoryCache(new MemoryCacheOptions()); + var tagHelperContext = GetTagHelperContext(id); + var tagHelperOutput = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + // Act + await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); + + // Assert + Assert.Empty(tagHelperOutput.PreContent.GetContent()); + Assert.Empty(tagHelperOutput.PostContent.GetContent()); + Assert.True(tagHelperOutput.IsContentModified); + Assert.Equal(childContent, tagHelperOutput.Content.GetContent()); +<<<<<<< HEAD + + // There are two calls to set (for the TCS and the processed value) + cache.Verify(c => c.CreateEntry( + /*key*/ It.IsAny()), + Times.Exactly(2)); +======= +>>>>>>> [Fixes #4246] Introducing CacheTagKey + } + + [Fact] + public async Task ProcessAsync_ReturnsCachedValue_IfVaryByParamIsUnchanged() + { + // Arrange - 1 + var id = "unique-id"; + var childContent = "original-child-content"; + var cache = new MemoryCache(new MemoryCacheOptions()); + var tagHelperContext1 = GetTagHelperContext(id); + var tagHelperOutput1 = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + VaryByQuery = "key1,key2", + ViewContext = GetViewContext(), + }; + cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new QueryString( + "?key1=value1&key2=value2"); + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var tagHelperContext2 = GetTagHelperContext(id); + var tagHelperOutput2 = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: "different-content"); + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + VaryByQuery = "key1,key2", + ViewContext = GetViewContext(), + }; + cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new QueryString( + "?key1=value1&key2=value2"); + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_RecalculatesValueIfCacheKeyChanges() + { + // Arrange - 1 + var id = "unique-id"; + var childContent1 = "original-child-content"; + var cache = new MemoryCache(new MemoryCacheOptions()); + var tagHelperContext1 = GetTagHelperContext(id); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.Append(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + VaryByCookie = "cookie1,cookie2", + ViewContext = GetViewContext(), + }; + cacheTagHelper1.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=value2"; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(id); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + VaryByCookie = "cookie1,cookie2", + ViewContext = GetViewContext(), + }; + cacheTagHelper2.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=not-value2"; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresOnIsSet() + { + // Arrange + var expiresOn = DateTimeOffset.UtcNow.AddMinutes(4); + var cache = new MemoryCache(new MemoryCacheOptions()); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ExpiresOn = expiresOn + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(); + + // Assert + Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresAfterIsSet() + { + // Arrange + var expiresAfter = TimeSpan.FromSeconds(42); + var cache = new MemoryCache(new MemoryCacheOptions()); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ExpiresAfter = expiresAfter + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(); + + // Assert + Assert.Equal(expiresAfter, cacheEntryOptions.AbsoluteExpirationRelativeToNow); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsSlidingExpiration_IfExpiresSlidingIsSet() + { + // Arrange + var expiresSliding = TimeSpan.FromSeconds(37); + var cache = new MemoryCache(new MemoryCacheOptions()); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ExpiresSliding = expiresSliding + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(); + + // Assert + Assert.Equal(expiresSliding, cacheEntryOptions.SlidingExpiration); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsCachePreservationPriority() + { + // Arrange + var priority = CacheItemPriority.High; + var cache = new MemoryCache(new MemoryCacheOptions()); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + Priority = priority + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(); + + // Assert + Assert.Equal(priority, cacheEntryOptions.Priority); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresAfter_ToExpireCacheEntry() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var id = "unique-id"; + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object }); + var tagHelperContext1 = GetTagHelperContext(id); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresAfter = TimeSpan.FromMinutes(10) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(id); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresAfter = TimeSpan.FromMinutes(10) + }; + currentTime = currentTime.AddMinutes(11); + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresOn_ToExpireCacheEntry() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var id = "unique-id"; + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object }); + var tagHelperContext1 = GetTagHelperContext(id); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresOn = currentTime.AddMinutes(5) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + currentTime = currentTime.AddMinutes(5).AddSeconds(2); + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(id); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresOn = currentTime.AddMinutes(5) + }; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresSliding_ToExpireCacheEntryWithSlidingExpiration() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var id = "unique-id"; + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object }); + var tagHelperContext1 = GetTagHelperContext(id); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresSliding = TimeSpan.FromSeconds(30) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + currentTime = currentTime.AddSeconds(35); + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(id); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresSliding = TimeSpan.FromSeconds(30) + }; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_FlowsEntryLinkThatAllowsAddingTriggersToAddedEntry() + { + // Arrange + var id = "some-id"; + var expectedContent = new DefaultTagHelperContent(); + expectedContent.SetContent("some-content"); + var tokenSource = new CancellationTokenSource(); + var cache = new MemoryCache(new MemoryCacheOptions()); + var cacheEntryOptions = new MemoryCacheEntryOptions() + .AddExpirationToken(new CancellationChangeToken(tokenSource.Token)); + var tagHelperContext = new TagHelperContext( + allAttributes: new TagHelperAttributeList(), + items: new Dictionary(), + uniqueId: id); + var tagHelperOutput = new TagHelperOutput( + "cache", + new TagHelperAttributeList { { "attr", "value" } }, + getChildContentAsync: (useCachedResult, encoder) => + { + TagHelperContent tagHelperContent; + if (!cache.TryGetValue("key1", out tagHelperContent)) + { + tagHelperContent = expectedContent; + cache.Set("key1", tagHelperContent, cacheEntryOptions); + } + + return Task.FromResult(tagHelperContent); + }); + tagHelperOutput.PreContent.SetContent(""); + tagHelperOutput.PostContent.SetContent(""); + var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + }; + + var cacheTagKey = CacheTagKey.From(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Act - 1 + await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); + Task cachedValue; + var result = cache.TryGetValue(cacheTagKey, out cachedValue); + + // Assert - 1 + Assert.Equal("HtmlEncode[[some-content]]", tagHelperOutput.Content.GetContent()); + Assert.True(result); + + // Act - 2 + tokenSource.Cancel(); + result = cache.TryGetValue(cacheTagKey, out cachedValue); + + // Assert - 2 + Assert.False(result); + Assert.Null(cachedValue); + } + + [Fact] + public async Task ProcessAsync_ComputesValueOnce_WithConcurrentRequests() + { + // Arrange + var id = "unique-id"; + var childContent = "some-content"; + var resetEvent1 = new ManualResetEvent(false); + var resetEvent2 = new ManualResetEvent(false); + var resetEvent3 = new ManualResetEvent(false); + var calls = 0; + var cache = new MemoryCache(new MemoryCacheOptions()); + + var tagHelperContext1 = GetTagHelperContext(id + 1); + var tagHelperContext2 = GetTagHelperContext(id + 2); + + var tagHelperOutput1 = new TagHelperOutput( + "cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent2.Set(); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var tagHelperOutput2 = new TagHelperOutput( + "cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent3.WaitOne(5000); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + // Act + + var task1 = Task.Run(async () => + { + resetEvent1.WaitOne(5000); + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + resetEvent3.Set(); + }); + + var task2 = Task.Run(async () => + { + resetEvent2.WaitOne(5000); + await cacheTagHelper2.ProcessAsync(tagHelperContext1, tagHelperOutput2); + }); + + resetEvent1.Set(); + await Task.WhenAll(task1, task2); + + // Assert + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent, tagHelperOutput1.Content.GetContent()); + + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + + Assert.Equal(1, calls); + } + + [Fact] + public async Task ProcessAsync_ExceptionInProcessing_DoesntBlockConcurrentRequests() + { + // Arrange + var id = "unique-id"; + var childContent = "some-content"; + var resetEvent1 = new ManualResetEvent(false); + var resetEvent2 = new ManualResetEvent(false); + var resetEvent3 = new ManualResetEvent(false); + var calls = 0; + var cache = new MemoryCache(new MemoryCacheOptions()); + + var tagHelperContext1 = GetTagHelperContext(id + 1); + var tagHelperContext2 = GetTagHelperContext(id + 2); + + var tagHelperOutput1 = new TagHelperOutput( + "cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent2.Set(); + + throw new Exception(); + }); + + var tagHelperOutput2 = new TagHelperOutput( + "cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent3.WaitOne(5000); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + // Act + + var task1 = Task.Run(async () => + { + resetEvent1.WaitOne(5000); + await Assert.ThrowsAsync(() => cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1)); + resetEvent3.Set(); + }); + + var task2 = Task.Run(async () => + { + resetEvent2.WaitOne(5000); + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + }); + + resetEvent1.Set(); + await Task.WhenAll(task1, task2); + + // Assert + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.False(tagHelperOutput1.IsContentModified); + Assert.Empty(tagHelperOutput1.Content.GetContent()); + + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + + Assert.Equal(2, calls); + } + + private static ViewContext GetViewContext() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ViewContext(actionContext, + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + private static TagHelperContext GetTagHelperContext(string id = "testid") + { + return new TagHelperContext( + allAttributes: new TagHelperAttributeList(), + items: new Dictionary(), + uniqueId: id); + } + + private static TagHelperOutput GetTagHelperOutput( + string tagName = "cache", + TagHelperAttributeList attributes = null, + string childContent = "some child content") + { + attributes = attributes ?? new TagHelperAttributeList { { "attr", "value" } }; + + return new TagHelperOutput( + tagName, + attributes, + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs new file mode 100644 index 0000000000..a4285725c2 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs @@ -0,0 +1,393 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers.Cache; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + public class CacheTagKeyTest + { + [Fact] + public void GenerateKey_ReturnsKeyBasedOnTagHelperUniqueId() + { + // Arrange + var id = Guid.NewGuid().ToString(); + var tagHelperContext = GetTagHelperContext(id); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + var expected = "CacheTagHelper||" + id; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Fact] + public void Equals_ReturnsTrueOnSameKey() + { + // Arrange + var id = Guid.NewGuid().ToString(); + var tagHelperContext1 = GetTagHelperContext(id); + var cacheTagHelper1 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var tagHelperContext2 = GetTagHelperContext(id); + var cacheTagHelper2 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + // Act + var cacheTagKey1 = new CacheTagKey(cacheTagHelper1, tagHelperContext1); + var cacheTagKey2 = new CacheTagKey(cacheTagHelper2, tagHelperContext2); + + // Assert + Assert.Equal(cacheTagKey1, cacheTagKey2); + } + + [Fact] + public void Equals_ReturnsFalseOnDifferentKey() + { + // Arrange + var tagHelperContext1 = GetTagHelperContext("some-id"); + var cacheTagHelper1 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var tagHelperContext2 = GetTagHelperContext("some-other-id"); + var cacheTagHelper2 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + // Act + var cacheTagKey1 = new CacheTagKey(cacheTagHelper1, tagHelperContext1); + var cacheTagKey2 = new CacheTagKey(cacheTagHelper2, tagHelperContext2); + + // Assert + Assert.NotEqual(cacheTagKey1, cacheTagKey2); + } + + [Fact] + public void GetHashCode_IsSameForSimilarCacheTagHelper() + { + // Arrange + var tagHelperContext1 = GetTagHelperContext("some-id"); + var cacheTagHelper1 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var tagHelperContext2 = GetTagHelperContext("some-id"); + var cacheTagHelper2 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var cacheKey1 = new CacheTagKey(cacheTagHelper1, tagHelperContext1); + var cacheKey2 = new CacheTagKey(cacheTagHelper2, tagHelperContext2); + + // Act + var hashcode1 = cacheKey1.GetHashCode(); + var hashcode2 = cacheKey2.GetHashCode(); + + // Assert + Assert.Equal(hashcode1, hashcode2); + } + + [Fact] + public void GetHashCode_VariesByUniqueId() + { + // Arrange + var tagHelperContext1 = GetTagHelperContext("some-id"); + var cacheTagHelper1 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var tagHelperContext2 = GetTagHelperContext("some-other-id"); + var cacheTagHelper2 = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext() + }; + + var cacheKey1 = new CacheTagKey(cacheTagHelper1, tagHelperContext1); + var cacheKey2 = new CacheTagKey(cacheTagHelper2, tagHelperContext2); + + // Act + var hashcode1 = cacheKey1.GetHashCode(); + var hashcode2 = cacheKey2.GetHashCode(); + + // Assert + Assert.NotEqual(hashcode1, hashcode2); + } + + [Fact] + public void GenerateKey_ReturnsKeyBasedOnTagHelperName() + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Name = name + }; + var expected = "DistributedCacheTagHelper||" + name; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("Vary-By-Value")] + [InlineData("Vary with spaces")] + [InlineData(" Vary with more spaces ")] + public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy) + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryBy = varyBy + }; + var expected = "CacheTagHelper||testid||VaryBy||" + varyBy; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")] + [InlineData("Cookie0,Cookie1", + "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData("Cookie0, Cookie1", + "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData(" Cookie0, , Cookie1 ", + "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData(",Cookie0,,Cookie1,", + "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected) + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCookie = varyByCookie + }; + cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] = + "Cookie0=Cookie0Value;Cookie1=Cookie1Value"; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("Accept-Language", "CacheTagHelper||testid||VaryByHeader(Accept-Language||en-us;charset=utf8)")] + [InlineData("X-CustomHeader,Accept-Encoding, NotAvailable", + "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] + [InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable", + "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] + public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected) + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByHeader = varyByHeader + }; + var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers; + headers["Accept-Language"] = "en-us;charset=utf8"; + headers["Accept-Encoding"] = "utf8"; + headers["X-CustomHeader"] = "Header-Value"; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("category", "CacheTagHelper||testid||VaryByQuery(category||cats)")] + [InlineData("Category,SortOrder,SortOption", + "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] + [InlineData("Category, SortOrder, SortOption, ", + "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] + public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected) + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByQuery = varyByQuery + }; + cacheTagHelper.ViewContext.HttpContext.Request.QueryString = + new QueryString("?sortoption=Adorability&Category=cats&sortOrder="); + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("id", "CacheTagHelper||testid||VaryByRoute(id||4)")] + [InlineData("Category,,Id,OptionRouteValue", + "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] + [InlineData(" Category, , Id, OptionRouteValue, ", + "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] + public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected) + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByRoute = varyByRoute + }; + cacheTagHelper.ViewContext.RouteData.Values["id"] = 4; + cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory"; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Fact] + public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated() + { + // Arrange + var expected = "CacheTagHelper||testid||VaryByUser||"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true + }; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Fact] + public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName() + { + // Arrange + var expected = "CacheTagHelper||testid||VaryByUser||test_name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true + }; + var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") }); + cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Fact] + public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey() + { + // Arrange + var expected = "CacheTagHelper||testid||VaryBy||custom-value||" + + "VaryByHeader(content-type||text/html)||VaryByUser||someuser"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true, + VaryByHeader = "content-type", + VaryBy = "custom-value" + }; + cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html"; + var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") }); + cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + private static ViewContext GetViewContext() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ViewContext(actionContext, + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + private static TagHelperContext GetTagHelperContext(string id = "testid") + { + return new TagHelperContext( + allAttributes: new TagHelperAttributeList(), + items: new Dictionary(), + uniqueId: id); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs index 6779a967a5..6d05e1373e 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Security.Claims; -using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -21,6 +19,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.WebEncoders.Testing; using Moq; using Xunit; @@ -29,253 +28,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { public class DistributedCacheTagHelperTest { - [Fact] - public void GenerateKey_ReturnsKeyBasedOnTagHelperName() - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - Name = name - }; - var expected = GetHashedBytes("DistributedCacheTagHelper||" + name); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - - [Theory] - [InlineData("Vary-By-Value")] - [InlineData("Vary with spaces")] - [InlineData(" Vary with more spaces ")] - public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy) - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryBy = varyBy, - Name = name - }; - var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||" + varyBy); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - - [Theory] - [InlineData("Cookie0", "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value)")] - [InlineData("Cookie0,Cookie1", - "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData("Cookie0, Cookie1", - "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData(" Cookie0, , Cookie1 ", - "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - [InlineData(",Cookie0,,Cookie1,", - "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] - public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected) - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByCookie = varyByCookie, - Name = name - }; - cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] = - "Cookie0=Cookie0Value;Cookie1=Cookie1Value"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("Accept-Language", "DistributedCacheTagHelper||some-name||VaryByHeader(Accept-Language||en-us;charset=utf8)")] - [InlineData("X-CustomHeader,Accept-Encoding, NotAvailable", - "DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] - [InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable", - "DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] - public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected) - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByHeader = varyByHeader, - Name = name - }; - var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers; - headers["Accept-Language"] = "en-us;charset=utf8"; - headers["Accept-Encoding"] = "utf8"; - headers["X-CustomHeader"] = "Header-Value"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("category", "DistributedCacheTagHelper||some-name||VaryByQuery(category||cats)")] - [InlineData("Category,SortOrder,SortOption", - "DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] - [InlineData("Category, SortOrder, SortOption, ", - "DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] - public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected) - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByQuery = varyByQuery, - Name = name - }; - cacheTagHelper.ViewContext.HttpContext.Request.QueryString = - new QueryString("?sortoption=Adorability&Category=cats&sortOrder="); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Theory] - [InlineData("id", "DistributedCacheTagHelper||some-name||VaryByRoute(id||4)")] - [InlineData("Category,,Id,OptionRouteValue", - "DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] - [InlineData(" Category, , Id, OptionRouteValue, ", - "DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] - public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected) - { - // Arrange - var name = "some-name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByRoute = varyByRoute, - Name = name - }; - cacheTagHelper.ViewContext.RouteData.Values["id"] = 4; - cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory"; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated() - { - // Arrange - var name = "some-name"; - var expected = "DistributedCacheTagHelper||some-name||VaryByUser||"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true, - Name = name - }; - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName() - { - // Arrange - var name = "some-name"; - var expected = "DistributedCacheTagHelper||some-name||VaryByUser||test_name"; - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true, - Name = name - }; - var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") }); - cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(GetHashedBytes(expected), key); - } - - [Fact] - public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey() - { - // Arrange - var name = "some-name"; - var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||custom-value||" + - "VaryByHeader(content-type||text/html)||VaryByUser||someuser"); - var tagHelperContext = GetTagHelperContext(); - var cacheTagHelper = new DistributedCacheTagHelper( - Mock.Of(), - new HtmlTestEncoder()) - { - ViewContext = GetViewContext(), - VaryByUser = true, - VaryByHeader = "content-type", - VaryBy = "custom-value", - Name = name - }; - cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html"; - var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") }); - cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); - - // Act - var key = cacheTagHelper.GenerateKey(tagHelperContext); - - // Assert - Assert.Equal(expected, key); - } - + [Fact] public async Task ProcessAsync_DoesNotCache_IfDisabled() { @@ -293,7 +46,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage.Object, Mock.Of(), - new HtmlTestEncoder()); + new HtmlTestEncoder(), + NullLoggerFactory.Instance); var tagHelperOutput = GetTagHelperOutput( attributes: new TagHelperAttributeList(), childContent: childContent); @@ -333,7 +87,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage.Object, Mock.Of(), - new HtmlTestEncoder()); + new HtmlTestEncoder(), + NullLoggerFactory.Instance); var tagHelperContext = GetTagHelperContext(); var tagHelperOutput = GetTagHelperOutput( attributes: new TagHelperAttributeList(), @@ -383,7 +138,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, formatter, - new HtmlTestEncoder()); + new HtmlTestEncoder(), + NullLoggerFactory.Instance); var cacheTagHelper1 = new DistributedCacheTagHelper( service, new HtmlTestEncoder()) @@ -439,7 +195,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder()); + new HtmlTestEncoder(), + NullLoggerFactory.Instance); var tagHelperContext1 = GetTagHelperContext(); var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); tagHelperOutput1.PreContent.Append(""); @@ -496,7 +253,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var cacheTagHelper = new DistributedCacheTagHelper( service, @@ -521,7 +279,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var cacheTagHelper = new DistributedCacheTagHelper( service, @@ -546,7 +305,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var cacheTagHelper = new DistributedCacheTagHelper( service, @@ -575,7 +335,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var tagHelperContext1 = GetTagHelperContext(); var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); @@ -636,7 +397,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var tagHelperContext1 = GetTagHelperContext(); var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); @@ -697,7 +459,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, Mock.Of(), - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var tagHelperContext1 = GetTagHelperContext(); var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); @@ -759,7 +522,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, formatter, - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var tagHelperContext1 = GetTagHelperContext(); var tagHelperContext2 = GetTagHelperContext(); @@ -852,7 +616,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var service = new DistributedCacheTagHelperService( storage, formatter, - new HtmlTestEncoder() + new HtmlTestEncoder(), + NullLoggerFactory.Instance ); var tagHelperContext1 = GetTagHelperContext(); var tagHelperContext2 = GetTagHelperContext(); @@ -985,16 +750,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers }); } - private static string GetHashedBytes(string input) - { - using (var sha = SHA256.Create()) - { - var contentBytes = Encoding.UTF8.GetBytes(input); - var hashedBytes = sha.ComputeHash(contentBytes); - return Convert.ToBase64String(hashedBytes); - } - } - private static IDistributedCacheTagHelperStorage GetStorage(MemoryCacheOptions options = null) { return new DistributedCacheTagHelperStorage(new MemoryDistributedCache(new MemoryCache(options ?? new MemoryCacheOptions()))); diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs.orig b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs.orig new file mode 100644 index 0000000000..f43a501178 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs.orig @@ -0,0 +1,1000 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers.Cache; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + public class DistributedCacheTagHelperTest + { +<<<<<<< HEAD + [Fact] + public void GenerateKey_ReturnsKeyBasedOnTagHelperName() + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Name = name + }; + var expected = GetHashedBytes("DistributedCacheTagHelper||" + name); + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("Vary-By-Value")] + [InlineData("Vary with spaces")] + [InlineData(" Vary with more spaces ")] + public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy) + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryBy = varyBy, + Name = name + }; + var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||" + varyBy); + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(expected, key); + } + + [Theory] + [InlineData("Cookie0", "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value)")] + [InlineData("Cookie0,Cookie1", + "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData("Cookie0, Cookie1", + "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData(" Cookie0, , Cookie1 ", + "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + [InlineData(",Cookie0,,Cookie1,", + "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")] + public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected) + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCookie = varyByCookie, + Name = name + }; + cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] = + "Cookie0=Cookie0Value;Cookie1=Cookie1Value"; + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Theory] + [InlineData("Accept-Language", "DistributedCacheTagHelper||some-name||VaryByHeader(Accept-Language||en-us;charset=utf8)")] + [InlineData("X-CustomHeader,Accept-Encoding, NotAvailable", + "DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] + [InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable", + "DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")] + public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected) + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByHeader = varyByHeader, + Name = name + }; + var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers; + headers["Accept-Language"] = "en-us;charset=utf8"; + headers["Accept-Encoding"] = "utf8"; + headers["X-CustomHeader"] = "Header-Value"; + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Theory] + [InlineData("category", "DistributedCacheTagHelper||some-name||VaryByQuery(category||cats)")] + [InlineData("Category,SortOrder,SortOption", + "DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] + [InlineData("Category, SortOrder, SortOption, ", + "DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")] + public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected) + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByQuery = varyByQuery, + Name = name + }; + cacheTagHelper.ViewContext.HttpContext.Request.QueryString = + new QueryString("?sortoption=Adorability&Category=cats&sortOrder="); + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Theory] + [InlineData("id", "DistributedCacheTagHelper||some-name||VaryByRoute(id||4)")] + [InlineData("Category,,Id,OptionRouteValue", + "DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] + [InlineData(" Category, , Id, OptionRouteValue, ", + "DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")] + public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected) + { + // Arrange + var name = "some-name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByRoute = varyByRoute, + Name = name + }; + cacheTagHelper.ViewContext.RouteData.Values["id"] = 4; + cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory"; + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Fact] + public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated() + { + // Arrange + var name = "some-name"; + var expected = "DistributedCacheTagHelper||some-name||VaryByUser||"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true, + Name = name + }; + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Fact] + public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName() + { + // Arrange + var name = "some-name"; + var expected = "DistributedCacheTagHelper||some-name||VaryByUser||test_name"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true, + Name = name + }; + var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") }); + cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(GetHashedBytes(expected), key); + } + + [Fact] + public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey() + { + // Arrange + var name = "some-name"; + var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||custom-value||" + + "VaryByHeader(content-type||text/html)||VaryByUser||someuser"); + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new DistributedCacheTagHelper( + Mock.Of(), + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByUser = true, + VaryByHeader = "content-type", + VaryBy = "custom-value", + Name = name + }; + cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html"; + var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") }); + cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity); + + // Act + var key = cacheTagHelper.GenerateKey(tagHelperContext); + + // Assert + Assert.Equal(expected, key); + } + +======= + +>>>>>>> [Fixes #4246] Introducing CacheTagKey + [Fact] + public async Task ProcessAsync_DoesNotCache_IfDisabled() + { + // Arrange + var childContent = "original-child-content"; + var storage = new Mock(); + var value = Encoding.UTF8.GetBytes("ok"); + storage.Setup(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ value, + /*options*/ It.IsAny())); + storage.Setup(c => c.GetAsync(It.IsAny())) + .Returns(Task.FromResult(value)); + var tagHelperContext = GetTagHelperContext(); + var service = new DistributedCacheTagHelperService( + storage.Object, + Mock.Of(), + new HtmlTestEncoder()); + var tagHelperOutput = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var cacheTagHelper = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = false + }; + + // Act + await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); + + // Assert + Assert.Equal(childContent, tagHelperOutput.Content.GetContent()); + storage.Verify(c => c.SetAsync( + /*key*/ It.IsAny(), + /*content*/ value, + /*options*/ It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ProcessAsync_ReturnsCachedValue_IfEnabled() + { + // Arrange + var childContent = "original-child-content"; + var storage = new Mock(); + var value = Encoding.UTF8.GetBytes(childContent); + storage.Setup(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ value, + /*options*/ It.IsAny())); + storage.Setup(c => c.GetAsync(It.IsAny())) + .Returns(Task.FromResult(null)); + var service = new DistributedCacheTagHelperService( + storage.Object, + Mock.Of(), + new HtmlTestEncoder()); + var tagHelperContext = GetTagHelperContext(); + var tagHelperOutput = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var cacheTagHelper = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true, + Name = "some-name" + }; + + // Act + await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); + + // Assert + Assert.Empty(tagHelperOutput.PreContent.GetContent()); + Assert.Empty(tagHelperOutput.PostContent.GetContent()); + Assert.True(tagHelperOutput.IsContentModified); + Assert.Equal(childContent, tagHelperOutput.Content.GetContent()); + + storage.Verify(c => c.GetAsync( + /*key*/ It.IsAny() + ), + Times.Once); + + storage.Verify(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ It.IsAny(), + /*options*/ It.IsAny()), + Times.Once); + + } + + [Fact] + public async Task ProcessAsync_ReturnsCachedValue_IfVaryByParamIsUnchanged() + { + // Arrange - 1 + var childContent = "original-child-content"; + var storage = GetStorage(); + var formatter = GetFormatter(); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: childContent); + var service = new DistributedCacheTagHelperService( + storage, + formatter, + new HtmlTestEncoder()); + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + Enabled = true, + VaryByQuery = "key1,key2", + ViewContext = GetViewContext(), + }; + cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new QueryString( + "?key1=value1&key2=value2"); + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var tagHelperContext2 = GetTagHelperContext(); + var tagHelperOutput2 = GetTagHelperOutput( + attributes: new TagHelperAttributeList(), + childContent: "different-content"); + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + Enabled = true, + VaryByQuery = "key1,key2", + ViewContext = GetViewContext(), + }; + cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new QueryString( + "?key1=value1&key2=value2"); + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_RecalculatesValueIfCacheKeyChanges() + { + // Arrange - 1 + var childContent1 = "original-child-content"; + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder()); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.Append(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + VaryByCookie = "cookie1,cookie2", + ViewContext = GetViewContext(), + }; + cacheTagHelper1.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=value2"; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + VaryByCookie = "cookie1,cookie2", + ViewContext = GetViewContext(), + }; + cacheTagHelper2.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=not-value2"; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresOnIsSet() + { + // Arrange + var expiresOn = DateTimeOffset.UtcNow.AddMinutes(4); + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var cacheTagHelper = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ExpiresOn = expiresOn + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions(); + + // Assert + Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresAfterIsSet() + { + // Arrange + var expiresAfter = TimeSpan.FromSeconds(42); + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var cacheTagHelper = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ExpiresAfter = expiresAfter + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions(); + + // Assert + Assert.Equal(expiresAfter, cacheEntryOptions.AbsoluteExpirationRelativeToNow); + } + + [Fact] + public void UpdateCacheEntryOptions_SetsSlidingExpiration_IfExpiresSlidingIsSet() + { + // Arrange + var expiresSliding = TimeSpan.FromSeconds(37); + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var cacheTagHelper = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ExpiresSliding = expiresSliding + }; + + // Act + var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions(); + + // Assert + Assert.Equal(expiresSliding, cacheEntryOptions.SlidingExpiration); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresAfter_ToExpireCacheEntry() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresAfter = TimeSpan.FromMinutes(10) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresAfter = TimeSpan.FromMinutes(10) + }; + currentTime = currentTime.AddMinutes(11); + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresOn_ToExpireCacheEntry() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresOn = currentTime.AddMinutes(5) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + currentTime = currentTime.AddMinutes(5).AddSeconds(2); + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresOn = currentTime.AddMinutes(5) + }; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_UsesExpiresSliding_ToExpireCacheEntryWithSlidingExpiration() + { + // Arrange - 1 + var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero); + var childContent1 = "original-child-content"; + var clock = new Mock(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresSliding = TimeSpan.FromSeconds(30) + }; + + // Act - 1 + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + + // Assert - 1 + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent()); + + // Arrange - 2 + currentTime = currentTime.AddSeconds(35); + var childContent2 = "different-content"; + var tagHelperContext2 = GetTagHelperContext(); + var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2); + tagHelperOutput2.PreContent.SetContent(""); + tagHelperOutput2.PostContent.SetContent(""); + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + ExpiresSliding = TimeSpan.FromSeconds(30) + }; + + // Act - 2 + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + + // Assert - 2 + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent()); + } + + [Fact] + public async Task ProcessAsync_ComputesValueOnce_WithConcurrentRequests() + { + // Arrange + var childContent = "some-content"; + var resetEvent1 = new ManualResetEvent(false); + var resetEvent2 = new ManualResetEvent(false); + var resetEvent3 = new ManualResetEvent(false); + var calls = 0; + var formatter = GetFormatter(); + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + formatter, + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperContext2 = GetTagHelperContext(); + + var tagHelperOutput1 = new TagHelperOutput( + "distributed-cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent2.Set(); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var tagHelperOutput2 = new TagHelperOutput( + "distributed-cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent3.WaitOne(5000); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + // Act + + var task1 = Task.Run(async () => + { + resetEvent1.WaitOne(5000); + await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1); + resetEvent3.Set(); + }); + + var task2 = Task.Run(async () => + { + resetEvent2.WaitOne(5000); + await cacheTagHelper2.ProcessAsync(tagHelperContext1, tagHelperOutput2); + }); + + resetEvent1.Set(); + await Task.WhenAll(task1, task2); + + // Assert + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.True(tagHelperOutput1.IsContentModified); + Assert.Equal(childContent, tagHelperOutput1.Content.GetContent()); + + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + + Assert.Equal(1, calls); + } + + [Fact] + public async Task ProcessAsync_ExceptionInProcessing_DoesntBlockConcurrentRequests() + { + // Arrange + var childContent = "some-content"; + var resetEvent1 = new ManualResetEvent(false); + var resetEvent2 = new ManualResetEvent(false); + var resetEvent3 = new ManualResetEvent(false); + var calls = 0; + var formatter = GetFormatter(); + var storage = GetStorage(); + var service = new DistributedCacheTagHelperService( + storage, + formatter, + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperContext2 = GetTagHelperContext(); + + var tagHelperOutput1 = new TagHelperOutput( + "distributed-cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent2.Set(); + + throw new Exception(); + }); + + var tagHelperOutput2 = new TagHelperOutput( + "distributed-cache", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + calls++; + resetEvent3.WaitOne(5000); + + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + + var cacheTagHelper1 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + var cacheTagHelper2 = new DistributedCacheTagHelper( + service, + new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + Enabled = true + }; + + // Act + + var task1 = Task.Run(async () => + { + resetEvent1.WaitOne(5000); + await Assert.ThrowsAsync(() => cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1)); + resetEvent3.Set(); + }); + + var task2 = Task.Run(async () => + { + resetEvent2.WaitOne(5000); + await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2); + }); + + resetEvent1.Set(); + await Task.WhenAll(task1, task2); + + // Assert + Assert.Empty(tagHelperOutput1.PreContent.GetContent()); + Assert.Empty(tagHelperOutput1.PostContent.GetContent()); + Assert.False(tagHelperOutput1.IsContentModified); + Assert.Empty(tagHelperOutput1.Content.GetContent()); + + Assert.Empty(tagHelperOutput2.PreContent.GetContent()); + Assert.Empty(tagHelperOutput2.PostContent.GetContent()); + Assert.True(tagHelperOutput2.IsContentModified); + Assert.Equal(childContent, tagHelperOutput2.Content.GetContent()); + + Assert.Equal(2, calls); + } + + [Fact] + public async Task Deserialize_DoesntAlterValue_WhenSerialized() + { + // Arrange + var content = "some content"; + var formatter = GetFormatter(); + var context = new DistributedCacheTagHelperFormattingContext + { + Html = new HtmlString(content) + }; + var serialized = await formatter.SerializeAsync(context); + + // Act + var deserialized = await formatter.DeserializeAsync(serialized); + + // Assert + Assert.Equal(deserialized.ToString(), content); + } + + private static ViewContext GetViewContext() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ViewContext(actionContext, + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + private static TagHelperContext GetTagHelperContext() + { + return new TagHelperContext( + allAttributes: new TagHelperAttributeList(), + items: new Dictionary(), + uniqueId: "testid"); + } + + private static TagHelperOutput GetTagHelperOutput( + string tagName = "distributed-cache", + TagHelperAttributeList attributes = null, + string childContent = "some child content") + { + attributes = attributes ?? new TagHelperAttributeList { { "attr", "value" } }; + + return new TagHelperOutput( + tagName, + attributes, + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetHtmlContent(childContent); + return Task.FromResult(tagHelperContent); + }); + } + + private static IDistributedCacheTagHelperStorage GetStorage(MemoryCacheOptions options = null) + { + return new DistributedCacheTagHelperStorage(new MemoryDistributedCache(new MemoryCache(options ?? new MemoryCacheOptions()))); + } + + private static IDistributedCacheTagHelperFormatter GetFormatter() + { + return new DistributedCacheTagHelperFormatter(); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/project.json b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/project.json index a7b689def1..9eec04db48 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/project.json +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/project.json @@ -13,7 +13,8 @@ "type": "build" }, "Microsoft.AspNetCore.Testing": "1.0.0-*", - "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*" + "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*", + "Microsoft.Extensions.Logging.Testing": "1.0.0-*" }, "testRunner": "xunit", "frameworks": {