From dab18ce08c2981b6ea7bd05a20e8e71fd8adadc7 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:19:12 -0700 Subject: [PATCH 01/13] Share captcha validation policy --- .../CaptchaValidationServiceTests.cs | 121 ++++++++++ .../Pages/Account/ForgotPassword.cshtml.cs | 9 +- .../Identity/Pages/Account/Login.cshtml.cs | 9 +- .../Identity/Pages/Account/Register.cshtml.cs | 227 +++++++++--------- .../Account/ResendEmailConfirmation.cshtml.cs | 9 +- .../Pages/Account/ResetPassword.cshtml.cs | 9 +- .../Controllers/ChatController.cs | 79 +++--- .../IServiceCollectionExtensions.cs | 1 + .../Services/CaptchaValidationOutcome.cs | 10 + .../Services/CaptchaValidationResult.cs | 8 + .../Services/CaptchaValidationService.cs | 29 +++ .../Services/ICaptchaValidationService.cs | 7 + 12 files changed, 341 insertions(+), 177 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationResult.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationService.cs create mode 100644 EssentialCSharp.Web/Services/ICaptchaValidationService.cs diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs new file mode 100644 index 00000000..d31e2f13 --- /dev/null +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -0,0 +1,121 @@ +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +public class CaptchaValidationServiceTests +{ + [Test] + public async Task ValidateAsync_Disabled_SkipsVerification() + { + StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = string.Empty, SiteKey = string.Empty }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.ShouldProceed).IsTrue(); + await Assert.That(captchaService.CallCount).IsEqualTo(0); + } + + [Test] + public async Task ValidateAsync_MissingToken_ReturnsMissingToken() + { + StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + CaptchaValidationResult result = await validationService.ValidateAsync(string.Empty, "127.0.0.1"); + + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.MissingToken); + await Assert.That(result.ShouldProceed).IsFalse(); + await Assert.That(captchaService.CallCount).IsEqualTo(0); + } + + [Test] + public async Task ValidateAsync_Unavailable_ReturnsUnavailable() + { + StubCaptchaService captchaService = new((_, _, _) => Task.FromResult(null)); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Unavailable); + await Assert.That(result.ShouldProceed).IsFalse(); + await Assert.That(captchaService.CallCount).IsEqualTo(1); + } + + [Test] + public async Task ValidateAsync_InvalidAndValid_ReturnExpectedOutcome() + { + StubCaptchaService invalidCaptchaService = new((_, _, _) => Task.FromResult(new HCaptchaResult + { + Success = false, + ErrorCodes = ["invalid-input-response"] + })); + using ServiceProvider invalidProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" }, + invalidCaptchaService); + + ICaptchaValidationService invalidValidationService = invalidProvider.GetRequiredService(); + CaptchaValidationResult invalidResult = await invalidValidationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(invalidResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Invalid); + await Assert.That(invalidResult.Response).IsNotNull(); + await Assert.That(invalidResult.ShouldProceed).IsFalse(); + + StubCaptchaService validCaptchaService = new((_, _, _) => Task.FromResult(new HCaptchaResult + { + Success = true + })); + using ServiceProvider validProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" }, + validCaptchaService); + + ICaptchaValidationService validValidationService = validProvider.GetRequiredService(); + CaptchaValidationResult validResult = await validValidationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(validResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Valid); + await Assert.That(validResult.ShouldProceed).IsTrue(); + await Assert.That(validCaptchaService.CallCount).IsEqualTo(1); + } + + private static ServiceProvider CreateServiceProvider(CaptchaOptions options, ICaptchaService captchaService) + { + ServiceCollection services = new(); + services.AddSingleton(Options.Create(options)); + services.AddSingleton(captchaService); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + private sealed class StubCaptchaService(Func> verifyAsync) : ICaptchaService + { + public int CallCount { get; private set; } + + public Task VerifyAsync(string secret, string response, string sitekey, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task VerifyAsync(string? response, CancellationToken cancellationToken = default) + => VerifyAsync(response, remoteIp: null, cancellationToken); + + public async Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) + { + CallCount++; + return await verifyAsync(response, remoteIp, cancellationToken); + } + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs index ef3c5ba1..7e566037 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Encodings.Web; using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; @@ -13,7 +12,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public class ForgotPasswordModel(UserManager userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel +public class ForgotPasswordModel(UserManager userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel { private InputModel? _Input; [BindProperty] @@ -36,8 +35,8 @@ public class InputModel public async Task OnPostAsync() { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (captchaResult?.Success != true) + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); return Page(); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 998ee7cb..c0c564c2 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,6 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Authentication; @@ -11,7 +10,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel +public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel { private InputModel? _Input; [BindProperty] @@ -68,8 +67,8 @@ public async Task OnPostAsync(string? returnUrl = null) returnUrl ??= Url.Content("~/"); string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (captchaResult?.Success != true) + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index a29d2254..a10be034 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -20,7 +20,7 @@ public partial class RegisterModel( SignInManager signInManager, ILogger logger, IEmailSender emailSender, - ICaptchaService captchaService, + ICaptchaValidationService captchaValidationService, IOptions optionsAccessor, IUserEmailStore emailStore) : PageModel { @@ -89,135 +89,140 @@ public async Task OnPostAsync(string? returnUrl = null) return Page(); } - if (string.IsNullOrEmpty(hCaptcha_response)) + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (!captchaResult.ShouldProceed) { - ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); - return Page(); - } - - HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (response is null) - { - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); - } - - // The JSON should also return a field "success" as true - // https://docs.hcaptcha.com/#verify-the-user-response-server-side - if (response.Success) - { - EssentialCSharpWebUser user = CreateUser(); - user.FirstName = Input.FirstName; - user.LastName = Input.LastName; - - await userStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None); - await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); - if (Input.Password is null) + if (captchaResult.Outcome == CaptchaValidationOutcome.MissingToken) { - LogPasswordNull(logger); - ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password"); + ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); return Page(); } - IdentityResult result = await userManager.CreateAsync(user, Input.Password); - - if (result.Succeeded) + if (captchaResult.Outcome == CaptchaValidationOutcome.Unavailable) { - LogUserCreatedWithPassword(logger); - - string userId = await userManager.GetUserIdAsync(user); - string code = await userManager.GenerateEmailConfirmationTokenAsync(user); - code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - string? callbackUrl = Url.Page( - "/Account/ConfirmEmail", - pageHandler: null, - values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, - protocol: Request.Scheme); - - if (callbackUrl is null) - { - ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null."); - return Page(); - } - if (Input.Email is null) + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return Page(); + } + if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) + { + HCaptchaResult? response = captchaResult.Response; + // The JSON should also return a field "success" as true + // https://docs.hcaptcha.com/#verify-the-user-response-server-side + if (response is null) { - ModelState.AddModelError(string.Empty, "Error: Email may not be null."); + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); return Page(); } - await emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); - if (userManager.Options.SignIn.RequireConfirmedAccount) - { - return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl }); - } - else + switch (response.ErrorCodes?.Length) { - await signInManager.SignInAsync(user, isPersistent: false); - return LocalRedirect(returnUrl); - } - } - foreach (IdentityError error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - } - else - { - switch (response.ErrorCodes?.Length) - { - case 0: - LogHCaptchaNoErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - break; - case > 1: - LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes)); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - break; - default: - { - if (response.ErrorCodes is null) - { - LogHCaptchaNullErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - break; - } - if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details)) + case 0: + LogHCaptchaNoErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + case > 1: + LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes)); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + default: { - switch (details.ErrorCode) + if (response.ErrorCodes is null) { - case HCaptchaErrorDetails.MissingInputResponse: - case HCaptchaErrorDetails.InvalidInputResponse: - case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse: - ModelState.AddModelError(string.Empty, details.FriendlyDescription); - LogHCaptchaErrorCode(logger, details.ToString()); - break; - case HCaptchaErrorDetails.BadRequest: - ModelState.AddModelError(string.Empty, details.FriendlyDescription); - LogHCaptchaErrorCode(logger, details.ToString()); - break; - case HCaptchaErrorDetails.MissingInputSecret: - case HCaptchaErrorDetails.InvalidInputSecret: - case HCaptchaErrorDetails.NotUsingDummyPasscode: - case HCaptchaErrorDetails.SitekeySecretMismatch: - LogHCaptchaCriticalErrorCode(logger, details.ToString()); - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - break; - default: - LogHCaptchaUnknownErrorCode(logger, details?.ErrorCode); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - break; + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); } - } - else - { + if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details)) + { + switch (details.ErrorCode) + { + case HCaptchaErrorDetails.MissingInputResponse: + case HCaptchaErrorDetails.InvalidInputResponse: + case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse: + ModelState.AddModelError(string.Empty, details.FriendlyDescription); + LogHCaptchaErrorCode(logger, details.ToString()); + return Page(); + case HCaptchaErrorDetails.BadRequest: + ModelState.AddModelError(string.Empty, details.FriendlyDescription); + LogHCaptchaErrorCode(logger, details.ToString()); + return Page(); + case HCaptchaErrorDetails.MissingInputSecret: + case HCaptchaErrorDetails.InvalidInputSecret: + case HCaptchaErrorDetails.NotUsingDummyPasscode: + case HCaptchaErrorDetails.SitekeySecretMismatch: + LogHCaptchaCriticalErrorCode(logger, details.ToString()); + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return Page(); + default: + LogHCaptchaUnknownErrorCode(logger, details?.ErrorCode); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + } + } + LogHCaptchaUnrecognizedErrorCode(logger, response.ErrorCodes.Single()); ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); } + } + } + + return Page(); + } - break; - } + EssentialCSharpWebUser user = CreateUser(); + user.FirstName = Input.FirstName; + user.LastName = Input.LastName; + await userStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None); + await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + if (Input.Password is null) + { + LogPasswordNull(logger); + ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password"); + return Page(); + } + IdentityResult result = await userManager.CreateAsync(user, Input.Password); + + if (result.Succeeded) + { + LogUserCreatedWithPassword(logger); + + string userId = await userManager.GetUserIdAsync(user); + string code = await userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + string? callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, + protocol: Request.Scheme); + + if (callbackUrl is null) + { + ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null."); + return Page(); + } + if (Input.Email is null) + { + ModelState.AddModelError(string.Empty, "Error: Email may not be null."); + return Page(); } + await emailSender.SendEmailAsync(Input.Email, "Confirm your email", + $"Please confirm your account by clicking here."); + + if (userManager.Options.SignIn.RequireConfirmedAccount) + { + return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl }); + } + else + { + await signInManager.SignInAsync(user, isPersistent: false); + return LocalRedirect(returnUrl); + } + } + foreach (IdentityError error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); } // If we got this far, something failed, redisplay form diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs index e7586fd2..12650f49 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Encodings.Web; using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -15,7 +14,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; [AllowAnonymous] -public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel +public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel { private InputModel? _Input; [BindProperty] @@ -37,8 +36,8 @@ public class InputModel public async Task OnPostAsync() { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (captchaResult?.Success != true) + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); return Page(); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs index a1c85a83..0149fada 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -1,7 +1,6 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text; using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -11,7 +10,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public class ResetPasswordModel(UserManager userManager, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel +public class ResetPasswordModel(UserManager userManager, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel { private InputModel? _Input; [BindProperty] @@ -63,8 +62,8 @@ public IActionResult OnGet(string? code = null) public async Task OnPostAsync() { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (captchaResult?.Success != true) + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); return Page(); diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 08f0d29a..77dede75 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Controllers; @@ -20,49 +19,19 @@ public partial class ChatController : ControllerBase { private readonly AIChatService _AIChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; - private readonly ICaptchaService _CaptchaService; - private readonly CaptchaOptions _CaptchaOptions; + private readonly ICaptchaValidationService _CaptchaValidationService; private readonly ILogger _Logger; public ChatController(ILogger logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService, - ICaptchaService captchaService, IOptions captchaOptions) + ICaptchaValidationService captchaValidationService) { _AIChatService = aiChatService; _ResponseIdValidationService = responseIdValidationService; - _CaptchaService = captchaService; - _CaptchaOptions = captchaOptions.Value; + _CaptchaValidationService = captchaValidationService; _Logger = logger; } - /// - /// Validates the hCaptcha token when captcha is configured. - /// Returns true when captcha is not configured (dev mode) or when the token is valid. - /// Returns false for missing or invalid tokens. - /// Returns null when hCaptcha cannot be reached, so the caller can fail closed. - /// - private async Task IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey)) - return true; // captcha not configured — skip validation - - if (string.IsNullOrWhiteSpace(token)) - return false; // token required when captcha is configured — reject without an outbound call - - HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct); - if (result is null) - { - LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail closed - return null; - } - - if (!result.Success) - { - LogCaptchaValidationFailed(_Logger, string.Join(',', result.ErrorCodes ?? [])); - } - return result.Success; - } - [HttpPost("message")] public async Task SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default) { @@ -74,11 +43,22 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque if (string.IsNullOrEmpty(userId)) return Unauthorized(); - bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); - if (captchaValid is null) - return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); - if (!captchaValid.Value) + CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + if (!captchaValidation.ShouldProceed) + { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) + { + LogCaptchaServiceUnavailable(_Logger); + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + + if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) + { + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + } + return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }); + } var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null @@ -130,15 +110,22 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } - bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); - if (captchaValid is null) - { - Response.StatusCode = 503; - await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); - return; - } - if (!captchaValid.Value) + CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) + { + LogCaptchaServiceUnavailable(_Logger); + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + return; + } + + if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) + { + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + } + Response.StatusCode = 403; await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); return; diff --git a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs index 74967040..00500e96 100644 --- a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs +++ b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static void AddCaptchaService(this IServiceCollection services, IConfigur { services.Configure(CaptchaOptions); services.AddSingleton(); + services.AddSingleton(); services.AddHttpClient("hCaptcha", c => { c.BaseAddress = new Uri("https://api.hcaptcha.com"); diff --git a/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs new file mode 100644 index 00000000..80bbabff --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs @@ -0,0 +1,10 @@ +namespace EssentialCSharp.Web.Services; + +public enum CaptchaValidationOutcome +{ + Disabled, + MissingToken, + Unavailable, + Invalid, + Valid +} diff --git a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs new file mode 100644 index 00000000..f891a8a9 --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs @@ -0,0 +1,8 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public sealed record CaptchaValidationResult(CaptchaValidationOutcome Outcome, HCaptchaResult? Response) +{ + public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Valid; +} diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs new file mode 100644 index 00000000..81f26549 --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -0,0 +1,29 @@ +using EssentialCSharp.Web.Models; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +public sealed class CaptchaValidationService(ICaptchaService captchaService, IOptions optionsAccessor) : ICaptchaValidationService +{ + private CaptchaOptions Options { get; } = optionsAccessor.Value; + + public Task ValidateAsync(string? response, CancellationToken cancellationToken = default) + => ValidateAsync(response, remoteIp: null, cancellationToken); + + public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(Options.SecretKey) || string.IsNullOrWhiteSpace(Options.SiteKey)) + return new CaptchaValidationResult(CaptchaValidationOutcome.Disabled, null); + + if (string.IsNullOrWhiteSpace(response)) + return new CaptchaValidationResult(CaptchaValidationOutcome.MissingToken, null); + + HCaptchaResult? result = await captchaService.VerifyAsync(response, remoteIp, cancellationToken); + if (result is null) + return new CaptchaValidationResult(CaptchaValidationOutcome.Unavailable, null); + + return new CaptchaValidationResult( + result.Success ? CaptchaValidationOutcome.Valid : CaptchaValidationOutcome.Invalid, + result); + } +} diff --git a/EssentialCSharp.Web/Services/ICaptchaValidationService.cs b/EssentialCSharp.Web/Services/ICaptchaValidationService.cs new file mode 100644 index 00000000..4b391a16 --- /dev/null +++ b/EssentialCSharp.Web/Services/ICaptchaValidationService.cs @@ -0,0 +1,7 @@ +namespace EssentialCSharp.Web.Services; + +public interface ICaptchaValidationService +{ + Task ValidateAsync(string? response, CancellationToken cancellationToken = default); + Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default); +} From 23b2ebac5b01770bddc7a058a20b49b0aecd82f6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:31:03 -0700 Subject: [PATCH 02/13] Fail closed on missing captcha config --- EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs | 4 ++-- EssentialCSharp.Web/Services/CaptchaValidationResult.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs index d31e2f13..3e0d960a 100644 --- a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -8,7 +8,7 @@ namespace EssentialCSharp.Web.Tests; public class CaptchaValidationServiceTests { [Test] - public async Task ValidateAsync_Disabled_SkipsVerification() + public async Task ValidateAsync_MissingConfig_RejectsWithoutVerification() { StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); using ServiceProvider serviceProvider = CreateServiceProvider( @@ -20,7 +20,7 @@ public async Task ValidateAsync_Disabled_SkipsVerification() CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); - await Assert.That(result.ShouldProceed).IsTrue(); + await Assert.That(result.ShouldProceed).IsFalse(); await Assert.That(captchaService.CallCount).IsEqualTo(0); } diff --git a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs index f891a8a9..c0613c3b 100644 --- a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs +++ b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs @@ -4,5 +4,5 @@ namespace EssentialCSharp.Web.Services; public sealed record CaptchaValidationResult(CaptchaValidationOutcome Outcome, HCaptchaResult? Response) { - public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Valid; + public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Valid; } From d360c484a11c643a7011bd0a1ea5836468da580f Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 22:22:17 -0700 Subject: [PATCH 03/13] Harden captcha failure handling --- .../Identity/Pages/Account/Register.cshtml.cs | 1 + .../Controllers/ChatController.cs | 17 +++++++++++++++++ EssentialCSharp.Web/Services/CaptchaService.cs | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index a10be034..b10ae4dd 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -167,6 +167,7 @@ public async Task OnPostAsync(string? returnUrl = null) } } + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); return Page(); } diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 77dede75..ff12bc6d 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -46,6 +46,12 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + { + LogCaptchaConfigurationMissing(_Logger); + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) { LogCaptchaServiceUnavailable(_Logger); @@ -113,6 +119,14 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + { + LogCaptchaConfigurationMissing(_Logger); + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + return; + } + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) { LogCaptchaServiceUnavailable(_Logger); @@ -280,6 +294,9 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] private static partial void LogCaptchaServiceUnavailable(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha configuration missing during chat request — failing closed (503)")] + private static partial void LogCaptchaConfigurationMissing(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")] private static partial void LogCaptchaValidationFailed(ILogger logger, string errorCodes); diff --git a/EssentialCSharp.Web/Services/CaptchaService.cs b/EssentialCSharp.Web/Services/CaptchaService.cs index 48d53b05..1e76f767 100644 --- a/EssentialCSharp.Web/Services/CaptchaService.cs +++ b/EssentialCSharp.Web/Services/CaptchaService.cs @@ -74,6 +74,10 @@ public partial class CaptchaService(IHttpClientFactory clientFactory, IOptions(await res.Content.ReadAsStringAsync(cancellationToken)); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) { LogSiteverifyFailed(logger, ex); From 4d81cbbe061ccb33ada5539687439501e6f3d281 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 19 May 2026 22:00:10 -0700 Subject: [PATCH 04/13] fix: Resolve compilation errors in AI integration and identity code - Remove invalid namespace import (Microsoft.Extensions.Http.Resilience doesn't export a Resilience namespace) - Add missing OpenAI NuGet package (v2.10.0) for ResponsesClient and CreateResponseOptions types - Fix syntax error in Register.cshtml.cs merge conflict resolution by removing unreachable code - Ensure compatibility with Microsoft.Extensions.AI.OpenAI dependency chain Fixes CodeQL and build failures on PR #1121. --- Directory.Packages.props | 1 + .../EssentialCSharp.Chat.Common.csproj | 1 + .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Areas/Identity/Pages/Account/Register.cshtml.cs | 4 +--- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cf6081db..40137857 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,5 +62,6 @@ + diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 1272ea00..08724ed3 100644 --- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj +++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj @@ -12,6 +12,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 9692e002..0113f6ad 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; using Microsoft.SemanticKernel; using Npgsql; diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 0f675ea9..5aae624d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -166,9 +166,7 @@ public async Task OnPostAsync(string? returnUrl = null) } } } - - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); + } EssentialCSharpWebUser user = CreateUser(); user.FirstName = Input.FirstName; From 780e0825870303b1cfe91015a2d365b735fe6638 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 19 May 2026 22:23:49 -0700 Subject: [PATCH 05/13] Address PR review comments: deduplication, null safety, behavior documentation - Accept 3254200755: Remove dead code overload from StubCaptchaService - Accept 3254200763: Fail closed when captcha is disabled in config (add error page) - Accept 3254200766: Document stricter validation requiring both keys - Accept 3256514826: Replace unreachable null check with assertion - Accept 3256514865: Extract helper methods to eliminate 54+ lines duplication - Accept 3254200636: Add explicit null guard for details variable Changes: - ChatController: Extracted HandleCaptchaValidationFailure helpers for both endpoints - Register.cshtml.cs: Added Disabled outcome handler, null assertion, explicit guard - CaptchaValidationService: Added XML documentation for behavior clarity - CaptchaValidationServiceTests: Removed unreachable dead code overload All tests passing (111 pass), build clean. --- .../CaptchaValidationServiceTests.cs | 3 - .../Identity/Pages/Account/Register.cshtml.cs | 25 +++-- .../Controllers/ChatController.cs | 98 +++++++++++-------- .../Services/CaptchaValidationService.cs | 10 ++ test_output.txt | 1 + 5 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 test_output.txt diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs index 3e0d960a..244d819b 100644 --- a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -106,9 +106,6 @@ private sealed class StubCaptchaService(Func VerifyAsync(string secret, string response, string sitekey, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - public Task VerifyAsync(string? response, CancellationToken cancellationToken = default) => VerifyAsync(response, remoteIp: null, cancellationToken); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 5aae624d..56c279be 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -92,6 +92,12 @@ public async Task OnPostAsync(string? returnUrl = null) CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); if (!captchaResult.ShouldProceed) { + if (captchaResult.Outcome == CaptchaValidationOutcome.Disabled) + { + LogHCaptchaDisabledWarning(logger); + ModelState.AddModelError(string.Empty, "Captcha verification is not configured. Please contact support."); + return Page(); + } if (captchaResult.Outcome == CaptchaValidationOutcome.MissingToken) { ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); @@ -105,14 +111,7 @@ public async Task OnPostAsync(string? returnUrl = null) if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) { HCaptchaResult? response = captchaResult.Response; - // The JSON should also return a field "success" as true - // https://docs.hcaptcha.com/#verify-the-user-response-server-side - if (response is null) - { - LogHCaptchaNullErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - } + ArgumentNullException.ThrowIfNull(response); switch (response.ErrorCodes?.Length) { @@ -134,6 +133,13 @@ public async Task OnPostAsync(string? returnUrl = null) } if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details)) { + if (details is null) + { + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + } + switch (details.ErrorCode) { case HCaptchaErrorDetails.MissingInputResponse: @@ -247,6 +253,9 @@ private EssentialCSharpWebUser CreateUser() [LoggerMessage(Level = LogLevel.Information, Message = "User created a new account with password.")] private static partial void LogUserCreatedWithPassword(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "Captcha is disabled in configuration")] + private static partial void LogHCaptchaDisabledWarning(ILogger logger); + [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha determined the passcode is not valid with zero error codes")] private static partial void LogHCaptchaNoErrorCodes(ILogger logger); diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 5e513a3d..e420a4dd 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -46,24 +46,7 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { - if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) - { - LogCaptchaConfigurationMissing(_Logger); - return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); - } - - if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) - { - LogCaptchaServiceUnavailable(_Logger); - return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); - } - - if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) - { - LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); - } - - return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }); + return HandleCaptchaValidationFailure(captchaValidation); } var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) @@ -129,29 +112,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { - if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) - { - LogCaptchaConfigurationMissing(_Logger); - Response.StatusCode = 503; - await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); - return; - } - - if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) - { - LogCaptchaServiceUnavailable(_Logger); - Response.StatusCode = 503; - await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); - return; - } - - if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) - { - LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); - } - - Response.StatusCode = 403; - await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); + await HandleCaptchaValidationFailureAsync(captchaValidation, cancellationToken); return; } @@ -337,6 +298,61 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } + private IActionResult HandleCaptchaValidationFailure(CaptchaValidationResult captchaValidation) + { + return captchaValidation.Outcome switch + { + CaptchaValidationOutcome.Disabled => LogAndReturn503(), + CaptchaValidationOutcome.Unavailable => LogAndReturn503(), + CaptchaValidationOutcome.Invalid => LogInvalidAndReturn403(), + _ => StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }) + }; + + IActionResult LogAndReturn503() + { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + LogCaptchaConfigurationMissing(_Logger); + else + LogCaptchaServiceUnavailable(_Logger); + + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + + IActionResult LogInvalidAndReturn403() + { + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }); + } + } + + private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult captchaValidation, CancellationToken cancellationToken) + { + switch (captchaValidation.Outcome) + { + case CaptchaValidationOutcome.Disabled: + case CaptchaValidationOutcome.Unavailable: + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + LogCaptchaConfigurationMissing(_Logger); + else + LogCaptchaServiceUnavailable(_Logger); + + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + break; + + case CaptchaValidationOutcome.Invalid: + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + Response.StatusCode = 403; + await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); + break; + + default: + Response.StatusCode = 403; + await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); + break; + } + } + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] private static partial void LogCaptchaServiceUnavailable(ILogger logger); diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs index 81f26549..acd68171 100644 --- a/EssentialCSharp.Web/Services/CaptchaValidationService.cs +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -10,6 +10,16 @@ public sealed class CaptchaValidationService(ICaptchaService captchaService, IOp public Task ValidateAsync(string? response, CancellationToken cancellationToken = default) => ValidateAsync(response, remoteIp: null, cancellationToken); + /// + /// Validates a captcha response. + /// + /// + /// IMPORTANT: Both and must be configured + /// (non-empty) for captcha validation to be enabled. If either key is missing, the validation is marked as + /// and the captcha service is not invoked. This is intentional: + /// HCaptcha requires both keys to function properly, and partial configuration (one key set, one missing) + /// indicates a deployment configuration error that should be detected early. + /// public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(Options.SecretKey) || string.IsNullOrWhiteSpace(Options.SiteKey)) diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 00000000..e06b42e7 --- /dev/null +++ b/test_output.txt @@ -0,0 +1 @@ +Running tests from D:\copilot-worktrees\EssentialCSharp.Web\benjaminmichaelis-super-giggle\EssentialCSharp.Web.Tests\bin\Release\net10.0\EssentialCSharp.Web.Tests.dll (net10.0|x64) From ebae8b20ab0464cb84bcd39dd45cd1b844ea3c12 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 20 May 2026 15:52:43 -0700 Subject: [PATCH 06/13] fix: address remaining captcha PR review feedback - enforce fail-closed fallback for unhandled non-proceeding captcha outcomes - keep defensive handling when Invalid outcome lacks response payload - remove redundant null-conditional access after non-null guard - consolidate chat disabled/unavailable handling into shared helpers - clarify captcha configuration completeness check in validation service --- .../Identity/Pages/Account/Register.cshtml.cs | 20 +++++++++++-- .../Controllers/ChatController.cs | 30 ++++++++++--------- .../Services/CaptchaValidationService.cs | 5 +++- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 56c279be..208f64f4 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -110,8 +110,12 @@ public async Task OnPostAsync(string? returnUrl = null) } if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) { - HCaptchaResult? response = captchaResult.Response; - ArgumentNullException.ThrowIfNull(response); + if (captchaResult.Response is not HCaptchaResult response) + { + LogHCaptchaInvalidOutcomeMissingResponse(logger); + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return Page(); + } switch (response.ErrorCodes?.Length) { @@ -160,7 +164,7 @@ public async Task OnPostAsync(string? returnUrl = null) ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); return Page(); default: - LogHCaptchaUnknownErrorCode(logger, details?.ErrorCode); + LogHCaptchaUnknownErrorCode(logger, details.ErrorCode); ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); return Page(); } @@ -172,6 +176,10 @@ public async Task OnPostAsync(string? returnUrl = null) } } } + + LogHCaptchaUnhandledOutcome(logger, captchaResult.Outcome.ToString()); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); } EssentialCSharpWebUser user = CreateUser(); @@ -276,4 +284,10 @@ private EssentialCSharpWebUser CreateUser() [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha returned unrecognized error code: {ErrorCode}")] private static partial void LogHCaptchaUnrecognizedErrorCode(ILogger logger, string errorCode); + + [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha invalid outcome did not include response payload")] + private static partial void LogHCaptchaInvalidOutcomeMissingResponse(ILogger logger); + + [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha returned unhandled non-proceeding outcome: {Outcome}")] + private static partial void LogHCaptchaUnhandledOutcome(ILogger logger, string outcome); } diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index e420a4dd..2ded4710 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -302,20 +302,15 @@ private IActionResult HandleCaptchaValidationFailure(CaptchaValidationResult cap { return captchaValidation.Outcome switch { - CaptchaValidationOutcome.Disabled => LogAndReturn503(), - CaptchaValidationOutcome.Unavailable => LogAndReturn503(), + CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Unavailable => LogAndReturn503(), CaptchaValidationOutcome.Invalid => LogInvalidAndReturn403(), _ => StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }) }; IActionResult LogAndReturn503() { - if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) - LogCaptchaConfigurationMissing(_Logger); - else - LogCaptchaServiceUnavailable(_Logger); - - return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + LogCaptchaUnavailable(captchaValidation.Outcome); + return StatusCode(503, CaptchaUnavailableResponseBody); } IActionResult LogInvalidAndReturn403() @@ -331,13 +326,9 @@ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult c { case CaptchaValidationOutcome.Disabled: case CaptchaValidationOutcome.Unavailable: - if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) - LogCaptchaConfigurationMissing(_Logger); - else - LogCaptchaServiceUnavailable(_Logger); - + LogCaptchaUnavailable(captchaValidation.Outcome); Response.StatusCode = 503; - await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + await Response.WriteAsJsonAsync(CaptchaUnavailableResponseBody, cancellationToken); break; case CaptchaValidationOutcome.Invalid: @@ -353,6 +344,17 @@ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult c } } + private static object CaptchaUnavailableResponseBody => + new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }; + + private void LogCaptchaUnavailable(CaptchaValidationOutcome outcome) + { + if (outcome == CaptchaValidationOutcome.Disabled) + LogCaptchaConfigurationMissing(_Logger); + else + LogCaptchaServiceUnavailable(_Logger); + } + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] private static partial void LogCaptchaServiceUnavailable(ILogger logger); diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs index acd68171..bcacfe9c 100644 --- a/EssentialCSharp.Web/Services/CaptchaValidationService.cs +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -6,6 +6,9 @@ namespace EssentialCSharp.Web.Services; public sealed class CaptchaValidationService(ICaptchaService captchaService, IOptions optionsAccessor) : ICaptchaValidationService { private CaptchaOptions Options { get; } = optionsAccessor.Value; + private bool IsCaptchaConfigurationComplete => + !string.IsNullOrWhiteSpace(Options.SecretKey) && + !string.IsNullOrWhiteSpace(Options.SiteKey); public Task ValidateAsync(string? response, CancellationToken cancellationToken = default) => ValidateAsync(response, remoteIp: null, cancellationToken); @@ -22,7 +25,7 @@ public Task ValidateAsync(string? response, Cancellatio /// public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(Options.SecretKey) || string.IsNullOrWhiteSpace(Options.SiteKey)) + if (!IsCaptchaConfigurationComplete) return new CaptchaValidationResult(CaptchaValidationOutcome.Disabled, null); if (string.IsNullOrWhiteSpace(response)) From d2f8cd8ef5eeb614119b27250e9cc88508527b16 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 21 May 2026 10:23:37 -0700 Subject: [PATCH 07/13] fix: address remaining PR review feedback - ignore local test output artifact to prevent noisy diffs - cache chat captcha unavailable response payload - add tests for partial captcha configuration guard paths --- .gitignore | 1 + .../CaptchaValidationServiceTests.cs | 34 +++++++++++++++++++ .../Controllers/ChatController.cs | 7 ++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 57015074..dea519cd 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ TestResults/ # Old or generated files to not commit build_output.txt +test_output.txt wwwroot/sitemap.xml wwwroot/Chapters EssentialCSharp.Web/wwwroot/Chapters diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs index 244d819b..f5ee23c2 100644 --- a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -24,6 +24,40 @@ public async Task ValidateAsync_MissingConfig_RejectsWithoutVerification() await Assert.That(captchaService.CallCount).IsEqualTo(0); } + [Test] + public async Task ValidateAsync_MissingSiteKey_RejectsWithoutVerification() + { + StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = string.Empty }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.ShouldProceed).IsFalse(); + await Assert.That(captchaService.CallCount).IsEqualTo(0); + } + + [Test] + public async Task ValidateAsync_MissingSecretKey_RejectsWithoutVerification() + { + StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = string.Empty, SiteKey = "sitekey" }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); + + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.ShouldProceed).IsFalse(); + await Assert.That(captchaService.CallCount).IsEqualTo(0); + } + [Test] public async Task ValidateAsync_MissingToken_ReturnsMissingToken() { diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 2ded4710..ed493691 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -344,8 +344,11 @@ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult c } } - private static object CaptchaUnavailableResponseBody => - new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }; + private static readonly object CaptchaUnavailableResponseBody = new + { + error = "Human verification is temporarily unavailable. Please try again later.", + errorCode = "captcha_unavailable" + }; private void LogCaptchaUnavailable(CaptchaValidationOutcome outcome) { From 445479bcc5ee42879765392c56563a1de9c6592a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 14 Oct 2026 00:07:25 -0700 Subject: [PATCH 08/13] Delete test_output.txt --- test_output.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test_output.txt diff --git a/test_output.txt b/test_output.txt deleted file mode 100644 index e06b42e7..00000000 --- a/test_output.txt +++ /dev/null @@ -1 +0,0 @@ -Running tests from D:\copilot-worktrees\EssentialCSharp.Web\benjaminmichaelis-super-giggle\EssentialCSharp.Web.Tests\bin\Release\net10.0\EssentialCSharp.Web.Tests.dll (net10.0|x64) From a81d28330730b1ee8c69145a5bc51b73e906810e Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 14 Oct 2026 01:16:44 -0700 Subject: [PATCH 09/13] refactor: harden captcha validation architecture --- .../CaptchaValidationServiceTests.cs | 28 ++- .../Pages/Account/ForgotPassword.cshtml.cs | 7 +- .../Identity/Pages/Account/Login.cshtml.cs | 7 +- .../Identity/Pages/Account/Register.cshtml.cs | 183 +++++++++--------- .../Account/ResendEmailConfirmation.cshtml.cs | 7 +- .../Pages/Account/ResetPassword.cshtml.cs | 7 +- .../Controllers/ChatController.cs | 11 +- .../Services/CaptchaService.cs | 4 +- .../Services/CaptchaValidationOutcome.cs | 2 +- .../Services/CaptchaValidationResult.cs | 26 ++- .../Services/CaptchaValidationService.cs | 26 ++- 11 files changed, 183 insertions(+), 125 deletions(-) diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs index f5ee23c2..1e9ac023 100644 --- a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -19,7 +19,7 @@ public async Task ValidateAsync_MissingConfig_RejectsWithoutVerification() CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); - await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured); await Assert.That(result.ShouldProceed).IsFalse(); await Assert.That(captchaService.CallCount).IsEqualTo(0); } @@ -36,7 +36,7 @@ public async Task ValidateAsync_MissingSiteKey_RejectsWithoutVerification() CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); - await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured); await Assert.That(result.ShouldProceed).IsFalse(); await Assert.That(captchaService.CallCount).IsEqualTo(0); } @@ -53,7 +53,7 @@ public async Task ValidateAsync_MissingSecretKey_RejectsWithoutVerification() CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); - await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); + await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured); await Assert.That(result.ShouldProceed).IsFalse(); await Assert.That(captchaService.CallCount).IsEqualTo(0); } @@ -92,6 +92,27 @@ public async Task ValidateAsync_Unavailable_ReturnsUnavailable() await Assert.That(captchaService.CallCount).IsEqualTo(1); } + [Test] + public async Task ValidateAsync_ForwardsCancellationToken() + { + using CancellationTokenSource cancellationTokenSource = new(); + CancellationToken receivedCancellationToken = default; + StubCaptchaService captchaService = new((_, _, cancellationToken) => + { + receivedCancellationToken = cancellationToken; + return Task.FromResult(new HCaptchaResult { Success = true }); + }); + using ServiceProvider serviceProvider = CreateServiceProvider( + new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" }, + captchaService); + + ICaptchaValidationService validationService = serviceProvider.GetRequiredService(); + + await validationService.ValidateAsync("token", "127.0.0.1", cancellationTokenSource.Token); + + await Assert.That(receivedCancellationToken).IsEqualTo(cancellationTokenSource.Token); + } + [Test] public async Task ValidateAsync_InvalidAndValid_ReturnExpectedOutcome() { @@ -130,6 +151,7 @@ public async Task ValidateAsync_InvalidAndValid_ReturnExpectedOutcome() private static ServiceProvider CreateServiceProvider(CaptchaOptions options, ICaptchaService captchaService) { ServiceCollection services = new(); + services.AddLogging(); services.AddSingleton(Options.Create(options)); services.AddSingleton(captchaService); services.AddSingleton(); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs index 7e566037..5c5af8d2 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs @@ -32,10 +32,13 @@ public class InputModel public string? Email { get; set; } } - public async Task OnPostAsync() + public async Task OnPostAsync(CancellationToken cancellationToken = default) { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync( + captchaToken, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index c0c564c2..d537de31 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -62,12 +62,15 @@ public async Task OnGetAsync(string? returnUrl = null) ReturnUrl = returnUrl; } - public async Task OnPostAsync(string? returnUrl = null) + public async Task OnPostAsync(string? returnUrl = null, CancellationToken cancellationToken = default) { returnUrl ??= Url.Content("~/"); string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync( + captchaToken, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 208f64f4..059b34ff 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -78,7 +78,7 @@ public async Task OnGetAsync(string? returnUrl = null) ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); } - public async Task OnPostAsync(string? returnUrl = null) + public async Task OnPostAsync(string? returnUrl = null, CancellationToken cancellationToken = default) { returnUrl ??= Url.Content("~/"); ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); @@ -89,96 +89,13 @@ public async Task OnPostAsync(string? returnUrl = null) return Page(); } - CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync( + hCaptcha_response, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); if (!captchaResult.ShouldProceed) { - if (captchaResult.Outcome == CaptchaValidationOutcome.Disabled) - { - LogHCaptchaDisabledWarning(logger); - ModelState.AddModelError(string.Empty, "Captcha verification is not configured. Please contact support."); - return Page(); - } - if (captchaResult.Outcome == CaptchaValidationOutcome.MissingToken) - { - ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); - return Page(); - } - if (captchaResult.Outcome == CaptchaValidationOutcome.Unavailable) - { - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); - } - if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) - { - if (captchaResult.Response is not HCaptchaResult response) - { - LogHCaptchaInvalidOutcomeMissingResponse(logger); - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); - } - - switch (response.ErrorCodes?.Length) - { - case 0: - LogHCaptchaNoErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - case > 1: - LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes)); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - default: - { - if (response.ErrorCodes is null) - { - LogHCaptchaNullErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - } - if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details)) - { - if (details is null) - { - LogHCaptchaNullErrorCodes(logger); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - } - - switch (details.ErrorCode) - { - case HCaptchaErrorDetails.MissingInputResponse: - case HCaptchaErrorDetails.InvalidInputResponse: - case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse: - ModelState.AddModelError(string.Empty, details.FriendlyDescription); - LogHCaptchaErrorCode(logger, details.ToString()); - return Page(); - case HCaptchaErrorDetails.BadRequest: - ModelState.AddModelError(string.Empty, details.FriendlyDescription); - LogHCaptchaErrorCode(logger, details.ToString()); - return Page(); - case HCaptchaErrorDetails.MissingInputSecret: - case HCaptchaErrorDetails.InvalidInputSecret: - case HCaptchaErrorDetails.NotUsingDummyPasscode: - case HCaptchaErrorDetails.SitekeySecretMismatch: - LogHCaptchaCriticalErrorCode(logger, details.ToString()); - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); - default: - LogHCaptchaUnknownErrorCode(logger, details.ErrorCode); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - } - } - - LogHCaptchaUnrecognizedErrorCode(logger, response.ErrorCodes.Single()); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - return Page(); - } - } - } - - LogHCaptchaUnhandledOutcome(logger, captchaResult.Outcome.ToString()); - ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + AddCaptchaValidationError(captchaResult); return Page(); } @@ -186,8 +103,8 @@ public async Task OnPostAsync(string? returnUrl = null) user.FirstName = Input.FirstName; user.LastName = Input.LastName; - await userStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None); - await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + await userStore.SetUserNameAsync(user, Input.UserName, cancellationToken); + await emailStore.SetEmailAsync(user, Input.Email, cancellationToken); if (Input.Password is null) { LogPasswordNull(logger); @@ -241,6 +158,87 @@ await emailSender.SendEmailAsync(Input.Email, "Confirm your email", return Page(); } + private void AddCaptchaValidationError(CaptchaValidationResult captchaResult) + { + switch (captchaResult.Outcome) + { + case CaptchaValidationOutcome.Misconfigured: + ModelState.AddModelError(string.Empty, "Captcha verification is not configured. Please contact support."); + return; + case CaptchaValidationOutcome.MissingToken: + ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); + return; + case CaptchaValidationOutcome.Unavailable: + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return; + case CaptchaValidationOutcome.Invalid when captchaResult.Response is not null: + AddInvalidCaptchaError(captchaResult.Response); + return; + case CaptchaValidationOutcome.Invalid: + LogHCaptchaInvalidOutcomeMissingResponse(logger); + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return; + default: + LogHCaptchaUnhandledOutcome(logger, captchaResult.Outcome.ToString()); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + } + + private void AddInvalidCaptchaError(HCaptchaResult response) + { + if (response.ErrorCodes is null) + { + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + + if (response.ErrorCodes.Length == 0) + { + LogHCaptchaNoErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + + if (response.ErrorCodes.Length > 1) + { + LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes)); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + + string errorCode = response.ErrorCodes[0]; + if (!HCaptchaErrorDetails.TryGetValue(errorCode, out HCaptchaErrorDetails? details) || details is null) + { + LogHCaptchaUnrecognizedErrorCode(logger, errorCode); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + + switch (details.ErrorCode) + { + case HCaptchaErrorDetails.MissingInputResponse: + case HCaptchaErrorDetails.InvalidInputResponse: + case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse: + case HCaptchaErrorDetails.BadRequest: + ModelState.AddModelError(string.Empty, details.FriendlyDescription); + LogHCaptchaErrorCode(logger, details.ToString()); + return; + case HCaptchaErrorDetails.MissingInputSecret: + case HCaptchaErrorDetails.InvalidInputSecret: + case HCaptchaErrorDetails.NotUsingDummyPasscode: + case HCaptchaErrorDetails.SitekeySecretMismatch: + LogHCaptchaCriticalErrorCode(logger, details.ToString()); + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return; + default: + LogHCaptchaUnknownErrorCode(logger, details.ErrorCode); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return; + } + } + private EssentialCSharpWebUser CreateUser() { try @@ -261,9 +259,6 @@ private EssentialCSharpWebUser CreateUser() [LoggerMessage(Level = LogLevel.Information, Message = "User created a new account with password.")] private static partial void LogUserCreatedWithPassword(ILogger logger); - [LoggerMessage(Level = LogLevel.Warning, Message = "Captcha is disabled in configuration")] - private static partial void LogHCaptchaDisabledWarning(ILogger logger); - [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha determined the passcode is not valid with zero error codes")] private static partial void LogHCaptchaNoErrorCodes(ILogger logger); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs index 12650f49..9dba0c1c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs @@ -33,10 +33,13 @@ public class InputModel public string? Email { get; set; } } - public async Task OnPostAsync() + public async Task OnPostAsync(CancellationToken cancellationToken = default) { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync( + captchaToken, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs index 0149fada..19ebf68e 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -59,10 +59,13 @@ public IActionResult OnGet(string? code = null) } } - public async Task OnPostAsync() + public async Task OnPostAsync(CancellationToken cancellationToken = default) { string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync( + captchaToken, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); if (!captchaResult.ShouldProceed) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index ed493691..f7cfbd46 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -302,7 +302,7 @@ private IActionResult HandleCaptchaValidationFailure(CaptchaValidationResult cap { return captchaValidation.Outcome switch { - CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Unavailable => LogAndReturn503(), + CaptchaValidationOutcome.Misconfigured or CaptchaValidationOutcome.Unavailable => LogAndReturn503(), CaptchaValidationOutcome.Invalid => LogInvalidAndReturn403(), _ => StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }) }; @@ -324,7 +324,7 @@ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult c { switch (captchaValidation.Outcome) { - case CaptchaValidationOutcome.Disabled: + case CaptchaValidationOutcome.Misconfigured: case CaptchaValidationOutcome.Unavailable: LogCaptchaUnavailable(captchaValidation.Outcome); Response.StatusCode = 503; @@ -352,18 +352,13 @@ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult c private void LogCaptchaUnavailable(CaptchaValidationOutcome outcome) { - if (outcome == CaptchaValidationOutcome.Disabled) - LogCaptchaConfigurationMissing(_Logger); - else + if (outcome == CaptchaValidationOutcome.Unavailable) LogCaptchaServiceUnavailable(_Logger); } [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] private static partial void LogCaptchaServiceUnavailable(ILogger logger); - [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha configuration missing during chat request — failing closed (503)")] - private static partial void LogCaptchaConfigurationMissing(ILogger logger); - [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")] private static partial void LogCaptchaValidationFailed(ILogger logger, string errorCodes); diff --git a/EssentialCSharp.Web/Services/CaptchaService.cs b/EssentialCSharp.Web/Services/CaptchaService.cs index 1e76f767..54a9c908 100644 --- a/EssentialCSharp.Web/Services/CaptchaService.cs +++ b/EssentialCSharp.Web/Services/CaptchaService.cs @@ -69,7 +69,7 @@ public partial class CaptchaService(IHttpClientFactory clientFactory, IOptions(await res.Content.ReadAsStringAsync(cancellationToken)); @@ -78,7 +78,7 @@ public partial class CaptchaService(IHttpClientFactory clientFactory, IOptions Outcome is CaptchaValidationOutcome.Valid; + + public static CaptchaValidationResult Misconfigured() => new(CaptchaValidationOutcome.Misconfigured, null); + + public static CaptchaValidationResult MissingToken() => new(CaptchaValidationOutcome.MissingToken, null); + + public static CaptchaValidationResult Unavailable() => new(CaptchaValidationOutcome.Unavailable, null); + + public static CaptchaValidationResult FromResponse(HCaptchaResult response) + { + ArgumentNullException.ThrowIfNull(response); + return new( + response.Success ? CaptchaValidationOutcome.Valid : CaptchaValidationOutcome.Invalid, + response); + } } diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs index bcacfe9c..473761fe 100644 --- a/EssentialCSharp.Web/Services/CaptchaValidationService.cs +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -3,12 +3,16 @@ namespace EssentialCSharp.Web.Services; -public sealed class CaptchaValidationService(ICaptchaService captchaService, IOptions optionsAccessor) : ICaptchaValidationService +public sealed partial class CaptchaValidationService( + ICaptchaService captchaService, + IOptions optionsAccessor, + ILogger logger) : ICaptchaValidationService { private CaptchaOptions Options { get; } = optionsAccessor.Value; private bool IsCaptchaConfigurationComplete => !string.IsNullOrWhiteSpace(Options.SecretKey) && !string.IsNullOrWhiteSpace(Options.SiteKey); + private int _configurationWarningLogged; public Task ValidateAsync(string? response, CancellationToken cancellationToken = default) => ValidateAsync(response, remoteIp: null, cancellationToken); @@ -19,24 +23,30 @@ public Task ValidateAsync(string? response, Cancellatio /// /// IMPORTANT: Both and must be configured /// (non-empty) for captcha validation to be enabled. If either key is missing, the validation is marked as - /// and the captcha service is not invoked. This is intentional: + /// and the captcha service is not invoked. This is intentional: /// HCaptcha requires both keys to function properly, and partial configuration (one key set, one missing) /// indicates a deployment configuration error that should be detected early. /// public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) { if (!IsCaptchaConfigurationComplete) - return new CaptchaValidationResult(CaptchaValidationOutcome.Disabled, null); + { + if (Interlocked.Exchange(ref _configurationWarningLogged, 1) == 0) + LogConfigurationMissing(logger); + + return CaptchaValidationResult.Misconfigured(); + } if (string.IsNullOrWhiteSpace(response)) - return new CaptchaValidationResult(CaptchaValidationOutcome.MissingToken, null); + return CaptchaValidationResult.MissingToken(); HCaptchaResult? result = await captchaService.VerifyAsync(response, remoteIp, cancellationToken); if (result is null) - return new CaptchaValidationResult(CaptchaValidationOutcome.Unavailable, null); + return CaptchaValidationResult.Unavailable(); - return new CaptchaValidationResult( - result.Success ? CaptchaValidationOutcome.Valid : CaptchaValidationOutcome.Invalid, - result); + return CaptchaValidationResult.FromResponse(result); } + + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha configuration is incomplete; validation is failing closed")] + private static partial void LogConfigurationMissing(ILogger logger); } From ade01278f25fc0219597ccfd66638066ccde09bf Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 14 Oct 2026 01:23:07 -0700 Subject: [PATCH 10/13] fix: remove duplicate OpenAI package version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 - 1 file changed, 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 40137857..cf6081db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,6 +62,5 @@ - From b017856bbc17b9729d81dc3ad89632af9a9231ac Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 14 Oct 2026 01:26:04 -0700 Subject: [PATCH 11/13] fix: align OpenAI package version with AI SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index cf6081db..8294d466 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,5 +62,6 @@ + From 33986e9474b1652c5e79aad48660a0bae972a0b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:49:30 +0000 Subject: [PATCH 12/13] fix: address chat captcha review feedback Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com> --- Directory.Packages.props | 2 +- .../ChatAvailabilityTests.cs | 32 +++++++++++++++++++ .../WebApplicationFactory.cs | 14 ++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8294d466..d5b44580 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,6 +62,6 @@ - + diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs index 74c6d919..e3a026d2 100644 --- a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs +++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs @@ -7,6 +7,8 @@ namespace EssentialCSharp.Web.Tests; public class ChatAvailabilityTests : IntegrationTestBase { private const string HCaptchaTestToken = "10000000-aaaa-bbbb-cccc-000000000001"; + private const string InvalidHCaptchaTestToken = "invalid-captcha-test-token"; + private const string UnavailableHCaptchaTestToken = "unavailable-captcha-test-token"; [Test] [Arguments("/api/chat/message", "chat-unavailable-message")] @@ -30,4 +32,34 @@ public async Task ChatEndpoint_WhenBackendUnavailable_Returns503WithContract(str using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable"); } + + [Test] + [Arguments("/api/chat/message", "chat-invalid-message", InvalidHCaptchaTestToken, HttpStatusCode.Forbidden, "captcha_failed")] + [Arguments("/api/chat/stream", "chat-invalid-stream", InvalidHCaptchaTestToken, HttpStatusCode.Forbidden, "captcha_failed")] + [Arguments("/api/chat/message", "chat-captcha-unavailable-message", UnavailableHCaptchaTestToken, HttpStatusCode.ServiceUnavailable, "captcha_unavailable")] + [Arguments("/api/chat/stream", "chat-captcha-unavailable-stream", UnavailableHCaptchaTestToken, HttpStatusCode.ServiceUnavailable, "captcha_unavailable")] + public async Task ChatEndpoint_WhenCaptchaValidationFails_ReturnsExpectedStatusAndCode( + string endpoint, + string userNameSeed, + string captchaResponse, + HttpStatusCode expectedStatus, + string expectedErrorCode) + { + HttpClient client = McpTestHelper.CreateClient(Factory); + + string userId = await McpTestHelper.CreateUserAsync(Factory, userNameSeed); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse }) + }; + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(expectedStatus); + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo(expectedErrorCode); + } } diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index f5f80972..640671a9 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -181,9 +181,17 @@ private sealed class PassingCaptchaService : ICaptchaService }; public Task VerifyAsync(string? response, CancellationToken cancellationToken = default) => - Task.FromResult(_Success); + VerifyAsync(response, remoteIp: null, cancellationToken: cancellationToken); - public Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) => - Task.FromResult(_Success); + public Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) + { + if (response == "unavailable-captcha-test-token") + return Task.FromResult(null); + + if (response == "invalid-captcha-test-token") + return Task.FromResult(new HCaptchaResult { Success = false }); + + return Task.FromResult(_Success); + } } } From 368c39b0a4c61af7099048801cef5d4b84970393 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 14 Oct 2026 09:25:15 -0700 Subject: [PATCH 13/13] fix: resolve OpenAI package version conflict --- Directory.Packages.props | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1a2a2a24..46d68feb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -61,7 +61,7 @@ - + @@ -69,6 +69,5 @@ -