diff --git a/CHANGELOG.md b/CHANGELOG.md index e36439dd..1d22d7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to the MMCA.Common packages are documented here. The format [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/) and are derived from git tags by MinVer (see [the published versioning policy](https://ivanball.github.io/docs/guides/common-VERSIONING.html)). +## [Unreleased] + +### Fixed + +- `HttpResultExecutor` treats Polly's `ExecutionRejectedException` (so `TimeoutRejectedException` and `BrokenCircuitException`) as a transport fault, exactly like `HttpRequestException`: the UI service returns the `Http.TransportFailure` result instead of throwing, so a gateway outage no longer turns a prerender into a 500. Cancellation of the caller's own token still propagates. +- `LoginProtectionService` fails open when the cache throws (Redis down), as `CacheSettings` promises a cache outage never becomes an error: the lockout and registration-limit checks answer success, and the failed-attempt increment, the reset and the registration count do nothing, each with a Warning log naming the operation (never the email or IP). Sign-in and registration used to fail with a 500. Cancellation of the caller's own token still propagates. The constructor takes a trailing optional `ILogger` (resolved by DI; existing calls compile unchanged, recompile needed). +- The `/register` duplicate-email alert names the address the server rejected, not the live field, and disappears as soon as the email field no longer equals it; typing the same address back shows nothing until the next submit re-checks it. Editing the field used to print an unchecked address as "already registered". +- Spanish user-administration strings (`UserAdminListResources.es.resx`) carry their accents and opening question marks; seven values used the unaccented spellings of "correo electronico", "administracion", "sesion", "cerrara", "podra", "volvera" and "elimino". +- `SetStoredPermissionsAsync` (the role-administration `PUT`) answers `Authorization.RoleNotFound` for a role outside the role universe (compiled catalog roles, `KnownRoles`, roles with stored grants), the same error `GetRoleAsync` returns, and stores nothing, for an empty and a non-empty list alike. An empty list used to answer 200 for a nonexistent role, and a non-empty one created the typo as a new role. +- `AddCommonServerTokenStorage()` also composes a handler onto the `"APIClient"` pipeline that stamps `X-Forwarded-For` with the visitor's remote IP (the page request during prerender, the circuit's connection afterwards; the same value the cookie-session refresh forwards), replacing any value already on the request. Blazor Server API calls used to carry no client address, so per-IP limits such as the registration rate limit keyed every visitor on the UI host's address. Nothing is sent when no request is in scope; the WebAssembly path is unchanged. + ## [1.224.0] - 2026-10-03 ### Fixed diff --git a/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs b/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs index 7a195822..6059b270 100644 --- a/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs +++ b/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs @@ -102,13 +102,7 @@ public async Task> GetRoleAsync( var stored = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false); var compiled = CompiledPermissions(role); - // A role the host has never named, never compiled a permission for and never granted one to - // does not exist as far as this surface is concerned. Reporting it as an empty role instead - // would make every typo look like a real role with nothing granted. - if (stored.Count == 0 - && compiled.Count == 0 - && !settings.Value.KnownRoles.Contains(role, StringComparer.OrdinalIgnoreCase) - && !catalog.Roles.Contains(role, StringComparer.OrdinalIgnoreCase)) + if (!IsKnownRole(role, stored, compiled)) { return Result.Failure(RoleNotFound(role)); } @@ -130,6 +124,15 @@ public async Task> SetStoredPermissionsAsync( return Result.Failure(RoleNotFound(role)); } + // The same existence rule GetRoleAsync applies, checked before anything is validated or + // written: a set must not be the route by which a typo becomes a role, with an empty list + // (answering success for a role that does not exist) or with a non-empty one (creating it). + var current = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false); + if (!IsKnownRole(role, current, CompiledPermissions(role))) + { + return Result.Failure(RoleNotFound(role)); + } + var desired = new HashSet( permissions.Where(permission => !string.IsNullOrWhiteSpace(permission)).Select(permission => permission.Trim()), StringComparer.Ordinal); @@ -159,7 +162,6 @@ public async Task> SetStoredPermissionsAsync( role)); } - var current = await store.GetPermissionsAsync(role, cancellationToken).ConfigureAwait(false); var existing = new HashSet(current, StringComparer.Ordinal); // Each grant and revoke commits on its own, so a failure or a throw part-way through leaves @@ -214,6 +216,24 @@ private SortedSet RoleUniverse(IEnumerable rolesWithGrants) return roles; } + /// + /// Whether a role belongs to the universe lists: a catalog role, a + /// configured KnownRoles entry, or a role that already carries a stored grant (plus a role + /// the registry compiles permissions for). + /// + /// + /// A role the host has never named, never compiled a permission for and never granted one to + /// does not exist as far as this surface is concerned. Reporting it as an empty role instead + /// would make every typo look like a real role with nothing granted. + /// + /// The role name. + /// The role's stored grants. + /// The role's compiled permissions. + /// when the role exists for this surface. + private bool IsKnownRole(string role, IReadOnlyList stored, IReadOnlyList compiled) => + compiled.Count > 0 + || RoleUniverse(stored.Count > 0 ? [role] : []).Contains(role); + private static Error RoleNotFound(string? role) => Error.NotFoundError( "Authorization.RoleNotFound", "The role was not found.", diff --git a/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs b/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs index 6404307f..2a01f082 100644 --- a/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs +++ b/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MMCA.Common.Application.Auth; using MMCA.Common.Application.Interfaces; @@ -15,12 +17,21 @@ namespace MMCA.Common.Infrastructure.Auth; /// Registration rate limit: limits registrations per IP address within a /// configurable time window. /// +/// +/// Fails open on a cache outage. The counters live in the cache, and the cache is an +/// optimization that never turns its own outage into an error (CacheSettings). When the cache +/// throws, a check answers success and an increment or reset does nothing, each with a warning log, +/// so an unreachable cache suspends the limits instead of failing every sign-in and registration. +/// Cancellation of the caller's own token still propagates. +/// /// -public sealed class LoginProtectionService( +public sealed partial class LoginProtectionService( ICacheService cacheService, - IOptions settings) : ILoginProtectionService + IOptions settings, + ILogger? logger = null) : ILoginProtectionService { private readonly LoginProtectionSettings _settings = settings.Value; + private readonly ILogger _logger = logger ?? NullLogger.Instance; /// /// Normalizes the supplied address the same way does before it is used in a @@ -39,7 +50,16 @@ public sealed class LoginProtectionService( public async Task CheckLockoutAsync(string email, CancellationToken cancellationToken = default) { var lockoutKey = LockoutKey(email); - var isLockedOut = await cacheService.GetAsync(lockoutKey, cancellationToken).ConfigureAwait(false) ?? false; + bool isLockedOut; + try + { + isLockedOut = await cacheService.GetAsync(lockoutKey, cancellationToken).ConfigureAwait(false) ?? false; + } + catch (Exception ex) when (IsCacheOutage(ex, cancellationToken)) + { + LogCacheUnavailable(_logger, nameof(CheckLockoutAsync), ex); + return Result.Success(); + } return isLockedOut ? Result.Failure(Error.TooManyRequests( @@ -61,29 +81,43 @@ public async Task IncrementFailedAttemptsAsync(string email, CancellationToken c // is what a credential-stuffing run against one account looks like, still trips the lockout. // Closing the gap needs the increment made atomic again WITHIN the hash layout (a Lua // script) or counters moved off IDistributedCache so both sides speak Redis strings. - var newCount = await cacheService.IncrementAsync( - AttemptsKey(email), - TimeSpan.FromMinutes(_settings.FailedAttemptWindowMinutes), - cancellationToken).ConfigureAwait(false); - - if (newCount >= _settings.MaxFailedAttempts) + try { - var excessAttempts = (int)Math.Min(newCount - _settings.MaxFailedAttempts, int.MaxValue); - - // Clamp the shift exponent: C# masks int shift counts to 5 bits, so 1 << 31 is negative - // and 1 << 32 wraps back to 1, silently shrinking (or negating) the lockout TTL for a - // sufficiently persistent attacker. 1 << 30 already exceeds any permitted - // MaxLockoutSeconds (range caps at 3600), so deep excess always lands on the cap. - var lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), _settings.MaxLockoutSeconds); - await cacheService.SetAsync(LockoutKey(email), true, TimeSpan.FromSeconds(lockoutSeconds), cancellationToken).ConfigureAwait(false); + var newCount = await cacheService.IncrementAsync( + AttemptsKey(email), + TimeSpan.FromMinutes(_settings.FailedAttemptWindowMinutes), + cancellationToken).ConfigureAwait(false); + + if (newCount >= _settings.MaxFailedAttempts) + { + var excessAttempts = (int)Math.Min(newCount - _settings.MaxFailedAttempts, int.MaxValue); + + // Clamp the shift exponent: C# masks int shift counts to 5 bits, so 1 << 31 is negative + // and 1 << 32 wraps back to 1, silently shrinking (or negating) the lockout TTL for a + // sufficiently persistent attacker. 1 << 30 already exceeds any permitted + // MaxLockoutSeconds (range caps at 3600), so deep excess always lands on the cap. + var lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), _settings.MaxLockoutSeconds); + await cacheService.SetAsync(LockoutKey(email), true, TimeSpan.FromSeconds(lockoutSeconds), cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (IsCacheOutage(ex, cancellationToken)) + { + LogCacheUnavailable(_logger, nameof(IncrementFailedAttemptsAsync), ex); } } /// public async Task ResetFailedAttemptsAsync(string email, CancellationToken cancellationToken = default) { - await cacheService.RemoveAsync(AttemptsKey(email), cancellationToken).ConfigureAwait(false); - await cacheService.RemoveAsync(LockoutKey(email), cancellationToken).ConfigureAwait(false); + try + { + await cacheService.RemoveAsync(AttemptsKey(email), cancellationToken).ConfigureAwait(false); + await cacheService.RemoveAsync(LockoutKey(email), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsCacheOutage(ex, cancellationToken)) + { + LogCacheUnavailable(_logger, nameof(ResetFailedAttemptsAsync), ex); + } } /// @@ -95,7 +129,16 @@ public async Task CheckRegistrationRateLimitAsync(string? ipAddress, Can } var key = RegistrationKey(ipAddress); - var registrationCount = await cacheService.GetAsync(key, cancellationToken).ConfigureAwait(false) ?? 0; + long registrationCount; + try + { + registrationCount = await cacheService.GetAsync(key, cancellationToken).ConfigureAwait(false) ?? 0; + } + catch (Exception ex) when (IsCacheOutage(ex, cancellationToken)) + { + LogCacheUnavailable(_logger, nameof(CheckRegistrationRateLimitAsync), ex); + return Result.Success(); + } return registrationCount >= _settings.MaxRegistrationsPerIpPerHour ? Result.Failure(Error.Unauthorized( @@ -116,11 +159,29 @@ public async Task IncrementRegistrationCountAsync(string? ipAddress, Cancellatio // Read-modify-write (see IncrementFailedAttemptsAsync for why the native-counter path was // removed), so the TTL is refreshed on every write. That makes the window slide rather than // stay anchored to the first registration, which only ever tightens the limit. - await cacheService.IncrementAsync( - RegistrationKey(ipAddress), - TimeSpan.FromMinutes(_settings.RegistrationRateLimitWindowMinutes), - cancellationToken).ConfigureAwait(false); + try + { + await cacheService.IncrementAsync( + RegistrationKey(ipAddress), + TimeSpan.FromMinutes(_settings.RegistrationRateLimitWindowMinutes), + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsCacheOutage(ex, cancellationToken)) + { + LogCacheUnavailable(_logger, nameof(IncrementRegistrationCountAsync), ex); + } } private static string RegistrationKey(string ipAddress) => $"registration:ip:{ipAddress}"; + + /// + /// Every cache fault counts as an outage except the caller's own cancellation, which keeps + /// propagating. A cancellation the caller did not request (a store-side timeout) is an outage. + /// + private static bool IsCacheOutage(Exception exception, CancellationToken cancellationToken) => + exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested; + + // The operation name is logged, never the key: the keys carry the email address or client IP. + [LoggerMessage(Level = LogLevel.Warning, Message = "Login protection could not reach the cache during {Operation}; failing open, so this call applies no lockout and no registration limit.")] + private static partial void LogCacheUnavailable(ILogger logger, string operation, Exception exception); } diff --git a/Source/Core/MMCA.Common.Infrastructure/PublicAPI.Unshipped.txt b/Source/Core/MMCA.Common.Infrastructure/PublicAPI.Unshipped.txt index 7dc5c581..24a3ddd7 100644 --- a/Source/Core/MMCA.Common.Infrastructure/PublicAPI.Unshipped.txt +++ b/Source/Core/MMCA.Common.Infrastructure/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +*REMOVED*MMCA.Common.Infrastructure.Auth.LoginProtectionService.LoginProtectionService(MMCA.Common.Application.Interfaces.ICacheService! cacheService, Microsoft.Extensions.Options.IOptions! settings) -> void +MMCA.Common.Infrastructure.Auth.LoginProtectionService.LoginProtectionService(MMCA.Common.Application.Interfaces.ICacheService! cacheService, Microsoft.Extensions.Options.IOptions! settings, Microsoft.Extensions.Logging.ILogger? logger = null) -> void diff --git a/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs b/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs index a21cbf8a..9a6ae0d2 100644 --- a/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs +++ b/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs @@ -28,10 +28,24 @@ public static class DependencyInjection /// same-origin refresh endpoint on the interactive circuit (ADR-022). Pair with the session /// cookie plumbing from MMCA.Common.API (AddServerAuthSessionCookie / /// UseCookieSessionRefresh) and a registered ITokenRefresher. + /// + /// Also forwards the visitor's address on this host's server-side "APIClient" calls: + /// each request carries X-Forwarded-For set to the remote IP of the HTTP request behind + /// the render (the page request during prerender, the circuit's connection afterwards), the + /// same value the cookie-session refresh already forwards, so per-client limits such as the + /// registration rate limit key on the visitor instead of on this host. Nothing is sent when no + /// request is in scope. + /// /// public IServiceCollection AddCommonServerTokenStorage() { services.AddHttpContextAccessor(); + + // The UI services' named client (AddUIShared); a second AddHttpClient call with the same + // name appends to that client's pipeline, whichever of the two registrations runs first. + services.AddTransient(); + services.AddHttpClient("APIClient").AddHttpMessageHandler(); + return services.AddScoped(); } diff --git a/Source/Presentation/MMCA.Common.UI.Web/Services/BrowserForwardedForHandler.cs b/Source/Presentation/MMCA.Common.UI.Web/Services/BrowserForwardedForHandler.cs new file mode 100644 index 00000000..8cc3e8ea --- /dev/null +++ b/Source/Presentation/MMCA.Common.UI.Web/Services/BrowserForwardedForHandler.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; + +namespace MMCA.Common.UI.Web.Services; + +/// +/// Stamps X-Forwarded-For with the browser's address on the server-side "APIClient" +/// calls a Blazor Server host makes for a visitor (the SSR prerender and the interactive circuit), so +/// the API keys per-client policy (the registration rate limit, the session's recorded IP) on the +/// visitor rather than on this host's own address, which every visitor shares. +/// +/// +/// +/// Same source and semantics as the cookie-session refresh. The address is the +/// of the HTTP request that carries this work: the page +/// request during prerender, the connection the circuit was established on afterwards. That value +/// has already been through this host's forwarded-headers middleware, so it is only as far back as +/// this host trusts its own proxies; a client-supplied X-Forwarded-For is never copied +/// verbatim. The header is single-valued and replaces any value already on the request. +/// +/// +/// Server only. Registered by AddCommonServerTokenStorage(), which only a Blazor +/// Server host calls; the WebAssembly client's calls reach the API through the same-origin proxy, +/// which stamps the header itself. When no request is in scope (a background call with no visitor +/// behind it) nothing is sent. +/// +/// +/// Reads the request in scope; an async-local, so it crosses the +/// handler's own DI scope. +internal sealed class BrowserForwardedForHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler +{ + /// The header the API's forwarded-headers configuration reads. + internal const string HeaderName = "X-Forwarded-For"; + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var remoteIpAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + if (remoteIpAddress is not null) + { + request.Headers.Remove(HeaderName); + request.Headers.TryAddWithoutValidation(HeaderName, remoteIpAddress); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminListResources.es.resx b/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminListResources.es.resx index f5175660..43a17dd7 100644 --- a/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminListResources.es.resx +++ b/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminListResources.es.resx @@ -19,7 +19,7 @@ usuario - Buscar por correo electronico... + Buscar por correo electrónico... Cancelar la carga de usuarios @@ -31,7 +31,7 @@ No se encontraron usuarios. - Correo electronico + Correo electrónico Rol @@ -55,7 +55,7 @@ Acciones - Acciones de administracion para {0} + Acciones de administración para {0} Bloquear cuenta @@ -73,19 +73,19 @@ Bloquear cuenta - Bloquear a {0}? Se cerrara su sesion y no podra iniciar sesion hasta que la cuenta se desbloquee. + ¿Bloquear a {0}? Se cerrará su sesión y no podrá iniciar sesión hasta que la cuenta se desbloquee. Desbloquear cuenta - Desbloquear a {0}? Podra iniciar sesion de nuevo. + ¿Desbloquear a {0}? Podrá iniciar sesión de nuevo. Cambiar rol - Cambiar el rol de {0} a {1}? Se cerrara su sesion y volvera a entrar con los nuevos permisos. + ¿Cambiar el rol de {0} a {1}? Se cerrará su sesión y volverá a entrar con los nuevos permisos. Cambiar rol @@ -109,7 +109,7 @@ No se pudo cambiar el rol. - El usuario se elimino correctamente. + El usuario se eliminó correctamente. No se pudo eliminar el usuario. diff --git a/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor b/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor index aae91570..5ed807f9 100644 --- a/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor +++ b/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor @@ -36,7 +36,13 @@ - @if (_emailAlreadyRegistered) + @* _rejectedEmail is the address the server rejected, exactly as submitted, so the alert can + only ever name an address the server checked, never the live field. Rule: a rejection + stands only while the field still holds that address. The first time the field differs + (an edit after the answer, or one made while the request was in flight) OnEmailEdited + dismisses it for good, so typing the same address back shows nothing until the next + submit re-checks it. *@ + @if (_rejectedEmail is not null) { @* The address already has an account, so the way forward is signing in rather than resubmitting the form: Severity.Warning plus the two routes out of the dead end. @@ -44,7 +50,7 @@ announces it unless it arrives as a live region. *@ - @L["Auth.Register.EmailAlreadyRegistered", _model.Email] + @L["Auth.Register.EmailAlreadyRegistered", _rejectedEmail ?? string.Empty] @* Underline.Always: an in-text link must be distinguishable without relying on colour alone (WCAG 2.1 AA / axe link-in-text-block). *@
@@ -73,7 +79,7 @@ autocomplete="family-name" /> - + _rejectedEmail = string.Equals(_model.Email, _rejectedEmail, StringComparison.Ordinal) ? _rejectedEmail : null; + private LegalSettings Legal => LegalOptions.Value; // The host opted into terms acceptance by publishing a Terms URL; the server stamps its own @@ -222,7 +231,7 @@ // EditForm + DataAnnotations before OnValidSubmit fires; this method only handles the call and // any server-side failure, surfaced in the form-level MudAlert. _errorMessage = null; - _emailAlreadyRegistered = false; + _rejectedEmail = null; // The disabled submit button is the primary guard; this covers a submit that arrives anyway // (Enter in a field while the button state is stale). @@ -241,11 +250,13 @@ _isLoading = true; + var submittedEmail = _model.Email; // The field stays editable while the request is in flight. + try { var result = await AuthService.RegisterAsync( new RegisterRequest( - _model.Email, + submittedEmail, _model.Password, _model.FirstName, _model.LastName, @@ -258,7 +269,8 @@ // red alert that invites a retry. Matched on the code, never on the wording. if (result.Errors.Any(e => string.Equals(e.Code, AuthErrorCodes.EmailAlreadyExists, StringComparison.Ordinal))) { - _emailAlreadyRegistered = true; + _rejectedEmail = submittedEmail; + OnEmailEdited(); // An edit made while the request was in flight dismisses it at once. return; } diff --git a/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs b/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs index 2f808592..4702db65 100644 --- a/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs +++ b/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs @@ -1,5 +1,6 @@ using System.Text.Json; using MMCA.Common.Shared.Abstractions; +using Polly; namespace MMCA.Common.UI.Services.Api; @@ -114,12 +115,14 @@ public static async Task> ExecuteAsync(Func>> operat /// /// The fault set a client-side HTTP call can raise once responses are handled: the request /// never got an answer (), the stream broke mid-body - /// (), or the payload could not be serialized or read - /// (). Anything else is a genuine programming fault and keeps - /// travelling as an exception. + /// (), the payload could not be serialized or read + /// (), or the client's resilience pipeline refused to run or finish + /// the call (, the base of Polly's + /// TimeoutRejectedException and BrokenCircuitException). Anything else is a + /// genuine programming fault and keeps travelling as an exception. /// private static bool IsTransportFault(Exception exception) => - exception is HttpRequestException or IOException or JsonException; + exception is HttpRequestException or IOException or JsonException or ExecutionRejectedException; private static Error TransportError(Exception exception) => // The exception text goes on Source, not Message: it is diagnostic detail, neither diff --git a/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/Administration/StoredPermissionRoleAdministrationServiceTests.cs b/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/Administration/StoredPermissionRoleAdministrationServiceTests.cs index d753a2c2..15e95d66 100644 --- a/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/Administration/StoredPermissionRoleAdministrationServiceTests.cs +++ b/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/Administration/StoredPermissionRoleAdministrationServiceTests.cs @@ -223,6 +223,72 @@ public async Task SetStoredPermissionsAsync_ForABlankRole_IsNotFoundRatherThanAS result.Errors[0].Type.Should().Be(ErrorType.NotFound); } + // ── A set never creates a role ── + [Fact] + public async Task SetStoredPermissionsAsync_ForAnUnknownRoleWithAnEmptySet_IsTheSameNotFoundAsGetRole() + { + var sut = CreateService(compiled: new PermissionRegistryBuilder().Grant("Manager", Manage)); + + var expected = await sut.GetRoleAsync("Ghost"); + var result = await sut.SetStoredPermissionsAsync("Ghost", []); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(); + result.Errors[0].Code.Should().Be("Authorization.RoleNotFound"); + result.Errors[0].Type.Should().Be(ErrorType.NotFound); + result.Errors[0].Should().BeEquivalentTo(expected.Errors[0], "a set answers an unknown role exactly as a read does"); + _invalidator.Verify(x => x.InvalidateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SetStoredPermissionsAsync_ForAnUnknownRoleWithPermissions_IsNotFoundAndStoresNothing() + { + var sut = CreateService(compiled: new PermissionRegistryBuilder().Grant("Manager", Manage)); + + var result = await sut.SetStoredPermissionsAsync("Managre", [Manage]); + + result.IsFailure.Should().BeTrue(); + result.Errors[0].Code.Should().Be("Authorization.RoleNotFound"); + result.Errors[0].Type.Should().Be(ErrorType.NotFound); + _store.Grants.Should().BeEmpty("a typo in the role name must not become a new role"); + } + + [Fact] + public async Task SetStoredPermissionsAsync_ForAnUnknownRoleWithAPermissionOutsideTheCatalog_IsNotFound() + { + var sut = CreateService(compiled: new PermissionRegistryBuilder().Grant("Manager", Manage)); + + var result = await sut.SetStoredPermissionsAsync("Ghost", ["x:y"]); + + result.Errors.Should().ContainSingle().Which.Code.Should().Be("Authorization.RoleNotFound"); + _store.Grants.Should().BeEmpty(); + } + + [Fact] + public async Task SetStoredPermissionsAsync_ForAConfiguredRoleWithNoGrantsYet_StillSucceeds() + { + var sut = CreateService( + compiled: new PermissionRegistryBuilder().Grant("Manager", Manage, Read), + knownRoles: ["Member"]); + + var result = await sut.SetStoredPermissionsAsync("Member", [Read]); + + result.IsSuccess.Should().BeTrue(); + (await _store.GetPermissionsAsync("Member")).Should().Equal(Read); + } + + [Fact] + public async Task SetStoredPermissionsAsync_ForARoleKnownOnlyByItsStoredGrants_CanStillBeCleared() + { + await _store.GrantAsync("Auditor", Read); + var sut = CreateService(compiled: new PermissionRegistryBuilder().Grant("Manager", Manage, Read)); + + var result = await sut.SetStoredPermissionsAsync("Auditor", []); + + result.IsSuccess.Should().BeTrue(); + (await _store.GetPermissionsAsync("Auditor")).Should().BeEmpty(); + } + private StoredPermissionRoleAdministrationService CreateService( PermissionRegistryBuilder compiled, IReadOnlyList? knownRoles = null, diff --git a/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs b/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs index 30ab1a3d..c7df6703 100644 --- a/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs +++ b/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs @@ -1,8 +1,10 @@ using AwesomeAssertions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MMCA.Common.Application.Interfaces; using MMCA.Common.Infrastructure.Auth; using MMCA.Common.Shared.Abstractions; +using Moq; namespace MMCA.Common.Infrastructure.Tests.Auth; @@ -265,7 +267,134 @@ public async Task IncrementFailedAttemptsAsync_MalformedEmail_StillCollapsesVari cache.Values.Should().HaveCount(1); } + // ── Cache outage: fail open (CacheSettings: a cache outage never becomes an error) ── + [Fact] + public async Task CheckLockoutAsync_WhenTheCacheIsDown_ReturnsSuccess() + { + var sut = CreateOutageSut(out _); + + Result result = await sut.CheckLockoutAsync(TestEmail); + + result.IsSuccess.Should().BeTrue("an unreachable cache holds no lockout, so sign-in proceeds"); + } + + [Fact] + public async Task CheckRegistrationRateLimitAsync_WhenTheCacheIsDown_ReturnsSuccess() + { + var sut = CreateOutageSut(out _); + + Result result = await sut.CheckRegistrationRateLimitAsync(TestIp); + + result.IsSuccess.Should().BeTrue("an unreachable cache holds no registration count, so registration proceeds"); + } + + [Fact] + public async Task IncrementFailedAttemptsAsync_WhenTheCacheIsDown_CompletesWithoutThrowing() + { + var sut = CreateOutageSut(out _); + + Func act = () => sut.IncrementFailedAttemptsAsync(TestEmail); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ResetFailedAttemptsAsync_WhenTheCacheIsDown_CompletesWithoutThrowing() + { + var sut = CreateOutageSut(out _); + + Func act = () => sut.ResetFailedAttemptsAsync(TestEmail); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task IncrementRegistrationCountAsync_WhenTheCacheIsDown_CompletesWithoutThrowing() + { + var sut = CreateOutageSut(out _); + + Func act = () => sut.IncrementRegistrationCountAsync(TestIp); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CheckLockoutAsync_WhenTheCacheIsDown_LogsAWarningWithoutTheEmail() + { + var sut = CreateOutageSut(out List<(LogLevel Level, string Message)> logged); + + await sut.CheckLockoutAsync(TestEmail); + + var entry = logged.Should().ContainSingle().Subject; + entry.Level.Should().Be(LogLevel.Warning); + entry.Message.Should().Contain(nameof(LoginProtectionService.CheckLockoutAsync)); + entry.Message.Should().NotContain(TestEmail, "the cache keys carry personal data and stay out of the log"); + } + + [Fact] + public async Task CheckLockoutAsync_WhenTheStoreCancelsOnItsOwn_TreatsItAsAnOutage() + { + // A store-side timeout surfaces as OperationCanceledException with the caller's token live. + var sut = new LoginProtectionService( + new ThrowingCacheService(new OperationCanceledException("store timeout")), + Options.Create(new LoginProtectionSettings())); + + Result result = await sut.CheckLockoutAsync(TestEmail); + + result.IsSuccess.Should().BeTrue(); + } + + [Theory] + [InlineData(nameof(LoginProtectionService.CheckLockoutAsync))] + [InlineData(nameof(LoginProtectionService.IncrementFailedAttemptsAsync))] + [InlineData(nameof(LoginProtectionService.ResetFailedAttemptsAsync))] + [InlineData(nameof(LoginProtectionService.CheckRegistrationRateLimitAsync))] + [InlineData(nameof(LoginProtectionService.IncrementRegistrationCountAsync))] + public async Task EveryMethod_WhenTheCallerCancels_StillThrowsOperationCanceled(string method) + { + var sut = CreateOutageSut(out _); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + Func act = method switch + { + nameof(LoginProtectionService.CheckLockoutAsync) => () => sut.CheckLockoutAsync(TestEmail, cts.Token), + nameof(LoginProtectionService.IncrementFailedAttemptsAsync) => () => sut.IncrementFailedAttemptsAsync(TestEmail, cts.Token), + nameof(LoginProtectionService.ResetFailedAttemptsAsync) => () => sut.ResetFailedAttemptsAsync(TestEmail, cts.Token), + nameof(LoginProtectionService.CheckRegistrationRateLimitAsync) => () => sut.CheckRegistrationRateLimitAsync(TestIp, cts.Token), + _ => () => sut.IncrementRegistrationCountAsync(TestIp, cts.Token), + }; + + await act.Should().ThrowAsync("the caller's own cancellation is never swallowed"); + } + // ── Helpers ── + private static LoginProtectionService CreateOutageSut(out List<(LogLevel Level, string Message)> logged) + { + var sink = new List<(LogLevel Level, string Message)>(); + logged = sink; + var logger = new Mock>(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + logger + .Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback(new InvocationAction(invocation => + { + var formatter = (Delegate)invocation.Arguments[4]!; + sink.Add(( + (LogLevel)invocation.Arguments[0]!, + (string)formatter.DynamicInvoke(invocation.Arguments[2], invocation.Arguments[3])!)); + })); + + return new LoginProtectionService( + new ThrowingCacheService(new InvalidOperationException("It was not possible to connect to the redis server(s).")), + Options.Create(new LoginProtectionSettings()), + logger.Object); + } private static (LoginProtectionService Sut, FakeCacheService Cache) CreateSut( int maxFailedAttempts = 5, int maxLockoutSeconds = 300, @@ -324,4 +453,34 @@ public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationTok return Task.CompletedTask; } } + + /// + /// An whose every member fails the way an unreachable store does, + /// including the members the interface implements by default, after first honoring the caller's + /// token exactly as a real store would. + /// + private sealed class ThrowingCacheService(Exception fault) : ICacheService + { + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Fail(cancellationToken); + + public Task GetFromSharedStoreAsync(string key, CancellationToken cancellationToken = default) => Fail(cancellationToken); + + public Task<(bool Found, T? Value)> TryGetAsync(string key, CancellationToken cancellationToken = default) => + Fail<(bool Found, T? Value)>(cancellationToken); + + public Task SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) => + Fail(cancellationToken); + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => Fail(cancellationToken); + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => Fail(cancellationToken); + + public Task IncrementAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default) => + Fail(cancellationToken); + + private Task Fail(CancellationToken cancellationToken) => + cancellationToken.IsCancellationRequested + ? Task.FromCanceled(cancellationToken) + : Task.FromException(fault); + } } diff --git a/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/UserAdminListSpanishResourcesTests.cs b/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/UserAdminListSpanishResourcesTests.cs new file mode 100644 index 00000000..423fdcd5 --- /dev/null +++ b/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/UserAdminListSpanishResourcesTests.cs @@ -0,0 +1,42 @@ +using System.Collections; +using System.Globalization; +using System.Resources; +using AwesomeAssertions; +using MMCA.Common.UI.Pages.Administration; + +namespace MMCA.Common.UI.Tests.Pages.Administration; + +/// +/// Pins the Spanish strings of the user-administration list to correctly accented wording: the +/// file once shipped "correo electronico" and similar unaccented forms on screen. +/// +public sealed class UserAdminListSpanishResourcesTests +{ + private static readonly CultureInfo Spanish = CultureInfo.GetCultureInfo("es"); + + private static readonly ResourceManager Resources = new(typeof(UserAdminListResources)); + + [Theory] + [InlineData("Column.Email", "Correo electrónico")] + [InlineData("Placeholder.Search", "Buscar por correo electrónico...")] + public void SpanishEmailWording_IsAccented(string key, string expected) => + Resources.GetString(key, Spanish).Should().Be(expected); + + [Fact] + public void NoSpanishValue_UsesAKnownUnaccentedForm() + { + string[] unaccented = ["electronico", "administracion", "sesion", "cerrara", "podra", "volvera", "elimino"]; + + var set = Resources.GetResourceSet(Spanish, createIfNotExists: true, tryParents: false); + set.Should().NotBeNull("the Spanish satellite resources must ship with the UI assembly"); + + var values = set!.Cast().Select(e => (string)e.Value!).ToList(); + values.Should().NotBeEmpty(); + foreach (var word in unaccented) + { + values.Should().NotContain( + v => v.Contains(word, StringComparison.OrdinalIgnoreCase), + $"'{word}' is missing its accent"); + } + } +} diff --git a/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs b/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs index f743ef7e..27872fa8 100644 --- a/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs +++ b/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs @@ -31,6 +31,10 @@ private static Result Registered() "refresh-token", new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc))); + private static Result DuplicateEmail() + => Result.Failure( + Error.Conflict(AuthErrorCodes.EmailAlreadyExists, "An account with this email already exists.")); + private void RegistrationReturns(Result result) => _auth .Setup(x => x.RegisterAsync(It.IsAny(), It.IsAny())) @@ -207,6 +211,63 @@ public void WhenTheEmailIsAlreadyRegistered_PointsTheUserAtSignInAndPasswordRese "the server's generic wording gives the user nowhere to go"); } + [Fact] + public void WhenTheEmailIsAlreadyRegistered_TheAlertNamesTheRejectedAddress() + { + RegistrationReturns(DuplicateEmail()); + var cut = RenderUnderTest(_ => { }); + FillRequiredFields(cut); + + cut.ClickButtonByText("Create Account"); + + cut.WaitForAssertion(() => + cut.Find("[data-testid='email-already-registered']").TextContent + .Should().Contain("ada@example.com is already registered")); + } + + [Fact] + public void EditingTheEmailAfterADuplicateRejection_RemovesTheAlert() + { + RegistrationReturns(DuplicateEmail()); + var cut = RenderUnderTest(_ => { }); + FillRequiredFields(cut); + cut.ClickButtonByText("Create Account"); + cut.WaitForAssertion(() => + cut.FindAll("[data-testid='email-already-registered']").Should().ContainSingle()); + + cut.Find("input[autocomplete='email']").Input("grace@example.com"); + + cut.FindAll("[data-testid='email-already-registered']").Should().BeEmpty( + "an address the server never checked must not be reported as already registered"); + cut.Markup.Should().NotContain("grace@example.com is already registered"); + } + + [Fact] + public void TypingTheRejectedAddressBack_ShowsNoAlertUntilTheNextSubmit() + { + RegistrationReturns(DuplicateEmail()); + var cut = RenderUnderTest(_ => { }); + FillRequiredFields(cut); + cut.ClickButtonByText("Create Account"); + cut.WaitForAssertion(() => + cut.FindAll("[data-testid='email-already-registered']").Should().ContainSingle()); + cut.Find("input[autocomplete='email']").Input("grace@example.com"); + + cut.Find("input[autocomplete='email']").Input("ada@example.com"); + + cut.FindAll("[data-testid='email-already-registered']").Should().BeEmpty( + "an edit dismisses the rejection for good; only a new submit re-checks the address"); + + cut.ClickButtonByText("Create Account"); + + cut.WaitForAssertion(() => + cut.Find("[data-testid='email-already-registered']").TextContent + .Should().Contain("ada@example.com is already registered")); + _auth.Verify( + x => x.RegisterAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + [Fact] public void WhenTheFailureIsNotADuplicateEmail_ShowsOnlyTheGenericAlert() { diff --git a/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs b/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs index 0e8d9698..71128405 100644 --- a/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs +++ b/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs @@ -2,6 +2,8 @@ using AwesomeAssertions; using MMCA.Common.Shared.Abstractions; using MMCA.Common.UI.Services.Api; +using Polly.CircuitBreaker; +using Polly.Timeout; namespace MMCA.Common.UI.Tests.Services.Api; @@ -17,6 +19,8 @@ public sealed class HttpResultExecutorTests private const string HttpFault = "http"; private const string IoFault = "io"; private const string JsonFault = "json"; + private const string PollyTimeoutFault = "polly-timeout"; + private const string PollyBrokenCircuitFault = "polly-broken-circuit"; [Fact] public void ErrorCodes_AreStable() @@ -82,6 +86,8 @@ public async Task ExecuteAsync_Generic_PassesAFailureThroughUnchanged() [InlineData(HttpFault)] [InlineData(IoFault)] [InlineData(JsonFault)] + [InlineData(PollyTimeoutFault)] + [InlineData(PollyBrokenCircuitFault)] public async Task ExecuteAsync_ConvertsATransportFaultIntoASingleUnexpectedFailure(string kind) { Exception fault = TransportFault(kind); @@ -100,6 +106,8 @@ public async Task ExecuteAsync_ConvertsATransportFaultIntoASingleUnexpectedFailu [InlineData(HttpFault)] [InlineData(IoFault)] [InlineData(JsonFault)] + [InlineData(PollyTimeoutFault)] + [InlineData(PollyBrokenCircuitFault)] public async Task ExecuteAsync_Generic_ConvertsATransportFaultIntoASingleUnexpectedFailure(string kind) { Exception fault = TransportFault(kind); @@ -266,6 +274,8 @@ public async Task ExecuteAsync_Generic_Throws_ForANullOperation() HttpFault => new HttpRequestException("connection refused"), IoFault => new IOException("the response stream ended unexpectedly"), JsonFault => new JsonException("unexpected token at position 0"), + PollyTimeoutFault => new TimeoutRejectedException("The operation didnt complete within the allowed timeout of 00:00:30."), + PollyBrokenCircuitFault => new BrokenCircuitException("The circuit is now open and is not allowing calls."), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown transport fault kind."), }; } diff --git a/Tests/Presentation/MMCA.Common.UI.Web.Tests/Services/BrowserForwardedForHandlerTests.cs b/Tests/Presentation/MMCA.Common.UI.Web.Tests/Services/BrowserForwardedForHandlerTests.cs new file mode 100644 index 00000000..4be6b38b --- /dev/null +++ b/Tests/Presentation/MMCA.Common.UI.Web.Tests/Services/BrowserForwardedForHandlerTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using AwesomeAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MMCA.Common.UI.Web.Services; + +namespace MMCA.Common.UI.Web.Tests.Services; + +/// +/// Pins : a server-side API call made for a visitor carries +/// that visitor's address in X-Forwarded-For as the single value, nothing is sent when no +/// visitor request is in scope, and AddCommonServerTokenStorage() composes the handler onto the +/// "APIClient" pipeline. +/// +public sealed class BrowserForwardedForHandlerTests +{ + private const string HeaderName = "X-Forwarded-For"; + private const string BrowserIp = "198.51.100.23"; + private static readonly Uri ApiUri = new("https://gateway.example.com/Auth/register"); + + [Fact] + public async Task SendAsync_WithAVisitorRequestInScope_AddsTheBrowserAddress() + { + var (client, inner) = CreateClient(VisitorContext(BrowserIp)); + + await client.PostAsync(ApiUri, content: null, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.GetValues(HeaderName).Should().ContainSingle().Which.Should().Be(BrowserIp); + } + + [Fact] + public async Task SendAsync_WithAnIPv6VisitorAddress_ForwardsIt() + { + var (client, inner) = CreateClient(VisitorContext("2001:db8::7")); + + await client.GetAsync(ApiUri, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.GetValues(HeaderName).Should().ContainSingle().Which.Should().Be("2001:db8::7"); + } + + [Fact] + public async Task SendAsync_WithNoRequestInScope_SendsNoHeader() + { + var (client, inner) = CreateClient(httpContext: null); + + await client.GetAsync(ApiUri, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.Contains(HeaderName).Should().BeFalse(); + } + + [Fact] + public async Task SendAsync_WhenTheRequestHasNoRemoteAddress_SendsNoHeader() + { + var (client, inner) = CreateClient(new DefaultHttpContext()); + + await client.GetAsync(ApiUri, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.Contains(HeaderName).Should().BeFalse(); + } + + [Fact] + public async Task SendAsync_WhenTheRequestAlreadyCarriesTheHeader_ReplacesItRatherThanAppending() + { + var (client, inner) = CreateClient(VisitorContext(BrowserIp)); + using var request = new HttpRequestMessage(HttpMethod.Get, ApiUri); + request.Headers.TryAddWithoutValidation(HeaderName, "203.0.113.99, 10.0.0.1"); + + await client.SendAsync(request, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.GetValues(HeaderName).Should().ContainSingle().Which.Should().Be(BrowserIp); + } + + [Fact] + public async Task SendAsync_IgnoresAForwardedForHeaderTheBrowserSentToThisHost() + { + // The browser's own X-Forwarded-For is client-supplied: only the connection address (as this + // host's forwarded-headers middleware resolved it) is forwarded. + var context = VisitorContext(BrowserIp); + context.Request.Headers[HeaderName] = "203.0.113.66"; + var (client, inner) = CreateClient(context); + + await client.GetAsync(ApiUri, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.GetValues(HeaderName).Should().ContainSingle().Which.Should().Be(BrowserIp); + } + + [Fact] + public async Task AddCommonServerTokenStorage_ComposesTheHandlerOntoTheApiClient() + { + var inner = new CapturingHandler(); + var services = new ServiceCollection(); + services.AddCommonServerTokenStorage(); + services.AddHttpClient("APIClient").ConfigurePrimaryHttpMessageHandler(() => inner); + await using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().HttpContext = VisitorContext(BrowserIp); + + using var client = provider.GetRequiredService().CreateClient("APIClient"); + await client.GetAsync(ApiUri, TestContext.Current.CancellationToken); + + inner.LastRequest!.Headers.GetValues(HeaderName).Should().ContainSingle().Which.Should().Be(BrowserIp); + } + + private static DefaultHttpContext VisitorContext(string remoteIp) + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp); + return context; + } + + private static (HttpClient Client, CapturingHandler Inner) CreateClient(HttpContext? httpContext) + { + var inner = new CapturingHandler(); + var handler = new BrowserForwardedForHandler(new HttpContextAccessor { HttpContext = httpContext }) + { + InnerHandler = inner, + }; + + return (new HttpClient(handler), inner); + } + + /// Stub inner handler that records the request the pipeline produced. + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request }); + } + } +}