Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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_MissingConfig_RejectsWithoutVerification()
{
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).IsFalse();
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<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 on lines +109 to +111
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