test(bot-protection): 增加 Turnstile 服务测试

This commit is contained in:
token
2026-08-31 09:45:59 +08:00
parent 724ec5e858
commit d226c1b977
3 changed files with 216 additions and 0 deletions
+2
View File
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TunnelClient", "src\TunnelC
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Certes", "src\Certes\Certes.csproj", "{A6D3C652-9825-4D51-88AF-1F91E61348E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastGateway.Tests", "tests\FastGateway.Tests\FastGateway.Tests.csproj", "{E7BBE0C4-33E1-4DD7-8B69-2E2C47052A7D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -0,0 +1,193 @@
using System.Net;
using System.Net.Http.Json;
using FastGateway.Dto;
using FastGateway.Infrastructure;
using FastGateway.Options;
using FastGateway.Services;
using Microsoft.Extensions.Options;
using Xunit;
namespace FastGateway.Tests;
public sealed class BotProtectionServiceTests
{
[Fact]
public void CreateClearance_ValidatesHostRoutePathAndSignature()
{
var service = CreateService();
var clearance = service.CreateClearance("Example.com:443", "route-a", "docs", "203.0.113.10");
Assert.True(service.ValidateClearance(clearance, "example.com", "route-a", "/docs", "203.0.113.10"));
Assert.False(service.ValidateClearance(clearance, "other.example.com", "route-a", "/docs", "203.0.113.10"));
Assert.False(service.ValidateClearance(clearance, "example.com", "route-b", "/docs", "203.0.113.10"));
Assert.False(service.ValidateClearance(clearance, "example.com", "route-a", "/other", "203.0.113.10"));
Assert.False(service.ValidateClearance(Tamper(clearance), "example.com", "route-a", "/docs", "203.0.113.10"));
}
[Fact]
public void CreateClearance_WhenIpBindingEnabled_RejectsDifferentIp()
{
var service = CreateService(options: new BotProtectionOptions { BindClearanceToIp = true });
var clearance = service.CreateClearance("example.com", "route-a", "/", "203.0.113.10");
Assert.True(service.ValidateClearance(clearance, "example.com", "route-a", "/", "203.0.113.10"));
Assert.False(service.ValidateClearance(clearance, "example.com", "route-a", "/", "203.0.113.11"));
}
[Fact]
public void CreateChallengeState_RejectsUnsafeReturnUrlAndConsumesOnce()
{
var service = CreateService();
var unsafeState = service.CreateChallengeState("example.com", "route-a", "https://evil.example/", "/", "business-access");
Assert.False(service.TryReadChallengeState(unsafeState, "example.com", out _));
var state = service.CreateChallengeState("example.com", "route-a", "/docs?q=1", "docs", "business-access");
Assert.True(service.TryReadChallengeState(state, "example.com", out var payload));
Assert.Equal("/docs?q=1", payload.ReturnUrl);
Assert.Equal("/docs", payload.RoutePath);
Assert.True(service.TryConsumeChallengeState(state, "example.com", out _));
Assert.False(service.TryConsumeChallengeState(state, "example.com", out _));
Assert.False(service.TryReadChallengeState(state, "other.example.com", out _));
}
[Fact]
public void CreateRouteMetadata_DefaultsOffAndPreservesExistingMetadata()
{
var existing = new Dictionary<string, string> { ["existing"] = "value" };
var unprotected = new Core.Entities.DomainName { Id = "route-a", EnableBotProtection = false };
var protectedRoute = new Core.Entities.DomainName { Id = "route-a", EnableBotProtection = true };
var withoutProtection = BotProtectionService.CreateRouteMetadata(unprotected, "/");
var withProtection = BotProtectionService.CreateRouteMetadata(protectedRoute, "/docs", existing);
Assert.Empty(withoutProtection);
Assert.Equal("value", withProtection["existing"]);
Assert.Equal("true", withProtection[BotProtectionService.ProtectionMetadataKey]);
Assert.Equal("route-a", withProtection[BotProtectionService.RouteIdMetadataKey]);
Assert.Equal("/docs", withProtection[BotProtectionService.RoutePathMetadataKey]);
}
[Fact]
public void TryConsumeVerifyRequest_EnforcesPerIpWindow()
{
var service = CreateService(options: new BotProtectionOptions { VerifyRequestsPerMinute = 2 });
Assert.True(service.TryConsumeVerifyRequest("203.0.113.10"));
Assert.True(service.TryConsumeVerifyRequest("203.0.113.10"));
Assert.False(service.TryConsumeVerifyRequest("203.0.113.10"));
Assert.True(service.TryConsumeVerifyRequest("203.0.113.11"));
}
[Fact]
public async Task VerifyTurnstileAsync_MapsSuccessAndSendsServerOnlySecret()
{
var handler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
{
Success = true,
Action = "business-access",
Hostname = "example.com"
}));
var service = CreateService(handler);
var result = await service.VerifyTurnstileAsync(
"turnstile-token", "business-access", "example.com", "203.0.113.10", CancellationToken.None);
Assert.Equal(BotVerificationStatus.Success, result.Status);
Assert.NotNull(handler.LastRequest);
Assert.Contains("secret=server-secret", handler.LastRequestBody);
Assert.Contains("response=turnstile-token", handler.LastRequestBody);
Assert.Contains("remoteip=203.0.113.10", handler.LastRequestBody);
Assert.Equal("https://challenges.cloudflare.com/turnstile/v0/siteverify", handler.LastRequest.RequestUri!.ToString());
}
[Fact]
public async Task VerifyTurnstileAsync_RejectsActionAndHostnameMismatch()
{
var actionHandler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
{
Success = true,
Action = "other-action",
Hostname = "example.com"
}));
var actionResult = await CreateService(actionHandler).VerifyTurnstileAsync(
"token", "business-access", "example.com", null, CancellationToken.None);
Assert.Equal(BotVerificationStatus.Rejected, actionResult.Status);
var hostnameHandler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
{
Success = true,
Action = "business-access",
Hostname = "other.example.com"
}));
var hostnameResult = await CreateService(hostnameHandler).VerifyTurnstileAsync(
"token", "business-access", "example.com", null, CancellationToken.None);
Assert.Equal(BotVerificationStatus.Rejected, hostnameResult.Status);
}
[Fact]
public async Task VerifyTurnstileAsync_MapsHttpFailureAndTimeoutToUnavailable()
{
var failureHandler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway));
var failureResult = await CreateService(failureHandler).VerifyTurnstileAsync(
"token", "business-access", "example.com", null, CancellationToken.None);
Assert.Equal(BotVerificationStatus.Unavailable, failureResult.Status);
var timeoutHandler = new StubHandler(_ => throw new HttpRequestException("network failure"));
var timeoutResult = await CreateService(timeoutHandler).VerifyTurnstileAsync(
"token", "business-access", "example.com", null, CancellationToken.None);
Assert.Equal(BotVerificationStatus.Unavailable, timeoutResult.Status);
}
private static BotProtectionService CreateService(
HttpMessageHandler? handler = null,
BotProtectionOptions? options = null)
{
options ??= new BotProtectionOptions();
options.SiteKey = "site-key";
options.SecretKey = "server-secret";
options.CookieSigningKey = "01234567890123456789012345678901";
var client = new HttpClient(handler ?? new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse())))
{
Timeout = TimeSpan.FromSeconds(5)
};
return new BotProtectionService(new StubHttpClientFactory(client), Microsoft.Extensions.Options.Options.Create(options));
}
private static HttpResponseMessage JsonResponse(TurnstileVerifyResponse response)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response, AppJsonContext.Default.TurnstileVerifyResponse)
};
}
private static string Tamper(string value)
{
var separator = value.IndexOf('.');
var signature = value[(separator + 1)..];
var replacement = signature[0] == 'A' ? 'B' : 'A';
return value[..(separator + 1)] + replacement + signature[1..];
}
private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string LastRequestBody { get; private set; } = string.Empty;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastRequestBody = request.Content == null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken);
return handler(request);
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit.v3" Version="3.1.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/FastGateway/FastGateway.csproj" />
</ItemGroup>
</Project>