feat(server): implement sealed share lifecycle
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
e02e024605
commit
42b93ae2d8
45 files changed
+6327
No files matched your search
@@ -0,0 +1,189 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user