From 59c4941a35e737b239b538434791578765959d92 Mon Sep 17 00:00:00 2001 From: Boromir Date: Sun, 10 May 2026 01:16:20 -0700 Subject: [PATCH 1/5] fix(profile): fall back to authenticated email on profile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Web/Features/UserManagement/Profile.razor | 111 +++++++++++++++++- src/Web/Program.cs | 1 + .../ProfileEmailAuthContractTests.cs | 42 +++++++ .../Web.Tests.Bunit/Features/ProfileTests.cs | 46 ++++++++ 4 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/Architecture.Tests/ProfileEmailAuthContractTests.cs diff --git a/src/Web/Features/UserManagement/Profile.razor b/src/Web/Features/UserManagement/Profile.razor index a7cbae47..5a146686 100644 --- a/src/Web/Features/UserManagement/Profile.razor +++ b/src/Web/Features/UserManagement/Profile.razor @@ -1,5 +1,6 @@ @page "/profile" @using System.Security.Claims +@using System.Text.Json @using MyBlog.Web.Security @attribute [Authorize] @@ -139,7 +140,7 @@ else private IReadOnlyList _roles = []; private IReadOnlyList _claims = []; - private static readonly HashSet _sensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet SensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase) { "nonce", "c_hash", "at_hash", "aud", "azp", "auth_time", "iat", "exp", "nbf" }; @@ -161,9 +162,7 @@ else "nickname", ClaimTypes.GivenName) ?? _user.Identity?.Name ?? "Unknown User"; - _emailAddress = GetFirstClaimValue(_user, - ClaimTypes.Email, - "email") ?? "No email claim found"; + _emailAddress = GetEmailAddress(_user) ?? "No email claim found"; _userId = GetFirstClaimValue(_user, ClaimTypes.NameIdentifier, @@ -178,12 +177,31 @@ else _roles = RoleClaimsHelper.GetRoles(_user); _isAdmin = _roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); _claims = _user.Claims - .Where(c => !_sensitiveClaimTypes.Contains(c.Type)) + .Where(c => !SensitiveClaimTypes.Contains(c.Type)) .OrderBy(c => c.Type) .ThenBy(c => c.Value) .ToList(); } + private static string? GetEmailAddress(ClaimsPrincipal user) + { + string? directEmail = GetFirstClaimValueMatching(user, + static claim => claim.Type.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("email", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(directEmail)) + { + return directEmail; + } + + return GetFirstClaimValueMatching(user, + static claim => claim.Type.Equals(ClaimTypes.Upn, StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("upn", StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("preferred_username", StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("emails", StringComparison.OrdinalIgnoreCase) + || IsEmailClaimType(claim.Type)); + } + private static string? GetFirstClaimValue(ClaimsPrincipal user, params string[] claimTypes) { foreach (var claimType in claimTypes) @@ -200,6 +218,89 @@ else } + private static string? GetFirstClaimValueMatching(ClaimsPrincipal user, Func predicate) + { + foreach (Claim claim in user.Claims.Where(predicate)) + { + foreach (string value in GetClaimValues(claim.Value)) + { + if (LooksLikeEmailAddress(value)) + { + return value; + } + } + } + + return null; + } + + private static IReadOnlyList GetClaimValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + string trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using JsonDocument document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Select(element => element.GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + return []; + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + private static bool IsEmailClaimType(string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return false; + } + + string tail = GetClaimTypeTail(claimType); + return tail.Equals("email", StringComparison.OrdinalIgnoreCase) + || tail.Equals("emails", StringComparison.OrdinalIgnoreCase); + } + + private static string GetClaimTypeTail(string claimType) + { + int lastSlash = claimType.LastIndexOf('/'); + int lastColon = claimType.LastIndexOf(':'); + int separatorIndex = Math.Max(lastSlash, lastColon); + + return separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; + } + + private static bool LooksLikeEmailAddress(string? value) + { + return !string.IsNullOrWhiteSpace(value) + && value.Contains('@', StringComparison.Ordinal) + && !value.Contains(' ', StringComparison.Ordinal); + } + private static string GetInitials(string displayName, string emailAddress) { var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", StringComparison.OrdinalIgnoreCase) diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 77660b28..a2fbc219 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -70,6 +70,7 @@ opts.Domain = auth0Domain; opts.ClientId = auth0ClientId; opts.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; + opts.Scope = "openid profile email"; opts.CallbackPath = "/signin-auth0"; }); diff --git a/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs b/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs new file mode 100644 index 00000000..0cd625fe --- /dev/null +++ b/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs @@ -0,0 +1,42 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : ProfileEmailAuthContractTests.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Architecture.Tests +//======================================================= + +using System.Text.RegularExpressions; + +namespace MyBlog.Architecture.Tests; + +public sealed class ProfileEmailAuthContractTests +{ + private static readonly string RepoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); + + [Fact] + public void ProgramShouldRequestEmailScopeForAuth0WebLogin() + { + // Arrange + var programSource = ReadRepoFile("src/Web/Program.cs"); + var configuresEmailScope = + Regex.IsMatch( + programSource, + @"\.Scope\s*=\s*""[^""]*\bemail\b[^""]*""", + RegexOptions.CultureInvariant) + || Regex.IsMatch( + programSource, + @"\.WithScope\s*\(\s*""[^""]*\bemail\b[^""]*""\s*\)", + RegexOptions.CultureInvariant); + + // Act / Assert + configuresEmailScope.Should().BeTrue( + because: "Auth0's ASP.NET Core SDK defaults to 'openid profile', so the web app must explicitly request the email scope if Profile.razor expects the signed-in principal to carry an email claim"); + } + + private static string ReadRepoFile(string relativePath) + { + return File.ReadAllText(Path.Combine(RepoRoot, relativePath)); + } +} diff --git a/tests/Web.Tests.Bunit/Features/ProfileTests.cs b/tests/Web.Tests.Bunit/Features/ProfileTests.cs index 8d0c8d69..a22ead04 100644 --- a/tests/Web.Tests.Bunit/Features/ProfileTests.cs +++ b/tests/Web.Tests.Bunit/Features/ProfileTests.cs @@ -39,6 +39,52 @@ public void ProfileRendersIdentityDetailsRolesPictureAndClaims() cut.Markup.Should().Contain("Engineering"); } + [Fact] + public void ProfileUsesOpenIdEmailClaimWhenFrameworkMappedEmailClaimIsMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|oidc-admin", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("email", "oidc-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("oidc-admin@example.com"); + } + + [Fact] + public void ProfileUsesPreferredUsernameAsEmailFallbackWhenDirectEmailClaimsAreMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|preferred-username", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("preferred_username", "preferred-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("preferred-admin@example.com"); + } + [Fact] public void ProfileUsesFallbackValuesWhenOptionalClaimsAreMissing() { From 4bb73f23369e1ab57f106952eefdd7bf93aec841 Mon Sep 17 00:00:00 2001 From: Boromir Date: Sun, 10 May 2026 01:31:43 -0700 Subject: [PATCH 2/5] fix(auth0): support legacy management config keys in user handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UserManagement/UserManagementHandler.cs | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 9797286c..33adb055 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -164,21 +164,20 @@ public async Task>> Handle(GetAvailableRolesQuery private async Task GetManagementClientAsync(CancellationToken cancellationToken) { -var domain = configuration["Auth0:ManagementApiDomain"] -?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); -var clientId = configuration["Auth0:ManagementApiClientId"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); -var clientSecret = configuration["Auth0:ManagementApiClientSecret"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); +var domain = GetRequiredManagementSetting("Auth0Management:Domain", "Auth0:ManagementApiDomain"); +var clientId = GetRequiredManagementSetting("Auth0Management:ClientId", "Auth0:ManagementApiClientId"); +var clientSecret = GetRequiredManagementSetting("Auth0Management:ClientSecret", "Auth0:ManagementApiClientSecret"); +var audience = GetOptionalManagementSetting("Auth0Management:Audience", "Auth0:ManagementApiAudience") + ?? $"https://{domain}/api/v2/"; -var httpClient = httpClientFactory.CreateClient(); +using var httpClient = httpClientFactory.CreateClient(); var tokenResponse = await httpClient.PostAsJsonAsync( $"https://{domain}/oauth/token", new { client_id = clientId, client_secret = clientSecret, -audience = $"https://{domain}/api/v2/", +audience, grant_type = "client_credentials" }, cancellationToken).ConfigureAwait(false); tokenResponse.EnsureSuccessStatusCode(); @@ -188,6 +187,27 @@ private async Task GetManagementClientAsync(CancellationTok clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); } +private string GetRequiredManagementSetting(string primaryKey, string legacyKey) +{ + return GetOptionalManagementSetting(primaryKey, legacyKey) + ?? throw new InvalidOperationException( + $"{primaryKey} not configured. {legacyKey} not configured."); +} + +private string? GetOptionalManagementSetting(params string[] keys) +{ + foreach (var key in keys) + { + var value = configuration[key]; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; +} + private sealed class TokenResponse { public string AccessToken { get; init; } = string.Empty; From fdf9d301e29b8f268f1406bded778e35347e1faf Mon Sep 17 00:00:00 2001 From: Boromir Date: Sun, 10 May 2026 01:39:40 -0700 Subject: [PATCH 3/5] chore: clean up profile email warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Features/UserManagement/UserManagementHandler.cs | 6 +++--- src/Web/Program.cs | 12 ++++++------ tests/Web.Tests.Bunit/Features/ProfileTests.cs | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 33adb055..658878d4 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -30,12 +30,12 @@ public async Task>> Handle( var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); var result = new List(); -await foreach (var user in usersPager) +await foreach (var user in usersPager.ConfigureAwait(false)) { var rolesPager = await client.Users.Roles.ListAsync( user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); var roles = new List(); -await foreach (var role in rolesPager) +await foreach (var role in rolesPager.ConfigureAwait(false)) { roles.Add(role.Name ?? string.Empty); } @@ -136,7 +136,7 @@ public async Task>> Handle(GetAvailableRolesQuery var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); var roles = new List(); -await foreach (var role in rolesPager) +await foreach (var role in rolesPager.ConfigureAwait(false)) { roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index a2fbc219..056faa54 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -81,8 +81,7 @@ var existingOnTokenValidated = options.Events.OnTokenValidated; options.Events.OnTokenValidated = async context => { - if (existingOnTokenValidated is not null) - await existingOnTokenValidated(context); + await existingOnTokenValidated(context).ConfigureAwait(false); if (context.Principal?.Identity is not ClaimsIdentity identity) { @@ -161,7 +160,7 @@ var props = new LoginAuthenticationPropertiesBuilder() .WithRedirectUri(safeReturn) .Build(); - await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props); + await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props).ConfigureAwait(false); }).AllowAnonymous(); app.MapGet("/Account/Logout", async ctx => @@ -169,8 +168,8 @@ var props = new LogoutAuthenticationPropertiesBuilder() .WithRedirectUri("/") .Build(); - await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props); - await ctx.SignOutAsync(); + await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props).ConfigureAwait(false); + await ctx.SignOutAsync().ConfigureAwait(false); }).RequireAuthorization(); // Test-only login endpoint for E2E testing (Development/Testing environments only) @@ -206,11 +205,12 @@ static async Task MapTestLoginEndpoint(HttpContext ctx, string? role) await ctx.SignInAsync("Cookies", principal, new AuthenticationProperties { IsPersistent = true, - }); + }).ConfigureAwait(false); ctx.Response.Redirect("/"); } // Exclude the compiler-generated Program class (top-level bootstrap statements) from coverage. +[SuppressMessage("Design", "CA1515:Consider making public types internal", Justification = "WebApplicationFactory requires a public entry point for integration tests.")] [ExcludeFromCodeCoverage(Justification = "Application bootstrap entry-point — not business logic")] public partial class Program { } diff --git a/tests/Web.Tests.Bunit/Features/ProfileTests.cs b/tests/Web.Tests.Bunit/Features/ProfileTests.cs index a22ead04..1fe11a0a 100644 --- a/tests/Web.Tests.Bunit/Features/ProfileTests.cs +++ b/tests/Web.Tests.Bunit/Features/ProfileTests.cs @@ -127,10 +127,10 @@ public void ProfileAdminRoleBadgeHasRedColorClasses() .FirstOrDefault(span => span.TextContent.Trim() == "Admin" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-red-100")); + && cls.Contains("bg-red-100", StringComparison.Ordinal)); adminBadge.Should().NotBeNull("Admin role should render with red-100 background"); - adminBadge!.GetAttribute("class").Should().Contain("text-red-800"); + adminBadge.GetAttribute("class").Should().Contain("text-red-800"); } [Fact] @@ -153,10 +153,10 @@ public void ProfileNonAdminRoleBadgeHasGreenColorClasses() .FirstOrDefault(span => span.TextContent.Trim() == "Author" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-green-100")); + && cls.Contains("bg-green-100", StringComparison.Ordinal)); authorBadge.Should().NotBeNull("Non-admin role should render with green-100 background"); - authorBadge!.GetAttribute("class").Should().Contain("text-green-800"); + authorBadge.GetAttribute("class").Should().Contain("text-green-800"); } [Fact] @@ -179,7 +179,7 @@ public void ProfileAdminHeaderBadgeHasRedBackgroundClass() .FirstOrDefault(span => span.GetAttribute("title") == "Administrator" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-red-600")); + && cls.Contains("bg-red-600", StringComparison.Ordinal)); headerBadge.Should().NotBeNull("Header Admin badge should render with bg-red-600"); } From d8db80981fc6f687c1c5047971fdf00aa45ad874 Mon Sep 17 00:00:00 2001 From: Boromir Date: Sun, 10 May 2026 09:33:32 -0700 Subject: [PATCH 4/5] fix(profile): harden email fallback and clear CI blocker (#278) --- Directory.Packages.props | 5 +- docs/build-log.txt | 56 +++ src/Web/Features/UserManagement/Profile.razor | 100 ++--- .../UserManagement/UserManagementHandler.cs | 348 +++++++++--------- src/Web/Program.cs | 2 +- .../Web.Tests.Bunit/Features/ProfileTests.cs | 49 +++ .../Handlers/UserManagementHandlerTests.cs | 172 ++++++++- 7 files changed, 509 insertions(+), 223 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a02c2454..2cd1e97d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ true + true @@ -21,6 +22,8 @@ + + @@ -49,4 +52,4 @@ - \ No newline at end of file + diff --git a/docs/build-log.txt b/docs/build-log.txt index 72eeed6c..4f7bee55 100644 --- a/docs/build-log.txt +++ b/docs/build-log.txt @@ -156,6 +156,62 @@ The solution builds successfully and all tests pass. The only issue is a minor code coverage gap of 0.54%. Adding tests for approximately 5 more lines will meet the 89% threshold. +ADDENDUM: PR #279 SHARPCOMPRESS / NU1902 CI INVESTIGATION +--------------------------------------------------------- +Generated: 2026-05-10 +Scope: Allowed-file remediation only. Preserved existing dirty app files in + src/Web/Features/UserManagement/UserManagementHandler.cs and + src/Web/Program.cs. + +ROOT CAUSE +---------- +- CI restore was failing with NU1902 because MongoDB.Driver 3.6.0 resolved the + transitive package SharpCompress 0.30.1. +- NuGet restore audit is enabled and warnings are treated as errors in CI, so + advisory GHSA-6c8g-7p36-r338 became a build-blocking restore error. +- Scratch-package verification during this session showed MongoDB.Driver 3.7.1 + and 3.8.0 still resolve SharpCompress 0.30.1, so upgrading only the driver was + not a minimal safe fix. + +FIX APPLIED +----------- +- Enabled CentralPackageTransitivePinningEnabled in Directory.Packages.props. +- Added a central transitive pin for SharpCompress 0.48.0. + +VERIFICATION +------------ +1. Command: dotnet restore MyBlog.slnx + Status: ✅ SUCCESS + Result: Restore completed without NU1902. + +2. Command: dotnet build MyBlog.slnx --configuration Release --no-restore + Status: ✅ SUCCESS + Result: Build completed with 326 existing analyzer/code-quality warnings and + 0 errors. No SharpCompress / NU1902 failure remained. + +3. Command: dotnet package list --project src/Web/Web.csproj --include-transitive --vulnerable --format json --no-restore + Status: ✅ SUCCESS + Result: Vulnerable package count = 0 + +4. Command: dotnet package list --project src/AppHost/AppHost.csproj --include-transitive --vulnerable --format json --no-restore + Status: ✅ SUCCESS + Result: Vulnerable package count = 0 + +5. Test verification (Release, --no-build) + Status: ✅ SUCCESS + Passed projects: + - tests/Architecture.Tests/Architecture.Tests.csproj + - tests/Domain.Tests/Domain.Tests.csproj + - tests/Web.Tests/Web.Tests.csproj + - tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj + - tests/Web.Tests.Integration/Web.Tests.Integration.csproj + - tests/AppHost.Tests/AppHost.Tests.csproj + +REMAINING BLOCKER +----------------- +None found for the SharpCompress / NU1902 issue after the package pin was +applied and verified. + ================================================================================ END OF BUILD LOG ================================================================================ diff --git a/src/Web/Features/UserManagement/Profile.razor b/src/Web/Features/UserManagement/Profile.razor index 5a146686..c68278ca 100644 --- a/src/Web/Features/UserManagement/Profile.razor +++ b/src/Web/Features/UserManagement/Profile.razor @@ -19,13 +19,13 @@ else
@if (!string.IsNullOrWhiteSpace(_pictureUrl)) { - @($ + @($ } else { -
+
@_initials
} @@ -35,8 +35,9 @@ else

@_displayName

@if (_isAdmin) { - Admin + Admin }

@_emailAddress

@@ -48,7 +49,8 @@ else
-
+

Identity

@@ -62,7 +64,8 @@ else
-
+

Roles

@if (_roles.Count > 0) { @@ -71,11 +74,13 @@ else { @if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase)) { - @role + @role } else { - @role + @role } }
@@ -91,40 +96,45 @@ else

Claims

-

Claims currently present on your authenticated user principal.

-
- - @if (_claims.Count == 0) - { -
-

No claims were found.

+

Claims currently present on your + authenticated user principal.

- } - else - { -
- - - - - - - - - @foreach (var claim in _claims) - { - - - - - } - -
Claim TypeValue
@claim.Type@claim.Value
+ + @if (_claims.Count == 0) + { +
+

No claims were found.

+
+ } + else + { +
+ + + + + + + + + @foreach (var claim in _claims) + { + + + + + } + +
+ Claim Type + Value
@claim.Type@claim.Value
+
+ } +
} - -
-} @code { [CascadingParameter] @@ -253,8 +263,9 @@ else { return document.RootElement .EnumerateArray() - .Select(element => element.GetString()) - .Where(value => !string.IsNullOrWhiteSpace(value)) + .Where(static element => element.ValueKind == JsonValueKind.String) + .Select(static element => element.GetString()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) .Cast() .ToArray(); } @@ -303,7 +314,8 @@ else private static string GetInitials(string displayName, string emailAddress) { - var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", StringComparison.OrdinalIgnoreCase) + var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", +StringComparison.OrdinalIgnoreCase) ? displayName : emailAddress; diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 658878d4..461d2052 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -7,6 +7,8 @@ //Project Name : Web //======================================================= +using System.Text.Json.Serialization; + using Auth0.ManagementApi; using Auth0.ManagementApi.Users; @@ -22,194 +24,200 @@ internal sealed class UserManagementHandler( IRequestHandler, IRequestHandler>> { -public async Task>> Handle( -GetUsersWithRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var result = new List(); -await foreach (var user in usersPager.ConfigureAwait(false)) -{ -var rolesPager = await client.Users.Roles.ListAsync( -user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager.ConfigureAwait(false)) -{ -roles.Add(role.Name ?? string.Empty); -} -result.Add(new UserWithRolesDto( -user.UserId ?? string.Empty, -user.Email ?? string.Empty, -user.Name ?? user.Email ?? string.Empty, -roles)); -} -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetUsersWithRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var result = new List(); + await foreach (var user in usersPager.ConfigureAwait(false)) + { + var rolesPager = await client.Users.Roles.ListAsync( + user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager.ConfigureAwait(false)) + { + roles.Add(role.Name ?? string.Empty); + } + result.Add(new UserWithRolesDto( + user.UserId ?? string.Empty, + user.Email ?? string.Empty, + user.Name ?? user.Email ?? string.Empty, + roles)); + } + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.AssignAsync( -request.UserId, -new AssignUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.AssignAsync( + request.UserId, + new AssignUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.DeleteAsync( -request.UserId, -new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.DeleteAsync( + request.UserId, + new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager.ConfigureAwait(false)) -{ -roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); -} -return Result.Ok>(roles); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager.ConfigureAwait(false)) + { + roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); + } + return Result.Ok>(roles); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -private async Task GetManagementClientAsync(CancellationToken cancellationToken) -{ -var domain = GetRequiredManagementSetting("Auth0Management:Domain", "Auth0:ManagementApiDomain"); -var clientId = GetRequiredManagementSetting("Auth0Management:ClientId", "Auth0:ManagementApiClientId"); -var clientSecret = GetRequiredManagementSetting("Auth0Management:ClientSecret", "Auth0:ManagementApiClientSecret"); -var audience = GetOptionalManagementSetting("Auth0Management:Audience", "Auth0:ManagementApiAudience") - ?? $"https://{domain}/api/v2/"; + private async Task GetManagementClientAsync(CancellationToken cancellationToken) + { + var domain = GetRequiredManagementSetting("Auth0Management:Domain", "Auth0:ManagementApiDomain"); + var clientId = GetRequiredManagementSetting("Auth0Management:ClientId", "Auth0:ManagementApiClientId"); + var clientSecret = GetRequiredManagementSetting("Auth0Management:ClientSecret", "Auth0:ManagementApiClientSecret"); + var audience = GetOptionalManagementSetting("Auth0Management:Audience", "Auth0:ManagementApiAudience") + ?? $"https://{domain}/api/v2/"; -using var httpClient = httpClientFactory.CreateClient(); -var tokenResponse = await httpClient.PostAsJsonAsync( -$"https://{domain}/oauth/token", -new -{ -client_id = clientId, -client_secret = clientSecret, -audience, -grant_type = "client_credentials" -}, cancellationToken).ConfigureAwait(false); -tokenResponse.EnsureSuccessStatusCode(); -var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); -return new ManagementApiClient( -token: tokenData!.AccessToken, -clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); -} + using var httpClient = httpClientFactory.CreateClient(); + var tokenResponse = await httpClient.PostAsJsonAsync( + $"https://{domain}/oauth/token", + new + { + client_id = clientId, + client_secret = clientSecret, + audience, + grant_type = "client_credentials" + }, cancellationToken).ConfigureAwait(false); + tokenResponse.EnsureSuccessStatusCode(); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(tokenData?.AccessToken)) + { + throw new InvalidOperationException("Auth0 Management API token response did not contain a valid access_token."); + } -private string GetRequiredManagementSetting(string primaryKey, string legacyKey) -{ - return GetOptionalManagementSetting(primaryKey, legacyKey) - ?? throw new InvalidOperationException( - $"{primaryKey} not configured. {legacyKey} not configured."); -} + return new ManagementApiClient( + token: tokenData.AccessToken, + clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); + } -private string? GetOptionalManagementSetting(params string[] keys) -{ - foreach (var key in keys) + private string GetRequiredManagementSetting(string primaryKey, string legacyKey) + { + return GetOptionalManagementSetting(primaryKey, legacyKey) + ?? throw new InvalidOperationException( + $"{primaryKey} not configured. {legacyKey} not configured."); + } + + private string? GetOptionalManagementSetting(params string[] keys) { - var value = configuration[key]; - if (!string.IsNullOrWhiteSpace(value)) + foreach (var key in keys) { - return value; + var value = configuration[key]; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } } - } - return null; -} + return null; + } -private sealed class TokenResponse -{ -public string AccessToken { get; init; } = string.Empty; -} + private sealed class TokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = string.Empty; + } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 056faa54..ebafbd1f 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -120,7 +120,7 @@ // MediatR — scans Web assembly for all handlers builder.Services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); }); // FluentValidation — scans Web assembly for all validators diff --git a/tests/Web.Tests.Bunit/Features/ProfileTests.cs b/tests/Web.Tests.Bunit/Features/ProfileTests.cs index 1fe11a0a..139a93ab 100644 --- a/tests/Web.Tests.Bunit/Features/ProfileTests.cs +++ b/tests/Web.Tests.Bunit/Features/ProfileTests.cs @@ -85,6 +85,55 @@ public void ProfileUsesPreferredUsernameAsEmailFallbackWhenDirectEmailClaimsAreM emailLine.TextContent.Trim().Should().Be("preferred-admin@example.com"); } + [Fact] + public void ProfileUsesNamespacedEmailClaimTailWhenDirectEmailClaimsAreMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|namespaced-email", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("https://schemas.example.com/email", "namespaced-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("namespaced-admin@example.com"); + } + + [Fact] + public void ProfileIgnoresNonStringEntriesInJsonEmailsClaimAndUsesFirstStringEmail() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|json-emails", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("emails", "[{\"value\":\"ignore-me\"},\"json-array@example.com\",42]") + ]); + + IRenderedComponent? cut = null; + Action act = () => cut = RenderForUser(principal); + + // Act + act.Should().NotThrow(); + + // Assert + cut.Should().NotBeNull(); + cut!.Find("section.card div.space-y-2 > p").TextContent.Trim().Should().Be("json-array@example.com"); + } + [Fact] public void ProfileUsesFallbackValuesWhenOptionalClaimsAreMissing() { diff --git a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs index b82ad68e..3760601f 100644 --- a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs +++ b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs @@ -2,6 +2,9 @@ //======================================================= using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; @@ -203,7 +206,9 @@ public async Task Handle_GetAvailableRoles_ClientSecretMissing_ReturnsFailResult public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle(new GetUsersWithRolesQuery(), CancellationToken.None); @@ -217,7 +222,9 @@ public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult( public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle( @@ -232,7 +239,9 @@ public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle( @@ -247,7 +256,9 @@ public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); @@ -257,8 +268,131 @@ public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult( result.Error.Should().Contain("500"); } + // ── Management configuration/token success path ───────────────────────────────────────── + + [Fact] + public async Task GetManagementClientAsyncUsesPrimaryAuth0ManagementKeysAndConfiguredAudience() + { + // Arrange + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"primary-token\"}"); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithPrimaryKeys(new StaticHttpClientFactory(httpClient), "https://api.example.com/"); + + // Act + var client = await InvokeGetManagementClientAsync(handler); + httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); + using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); + + // Assert + client.GetType().FullName.Should().Be("Auth0.ManagementApi.ManagementApiClient"); + httpHandler.LastRequestUri.Should().Be(new Uri("https://primary.auth0.com/oauth/token")); + requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("primary-client-id"); + requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("primary-client-secret"); + requestBody.RootElement.GetProperty("audience").GetString().Should().Be("https://api.example.com/"); + requestBody.RootElement.GetProperty("grant_type").GetString().Should().Be("client_credentials"); + } + + [Fact] + public async Task GetManagementClientAsyncPrimaryWhitespaceFallsBackToLegacyKeysAndDefaultAudience() + { + // Arrange + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"legacy-token\"}"); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithLegacyFallback(new StaticHttpClientFactory(httpClient)); + + // Act + var client = await InvokeGetManagementClientAsync(handler); + httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); + using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); + + // Assert + client.GetType().FullName.Should().Be("Auth0.ManagementApi.ManagementApiClient"); + httpHandler.LastRequestUri.Should().Be(new Uri("https://legacy.auth0.com/oauth/token")); + requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("legacy-client-id"); + requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("legacy-client-secret"); + requestBody.RootElement.GetProperty("audience").GetString().Should().Be("https://legacy.auth0.com/api/v2/"); + requestBody.RootElement.GetProperty("grant_type").GetString().Should().Be("client_credentials"); + } + + [Fact] + public void TokenResponseDeserializesAccessTokenFromAuth0SnakeCasePayload() + { + // Arrange + var tokenResponseType = typeof(UserManagementHandler).GetNestedType("TokenResponse", BindingFlags.NonPublic); + tokenResponseType.Should().NotBeNull(); + if (tokenResponseType is null) + { + throw new InvalidOperationException("TokenResponse type was not found."); + } + + // Act + var tokenData = JsonSerializer.Deserialize("{\"access_token\":\"abc123\"}", tokenResponseType); + var accessToken = tokenResponseType + .GetProperty("AccessToken", BindingFlags.Instance | BindingFlags.Public) + ?.GetValue(tokenData) as string; + + // Assert + tokenData.Should().NotBeNull(); + accessToken.Should().Be("abc123"); + } + // ── helpers ─────────────────────────────────────────────────────────────────────────────── + private static UserManagementHandler BuildHandlerWithPrimaryKeys(IHttpClientFactory httpFactory, string audience) + { + var config = Substitute.For(); + config["Auth0Management:Domain"].Returns("primary.auth0.com"); + config["Auth0Management:ClientId"].Returns("primary-client-id"); + config["Auth0Management:ClientSecret"].Returns("primary-client-secret"); + config["Auth0Management:Audience"].Returns(audience); + config["Auth0:ManagementApiDomain"].Returns("legacy.auth0.com"); + config["Auth0:ManagementApiClientId"].Returns("legacy-client-id"); + config["Auth0:ManagementApiClientSecret"].Returns("legacy-client-secret"); + return new UserManagementHandler(config, httpFactory); + } + + private static UserManagementHandler BuildHandlerWithLegacyFallback(IHttpClientFactory httpFactory) + { + var config = Substitute.For(); + config["Auth0Management:Domain"].Returns(" "); + config["Auth0Management:ClientId"].Returns("\t"); + config["Auth0Management:ClientSecret"].Returns(" "); + config["Auth0Management:Audience"].Returns(" "); + config["Auth0:ManagementApiDomain"].Returns("legacy.auth0.com"); + config["Auth0:ManagementApiClientId"].Returns("legacy-client-id"); + config["Auth0:ManagementApiClientSecret"].Returns("legacy-client-secret"); + return new UserManagementHandler(config, httpFactory); + } + + private static async Task InvokeGetManagementClientAsync(UserManagementHandler handler) + { + var method = typeof(UserManagementHandler).GetMethod("GetManagementClientAsync", BindingFlags.Instance | BindingFlags.NonPublic); + method.Should().NotBeNull(); + if (method is null) + { + throw new InvalidOperationException("GetManagementClientAsync was not found."); + } + + var task = method.Invoke(handler, [CancellationToken.None]) as Task; + task.Should().NotBeNull(); + if (task is null) + { + throw new InvalidOperationException("GetManagementClientAsync did not return a task."); + } + + await task.ConfigureAwait(false); + + var resultProperty = task.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public); + resultProperty.Should().NotBeNull(); + if (resultProperty is null) + { + throw new InvalidOperationException("GetManagementClientAsync task result property was not found."); + } + + return resultProperty.GetValue(task) + ?? throw new InvalidOperationException("GetManagementClientAsync returned a null client."); + } + private static UserManagementHandler BuildHandlerClientIdMissing() { var config = Substitute.For(); @@ -276,14 +410,12 @@ private static UserManagementHandler BuildHandlerClientSecretMissing() return new UserManagementHandler(config, Substitute.For()); } - private static UserManagementHandler BuildHandlerHttpFail(HttpStatusCode statusCode) + private static UserManagementHandler BuildHandlerHttpFail(IHttpClientFactory httpFactory) { var config = Substitute.For(); config["Auth0:ManagementApiDomain"].Returns("test.auth0.com"); config["Auth0:ManagementApiClientId"].Returns("test-client-id"); config["Auth0:ManagementApiClientSecret"].Returns("test-client-secret"); - var httpFactory = Substitute.For(); - httpFactory.CreateClient().Returns(new HttpClient(new StubHttpHandler(statusCode))); return new UserManagementHandler(config, httpFactory); } @@ -293,5 +425,31 @@ protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(statusCode)); } + + private sealed class StaticHttpClientFactory(HttpClient httpClient) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => httpClient; + } + + private sealed class RecordingTokenHttpHandler(string responseJson) : HttpMessageHandler + { + public Uri? LastRequestUri { get; private set; } + + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri; + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }; + } + } } From 318ae7afd46fb926cbe43ec8c5a00b87a2f06ea9 Mon Sep 17 00:00:00 2001 From: Boromir Date: Sun, 10 May 2026 11:12:16 -0700 Subject: [PATCH 5/5] test(profile): align handler tests with public Handle surface (#278) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Handlers/UserManagementHandlerTests.cs | 111 +++++++----------- 1 file changed, 41 insertions(+), 70 deletions(-) diff --git a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs index 3760601f..df0d3571 100644 --- a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs +++ b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs @@ -2,7 +2,6 @@ //======================================================= using System.Net; -using System.Reflection; using System.Text; using System.Text.Json; @@ -10,10 +9,12 @@ using MyBlog.Web.Features.UserManagement; -namespace Unit.Handlers; +namespace Web.Handlers; public class UserManagementHandlerTests { + private const string InvalidAccessTokenError = "Auth0 Management API token response did not contain a valid access_token."; + private readonly IConfiguration _config = Substitute.For(); private readonly IHttpClientFactory _httpFactory = Substitute.For(); private readonly UserManagementHandler _handler; @@ -27,7 +28,7 @@ public UserManagementHandlerTests() // ── Domain missing ────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_DomainMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesDomainMissingReturnsFailResult() { // Arrange (none) @@ -40,7 +41,7 @@ public async Task Handle_GetUsersWithRoles_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_AssignRole_DomainMissing_ReturnsFailResult() + public async Task HandleAssignRoleDomainMissingReturnsFailResult() { // Arrange (none) @@ -54,7 +55,7 @@ public async Task Handle_AssignRole_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_DomainMissing_ReturnsFailResult() + public async Task HandleRemoveRoleDomainMissingReturnsFailResult() { // Arrange (none) @@ -68,7 +69,7 @@ public async Task Handle_RemoveRole_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_DomainMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesDomainMissingReturnsFailResult() { // Arrange (none) @@ -83,7 +84,7 @@ public async Task Handle_GetAvailableRoles_DomainMissing_ReturnsFailResult() // ── ClientId missing ──────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_ClientIdMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -97,7 +98,7 @@ public async Task Handle_GetUsersWithRoles_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_AssignRole_ClientIdMissing_ReturnsFailResult() + public async Task HandleAssignRoleClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -112,7 +113,7 @@ public async Task Handle_AssignRole_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_ClientIdMissing_ReturnsFailResult() + public async Task HandleRemoveRoleClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -127,7 +128,7 @@ public async Task Handle_RemoveRole_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_ClientIdMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -143,7 +144,7 @@ public async Task Handle_GetAvailableRoles_ClientIdMissing_ReturnsFailResult() // ── ClientSecret missing ────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_ClientSecretMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -157,7 +158,7 @@ public async Task Handle_GetUsersWithRoles_ClientSecretMissing_ReturnsFailResult } [Fact] - public async Task Handle_AssignRole_ClientSecretMissing_ReturnsFailResult() + public async Task HandleAssignRoleClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -172,7 +173,7 @@ public async Task Handle_AssignRole_ClientSecretMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_ClientSecretMissing_ReturnsFailResult() + public async Task HandleRemoveRoleClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -187,7 +188,7 @@ public async Task Handle_RemoveRole_ClientSecretMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_ClientSecretMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -203,7 +204,7 @@ public async Task Handle_GetAvailableRoles_ClientSecretMissing_ReturnsFailResult // ── HTTP token endpoint fails ──────────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult() + public async Task HandleGetUsersWithRolesTokenEndpointFailsReturnsFailResult() { // Arrange using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); @@ -219,7 +220,7 @@ public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult( } [Fact] - public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() + public async Task HandleAssignRoleTokenEndpointFailsReturnsFailResult() { // Arrange using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); @@ -236,7 +237,7 @@ public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() + public async Task HandleRemoveRoleTokenEndpointFailsReturnsFailResult() { // Arrange using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); @@ -253,7 +254,7 @@ public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult() + public async Task HandleGetAvailableRolesTokenEndpointFailsReturnsFailResult() { // Arrange using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); @@ -268,23 +269,24 @@ public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult( result.Error.Should().Contain("500"); } - // ── Management configuration/token success path ───────────────────────────────────────── + // ── Management configuration/token contract ────────────────────────────────────────────── [Fact] - public async Task GetManagementClientAsyncUsesPrimaryAuth0ManagementKeysAndConfiguredAudience() + public async Task HandleGetAvailableRolesPrimaryManagementKeysUsePrimaryConfigAndConfiguredAudience() { // Arrange - using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"primary-token\"}"); + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\" \"}"); using var httpClient = new HttpClient(httpHandler, disposeHandler: false); var handler = BuildHandlerWithPrimaryKeys(new StaticHttpClientFactory(httpClient), "https://api.example.com/"); // Act - var client = await InvokeGetManagementClientAsync(handler); + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); // Assert - client.GetType().FullName.Should().Be("Auth0.ManagementApi.ManagementApiClient"); + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); httpHandler.LastRequestUri.Should().Be(new Uri("https://primary.auth0.com/oauth/token")); requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("primary-client-id"); requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("primary-client-secret"); @@ -293,20 +295,21 @@ public async Task GetManagementClientAsyncUsesPrimaryAuth0ManagementKeysAndConfi } [Fact] - public async Task GetManagementClientAsyncPrimaryWhitespaceFallsBackToLegacyKeysAndDefaultAudience() + public async Task HandleGetAvailableRolesWhitespacePrimaryManagementKeysFallBackToLegacyConfig() { // Arrange - using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"legacy-token\"}"); + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"\"}"); using var httpClient = new HttpClient(httpHandler, disposeHandler: false); var handler = BuildHandlerWithLegacyFallback(new StaticHttpClientFactory(httpClient)); // Act - var client = await InvokeGetManagementClientAsync(handler); + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); // Assert - client.GetType().FullName.Should().Be("Auth0.ManagementApi.ManagementApiClient"); + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); httpHandler.LastRequestUri.Should().Be(new Uri("https://legacy.auth0.com/oauth/token")); requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("legacy-client-id"); requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("legacy-client-secret"); @@ -314,26 +317,23 @@ public async Task GetManagementClientAsyncPrimaryWhitespaceFallsBackToLegacyKeys requestBody.RootElement.GetProperty("grant_type").GetString().Should().Be("client_credentials"); } - [Fact] - public void TokenResponseDeserializesAccessTokenFromAuth0SnakeCasePayload() + [Theory] + [InlineData("{\"access_token\":\"\"}")] + [InlineData("{\"access_token\":\" \"}")] + [InlineData("{}")] + public async Task HandleGetAvailableRolesBlankOrMissingAccessTokenReturnsExplicitFailure(string tokenResponseJson) { // Arrange - var tokenResponseType = typeof(UserManagementHandler).GetNestedType("TokenResponse", BindingFlags.NonPublic); - tokenResponseType.Should().NotBeNull(); - if (tokenResponseType is null) - { - throw new InvalidOperationException("TokenResponse type was not found."); - } + using var httpHandler = new RecordingTokenHttpHandler(tokenResponseJson); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithPrimaryKeys(new StaticHttpClientFactory(httpClient), "https://api.example.com/"); // Act - var tokenData = JsonSerializer.Deserialize("{\"access_token\":\"abc123\"}", tokenResponseType); - var accessToken = tokenResponseType - .GetProperty("AccessToken", BindingFlags.Instance | BindingFlags.Public) - ?.GetValue(tokenData) as string; + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); // Assert - tokenData.Should().NotBeNull(); - accessToken.Should().Be("abc123"); + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); } // ── helpers ─────────────────────────────────────────────────────────────────────────────── @@ -364,35 +364,6 @@ private static UserManagementHandler BuildHandlerWithLegacyFallback(IHttpClientF return new UserManagementHandler(config, httpFactory); } - private static async Task InvokeGetManagementClientAsync(UserManagementHandler handler) - { - var method = typeof(UserManagementHandler).GetMethod("GetManagementClientAsync", BindingFlags.Instance | BindingFlags.NonPublic); - method.Should().NotBeNull(); - if (method is null) - { - throw new InvalidOperationException("GetManagementClientAsync was not found."); - } - - var task = method.Invoke(handler, [CancellationToken.None]) as Task; - task.Should().NotBeNull(); - if (task is null) - { - throw new InvalidOperationException("GetManagementClientAsync did not return a task."); - } - - await task.ConfigureAwait(false); - - var resultProperty = task.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public); - resultProperty.Should().NotBeNull(); - if (resultProperty is null) - { - throw new InvalidOperationException("GetManagementClientAsync task result property was not found."); - } - - return resultProperty.GetValue(task) - ?? throw new InvalidOperationException("GetManagementClientAsync returned a null client."); - } - private static UserManagementHandler BuildHandlerClientIdMissing() { var config = Substitute.For();