From df67ad4180f4446064f5a749da002d66b81382b9 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Thu, 28 May 2026 13:44:14 +0100 Subject: [PATCH 1/2] test(auth): add failing tests for token cache registration (#37) AuthService currently takes only 2 constructor args (IPublicClientApplication, ILogger). These tests require a 3rd arg (ITokenCacheService) and assert that: - SignInInteractiveAsync calls ITokenCacheService.RegisterAsync exactly once - AcquireTokenSilentAsync calls ITokenCacheService.RegisterAsync exactly once - Calling SignInInteractiveAsync twice only triggers RegisterAsync once (guard) - MsalUiRequiredException in AcquireTokenSilentAsync logs at Error level Build result: 4 errors (2 pre-existing in GivenAResult.cs; 2 new CS1729 'AuthService does not contain a constructor that takes 3 arguments'). Co-Authored-By: Claude Sonnet 4.6 --- .../Auth/GivenAnAuthService.cs | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs index d2d61bc..76e0980 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs @@ -2,22 +2,87 @@ 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>(); + 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_throws_authentication_canceled_error_code_then_returns_cancelled_error() { From d6cddea11482827b89ea1feec46457ca0e2d4e5e Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Thu, 28 May 2026 14:14:10 +0100 Subject: [PATCH 2/2] fix(auth): inject ITokenCacheService and add registration guard (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ITokenCacheService as third constructor parameter to AuthService - Add EnsureCacheRegisteredAsync guard — calls RegisterAsync once per instance; subsequent calls on either SignIn or AcquireTokenSilent are no-ops so the file-backed cache is registered exactly once per session - Call EnsureCacheRegisteredAsync at the top of SignInInteractiveAsync and AcquireTokenSilentAsync so every token operation activates the cache - Add LogAuthFailed call in MsalUiRequiredException catch block — error paths must log per functional-usage.md rule 10 - Update GivenAnAuthService factory and add 8 new tests covering cache registration, cross-method guard, failure retry, and logging Closes #37 Co-Authored-By: Claude Sonnet 4.6 --- .../Auth/AuthService.cs | 19 +++++- .../Auth/GivenAnAuthService.cs | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) 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 76e0980..60002e3 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs @@ -71,6 +71,7 @@ public async Task when_acquire_token_silent_throws_msal_ui_required_then_logger_ 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); @@ -83,6 +84,73 @@ public async Task when_acquire_token_silent_throws_msal_ui_required_then_logger_ 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() {