Files
WebDrop/server/tests/WebDrop.Api.Tests/CapTests.cs
T
2026-10-02 00:59:56 +08:00

190 lines
7.1 KiB
C#

using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebDrop.Api;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
/// <summary>
/// 只实现 Cap Standalone 的 POST /{site_key}/siteverify:请求体 {secret, response},响应 {success}。
/// 规则:secret 必须是 "s3cret",token 必须以 "good-" 开头,且每个 token 只能用一次。
/// </summary>
public sealed class FakeCap : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly HashSet<string> _used = [];
public List<(string Secret, string Response)> Calls { get; } = [];
public bool Broken { get; set; }
public Uri BaseUri { get; private set; } = null!;
public const string Secret = "s3cret";
public string SiteverifyUrl => new Uri(BaseUri, "/site1/siteverify").ToString();
public FakeCap()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Configuration["urls"] = "http://127.0.0.1:0";
builder.Logging.ClearProviders();
_app = builder.Build();
_app.MapPost("/{site}/siteverify", async (HttpContext ctx) =>
{
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
string secret = doc.RootElement.GetProperty("secret").GetString()!;
string response = doc.RootElement.GetProperty("response").GetString()!;
lock (Calls) Calls.Add((secret, response));
if (Broken) return Results.StatusCode(500);
bool ok;
lock (_used) ok = secret == Secret && response.StartsWith("good-", StringComparison.Ordinal) && _used.Add(response);
return Results.Json(new { success = ok });
});
}
public async Task StartAsync()
{
await _app.StartAsync();
BaseUri = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First());
}
public async Task StopAsync() => await _app.StopAsync();
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public class CapTests : IAsyncLifetime
{
private const string WidgetEndpoint = "https://cap.example.test/site1/";
private readonly FakeCap _cap = new();
private ServerFixture _server = null!;
public async Task InitializeAsync()
{
await _cap.StartAsync();
_server = ServerFixture.WithSettings(new()
{
["WebDrop:Captcha:Provider"] = "cap",
["WebDrop:Captcha:ApiEndpoint"] = WidgetEndpoint,
["WebDrop:Captcha:SiteverifyUrl"] = _cap.SiteverifyUrl,
["WebDrop:Captcha:Secret"] = FakeCap.Secret,
["WebDrop:Captcha:TimeoutSeconds"] = "2",
});
await _server.InitializeAsync();
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
await _cap.DisposeAsync();
}
[Fact]
public async Task InfoAdvertisesTheWidgetEndpointButNeverTheSecret()
{
using var resp = await _server.Http.GetAsync("/api/v1/info");
string text = await resp.Content.ReadAsStringAsync();
var captcha = JsonDocument.Parse(text).RootElement.GetProperty("captcha");
Assert.Equal("cap", captcha.GetProperty("provider").GetString());
Assert.True(captcha.GetProperty("enabled").GetBoolean());
Assert.Equal(WidgetEndpoint, captcha.GetProperty("api_endpoint").GetString());
Assert.DoesNotContain(FakeCap.Secret, text);
Assert.DoesNotContain("siteverify", text);
}
[Fact]
public async Task CreateWithoutATokenIsRefusedAndCapIsNotCalled()
{
var (_, resp) = await _server.TryCreateAsync();
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
Assert.Empty(_cap.Calls);
}
[Fact]
public async Task CreateWithAnInvalidTokenIsRefused()
{
var (_, resp) = await _server.TryCreateAsync(capToken: "bad-token");
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
Assert.Single(_cap.Calls);
}
[Fact]
public async Task CreateWithAValidTokenSucceedsAndForwardsTheSecretOnlyToCap()
{
var (ch, resp) = await _server.TryCreateAsync(capToken: "good-1");
using (resp) Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var call = Assert.Single(_cap.Calls);
Assert.Equal(FakeCap.Secret, call.Secret);
Assert.Equal("good-1", call.Response);
// 创建之后的普通操作不需要验证码
using var get = await _server.Http.SendAsync(_server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
Assert.Single(_cap.Calls);
}
[Fact]
public async Task ATokenCannotBeUsedTwice()
{
var (_, first) = await _server.TryCreateAsync(capToken: "good-once");
using (first) Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var (_, second) = await _server.TryCreateAsync(capToken: "good-once");
using (second) await AssertErrorAsync(second, 428, "captcha_required");
}
[Fact]
public async Task OversizedTokensAreRejectedWithoutBeingForwarded()
{
var (_, resp) = await _server.TryCreateAsync(capToken: "good-" + new string('x', 5000));
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
Assert.Empty(_cap.Calls);
}
[Fact]
public async Task CapMalfunctionIsReportedAsUnavailableNotAsAUserFailure()
{
_cap.Broken = true;
var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-broken");
using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable");
}
[Fact]
public async Task CapBeingDownIsReportedAsUnavailable()
{
await _cap.StopAsync();
var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-down");
using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable");
}
}
public class CaptchaConfigurationTests
{
[Theory]
[InlineData("cap", "", "https://cap.example.test/s/", "s3cret")] // 缺 SiteverifyUrl
[InlineData("cap", "https://cap.example.test/s/siteverify", "", "s3cret")] // 缺 ApiEndpoint
[InlineData("cap", "https://cap.example.test/s/siteverify", "https://cap.example.test/s/", "")] // 缺 Secret
[InlineData("cap", "ftp://cap.example.test/s/siteverify", "https://cap.example.test/s/", "s3cret")] // 非 http(s)
[InlineData("bogus", "", "", "")]
public async Task IncompleteOrUnknownConfigurationFailsAtStartup(string provider, string verifyUrl, string apiEndpoint, string secret)
{
var server = ServerFixture.WithSettings(new()
{
["WebDrop:Captcha:Provider"] = provider,
["WebDrop:Captcha:SiteverifyUrl"] = verifyUrl,
["WebDrop:Captcha:ApiEndpoint"] = apiEndpoint,
["WebDrop:Captcha:Secret"] = secret,
});
await Assert.ThrowsAsync<InvalidOperationException>(() => server.InitializeAsync());
}
}