feat(server): implement sealed share lifecycle

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:56 +08:00
1 parent e02e024605
commit 42b93ae2d8
45 files changed
+6327

No files matched your search

+189
View File
@@ -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());
}
}
+432
View File
@@ -0,0 +1,432 @@
using System.Net;
using System.Text.Json;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
{
private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill);
[Fact]
public async Task InfoIsPublicAndResponsesCarrySecurityHeaders()
{
using var resp = await s.Http.GetAsync("/api/v1/info");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await ReadJsonAsync(resp);
Assert.Equal(1, json.GetProperty("protocol").GetInt32());
Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean());
Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32());
// 默认不启用验证码,不下发 Cap 的地址
var captcha = json.GetProperty("captcha");
Assert.Equal("none", captcha.GetProperty("provider").GetString());
Assert.False(captcha.GetProperty("enabled").GetBoolean());
Assert.False(captcha.TryGetProperty("api_endpoint", out _));
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single());
Assert.Contains("frame-ancestors 'none'", resp.Headers.GetValues("Content-Security-Policy").Single());
Assert.False(resp.Headers.Contains("Server"));
}
[Fact]
public async Task CreateReturnsChannelInfoAndRejectsDuplicates()
{
var ch = await s.CreateAsync();
using var get = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
var info = await ReadJsonAsync(get);
Assert.Equal(ch.Id, info.GetProperty("id").GetString());
Assert.Equal("enc", info.GetProperty("mode").GetString());
Assert.Equal(1, info.GetProperty("seats_used").GetInt32());
Assert.Equal(2, info.GetProperty("settings").GetProperty("seats").GetInt32());
Assert.Equal("own", info.GetProperty("settings").GetProperty("delete_policy").GetString());
Assert.Equal(1, info.GetProperty("oldest_seq").GetInt64());
// 同一个 id 再创建一次
var body = new Dictionary<string, object?>
{
["id"] = ch.Id, ["mode"] = "enc",
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
};
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
await AssertErrorAsync(await s.Http.SendAsync(req), 409, "conflict");
}
[Fact]
public async Task CreateWithBadJsonIsRejected()
{
var ch = new Chan(NewId(), ChannelMode.Raw);
using var badJson = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
new StringContent("{not json", System.Text.Encoding.UTF8, "application/json"));
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
}
[Fact]
public async Task ErrorsUseStableCodes()
{
var ch = await s.CreateAsync();
// 令牌错误
var wrong = new Who("owner", new byte[32], ch.OwnerSecret);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", wrong)), 401, "unauthorized");
// 频道不存在
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", ch.Owner)), 404, "not_found");
// id 格式错误
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, "/api/v1/channels/not-an-id", ch.Owner)), 400, "bad_request");
// 缺少角色头
using var noRole = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/channels/{ch.Id}");
await AssertErrorAsync(await s.Http.SendAsync(noRole), 400, "bad_request");
// 令牌格式错误
using var badToken = s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner);
badToken.Headers.Authorization = new("Bearer", "###");
await AssertErrorAsync(await s.Http.SendAsync(badToken), 400, "bad_request");
// 未入席
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
}
[Fact]
public async Task AppendIsIdempotentOverHttp()
{
var ch = await s.CreateAsync();
string item = NewId();
var frame = Small(item);
using var first = await s.AppendAsync(ch, ch.Owner, frame);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(1, (await ReadJsonAsync(first)).GetProperty("seq").GetInt64());
using var again = await s.AppendAsync(ch, ch.Owner, frame);
Assert.Equal(HttpStatusCode.OK, again.StatusCode);
Assert.Equal(1, (await ReadJsonAsync(again)).GetProperty("seq").GetInt64());
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Small(item, 0x43)), 409, "conflict");
}
[Fact]
public async Task LogReturnsBinaryRecordsWithLastSeqHeader()
{
var ch = await s.CreateAsync();
string a = NewId(), b = NewId();
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0", ch.Owner);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("application/octet-stream", resp.Content.Headers.ContentType!.MediaType);
Assert.Equal("2", resp.Headers.GetValues("X-WD-Last-Seq").Single());
var records = ParseLog(body);
Assert.Equal([1L, 2L], records.Select(r => r.Seq));
Assert.All(records, r => Assert.Equal(LogKind.Frame, r.Kind));
Assert.Equal(Small(a, 1), records[0].Body);
var (_, tail) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2", ch.Owner);
Assert.Equal(2, ParseOne(tail).Seq);
var (_, none) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=3", ch.Owner);
Assert.Empty(none);
}
[Fact]
public async Task LongPollWakesWhenAFrameArrives()
{
var ch = await s.CreateAsync();
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
var started = DateTime.UtcNow;
var poll = s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2&wait=20", ch.Owner);
await Task.Delay(300);
Assert.False(poll.IsCompleted); // 还在等
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
var (_, body) = await poll.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(2, ParseOne(body).Seq);
Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10));
}
[Fact]
public async Task LongPollReturnsEmptyAfterTheWaitExpires()
{
var ch = await s.CreateAsync();
var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0&wait=1", ch.Owner);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Empty(body);
}
[Fact]
public async Task OversizedBodiesAreRejected()
{
var ch = await s.CreateAsync(ChannelMode.Raw);
// 超过 1 MiB 负载上限但小于 Kestrel 的 2 MiB:服务自己拒绝
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large");
// 超过 Kestrel 上限:框架拒绝,仍映射为同一错误码
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 3_000_000)), 413, "too_large");
}
[Fact]
public async Task RawChannelCarriesArbitraryBytesAndIsReadableWithoutAToken()
{
var ch = await s.CreateAsync(ChannelMode.Raw);
string item = NewId();
using var put = await s.AppendAsync(ch, ch.Owner, Frame(item, 0, 1, 300, 0x7E)); // 非 enc,任意长度
Assert.Equal(HttpStatusCode.Created, put.StatusCode);
var anonymous = new Who("read", null, ch.MemberSecret);
using var join = await s.JoinAsync(ch, anonymous);
Assert.Equal(HttpStatusCode.OK, join.StatusCode);
var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", anonymous);
Assert.Equal(Frame(item, 0, 1, 300, 0x7E), ParseOne(body).Body);
}
[Fact]
public async Task SeatsJoinListAndRelease()
{
var ch = await s.CreateAsync();
using var joined = await s.JoinAsync(ch, ch.Reader);
var j = await ReadJsonAsync(joined);
Assert.Equal(2, j.GetProperty("seats_used").GetInt32());
Assert.Equal(2, j.GetProperty("seats_max").GetInt32());
string memberHash = j.GetProperty("endpoint_hash").GetString()!;
// 第三个终端被拒绝
var third = new Who("read", ch.ReadToken, System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "seats_full");
// 成员不能看席位列表,所有者可以
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Reader)), 403, "forbidden");
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
Assert.Equal(2, (await ReadJsonAsync(list)).GetArrayLength());
// 成员上传加密设备名
using var label = await s.Http.SendAsync(s.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/seats/me", ch.Reader, Bytes([9, 8, 7])));
Assert.Equal(HttpStatusCode.NoContent, label.StatusCode);
using var list2 = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
Assert.Contains((await ReadJsonAsync(list2)).EnumerateArray(), e => e.TryGetProperty("label", out var l) && l.GetString() == B64([9, 8, 7]));
// 所有者释放后,成员失去访问
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
}
[Fact]
public async Task SettingsCanOnlyBeChangedByTheOwner()
{
var ch = await s.CreateAsync();
await s.JoinAsync(ch, ch.Member);
var patch = () => JsonBody(new { seats = 4, members_can_upload = false });
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Member, patch())), 403, "forbidden");
using var ok = await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, patch()));
var settings = (await ReadJsonAsync(ok)).GetProperty("settings");
Assert.Equal(4, settings.GetProperty("seats").GetInt32());
Assert.False(settings.GetProperty("members_can_upload").GetBoolean());
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "forbidden");
await AssertErrorAsync(
await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { seats = 0 }))),
400, "bad_request");
}
[Fact]
public async Task DeleteItemLeavesATombstone()
{
var ch = await s.CreateAsync();
string item = NewId();
await s.AppendAsync(ch, ch.Owner, Small(item));
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner);
var tombstone = ParseOne(body);
Assert.Equal(LogKind.Tombstone, tombstone.Kind);
Assert.Equal((byte)TombstoneReason.Deleted, tombstone.Body[16]);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner)), 404, "not_found");
}
[Fact]
public async Task GatedItemIsAnnouncedAndDownloadedWithACountedLimit()
{
var ch = await s.CreateAsync();
string item = NewId();
var frames = new[]
{
Frame(item, 0, GatedEnc, EncPayload, 1),
Frame(item, 1, GatedEnc, EncPayload, 2),
Frame(item, 2, GatedFinalEnc, EncPayload, 3),
};
foreach (var f in frames) Assert.True((await s.AppendAsync(ch, ch.Owner, f, maxDownloads: "1")).IsSuccessStatusCode);
// 日志里只有一条 announce
var (_, logBody) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner);
Assert.Equal(LogKind.Announce, ParseOne(logBody).Kind);
// 第一次下载拿到全部三帧,按顺序
string path = $"/api/v1/channels/{ch.Id}/items/{item}/frames";
var (resp, body) = await s.GetBytesAsync(path, ch.Owner);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal(frames.SelectMany(f => f).ToArray(), body);
// 上限为 1:第二次从头下载被拒绝,续传(from>0)在宽限期内仍可用
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, path, ch.Owner)), 410, "gone");
var (_, resume) = await s.GetBytesAsync(path + "?from=2", ch.Owner);
Assert.Equal(frames[2], resume);
}
[Fact]
public async Task DeleteChannelRemovesIt()
{
var ch = await s.CreateAsync();
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden");
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
}
[Fact]
public async Task ParallelClientsGetUniqueSeqsOverHttp()
{
var ch = await s.CreateAsync();
var seqs = await Task.WhenAll(Enumerable.Range(0, 40).Select(async _ =>
{
using var r = await s.AppendAsync(ch, ch.Owner, Small(NewId()));
Assert.Equal(HttpStatusCode.Created, r.StatusCode);
return (await ReadJsonAsync(r)).GetProperty("seq").GetInt64();
}));
Assert.Equal(Enumerable.Range(1, 40).Select(i => (long)i), seqs.Order());
}
}
public class RateLimitTests
{
[Fact]
public async Task ExceedingTheLimitReturns429WithTheStableErrorBody()
{
await using var server = await StartAsync(new() { ["WebDrop:RequestsPerMinute"] = "5" });
var codes = new List<int>();
for (int i = 0; i < 8; i++)
{
using var r = await server.Http.GetAsync("/api/v1/info");
codes.Add((int)r.StatusCode);
if (r.StatusCode == HttpStatusCode.TooManyRequests)
Assert.Equal("rate_limited", (await ReadJsonAsync(r)).GetProperty("error").GetString());
}
Assert.Equal(5, codes.Count(c => c == 200));
Assert.Equal(3, codes.Count(c => c == 429));
}
[Fact]
public async Task ChannelCreationHasItsOwnStricterLimit()
{
await using var server = await StartAsync(new() { ["WebDrop:ChannelCreatesPerMinute"] = "2" });
var statuses = new List<int>();
for (int i = 0; i < 4; i++)
{
var ch = new Chan(NewId(), ChannelMode.Raw);
using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
using var r = await server.Http.SendAsync(req);
statuses.Add((int)r.StatusCode);
}
Assert.Equal([201, 201, 429, 429], statuses);
}
private static async Task<ServerFixture> StartAsync(Dictionary<string, string> overrides)
{
var f = ServerFixture.WithSettings(overrides);
await f.InitializeAsync();
return f;
}
}
public class ErrorResponseHeaderTests(ServerFixture s) : IClassFixture<ServerFixture>
{
[Fact]
public async Task ErrorResponsesStillCarrySecurityHeaders()
{
using var resp = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32])));
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single());
}
}
public class CorsTests
{
private const string Origin = "https://app.example.test";
private static async Task<ServerFixture> StartAsync()
{
var f = ServerFixture.WithSettings(new() { ["WebDrop:CorsOrigins:0"] = Origin });
await f.InitializeAsync();
return f;
}
[Fact]
public async Task PreflightAllowsTheProtocolHeadersFromTheConfiguredOrigin()
{
await using var server = await StartAsync();
using var req = new HttpRequestMessage(HttpMethod.Options, $"/api/v1/channels/{NewId()}/frames");
req.Headers.Add("Origin", Origin);
req.Headers.Add("Access-Control-Request-Method", "POST");
req.Headers.Add("Access-Control-Request-Headers", "authorization,x-wd-role,x-wd-endpoint,x-wd-max-downloads,x-wd-cap,content-type");
using var resp = await server.Http.SendAsync(req);
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single());
string allowed = resp.Headers.GetValues("Access-Control-Allow-Headers").Single().ToLowerInvariant();
Assert.Contains("x-wd-role", allowed);
Assert.Contains("authorization", allowed);
Assert.Contains("x-wd-cap", allowed); // 创建频道要带验证码 token
}
[Fact]
public async Task ErrorResponsesAreReadableFromTheConfiguredOrigin()
{
await using var server = await StartAsync();
using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32]));
req.Headers.Add("Origin", Origin);
using var resp = await server.Http.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
// 浏览器端要能读到 4xx 的错误体,响应必须带 CORS 头
Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single());
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
}
[Fact]
public async Task OtherOriginsGetNoCorsHeaders()
{
await using var server = await StartAsync();
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/info");
req.Headers.Add("Origin", "https://evil.example.test");
using var resp = await server.Http.SendAsync(req);
Assert.False(resp.Headers.Contains("Access-Control-Allow-Origin"));
}
[Fact]
public async Task LastSeqHeaderIsExposedToBrowsers()
{
await using var server = await StartAsync();
var ch = await server.CreateAsync();
using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", ch.Owner);
req.Headers.Add("Origin", Origin);
using var resp = await server.Http.SendAsync(req);
Assert.Contains("X-WD-Last-Seq", resp.Headers.GetValues("Access-Control-Expose-Headers").Single());
}
}
@@ -0,0 +1,199 @@
using System.Buffers.Binary;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebDrop.Api;
using WebDrop.Core;
namespace WebDrop.Api.Tests;
/// <summary>在真实 Kestrel 上启动服务(随机端口、临时数据目录),测试走真实的 HTTP / WebSocket / SSE。</summary>
public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N"));
private readonly Dictionary<string, string> _overrides;
public WebApplication App { get; private set; } = null!;
public HttpClient Http { get; private set; } = null!;
public Uri BaseUri { get; private set; } = null!;
public ServerFixture() : this([]) { }
private ServerFixture(Dictionary<string, string> overrides) => _overrides = overrides;
/// <summary>xunit 要求 class fixture 只有一个公开构造函数,带配置覆盖的版本走工厂方法。</summary>
public static ServerFixture WithSettings(Dictionary<string, string> overrides) => new(overrides);
public async Task InitializeAsync()
{
App = WebDropApp.Build([], b =>
{
b.Configuration["WebDrop:DataDir"] = _dir;
b.Configuration["WebDrop:RequestsPerMinute"] = "1000000";
b.Configuration["WebDrop:ChannelCreatesPerMinute"] = "1000000";
foreach (var (key, value) in _overrides) b.Configuration[key] = value;
b.Configuration["urls"] = "http://127.0.0.1:0";
b.Logging.ClearProviders();
});
await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseUri = new Uri(address);
Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) };
}
public async Task DisposeAsync()
{
Http.Dispose();
await App.StopAsync();
await App.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
}
ValueTask IAsyncDisposable.DisposeAsync() => new(DisposeAsync());
// ───── 客户端侧的凭据与请求构造(模拟浏览器从 K / A 派生出的令牌) ─────
public sealed class Chan(string id, ChannelMode mode)
{
public string Id { get; } = id;
public ChannelMode Mode { get; } = mode;
public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] WriteToken { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] OwnerSecret { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] MemberSecret { get; } = RandomNumberGenerator.GetBytes(32);
public Who Owner => new("owner", OwnerToken, OwnerSecret);
public Who Member => new("write", WriteToken, MemberSecret);
public Who Reader => new("read", ReadToken, MemberSecret);
}
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret);
public static string B64(byte[] bytes) => Codec.Encode(bytes);
public static string NewId() => B64(RandomNumberGenerator.GetBytes(16));
public HttpRequestMessage Req(HttpMethod method, string path, Who who, HttpContent? content = null)
{
var req = new HttpRequestMessage(method, path) { Content = content };
req.Headers.Add("X-WD-Role", who.Role);
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
return req;
}
public async Task<Chan> CreateAsync(ChannelMode mode = ChannelMode.Enc, object? settings = null)
{
var (ch, resp) = await TryCreateAsync(mode, settings);
using (resp) Assert.Equal(System.Net.HttpStatusCode.Created, resp.StatusCode);
return ch;
}
/// <summary>创建频道并返回原始响应,用于测试验证码等失败路径。</summary>
public async Task<(Chan Chan, HttpResponseMessage Resp)> TryCreateAsync(
ChannelMode mode = ChannelMode.Enc, object? settings = null, string? capToken = null)
{
var ch = new Chan(NewId(), mode);
var body = new Dictionary<string, object?>
{
["id"] = ch.Id,
["mode"] = mode == ChannelMode.Enc ? "enc" : "raw",
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)),
};
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
if (settings is not null) body["settings"] = settings;
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
return (ch, await Http.SendAsync(req));
}
public static StringContent JsonBody(object value) =>
new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json");
public static ByteArrayContent Bytes(byte[] bytes)
{
var c = new ByteArrayContent(bytes);
c.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
return c;
}
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
public async Task<HttpResponseMessage> AppendAsync(Chan ch, Who who, byte[] frame, string? maxDownloads = null)
{
var req = Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", who, Bytes(frame));
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
return await Http.SendAsync(req);
}
public async Task<(HttpResponseMessage Resp, byte[] Body)> GetBytesAsync(string path, Who who)
{
var resp = await Http.SendAsync(Req(HttpMethod.Get, path, who));
return (resp, await resp.Content.ReadAsByteArrayAsync());
}
public static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage resp) =>
JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
public static async Task AssertErrorAsync(HttpResponseMessage resp, int status, string code)
{
Assert.Equal(status, (int)resp.StatusCode);
Assert.Equal(code, (await ReadJsonAsync(resp)).GetProperty("error").GetString());
}
// ───── 帧与日志的线格式 ─────
public const int EncPayload = 1024 + 16;
public const byte FinalEnc = 1 | 8;
public const byte GatedEnc = 4 | 8;
public const byte GatedFinalEnc = 4 | 8 | 1;
public static byte[] Frame(string itemId, uint index, byte flags, int payloadLength, byte fill = 0x42)
{
Codec.TryDecodeExact(itemId, 16, out var item);
var bytes = new byte[26 + payloadLength];
bytes[0] = 1;
bytes[1] = flags;
item.CopyTo(bytes, 2);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength);
bytes.AsSpan(26).Fill(fill);
return bytes;
}
public sealed record Rec(long Seq, LogKind Kind, byte[] Body);
/// <summary>解析 log 接口返回的记录拼接:seq(8) ‖ kind(1) ‖ body。frame 的 body 自带 payload_len,其余定长。</summary>
public static List<Rec> ParseLog(byte[] data)
{
var list = new List<Rec>();
int p = 0;
while (p < data.Length)
{
long seq = BinaryPrimitives.ReadInt64BigEndian(data.AsSpan(p));
var kind = (LogKind)data[p + 8];
int bodyLen = kind switch
{
LogKind.Frame => 26 + (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(p + 9 + 22)),
LogKind.Tombstone => 17,
LogKind.Announce => 25,
_ => throw new InvalidOperationException("未知记录类型"),
};
list.Add(new Rec(seq, kind, data[(p + 9)..(p + 9 + bodyLen)]));
p += 9 + bodyLen;
}
return list;
}
public static Rec ParseOne(byte[] data) => Assert.Single(ParseLog(data));
}
@@ -0,0 +1,139 @@
using System.Net;
using System.Security.Cryptography;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixture>
{
private sealed class ShareClient(string id)
{
public string Id { get; } = id;
public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] OwnerEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] VisitorEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
public byte[] OtherEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
public Who Owner => new("owner", OwnerToken, OwnerEndpoint);
public Who CreatorReader => new("read", ReadToken, OwnerEndpoint);
public Who Visitor => new("read", ReadToken, VisitorEndpoint);
public Who OtherVisitor => new("read", ReadToken, OtherEndpoint);
}
[Fact]
public async Task DraftSealsIntoReadOnlyShareWithVisitorAndDownloadLimits()
{
var share = new ShareClient(NewId());
using (var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
{
id = share.Id,
read_hash = B64(TokenHash.Compute(share.ReadToken)),
owner_hash = B64(TokenHash.Compute(share.OwnerToken)),
settings = new { ttl_seconds = 3600, max_visitors = 1, file_max_downloads = 1 },
})))
using (var created = await server.Http.SendAsync(req))
{
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("draft", (await ReadJsonAsync(created)).GetProperty("state").GetString());
}
using (var draftRead = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.Owner)))
Assert.Equal(HttpStatusCode.OK, draftRead.StatusCode);
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get,
$"/api/v1/shares/{share.Id}", share.CreatorReader)), 404, "not_found");
string manifestId = NewId();
byte[] manifest = Frame(manifestId, 0, FinalEnc, EncPayload, 0x11);
using (var putManifest = await server.Http.SendAsync(server.Req(HttpMethod.Post,
$"/api/v1/shares/{share.Id}/frames", share.Owner, Bytes(manifest))))
Assert.Equal(HttpStatusCode.Created, putManifest.StatusCode);
string fileId = NewId();
byte[] first = Frame(fileId, 0, GatedEnc, EncPayload, 0x21);
byte[] last = Frame(fileId, 1, GatedFinalEnc, EncPayload, 0x22);
using (var putFirst = await SendFrame(share, share.Owner, first, "1"))
Assert.Equal(HttpStatusCode.Created, putFirst.StatusCode);
using (var putLast = await SendFrame(share, share.Owner, last))
Assert.Equal(HttpStatusCode.Created, putLast.StatusCode);
using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post,
$"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId }))))
{
Assert.Equal(HttpStatusCode.OK, finish.StatusCode);
var info = await ReadJsonAsync(finish);
Assert.Equal("sealed", info.GetProperty("state").GetString());
Assert.Equal(manifestId, info.GetProperty("manifest_item_id").GetString());
Assert.Single(info.GetProperty("items").EnumerateArray()); // manifest is not a downloadable file
}
await AssertErrorAsync(await SendFrame(share, share.Owner, Frame(NewId(), 0, FinalEnc, EncPayload)), 409, "sealed");
using (var invalidRole = await server.Http.SendAsync(server.Req(HttpMethod.Get,
$"/api/v1/shares/{share.Id}", new Who("write", share.ReadToken, share.OtherEndpoint))))
await AssertErrorAsync(invalidRole, 400, "bad_request");
var (manifestResponse, manifestBody) = await server.GetBytesAsync(
$"/api/v1/shares/{share.Id}/manifest", share.CreatorReader);
using (manifestResponse) Assert.Equal(manifest, manifestBody);
var (visitorManifestResponse, visitorManifest) = await server.GetBytesAsync(
$"/api/v1/shares/{share.Id}/manifest", share.Visitor);
using (visitorManifestResponse) Assert.Equal(manifest, visitorManifest);
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get,
$"/api/v1/shares/{share.Id}/manifest", share.OtherVisitor)), 403, "visitors_full");
string downloadPath = $"/api/v1/shares/{share.Id}/items/{fileId}/frames";
var (downloadResponse, downloaded) = await server.GetBytesAsync(downloadPath, share.Visitor);
using (downloadResponse) Assert.Equal(first.Concat(last), downloaded);
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, downloadPath, share.Visitor)), 410, "gone");
var (resumeResponse, resumed) = await server.GetBytesAsync(downloadPath + "?from=1", share.Visitor);
using (resumeResponse) Assert.Equal(last, resumed);
using var finalInfo = await server.Http.SendAsync(server.Req(HttpMethod.Get,
$"/api/v1/shares/{share.Id}", share.CreatorReader));
var file = (await ReadJsonAsync(finalInfo)).GetProperty("items").EnumerateArray().Single();
Assert.Equal(0, file.GetProperty("downloads_left").GetInt32());
}
[Fact]
public async Task ShareAndChannelIdsShareOneUniqueNamespace()
{
var share = new ShareClient(NewId());
using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode);
byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32);
var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner,
JsonBody(new { id = share.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
await AssertErrorAsync(collision, 409, "conflict");
string channelId = NewId();
byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32);
var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner,
JsonBody(new { id = channelId, mode = "raw", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
Assert.Equal(HttpStatusCode.Created, channel.StatusCode);
using var shareCollision = await CreateShare(new ShareClient(channelId));
await AssertErrorAsync(shareCollision, 409, "conflict");
}
private async Task<HttpResponseMessage> CreateShare(ShareClient share)
{
using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
{
id = share.Id,
read_hash = B64(TokenHash.Compute(share.ReadToken)),
owner_hash = B64(TokenHash.Compute(share.OwnerToken)),
}));
return await server.Http.SendAsync(req);
}
private async Task<HttpResponseMessage> SendFrame(ShareClient share, Who who, byte[] bytes, string? maxDownloads = null)
{
using var req = server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/frames", who, Bytes(bytes));
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
return await server.Http.SendAsync(req);
}
}
@@ -0,0 +1,242 @@
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill);
private async Task<ClientWebSocket> ConnectWs(Chan ch, Who who, long from = 0)
{
var ws = new ClientWebSocket();
ws.Options.AddSubProtocol("wd.v1");
ws.Options.AddSubProtocol($"wd.role.{who.Role}");
if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}");
ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}");
var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri;
using var cts = new CancellationTokenSource(Timeout);
await ws.ConnectAsync(uri, cts.Token);
return ws;
}
private static async Task<(WebSocketMessageType Type, byte[] Data)> Receive(ClientWebSocket ws)
{
using var cts = new CancellationTokenSource(Timeout);
var buffer = new byte[2 * 1024 * 1024];
int total = 0;
while (true)
{
var r = await ws.ReceiveAsync(buffer.AsMemory(total), cts.Token);
if (r.MessageType == WebSocketMessageType.Close) return (WebSocketMessageType.Close, []);
total += r.Count;
if (r.EndOfMessage) return (r.MessageType, buffer[..total]);
}
}
[Fact]
public async Task WebSocketReplaysHistoryThenPushesNewFrames()
{
var ch = await s.CreateAsync();
string a = NewId(), b = NewId();
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
using var ws = await ConnectWs(ch, ch.Owner);
Assert.Equal("wd.v1", ws.SubProtocol);
var (type, data) = await Receive(ws);
Assert.Equal(WebSocketMessageType.Binary, type);
var first = ParseOne(data);
Assert.Equal(1, first.Seq);
Assert.Equal(Small(a, 1), first.Body);
// 连接建立之后追加的帧会被实时推送
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
var (_, pushed) = await Receive(ws);
Assert.Equal(2, ParseOne(pushed).Seq);
Assert.Equal(Small(b, 2), ParseOne(pushed).Body);
}
[Fact]
public async Task WebSocketResumesFromTheRequestedSeq()
{
var ch = await s.CreateAsync();
for (int i = 0; i < 4; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i));
using var ws = await ConnectWs(ch, ch.Owner, from: 3);
Assert.Equal(3, ParseOne((await Receive(ws)).Data).Seq);
Assert.Equal(4, ParseOne((await Receive(ws)).Data).Seq);
}
[Fact]
public async Task WebSocketDeliversUploadsFromAnotherMember()
{
var ch = await s.CreateAsync();
await s.JoinAsync(ch, ch.Member);
using var owner = await ConnectWs(ch, ch.Owner);
using var member = await ConnectWs(ch, ch.Member);
string item = NewId();
using var put = await s.AppendAsync(ch, ch.Member, Small(item, 9));
Assert.Equal(HttpStatusCode.Created, put.StatusCode);
foreach (var ws in new[] { owner, member })
{
var (_, data) = await Receive(ws);
Assert.Equal(Small(item, 9), ParseOne(data).Body);
}
}
[Fact]
public async Task WebSocketUpgradeIsRefusedWithoutValidCredentials()
{
var ch = await s.CreateAsync();
// 令牌错误:在升级之前就以 HTTP 401 拒绝,而不是先建立连接再断开
var bad = new Who("owner", new byte[32], ch.OwnerSecret);
var ex = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, bad));
Assert.Contains("401", ex.Message);
// 未入席
var stranger = new Who("write", ch.WriteToken, new byte[32]);
var ex2 = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, stranger));
Assert.Contains("403", ex2.Message);
}
[Fact]
public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
{
var ch = await s.CreateAsync();
var joined = await s.JoinAsync(ch, ch.Member);
string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!;
using var member = await ConnectWs(ch, ch.Member);
// 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
bool closed = false;
for (int i = 0; i < 5 && !closed; i++)
{
var (type, data) = await Receive(member);
if (type == WebSocketMessageType.Close)
{
closed = true;
Assert.Equal(WebSocketCloseStatus.PolicyViolation, member.CloseStatus);
Assert.Equal("not_joined", member.CloseStatusDescription);
}
else if (type == WebSocketMessageType.Text)
{
Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
}
}
Assert.True(closed);
}
[Fact]
public async Task WebSocketEndsWhenTheChannelIsDeleted()
{
var ch = await s.CreateAsync();
using var ws = await ConnectWs(ch, ch.Owner);
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var (type, _) = await Receive(ws);
Assert.Equal(WebSocketMessageType.Close, type);
Assert.Equal("not_found", ws.CloseStatusDescription);
}
[Fact]
public async Task WebSocketClientCanCloseCleanly()
{
var ch = await s.CreateAsync();
using var ws = await ConnectWs(ch, ch.Owner);
using var cts = new CancellationTokenSource(Timeout);
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
Assert.Equal(WebSocketState.Closed, ws.State);
}
// ───── SSE ─────
private sealed record SseEvent(string Event, string? Id, string Data);
private async IAsyncEnumerable<SseEvent> ReadSse(string path, Who who, Dictionary<string, string>? extraHeaders = null)
{
using var req = s.Req(HttpMethod.Get, path, who);
foreach (var (k, v) in extraHeaders ?? []) req.Headers.Add(k, v);
using var cts = new CancellationTokenSource(Timeout);
using var resp = await s.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("text/event-stream", resp.Content.Headers.ContentType!.MediaType);
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
using var reader = new StreamReader(stream, Encoding.UTF8);
string ev = "message", data = "";
string? id = null;
while (await reader.ReadLineAsync(cts.Token) is { } line)
{
if (line.Length == 0)
{
if (data.Length > 0) yield return new SseEvent(ev, id, data);
ev = "message"; data = ""; id = null;
}
else if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("id: ")) id = line[4..];
else if (line.StartsWith("data: ")) data = line[6..];
}
}
private static byte[] DecodeB64(string text) => System.Buffers.Text.Base64Url.DecodeFromChars(text);
[Fact]
public async Task SseStreamsHistoryThenNewFrames()
{
var ch = await s.CreateAsync();
string a = NewId(), b = NewId();
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner).GetAsyncEnumerator();
Assert.True(await events.MoveNextAsync());
Assert.Equal("log", events.Current.Event);
Assert.Equal("1", events.Current.Id);
Assert.Equal(Small(a, 1), ParseOne(DecodeB64(events.Current.Data)).Body);
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
Assert.True(await events.MoveNextAsync());
Assert.Equal("2", events.Current.Id);
Assert.Equal(Small(b, 2), ParseOne(DecodeB64(events.Current.Data)).Body);
}
[Fact]
public async Task SseResumesFromLastEventId()
{
var ch = await s.CreateAsync();
for (int i = 0; i < 3; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i));
await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner,
new() { ["Last-Event-ID"] = "2" }).GetAsyncEnumerator();
Assert.True(await events.MoveNextAsync());
Assert.Equal("3", events.Current.Id); // 断线前处理到 2,续传从 3 开始
}
[Fact]
public async Task SseRefusesBadCredentialsWithAJsonError()
{
var ch = await s.CreateAsync();
var bad = new Who("owner", new byte[32], ch.OwnerSecret);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/events", bad)), 401, "unauthorized");
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebDrop.Api\WebDrop.Api.csproj" />
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
</ItemGroup>
</Project>