Skip to content
121 changes: 121 additions & 0 deletions EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<ICaptchaValidationService>();

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]
Comment thread
BenjaminMichaelis marked this conversation as resolved.
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<ICaptchaValidationService>();

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<HCaptchaResult?>(null));
using ServiceProvider serviceProvider = CreateServiceProvider(
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
captchaService);

ICaptchaValidationService validationService = serviceProvider.GetRequiredService<ICaptchaValidationService>();

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<HCaptchaResult?>(new HCaptchaResult
{
Success = false,
ErrorCodes = ["invalid-input-response"]
}));
using ServiceProvider invalidProvider = CreateServiceProvider(
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
invalidCaptchaService);

ICaptchaValidationService invalidValidationService = invalidProvider.GetRequiredService<ICaptchaValidationService>();
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<HCaptchaResult?>(new HCaptchaResult
{
Success = true
}));
using ServiceProvider validProvider = CreateServiceProvider(
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
validCaptchaService);

ICaptchaValidationService validValidationService = validProvider.GetRequiredService<ICaptchaValidationService>();
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<ICaptchaValidationService, CaptchaValidationService>();
return services.BuildServiceProvider();
}

private sealed class StubCaptchaService(Func<string?, string?, CancellationToken, Task<HCaptchaResult?>> verifyAsync) : ICaptchaService
{
public int CallCount { get; private set; }

public Task<HCaptchaResult?> VerifyAsync(string secret, string response, string sitekey, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
public Task<HCaptchaResult?> VerifyAsync(string? response, CancellationToken cancellationToken = default)
=> VerifyAsync(response, remoteIp: null, cancellationToken);

public async Task<HCaptchaResult?> VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
{
CallCount++;
return await verifyAsync(response, remoteIp, cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +12,7 @@

namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;

public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
Expand All @@ -36,8 +35,8 @@ public class InputModel
public async Task<IActionResult> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,7 +10,7 @@

namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;

public partial class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
public partial class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaValidationService captchaValidationService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
Expand Down Expand Up @@ -68,8 +67,8 @@ public async Task<IActionResult> 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();
Expand Down
Loading
Loading