diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs index fa177f3..84a43d1 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs @@ -6,15 +6,18 @@ namespace AStar.Dev.CloudSyncFunctional.Auth; /// -public sealed partial class AuthService(IPublicClientApplication app, ILogger logger) : IAuthService +public sealed partial class AuthService(IPublicClientApplication app, ILogger logger, ITokenCacheService tokenCacheService) : IAuthService { private static readonly string[] Scopes = ["Files.ReadWrite", "offline_access", "User.Read"]; + private bool _cacheRegistered; /// public async Task> SignInInteractiveAsync(CancellationToken cancellationToken = default) { try { + await EnsureCacheRegisteredAsync(cancellationToken).ConfigureAwait(false); + var msalResult = await app .AcquireTokenInteractive(Scopes) .WithPrompt(Prompt.SelectAccount) @@ -35,11 +38,13 @@ public async Task> SignInInteractiveAsync(Cancella catch (MsalException ex) { LogAuthFailed(logger, ex.Message); + return AuthErrorFactory.Failed(ex.Message); } catch (Exception ex) { LogAuthFailed(logger, ex.Message); + return AuthErrorFactory.Failed(ex.Message); } } @@ -49,6 +54,8 @@ public async Task> AcquireTokenSilentAsync(string { try { + await EnsureCacheRegisteredAsync(cancellationToken).ConfigureAwait(false); + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId); if (account is null) @@ -60,11 +67,14 @@ public async Task> AcquireTokenSilentAsync(string } catch (MsalUiRequiredException) { + LogAuthFailed(logger, "Re-authentication required."); + return AuthErrorFactory.Failed("Re-authentication required."); } catch (Exception ex) { LogAuthFailed(logger, ex.Message); + return AuthErrorFactory.Failed(ex.Message); } } @@ -86,6 +96,13 @@ public async Task> GetCachedAccountIdsAsync() return [.. accounts.Where(a => a.HomeAccountId is not null).Select(a => a.HomeAccountId!.Identifier)]; } + private async Task EnsureCacheRegisteredAsync(CancellationToken cancellationToken) + { + if (_cacheRegistered) return; + await tokenCacheService.RegisterAsync(app, cancellationToken).ConfigureAwait(false); + _cacheRegistered = true; + } + private static AuthResult BuildAuthResult(AuthenticationResult result) { var displayName = result.ClaimsPrincipal?.FindFirst("name")?.Value ?? result.Account.Username; diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs index d2d61bc..60002e3 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs @@ -2,22 +2,155 @@ using AStar.Dev.FunctionalParadigm; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; +using MELogLevel = Microsoft.Extensions.Logging.LogLevel; namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Auth; public sealed class GivenAnAuthService { - private static AuthService CreateSut(IPublicClientApplication? app = null) => - new(app ?? Substitute.For(), Substitute.For>()); + private static AuthService CreateSut(IPublicClientApplication? app = null, ITokenCacheService? tokenCacheService = null) => + new(app ?? Substitute.For(), Substitute.For>(), tokenCacheService ?? Substitute.For()); private static IAccount CreateAccount(string identifier) { var account = Substitute.For(); var homeAccountId = new Microsoft.Identity.Client.AccountId(identifier, identifier, "tenant"); account.HomeAccountId.Returns(homeAccountId); + return account; } + [Fact] + public async Task when_sign_in_is_called_then_cache_register_is_called() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + var tokenCacheService = Substitute.For(); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any()); + } + + [Fact] + public async Task when_acquire_token_silent_is_called_then_cache_register_is_called() + { + var app = Substitute.For(); + app.GetAccountsAsync().Returns(Task.FromResult>([])); + var tokenCacheService = Substitute.For(); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken); + + await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any()); + } + + [Fact] + public async Task when_cache_already_registered_sign_in_called_twice_then_cache_register_called_only_once() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + var tokenCacheService = Substitute.For(); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any()); + } + + [Fact] + public async Task when_acquire_token_silent_throws_msal_ui_required_then_logger_logs_error() + { + var app = Substitute.For(); + var account = CreateAccount("target-id"); + app.GetAccountsAsync().Returns(Task.FromResult>([account])); + app.When(a => a.AcquireTokenSilent(Arg.Any>(), Arg.Any())) + .Do(_ => throw new MsalUiRequiredException("code", "message")); + var logger = Substitute.For>(); + logger.IsEnabled(MELogLevel.Error).Returns(true); + var sut = new AuthService(app, logger, Substitute.For()); + + _ = await sut.AcquireTokenSilentAsync("target-id", TestContext.Current.CancellationToken); + + logger.Received(1).Log( + MELogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task when_sign_in_registers_cache_then_silent_acquire_skips_registration() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + app.GetAccountsAsync().Returns(Task.FromResult>([])); + var tokenCacheService = Substitute.For(); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + _ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken); + + await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any()); + } + + [Fact] + public async Task when_silent_acquire_registers_cache_then_sign_in_skips_registration() + { + var app = Substitute.For(); + app.GetAccountsAsync().Returns(Task.FromResult>([])); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + var tokenCacheService = Substitute.For(); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken); + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any()); + } + + [Fact] + public async Task when_register_async_throws_during_sign_in_then_returns_failed_error() + { + var app = Substitute.For(); + var tokenCacheService = Substitute.For(); + tokenCacheService.RegisterAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("cache unavailable"))); + var sut = CreateSut(app, tokenCacheService); + + var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + var fail = result.ShouldBeOfType>(); + fail.Error.ShouldBeOfType(); + fail.Error.Message.ShouldContain("cache unavailable"); + } + + [Fact] + public async Task when_register_async_throws_then_next_sign_in_call_retries_registration() + { + var app = Substitute.For(); + app.When(a => a.AcquireTokenInteractive(Arg.Any>())) + .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled")); + var tokenCacheService = Substitute.For(); + tokenCacheService.RegisterAsync(Arg.Any(), Arg.Any()) + .Returns( + Task.FromException(new InvalidOperationException("cache unavailable")), + Task.CompletedTask); + var sut = CreateSut(app, tokenCacheService); + + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken); + + await tokenCacheService.Received(2).RegisterAsync(app, Arg.Any()); + } + [Fact] public async Task when_sign_in_throws_authentication_canceled_error_code_then_returns_cancelled_error() {