190 lines
7.1 KiB
C#
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());
|
|
}
|
|
}
|