diff --git a/src/Components/Authorization/src/AuthorizeViewCore.cs b/src/Components/Authorization/src/AuthorizeViewCore.cs index b225bb19bdef..458966059707 100644 --- a/src/Components/Authorization/src/AuthorizeViewCore.cs +++ b/src/Components/Authorization/src/AuthorizeViewCore.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Components.Authorization public abstract class AuthorizeViewCore : ComponentBase { private AuthenticationState currentAuthenticationState; - private bool isAuthorized; + private bool? isAuthorized; /// /// The content that will be displayed if the user is authorized. @@ -54,11 +54,11 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { // We're using the same sequence number for each of the content items here // so that we can update existing instances if they are the same shape - if (currentAuthenticationState == null) + if (isAuthorized == null) { builder.AddContent(0, Authorizing); } - else if (isAuthorized) + else if (isAuthorized == true) { var authorized = Authorized ?? ChildContent; builder.AddContent(0, authorized?.Invoke(currentAuthenticationState)); @@ -85,13 +85,10 @@ protected override async Task OnParametersSetAsync() throw new InvalidOperationException($"Authorization requires a cascading parameter of type Task<{nameof(AuthenticationState)}>. Consider using {typeof(CascadingAuthenticationState).Name} to supply this."); } - // First render in pending state - // If the task has already completed, this render will be skipped - currentAuthenticationState = null; + // Clear the previous result of authorization + // This will cause the Authorizing state to be displayed until the authorization has been completed + isAuthorized = null; - // Then render in completed state - // Importantly, we *don't* call StateHasChanged between the following async steps, - // otherwise we'd display an incorrect UI state while waiting for IsAuthorizedAsync currentAuthenticationState = await AuthenticationState; isAuthorized = await IsAuthorizedAsync(currentAuthenticationState.User); } diff --git a/src/Components/Authorization/test/AuthorizeViewTest.cs b/src/Components/Authorization/test/AuthorizeViewTest.cs index a28b8808c43a..ce84e611cc42 100644 --- a/src/Components/Authorization/test/AuthorizeViewTest.cs +++ b/src/Components/Authorization/test/AuthorizeViewTest.cs @@ -292,6 +292,68 @@ public void RendersNothingUntilAuthorizationCompleted() }); } + [Fact] + public void RendersAuthorizingUntilAuthorizationCompletedAsync() + { + // Covers https://github.com/dotnet/aspnetcore/pull/31794 + // Arrange + var @event = new ManualResetEventSlim(); + var authorizationService = new TestAsyncAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); + renderer.OnUpdateDisplayComplete = () => { @event.Set(); }; + var rootComponent = WrapInAuthorizeView( + authorizing: builder => builder.AddContent(0, "Auth pending..."), + authorized: context => builder => builder.AddContent(0, $"Hello, {context.User.Identity.Name}!")); + + var authTcs = new TaskCompletionSource(); + // Complete the authentication beforehand + authTcs.SetResult(CreateAuthenticationState("Monsieur").Result); + + rootComponent.AuthenticationState = authTcs.Task; + + // Act/Assert 1: Auth pending + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + var batch1 = renderer.Batches.Single(); + var authorizeViewComponentId = batch1.GetComponentFrames().Single().ComponentId; + var diff1 = batch1.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff1.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch1.ReferenceFrames[edit.ReferenceFrameIndex], + "Auth pending..."); + }); + + // Act/Assert 2: Auth process completes asynchronously + @event.Reset(); + + // Wait for authorization to complete asynchronously + @event.Wait(Timeout); + + Assert.Equal(2, renderer.Batches.Count); + var batch2 = renderer.Batches[1]; + var diff2 = batch2.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff2.Edits, edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + Assert.Equal(0, edit.SiblingIndex); + AssertFrame.Text( + batch2.ReferenceFrames[edit.ReferenceFrameIndex], + "Hello, Monsieur!"); + }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Monsieur", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType(req)); + }); + } + [Fact] public void RendersAuthorizingUntilAuthorizationCompleted() { diff --git a/src/Components/Authorization/test/TestAsyncAuthorizationService.cs b/src/Components/Authorization/test/TestAsyncAuthorizationService.cs new file mode 100644 index 000000000000..ac273a4b087b --- /dev/null +++ b/src/Components/Authorization/test/TestAsyncAuthorizationService.cs @@ -0,0 +1,38 @@ +// 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.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.Components.Authorization +{ + public class TestAsyncAuthorizationService : IAuthorizationService + { + public AuthorizationResult NextResult { get; set; } + = AuthorizationResult.Failed(); + + public List<(ClaimsPrincipal user, object resource, IEnumerable requirements)> AuthorizeCalls { get; } + = new List<(ClaimsPrincipal user, object resource, IEnumerable requirements)>(); + + public async Task AuthorizeAsync(ClaimsPrincipal user, object resource, IEnumerable requirements) + { + AuthorizeCalls.Add((user, resource, requirements)); + + // Make Authorization run asynchronously + await Task.Yield(); + + // The TestAuthorizationService doesn't actually apply any authorization requirements + // It just returns the specified NextResult, since we're not trying to test the logic + // in DefaultAuthorizationService or similar here. So it's up to tests to set a desired + // NextResult and assert that the expected criteria were passed by inspecting AuthorizeCalls. + return NextResult; + } + + public Task AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName) + => throw new NotImplementedException(); + + } +}