diff --git a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs index 33ce74b0fc36..57e3b9fcce98 100644 --- a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -61,7 +60,11 @@ private static PropertyGetter PropertyGetterFactory((Type type, string propertyN return new PropertyGetter(type, propertyInfo); } - internal ISession? GetSession() => _httpContext?.Features.Get()?.Session; + // A null HttpContext means we're rendering interactively (Server circuit or WebAssembly), + // where the session isn't available; yield null instead of failing. When an HttpContext is + // present (static SSR) an unavailable session is a misconfiguration and fails fast. + internal ISession? GetSession() + => _httpContext is null ? null : SessionResolver.GetRequiredSession(_httpContext); internal void RegisterValueCallback(string sessionKey, Func valueGetter) { @@ -81,6 +84,7 @@ internal Task PersistAllValues() var session = GetSession(); if (session is null) { + Log.SessionUnavailable(_logger); return Task.CompletedTask; } @@ -120,6 +124,9 @@ private static partial class Log [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from session failed.", EventName = "SessionDeserializeFail")] public static partial void SessionDeserializeFail(ILogger logger, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "No active HttpContext is available (interactive rendering); [SupplyParameterFromSession] is skipped.", EventName = "SessionUnavailable")] + public static partial void SessionUnavailable(ILogger logger); } internal partial class SessionSubscription : CascadingParameterSubscription @@ -153,6 +160,7 @@ public SessionSubscription( var session = _owner.GetSession(); if (session is null) { + Log.SessionUnavailable(_owner._logger); return null; } diff --git a/src/Components/Endpoints/src/SessionResolver.cs b/src/Components/Endpoints/src/SessionResolver.cs new file mode 100644 index 000000000000..c3a1fd1e84c3 --- /dev/null +++ b/src/Components/Endpoints/src/SessionResolver.cs @@ -0,0 +1,24 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static class SessionResolver +{ + // Matches the message thrown by HttpContext.Session so both session-consuming + // features surface the same InvalidOperationException when the session is unavailable. + internal const string SessionNotConfiguredMessage = "Session has not been configured for this application or request."; + + internal static ISession GetRequiredSession(HttpContext httpContext) + { + var session = httpContext.Session; + if (session is null) + { + throw new InvalidOperationException(SessionNotConfiguredMessage); + } + + return session; + } +} diff --git a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs index 05c95a6b4ff0..172ed33f2a9c 100644 --- a/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs @@ -43,19 +43,20 @@ public CookieTempDataProvider( ArgumentNullException.ThrowIfNull(context); var cookieName = _options.TempDataCookie.Name ?? CookieName; - try + if (!context.Request.Cookies.ContainsKey(cookieName)) { - if (!context.Request.Cookies.ContainsKey(cookieName)) - { - Log.TempDataCookieNotFound(_logger, cookieName); - return ReadOnlyDictionary.Empty; - } - var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName); - if (serializedDataFromCookie is null) - { - return ReadOnlyDictionary.Empty; - } + Log.TempDataCookieNotFound(_logger, cookieName); + return ReadOnlyDictionary.Empty; + } + var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName); + if (serializedDataFromCookie is null) + { + return ReadOnlyDictionary.Empty; + } + + try + { byte[]? rentedDecodeBuffer = null; var maxDecodedSize = Base64Url.GetMaxDecodedLength(serializedDataFromCookie.Length); var decodeBuffer = maxDecodedSize <= 256 @@ -65,6 +66,10 @@ public CookieTempDataProvider( try { var decodeStatus = Base64Url.DecodeFromChars(serializedDataFromCookie, decodeBuffer, out _, out var bytesWritten); + if (decodeStatus != OperationStatus.Done) + { + throw new FormatException("The TempData cookie did not contain valid Base64Url-encoded data."); + } var protectedBytes = decodeBuffer[..bytesWritten]; Dictionary? dataFromCookie; diff --git a/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs b/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs index a0f81222d83d..677aed0bcc5d 100644 --- a/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs +++ b/src/Components/Endpoints/src/TempData/SessionStorageTempDataProvider.cs @@ -25,10 +25,10 @@ public SessionStorageTempDataProvider( { ArgumentNullException.ThrowIfNull(context); + var session = SessionResolver.GetRequiredSession(context); + try { - var session = context.Session; - if (session.TryGetValue(TempDataSessionStateKey, out var value)) { var dataFromSession = JsonSerializer.Deserialize>(value); @@ -63,7 +63,7 @@ public void SaveTempData(HttpContext context, IDictionary value } } - var session = context.Session; + var session = SessionResolver.GetRequiredSession(context); if (values.Count == 0) { session.Remove(TempDataSessionStateKey); diff --git a/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs b/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs index f2089a638f77..d3f66faaab83 100644 --- a/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/TempData/TempDataCascadingValueSupplier.cs @@ -99,6 +99,9 @@ private static partial class Log [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from TempData failed.", EventName = "TempDataDeserializeFail")] public static partial void TempDataDeserializeFail(ILogger logger, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "No active HttpContext is available (interactive rendering); the [SupplyParameterFromTempData] value is skipped and will be null.", EventName = "TempDataUnavailableForRead")] + public static partial void TempDataUnavailableForRead(ILogger logger); } internal partial class TempDataSubscription : CascadingParameterSubscription @@ -137,6 +140,7 @@ public TempDataSubscription( var tempData = _owner.GetTempData(); if (tempData is null) { + Log.TempDataUnavailableForRead(_owner._logger); return null; } diff --git a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs index 7c4b72210459..7e3145e58e5e 100644 --- a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs +++ b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs @@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -140,7 +142,23 @@ public async Task PersistAllValues_LowercasesSessionKey() } [Fact] - public async Task PersistAllValues_NoOp_WhenSessionUnavailable() + public async Task PersistAllValues_LogsWarningAndSkips_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new SessionCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + supplier.RegisterValueCallback("key", () => "value"); + + // No SetRequestContext: mimics interactive rendering where no HttpContext is available. + await supplier.PersistAllValues(); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionUnavailable", write.EventId.Name); + } + + [Fact] + public async Task PersistAllValues_Throws_WhenSessionUnavailable() { _supplier.RegisterValueCallback("key", () => "value"); @@ -148,7 +166,19 @@ public async Task PersistAllValues_NoOp_WhenSessionUnavailable() httpContext.Features.Set(new TestHttpResponseFeature()); _supplier.SetRequestContext(httpContext); - await _supplier.PersistAllValues(); + await Assert.ThrowsAsync(() => _supplier.PersistAllValues()); + } + + [Fact] + public async Task PersistAllValues_Throws_WhenSessionIsNull() + { + _supplier.RegisterValueCallback("key", () => "value"); + + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature(null!)); + _supplier.SetRequestContext(httpContext); + + await Assert.ThrowsAsync(() => _supplier.PersistAllValues()); } [Fact] diff --git a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs index 976afc7a3993..89c83430aca5 100644 --- a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs +++ b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using static Microsoft.AspNetCore.Components.Endpoints.SessionCascadingValueSupplierTest; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -43,6 +45,35 @@ public void GetValue_ReturnsNull_WhenHttpContextNotSet() Assert.Null(result); } + [Fact] + public void GetValue_LogsWarning_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new SessionCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + var subscription = new SessionCascadingValueSupplier.SessionSubscription( + supplier, "key", typeof(string), () => _component.Value); + + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionUnavailable", write.EventId.Name); + } + + [Fact] + public void GetValue_Throws_WhenSessionIsNull() + { + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature(null!)); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("key", typeof(string)); + + Assert.Throws(() => subscription.GetCurrentValue()); + } + [Fact] public void GetValue_ReturnsNull_WhenKeyNotFound() { diff --git a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs index 4b0c610ae99e..520ec9b4a85f 100644 --- a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs +++ b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs @@ -71,13 +71,30 @@ public void Save_ThrowsForUnsupportedType() } [Fact] - public void Load_ReturnsEmptyTempData_WhenSessionThrows() + public void Load_Throws_WhenSessionNotConfigured() { var httpContext = CreateHttpContext(throwOnSessionAccess: true); - var tempData = _sessionStateTempDataProvider.LoadTempData(httpContext); - Assert.NotNull(tempData); - Assert.Empty(tempData); + Assert.Throws(() => _sessionStateTempDataProvider.LoadTempData(httpContext)); + } + + [Fact] + public void Load_Throws_WhenSessionIsNull() + { + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestSessionFeature { Session = null }); + + Assert.Throws(() => _sessionStateTempDataProvider.LoadTempData(httpContext)); + } + + [Fact] + public void Save_Throws_WhenSessionNotConfigured() + { + var httpContext = CreateHttpContext(throwOnSessionAccess: true); + var tempData = CreateTempData(); + tempData["Key1"] = "Value1"; + + Assert.Throws(() => _sessionStateTempDataProvider.SaveTempData(httpContext, tempData.Save())); } [Fact] diff --git a/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs b/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs index cf91bb6ad405..2b55d155488b 100644 --- a/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs +++ b/src/Components/Endpoints/test/TempData/TempDataSubscriptionTest.cs @@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Moq; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -47,6 +49,23 @@ public void GetValue_ReturnsNull_WhenHttpContextNotSet() Assert.Null(result); } + [Fact] + public void GetValue_LogsWarning_WhenHttpContextNotSet() + { + var sink = new TestSink(); + var supplier = new TempDataCascadingValueSupplier( + new TestLoggerFactory(sink, enabled: true).CreateLogger()); + var subscription = new TempDataCascadingValueSupplier.TempDataSubscription( + supplier, "key", typeof(string), () => _component.Value); + + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("TempDataUnavailableForRead", write.EventId.Name); + } + [Fact] public void GetValue_ReturnsNull_WhenKeyNotFound() { diff --git a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs index 19bde0ee6c09..69e0e6c7180f 100644 --- a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs +++ b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs @@ -160,7 +160,7 @@ public void SupplyParameterFromTempDataReadsAndSavesValues() [Fact] public void StreamingSSR_CookieTempData_DoesNotPersistValuesWrittenAfterFirstFlush() { - Navigate($"{ServerPathBase}/streaming-session-persistence"); + Navigate($"{ServerPathBase}/streaming-cookie-tempdata-persistence"); Browser.Exists(By.Id("streaming-complete")); Navigate($"{ServerPathBase}/tempdata"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor new file mode 100644 index 000000000000..79c5c329794a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingCookieTempDataPersistence.razor @@ -0,0 +1,37 @@ +@page "/streaming-cookie-tempdata-persistence" +@attribute [StreamRendering] + +

Streaming Cookie TempData Persistence Test

+ +@if (_done) +{ +

Done

+} +else +{ +

Waiting for streaming...

+} + +@code { + private bool _done; + + [SupplyParameterFromTempData] + private string SupplyParameterFromTempDataValue { get; set; } = string.Empty; + + [CascadingParameter] + public ITempData? TempData { get; set; } + + protected override async Task OnInitializedAsync() + { + await EnterStreamingPhaseAsync(); + + SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; + if (TempData is not null) + { + TempData["Message"] = "streaming-tempdata-message"; + } + _done = true; + } + + private static Task EnterStreamingPhaseAsync() => Task.Delay(100); +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor index a9f3ba676c45..8ef84f618f19 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor @@ -26,10 +26,7 @@ else protected override async Task OnInitializedAsync() { - // The await is critical: it causes the component to enter the streaming phase. - // Values set AFTER the await are the ones that were silently lost with the old - // OnStarting-based persistence, because OnStarting fires before streaming begins. - await Task.Delay(100); + await EnterStreamingPhaseAsync(); Email = "set-during-streaming"; SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; @@ -39,4 +36,6 @@ else } _done = true; } + + private static Task EnterStreamingPhaseAsync() => Task.Delay(100); }