Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,6 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
<PackageVersion Include="DotnetSitemapGenerator" Version="2.0.0" />
<PackageVersion Include="OpenAI" Version="2.10.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
<PackageReference Include="OpenAI" />
<PackageReference Include="Microsoft.SourceLink.GitHub">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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]
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? 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 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,10 +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