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; /// /// 只实现 Cap Standalone 的 POST /{site_key}/siteverify:请求体 {secret, response},响应 {success}。 /// 规则:secret 必须是 "s3cret",token 必须以 "good-" 开头,且每个 token 只能用一次。 /// public sealed class FakeCap : IAsyncDisposable { private readonly WebApplication _app; private readonly HashSet _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().Features.Get()!.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(() => server.InitializeAsync()); } }