diff --git a/src/Components/Components/src/ITempData.cs b/src/Components/Components/src/ITempData.cs new file mode 100644 index 000000000000..70f637e7af6e --- /dev/null +++ b/src/Components/Components/src/ITempData.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Provides a dictionary for storing data that is needed for subsequent requests. +/// Data stored in TempData is automatically removed after it is read unless +/// or is called, or it is accessed via . +/// +public interface ITempData : IDictionary +{ + /// + /// Gets the value associated with the specified key and then schedules it for deletion. + /// + object? Get(string key); + + /// + /// Gets the value associated with the specified key without scheduling it for deletion. + /// + object? Peek(string key); + + /// + /// Makes all of the keys currently in TempData persist for another request. + /// + void Keep(); + + /// + /// Makes the element with the persist for another request. + /// + void Keep(string key); +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 908e19bcf6f2..2e3f440a6e8b 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +1,23 @@ #nullable enable +Microsoft.AspNetCore.Components.ITempData +Microsoft.AspNetCore.Components.ITempData.Get(string! key) -> object? +Microsoft.AspNetCore.Components.ITempData.Keep() -> void +Microsoft.AspNetCore.Components.ITempData.Keep(string! key) -> void +Microsoft.AspNetCore.Components.ITempData.Peek(string! key) -> object? +Microsoft.AspNetCore.Components.TempData +Microsoft.AspNetCore.Components.TempData.ContainsValue(object? value) -> bool +Microsoft.AspNetCore.Components.TempData.TempData() -> void +Microsoft.AspNetCore.Components.TempData.Get(string! key) -> object? +Microsoft.AspNetCore.Components.TempData.this[string! key].get -> object? +Microsoft.AspNetCore.Components.TempData.this[string! key].set -> void +Microsoft.AspNetCore.Components.TempData.Keep() -> void +Microsoft.AspNetCore.Components.TempData.Keep(string! key) -> void +Microsoft.AspNetCore.Components.TempData.Peek(string! key) -> object? +Microsoft.AspNetCore.Components.TempData.Clear() -> void +Microsoft.AspNetCore.Components.TempData.Remove(string! key) -> bool +Microsoft.AspNetCore.Components.TempData.ContainsKey(string! key) -> bool +Microsoft.AspNetCore.Components.TempData.Load(System.Collections.Generic.IDictionary! data) -> void +Microsoft.AspNetCore.Components.TempData.Save() -> System.Collections.Generic.IDictionary! Microsoft.AspNetCore.Components.IComponentPropertyActivator Microsoft.AspNetCore.Components.IComponentPropertyActivator.GetActivator(System.Type! componentType) -> System.Action! *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void diff --git a/src/Components/Components/src/TempData.cs b/src/Components/Components/src/TempData.cs new file mode 100644 index 000000000000..3778e385bf32 --- /dev/null +++ b/src/Components/Components/src/TempData.cs @@ -0,0 +1,200 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; + +namespace Microsoft.AspNetCore.Components; + +/// +public class TempData : ITempData +{ + private readonly Dictionary _data = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _retainedKeys = new(StringComparer.OrdinalIgnoreCase); + + /// + public object? this[string key] + { + get => Get(key); + set + { + _data[key] = value; + _retainedKeys.Add(key); + } + } + + /// + public object? Get(string key) + { + _retainedKeys.Remove(key); + return _data.GetValueOrDefault(key); + } + + /// + public object? Peek(string key) + { + return _data.GetValueOrDefault(key); + } + + /// + public void Keep() + { + _retainedKeys.Clear(); + _retainedKeys.UnionWith(_data.Keys); + } + + /// + public void Keep(string key) + { + if (_data.ContainsKey(key)) + { + _retainedKeys.Add(key); + } + } + + /// + public bool ContainsKey(string key) + { + return _data.ContainsKey(key); + } + + /// + public bool Remove(string key) + { + _retainedKeys.Remove(key); + return _data.Remove(key); + } + + /// + /// Returns true if the TempData dictionary contains the specified . + /// + public bool ContainsValue(object? value) + { + return _data.ContainsValue(value); + } + + /// + /// Gets the data that should be saved for the next request. + /// + public IDictionary Save() + { + var dataToSave = new Dictionary(); + foreach (var key in _retainedKeys) + { + dataToSave[key] = _data[key]; + } + return dataToSave; + } + + /// + /// Loads data from a into the TempData dictionary. + /// + public void Load(IDictionary data) + { + _data.Clear(); + _retainedKeys.Clear(); + foreach (var kvp in data) + { + _data[kvp.Key] = kvp.Value; + _retainedKeys.Add(kvp.Key); + } + } + + /// + public void Clear() + { + _data.Clear(); + _retainedKeys.Clear(); + } + + ICollection IDictionary.Keys => _data.Keys; + + ICollection IDictionary.Values => _data.Values; + + int ICollection>.Count => _data.Count; + + bool ICollection>.IsReadOnly => ((ICollection>)_data).IsReadOnly; + + void IDictionary.Add(string key, object? value) + { + this[key] = value; + } + + bool IDictionary.TryGetValue(string key, out object? value) + { + value = Get(key); + return ContainsKey(key); + } + + void ICollection>.Add(KeyValuePair item) + { + ((IDictionary)this).Add(item.Key, item.Value); + } + + bool ICollection>.Contains(KeyValuePair item) + { + return ContainsKey(item.Key) && Equals(Peek(item.Key), item.Value); + } + + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((ICollection>)_data).CopyTo(array, arrayIndex); + } + + bool ICollection>.Remove(KeyValuePair item) + { + if (ContainsKey(item.Key) && Equals(Peek(item.Key), item.Value)) + { + return Remove(item.Key); + } + return false; + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + return new TempDataEnumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new TempDataEnumerator(this); + } + + class TempDataEnumerator : IEnumerator> + { + private readonly TempData _tempData; + private readonly IEnumerator> _innerEnumerator; + + public TempDataEnumerator(TempData tempData) + { + _tempData = tempData; + _innerEnumerator = tempData._data.GetEnumerator(); + } + + public KeyValuePair Current + { + get + { + var kvp = _innerEnumerator.Current; + _tempData.Remove(kvp.Key); + return kvp; + } + } + + object IEnumerator.Current => _innerEnumerator.Current; + + public void Dispose() + { + _innerEnumerator.Dispose(); + } + + public bool MoveNext() + { + return _innerEnumerator.MoveNext(); + } + + public void Reset() + { + _innerEnumerator.Reset(); + } + } +} diff --git a/src/Components/Components/test/TempDataTest.cs b/src/Components/Components/test/TempDataTest.cs new file mode 100644 index 000000000000..e02c062ffaad --- /dev/null +++ b/src/Components/Components/test/TempDataTest.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +public class TempDataTest +{ + [Fact] + public void Indexer_CanSetAndGetValues() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + var value = tempData["Key1"]; + Assert.Equal("Value1", value); + } + + [Fact] + public void Get_ReturnsValueAndRemovesFromRetainedKeys() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + + var value = tempData.Get("Key1"); + + Assert.Equal("Value1", value); + var saved = tempData.Save(); + Assert.Empty(saved); + } + + [Fact] + public void Get_ReturnsNullForNonExistentKey() + { + var tempData = new TempData(); + var value = tempData.Get("NonExistent"); + Assert.Null(value); + } + + [Fact] + public void Peek_ReturnsValueWithoutRemovingFromRetainedKeys() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + var value = tempData.Peek("Key1"); + Assert.Equal("Value1", value); + value = tempData.Get("Key1"); + Assert.Equal("Value1", value); + } + + [Fact] + public void Peek_ReturnsNullForNonExistentKey() + { + var tempData = new TempData(); + var value = tempData.Peek("NonExistent"); + Assert.Null(value); + } + + [Fact] + public void Keep_RetainsAllKeys() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + tempData["Key2"] = "Value2"; + _ = tempData.Get("Key1"); + _ = tempData.Get("Key2"); + + tempData.Keep(); + + var value1 = tempData.Get("Key1"); + var value2 = tempData.Get("Key2"); + Assert.Equal("Value1", value1); + Assert.Equal("Value2", value2); + } + + [Fact] + public void KeepWithKey_RetainsSpecificKey() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + tempData["Key2"] = "Value2"; + _ = tempData.Get("Key1"); + _ = tempData.Get("Key2"); + + tempData.Keep("Key1"); + + var saved = tempData.Save(); + Assert.Single(saved); + Assert.Equal("Value1", saved["Key1"]); + } + + [Fact] + public void KeepWithKey_DoesNothingForNonExistentKey() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + _ = tempData.Get("Key1"); + + tempData.Keep("NonExistent"); + + var value = tempData.Get("NonExistent"); + Assert.Null(value); + } + + [Fact] + public void ContainsKey_ReturnsTrueForExistingKey() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + var result = tempData.ContainsKey("Key1"); + Assert.True(result); + } + + [Fact] + public void ContainsKey_ReturnsFalseForNonExistentKey() + { + var tempData = new TempData(); + var result = tempData.ContainsKey("NonExistent"); + Assert.False(result); + } + + [Fact] + public void Remove_RemovesKeyAndReturnsTrue() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + + var result = tempData.Remove("Key1"); + + Assert.True(result); + var value = tempData.Get("Key1"); + Assert.Null(value); + } + + [Fact] + public void Remove_ReturnsFalseForNonExistentKey() + { + var tempData = new TempData(); + var result = tempData.Remove("NonExistent"); + Assert.False(result); + } + + [Fact] + public void Save_ReturnsOnlyRetainedKeys() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + tempData["Key2"] = "Value2"; + tempData["Key3"] = "Value3"; + _ = tempData.Get("Key1"); + _ = tempData.Get("Key2"); + + var saved = tempData.Save(); + + Assert.Single(saved); + Assert.Equal("Value3", saved["Key3"]); + } + + [Fact] + public void Load_PopulatesDataFromDictionary() + { + var tempData = new TempData(); + var dataToLoad = new Dictionary + { + ["Key1"] = "Value1", + ["Key2"] = "Value2" + }; + + tempData.Load(dataToLoad); + + Assert.Equal("Value1", tempData.Get("Key1")); + Assert.Equal("Value2", tempData.Get("Key2")); + } + + [Fact] + public void Load_ClearsExistingDataBeforeLoading() + { + var tempData = new TempData(); + tempData["ExistingKey"] = "ExistingValue"; + var dataToLoad = new Dictionary + { + ["NewKey"] = "NewValue" + }; + + tempData.Load(dataToLoad); + + Assert.False(tempData.ContainsKey("ExistingKey")); + Assert.True(tempData.ContainsKey("NewKey")); + } + + [Fact] + public void Clear_RemovesAllData() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + tempData["Key2"] = "Value2"; + + tempData.Clear(); + + Assert.Null(tempData.Get("Key1")); + Assert.Null(tempData.Get("Key2")); + } + + [Fact] + public void Indexer_IsCaseInsensitive() + { + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + var value = tempData["KEY1"]; + Assert.Equal("Value1", value); + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index dc365194fcbe..4e3a0bd2a49f 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -74,6 +74,26 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddCascadingValue(sp => + { + var httpContext = sp.GetRequiredService().HttpContext; + if (httpContext is null) + { + return null!; + } + var key = typeof(ITempData); + if (!httpContext.Items.TryGetValue(key, out var tempData)) + { + var tempDataInstance = TempDataService.Load(httpContext); + httpContext.Items[key] = tempDataInstance; + httpContext.Response.OnStarting(() => + { + TempDataService.Save(httpContext, tempDataInstance); + return Task.CompletedTask; + }); + } + return (ITempData)httpContext.Items[key]!; + }); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs new file mode 100644 index 000000000000..2800d45a224f --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed partial class TempDataService +{ + private const string CookieName = ".AspNetCore.Components.TempData"; + private const string PurposeString = "Microsoft.AspNetCore.Components.Endpoints.TempDataService"; + private const int MaxEncodedLength = 4050; + + private static IDataProtector GetDataProtector(HttpContext httpContext) + { + var dataProtectionProvider = httpContext.RequestServices.GetRequiredService(); + return dataProtectionProvider.CreateProtector(PurposeString); + } + + public static TempData Load(HttpContext httpContext) + { + try + { + var returnTempData = new TempData(); + var serializedDataFromCookie = httpContext.Request.Cookies[CookieName]; + if (serializedDataFromCookie is null) + { + return returnTempData; + } + + var protectedBytes = WebEncoders.Base64UrlDecode(serializedDataFromCookie); + var unprotectedBytes = GetDataProtector(httpContext).Unprotect(protectedBytes); + + var dataFromCookie = JsonSerializer.Deserialize>(unprotectedBytes); + + if (dataFromCookie is null) + { + return returnTempData; + } + + var convertedData = new Dictionary(); + foreach (var kvp in dataFromCookie) + { + convertedData[kvp.Key] = ConvertJsonElement(kvp.Value); + } + + returnTempData.Load(convertedData); + return returnTempData; + } + catch (Exception ex) + { + // If any error occurs during loading (e.g. data protection key changed, malformed cookie), + // return an empty TempData dictionary. + if (httpContext.RequestServices.GetService>() is { } logger) + { + Log.TempDataCookieLoadFailure(logger, CookieName, ex); + } + + httpContext.Response.Cookies.Delete(CookieName, new CookieOptions + { + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + return new TempData(); + } + } + + public static void Save(HttpContext httpContext, TempData tempData) + { + var dataToSave = tempData.Save(); + foreach (var kvp in dataToSave) + { + if (!CanSerializeType(kvp.Value?.GetType() ?? typeof(object))) + { + throw new InvalidOperationException($"TempData cannot store values of type '{kvp.Value?.GetType()}'."); + } + } + + if (dataToSave.Count == 0) + { + httpContext.Response.Cookies.Delete(CookieName, new CookieOptions + { + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + return; + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(dataToSave); + var protectedBytes = GetDataProtector(httpContext).Protect(bytes); + var encodedValue = WebEncoders.Base64UrlEncode(protectedBytes); + + if (encodedValue.Length > MaxEncodedLength) + { + if (httpContext.RequestServices.GetService>() is { } logger) + { + Log.TempDataCookieSaveFailure(logger, CookieName); + } + + httpContext.Response.Cookies.Delete(CookieName, new CookieOptions + { + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + return; + } + + httpContext.Response.Cookies.Append(CookieName, encodedValue, new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = httpContext.Request.IsHttps, + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + } + + private static object? ConvertJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + if (element.TryGetGuid(out var guid)) + { + return guid; + } + if (element.TryGetDateTime(out var dateTime)) + { + return dateTime; + } + return element.GetString(); + case JsonValueKind.Number: + return element.GetInt32(); + case JsonValueKind.True: + case JsonValueKind.False: + return element.GetBoolean(); + case JsonValueKind.Null: + return null; + case JsonValueKind.Array: + return DeserializeArray(element); + case JsonValueKind.Object: + return DeserializeDictionaryEntry(element); + default: + throw new InvalidOperationException($"TempData cannot deserialize value of type '{element.ValueKind}'."); + } + } + + private static object? DeserializeArray(JsonElement arrayElement) + { + var arrayLength = arrayElement.GetArrayLength(); + if (arrayLength == 0) + { + return null; + } + if (arrayElement[0].ValueKind == JsonValueKind.String) + { + var array = new List(arrayLength); + foreach (var item in arrayElement.EnumerateArray()) + { + array.Add(item.GetString()); + } + return array.ToArray(); + } + else if (arrayElement[0].ValueKind == JsonValueKind.Number) + { + var array = new List(arrayLength); + foreach (var item in arrayElement.EnumerateArray()) + { + array.Add(item.GetInt32()); + } + return array.ToArray(); + } + throw new InvalidOperationException($"TempData cannot deserialize array of type '{arrayElement[0].ValueKind}'."); + } + + private static Dictionary DeserializeDictionaryEntry(JsonElement objectElement) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + foreach (var item in objectElement.EnumerateObject()) + { + dictionary[item.Name] = item.Value.GetString(); + } + return dictionary; + } + + private static bool CanSerializeType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return + type.IsEnum || + type == typeof(int) || + type == typeof(string) || + type == typeof(bool) || + type == typeof(DateTime) || + type == typeof(Guid) || + typeof(ICollection).IsAssignableFrom(type) || + typeof(ICollection).IsAssignableFrom(type) || + typeof(IDictionary).IsAssignableFrom(type); + } + + private static partial class Log + { + [LoggerMessage(3, LogLevel.Warning, "The temp data cookie {CookieName} could not be loaded.", EventName = "TempDataCookieLoadFailure")] + public static partial void TempDataCookieLoadFailure(ILogger logger, string cookieName, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "The temp data cookie {CookieName} could not be saved, because it is too large to fit in a single cookie.", EventName = "TempDataCookieSaveFailure")] + public static partial void TempDataCookieSaveFailure(ILogger logger, string cookieName); + } +} diff --git a/src/Components/Endpoints/test/TempDataServiceTest.cs b/src/Components/Endpoints/test/TempDataServiceTest.cs new file mode 100644 index 000000000000..8b49658d8f3f --- /dev/null +++ b/src/Components/Endpoints/test/TempDataServiceTest.cs @@ -0,0 +1,297 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.Extensions.DependencyInjection; + +public class TempDataServiceTest +{ + [Fact] + public void Load_ReturnsEmptyTempData_WhenNoCookieExists() + { + var httpContext = CreateHttpContext(); + + var tempData = TempDataService.Load(httpContext); + + Assert.NotNull(tempData); + Assert.Empty(tempData.Save()); + } + + [Fact] + public void Save_DeletesCookie_WhenNoDataToSave() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + + TempDataService.Save(httpContext, tempData); + + var cookieFeature = httpContext.Features.Get(); + Assert.NotNull(cookieFeature); + Assert.Contains(".AspNetCore.Components.TempData", cookieFeature.DeletedCookies); + } + + [Fact] + public void Save_SetsCookie_WhenDataExists() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + + TempDataService.Save(httpContext, tempData); + + var cookieFeature = httpContext.Features.Get(); + Assert.NotNull(cookieFeature); + Assert.True(cookieFeature.SetCookies.ContainsKey(".AspNetCore.Components.TempData")); + } + + [Fact] + public void RoundTrip_PreservesStringValue() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["StringKey"] = "StringValue"; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal("StringValue", loadedTempData.Peek("StringKey")); + } + + [Fact] + public void RoundTrip_PreservesIntValue() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["IntKey"] = 42; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(42, loadedTempData.Peek("IntKey")); + } + + [Fact] + public void RoundTrip_PreservesBoolValue() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["BoolKey"] = true; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(true, loadedTempData.Peek("BoolKey")); + } + + [Fact] + public void RoundTrip_PreservesGuidValue() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + var guid = Guid.NewGuid(); + tempData["GuidKey"] = guid; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(guid, loadedTempData.Peek("GuidKey")); + } + + [Fact] + public void RoundTrip_PreservesDateTimeValue() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + var dateTime = new DateTime(2025, 12, 15, 10, 30, 0, DateTimeKind.Utc); + tempData["DateTimeKey"] = dateTime; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(dateTime, loadedTempData.Peek("DateTimeKey")); + } + + [Fact] + public void RoundTrip_PreservesStringArray() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + var array = new[] { "one", "two", "three" }; + tempData["ArrayKey"] = array; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(array, loadedTempData.Peek("ArrayKey")); + } + + [Fact] + public void RoundTrip_PreservesIntArray() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + var array = new[] { 1, 2, 3 }; + tempData["ArrayKey"] = array; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal(array, loadedTempData.Peek("ArrayKey")); + } + + [Fact] + public void RoundTrip_PreservesDictionary() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + var dict = new Dictionary { ["a"] = "1", ["b"] = "2" }; + tempData["DictKey"] = dict; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + var loadedDict = Assert.IsType>(loadedTempData.Peek("DictKey")); + Assert.Equal("1", loadedDict["a"]); + Assert.Equal("2", loadedDict["b"]); + } + + [Fact] + public void RoundTrip_PreservesMultipleDifferentValues() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["Key1"] = "Value1"; + tempData["Key2"] = 123; + tempData["Key3"] = true; + + TempDataService.Save(httpContext, tempData); + SimulateCookieRoundTrip(httpContext); + var loadedTempData = TempDataService.Load(httpContext); + + Assert.Equal("Value1", loadedTempData.Peek("Key1")); + Assert.Equal(123, loadedTempData.Peek("Key2")); + Assert.Equal(true, loadedTempData.Peek("Key3")); + } + + [Fact] + public void Save_ThrowsForUnsupportedType() + { + var httpContext = CreateHttpContext(); + var tempData = new TempData(); + tempData["Key"] = new object(); + + Assert.Throws(() => TempDataService.Save(httpContext, tempData)); + } + + [Fact] + public void Load_ReturnsEmptyTempData_ForInvalidBase64Cookie() + { + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Cookie"] = ".AspNetCore.Components.TempData=not-valid-base64!!!"; + + var tempData = TempDataService.Load(httpContext); + + Assert.NotNull(tempData); + Assert.Empty(tempData.Save()); + } + + [Fact] + public void Load_ReturnsEmptyTempData_ForUnsupportedType() + { + var httpContext = CreateHttpContext(); + var json = "{\"Key\":[true, false, true]}"; + var encoded = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes(json)); + httpContext.Request.Headers["Cookie"] = $".AspNetCore.Components.TempData={encoded}"; + + var tempData = TempDataService.Load(httpContext); + + Assert.NotNull(tempData); + Assert.Empty(tempData.Save()); + } + + private static DefaultHttpContext CreateHttpContext() + { + var services = new ServiceCollection() + .AddSingleton() + .BuildServiceProvider(); + + var httpContext = new DefaultHttpContext + { + RequestServices = services + }; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("localhost"); + + var cookieFeature = new TestResponseCookiesFeature(); + httpContext.Features.Set(cookieFeature); + httpContext.Features.Set(cookieFeature); + + return httpContext; + } + + private static void SimulateCookieRoundTrip(HttpContext httpContext) + { + var cookieFeature = httpContext.Features.Get(); + if (cookieFeature != null && cookieFeature.SetCookies.TryGetValue(".AspNetCore.Components.TempData", out var cookieValue)) + { + httpContext.Request.Headers["Cookie"] = $".AspNetCore.Components.TempData={cookieValue}"; + } + } + + private class PassThroughDataProtectionProvider : IDataProtectionProvider + { + public IDataProtector CreateProtector(string purpose) => new PassThroughDataProtector(); + + private class PassThroughDataProtector : IDataProtector + { + public IDataProtector CreateProtector(string purpose) => this; + public byte[] Protect(byte[] plaintext) => plaintext; + public byte[] Unprotect(byte[] protectedData) => protectedData; + } + } + + private class TestResponseCookiesFeature : IResponseCookiesFeature + { + public Dictionary SetCookies { get; } = new(); + public HashSet DeletedCookies { get; } = new(); + + public IResponseCookies Cookies => new TestResponseCookies(this); + + private class TestResponseCookies : IResponseCookies + { + private readonly TestResponseCookiesFeature _feature; + + public TestResponseCookies(TestResponseCookiesFeature feature) + { + _feature = feature; + } + + public void Append(string key, string value) => Append(key, value, new CookieOptions()); + + public void Append(string key, string value, CookieOptions options) + { + _feature.SetCookies[key] = value; + } + + public void Delete(string key) => Delete(key, new CookieOptions()); + + public void Delete(string key, CookieOptions options) + { + _feature.DeletedCookies.Add(key); + } + } + } +} diff --git a/src/Components/test/E2ETest/Tests/TempDataTest.cs b/src/Components/test/E2ETest/Tests/TempDataTest.cs new file mode 100644 index 000000000000..75ed38cdda2c --- /dev/null +++ b/src/Components/test/E2ETest/Tests/TempDataTest.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.E2ETesting; +using Xunit.Abstractions; +using OpenQA.Selenium; +using TestServer; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class TempDataTest : ServerTestBase>> +{ + private const string TempDataCookieName = ".AspNetCore.Components.TempData"; + + public TempDataTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + + protected override void InitializeAsyncCore() + { + base.InitializeAsyncCore(); + Browser.Manage().Cookies.DeleteCookieNamed(TempDataCookieName); + } + + [Fact] + public void TempDataCanPersistThroughNavigation() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataCanPersistThroughDifferentPages() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button-diff-page")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataPeekDoesntDelete() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("Peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + } + + [Fact] + public void TempDataKeepAllElements() + { + Navigate($"{ServerPathBase}/tempdata?ValueToKeep=all"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataKeepOneElement() + { + Navigate($"{ServerPathBase}/tempdata?ValueToKeep=KeptValue"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + } + + [Fact] + public void CanRemoveTheElementWithRemove() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("Peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + Browser.FindElement(By.Id("delete-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("No peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + } + + [Fact] + public void CanCheckIfTempDataContainsKey() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("False", () => Browser.FindElement(By.Id("contains-peeked-value")).Text); + Browser.Equal("False", () => Browser.FindElement(By.Id("contains-message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("True", () => Browser.FindElement(By.Id("contains-peeked-value")).Text); + Browser.Equal("True", () => Browser.FindElement(By.Id("contains-message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("True", () => Browser.FindElement(By.Id("contains-peeked-value")).Text); + Browser.Equal("False", () => Browser.FindElement(By.Id("contains-message")).Text); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/Program.cs b/src/Components/test/testassets/Components.TestServer/Program.cs index 2f06b00b72ac..f42fbdc26f2e 100644 --- a/src/Components/test/testassets/Components.TestServer/Program.cs +++ b/src/Components/test/testassets/Components.TestServer/Program.cs @@ -38,6 +38,7 @@ public static async Task Main(string[] args) ["Hot Reload"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Dev server client-side blazor"] = CreateDevServerHost(CreateAdditionalArgs(args)), ["Global Interactivity"] = (BuildWebHost>(CreateAdditionalArgs(args)), "/subdir"), + ["SSR (No Interactivity)"] = (BuildWebHost>(CreateAdditionalArgs(args)), "/subdir"), }; var mainHost = BuildWebHost(args); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataComponent.razor new file mode 100644 index 000000000000..ed4ce0680f49 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataComponent.razor @@ -0,0 +1,101 @@ +@page "/tempdata" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

TempData Basic Test

+ +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ +

@_message

+

@_peekedValue

+

@_keptValue

+ +

@_containsMessageKey

+

@_containsPeekedValueKey

+ +@code { + [SupplyParameterFromForm(Name = "_handler")] + public string? Handler { get; set; } + + [CascadingParameter] + public ITempData? TempData { get; set; } + + [SupplyParameterFromQuery] + public string ValueToKeep { get; set; } = string.Empty; + + [SupplyParameterFromQuery] + public string ContainsKey { get; set; } = string.Empty; + + private string? _message; + private string? _peekedValue; + private string? _keptValue; + + private bool _containsMessageKey; + private bool _containsPeekedValueKey; + + protected override void OnInitialized() + { + _containsMessageKey = TempData?.ContainsKey("Message") ?? false; + _containsPeekedValueKey = TempData?.ContainsKey("PeekedValue") ?? false; + + if (Handler is not null) + { + return; + } + _message = TempData!.Get("Message") as string ?? "No message"; + _peekedValue = TempData!.Peek("PeekedValue") as string ?? "No peeked value"; + _keptValue = TempData!.Get("KeptValue") as string ?? "No kept value"; + + if (ValueToKeep == "all") + { + TempData!.Keep(); + } + else if (!string.IsNullOrEmpty(ValueToKeep)) + { + TempData!.Keep(ValueToKeep); + } + } + + private void SetValues(bool differentPage = false) + { + TempData!["Message"] = "Message"; + TempData!["PeekedValue"] = "Peeked value"; + TempData!["KeptValue"] = "Kept value"; + if (differentPage) + { + NavigateToDifferentPage(); + return; + } + NavigateToSamePageKeep(ValueToKeep); + } + + private void DeletePeekedValue() + { + TempData!.Remove("PeekedValue"); + NavigateToSamePage(); + } + + private void NavigateToSamePage() => NavigationManager.NavigateTo("/subdir/tempdata", forceLoad: true); + private void NavigateToSamePageKeep(string valueToKeep) => NavigationManager.NavigateTo($"/subdir/tempdata?ValueToKeep={valueToKeep}", forceLoad: true); + private void NavigateToDifferentPage() => NavigationManager.NavigateTo("/subdir/tempdata/read", forceLoad: true); +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor new file mode 100644 index 000000000000..950e58e1c24b --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor @@ -0,0 +1,33 @@ +@page "/tempdata/read" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

TempData Read Test

+ +

@_message

+

@_peekedValue

+

@_keptValue

+ + +@code { + [CascadingParameter] + public ITempData? TempData { get; set; } + + private string? _message; + private string? _peekedValue; + private string? _keptValue; + + protected override void OnInitialized() + { + if (TempData is null) + { + return; + } + _message = TempData.Get("Message") as string; + _message ??= "No message"; + _peekedValue = TempData.Get("PeekedValue") as string; + _peekedValue ??= "No peeked value"; + _keptValue = TempData.Get("KeptValue") as string; + _keptValue ??= "No kept value"; + } +}