Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="OpenAI" Version="2.12.0" />
<PackageVersion Include="OpenAI" Version="2.14.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
Expand Down
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
174 changes: 174 additions & 0 deletions EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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.Misconfigured);
await Assert.That(result.ShouldProceed).IsFalse();
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<ICaptchaValidationService>();

CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");

await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured);
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<ICaptchaValidationService>();

CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");

await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured);
await Assert.That(result.ShouldProceed).IsFalse();
await Assert.That(captchaService.CallCount).IsEqualTo(0);
}

[Test]
Comment thread
BenjaminMichaelis marked this conversation as resolved.
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_ForwardsCancellationToken()
{
using CancellationTokenSource cancellationTokenSource = new();
CancellationToken receivedCancellationToken = default;
StubCaptchaService captchaService = new((_, _, cancellationToken) =>
{
receivedCancellationToken = cancellationToken;
return Task.FromResult<HCaptchaResult?>(new HCaptchaResult { Success = true });
});
using ServiceProvider serviceProvider = CreateServiceProvider(
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
captchaService);

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

await validationService.ValidateAsync("token", "127.0.0.1", cancellationTokenSource.Token);

await Assert.That(receivedCancellationToken).IsEqualTo(cancellationTokenSource.Token);
}

[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.AddLogging();
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);
}
}
}
32 changes: 32 additions & 0 deletions EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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);
}
}
14 changes: 11 additions & 3 deletions EssentialCSharp.Web.Tests/WebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,17 @@ private sealed class PassingCaptchaService : ICaptchaService
};

public Task<HCaptchaResult?> VerifyAsync(string? response, CancellationToken cancellationToken = default) =>
Task.FromResult<HCaptchaResult?>(_Success);
VerifyAsync(response, remoteIp: null, cancellationToken: cancellationToken);

public Task<HCaptchaResult?> VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) =>
Task.FromResult<HCaptchaResult?>(_Success);
public Task<HCaptchaResult?> VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
{
if (response == "unavailable-captcha-test-token")
return Task.FromResult<HCaptchaResult?>(null);

if (response == "invalid-captcha-test-token")
return Task.FromResult<HCaptchaResult?>(new HCaptchaResult { Success = false });

return Task.FromResult<HCaptchaResult?>(_Success);
}
}
}
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 @@ -33,11 +32,14 @@ public class InputModel
public string? Email { get; set; }
}

public async Task<IActionResult> OnPostAsync()
public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken = default)
{
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(),
cancellationToken);
if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
return Page();
Expand Down
14 changes: 7 additions & 7 deletions EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs
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 @@ -63,15 +62,16 @@ public async Task OnGetAsync(string? returnUrl = null)
ReturnUrl = returnUrl;
}

public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
public async Task<IActionResult> OnPostAsync(string? returnUrl = null, CancellationToken cancellationToken = default)
{
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(),
cancellationToken);
if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
Expand Down
Loading
Loading