Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/Components/Endpoints/src/SessionCascadingValueSupplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,7 +60,11 @@ private static PropertyGetter PropertyGetterFactory((Type type, string propertyN
return new PropertyGetter(type, propertyInfo);
}

internal ISession? GetSession() => _httpContext?.Features.Get<ISessionFeature>()?.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<object?> valueGetter)
{
Expand All @@ -81,6 +84,7 @@ internal Task PersistAllValues()
var session = GetSession();
if (session is null)
{
Log.SessionUnavailable(_logger);
return Task.CompletedTask;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -153,6 +160,7 @@ public SessionSubscription(
var session = _owner.GetSession();
if (session is null)
{
Log.SessionUnavailable(_owner._logger);
return null;
}

Expand Down
24 changes: 24 additions & 0 deletions src/Components/Endpoints/src/SessionResolver.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 16 additions & 11 deletions src/Components/Endpoints/src/TempData/CookieTempDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>.Empty;
}
var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName);
if (serializedDataFromCookie is null)
{
return ReadOnlyDictionary<string, object?>.Empty;
}
Log.TempDataCookieNotFound(_logger, cookieName);
return ReadOnlyDictionary<string, object?>.Empty;
}

var serializedDataFromCookie = _chunkingCookieManager.GetRequestCookie(context, cookieName);
if (serializedDataFromCookie is null)
{
return ReadOnlyDictionary<string, object?>.Empty;
}

try
{
byte[]? rentedDecodeBuffer = null;
var maxDecodedSize = Base64Url.GetMaxDecodedLength(serializedDataFromCookie.Length);
var decodeBuffer = maxDecodedSize <= 256
Comment thread
dariatiurina marked this conversation as resolved.
Expand All @@ -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<string, JsonElement>? dataFromCookie;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dictionary<string, JsonElement>>(value);
Expand Down Expand Up @@ -63,7 +63,7 @@ public void SaveTempData(HttpContext context, IDictionary<string, object?> value
}
}

var session = context.Session;
var session = SessionResolver.GetRequiredSession(context);
if (values.Count == 0)
{
session.Remove(TempDataSessionStateKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -137,6 +140,7 @@ public TempDataSubscription(
var tempData = _owner.GetTempData();
if (tempData is null)
{
Log.TempDataUnavailableForRead(_owner._logger);
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -140,15 +142,43 @@ 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<SessionCascadingValueSupplier>());
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");

var httpContext = new DefaultHttpContext();
httpContext.Features.Set<IHttpResponseFeature>(new TestHttpResponseFeature());
_supplier.SetRequestContext(httpContext);

await _supplier.PersistAllValues();
await Assert.ThrowsAsync<InvalidOperationException>(() => _supplier.PersistAllValues());
}

[Fact]
public async Task PersistAllValues_Throws_WhenSessionIsNull()
{
_supplier.RegisterValueCallback("key", () => "value");

var httpContext = new DefaultHttpContext();
httpContext.Features.Set<ISessionFeature>(new TestSessionFeature(null!));
_supplier.SetRequestContext(httpContext);

await Assert.ThrowsAsync<InvalidOperationException>(() => _supplier.PersistAllValues());
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SessionCascadingValueSupplier>());
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<Microsoft.AspNetCore.Http.Features.ISessionFeature>(new TestSessionFeature(null!));
_supplier.SetRequestContext(httpContext);

var subscription = CreateSubscription("key", typeof(string));

Assert.Throws<InvalidOperationException>(() => subscription.GetCurrentValue());
}

[Fact]
public void GetValue_ReturnsNull_WhenKeyNotFound()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() => _sessionStateTempDataProvider.LoadTempData(httpContext));
}

[Fact]
public void Load_Throws_WhenSessionIsNull()
{
var httpContext = new DefaultHttpContext();
httpContext.Features.Set<ISessionFeature>(new TestSessionFeature { Session = null });

Assert.Throws<InvalidOperationException>(() => _sessionStateTempDataProvider.LoadTempData(httpContext));
}

[Fact]
public void Save_Throws_WhenSessionNotConfigured()
{
var httpContext = CreateHttpContext(throwOnSessionAccess: true);
var tempData = CreateTempData();
tempData["Key1"] = "Value1";

Assert.Throws<InvalidOperationException>(() => _sessionStateTempDataProvider.SaveTempData(httpContext, tempData.Save()));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TempDataCascadingValueSupplier>());
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()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Components/test/E2ETest/Tests/TempDataCookieTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@page "/streaming-cookie-tempdata-persistence"
@attribute [StreamRendering]

<h1>Streaming Cookie TempData Persistence Test</h1>

@if (_done)
{
<p id="streaming-complete">Done</p>
}
else
{
<p id="streaming-status">Waiting for streaming...</p>
}

@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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,4 +36,6 @@ else
}
_done = true;
}

private static Task EnterStreamingPhaseAsync() => Task.Delay(100);
}
Loading