using System.Net; using System.Security.Cryptography; using System.Text.Json; using WebDrop.Api; using WebDrop.Core; using static WebDrop.Api.Tests.ServerFixture; namespace WebDrop.Api.Tests; public class RestTests(ServerFixture s) : IClassFixture { 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.Equal(ApiContract.Version, json.GetProperty("contract_version").GetString()); 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 { ["id"] = ch.Id, ["mode"] = "enc", ["settings"] = new { name = "Duplicate" }, ["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"); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task CreateRequiresSpaceName(string? name) { var ch = new Chan(NewId(), ChannelMode.Raw); var body = new Dictionary { ["id"] = ch.Id, ["mode"] = "raw", ["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)), }; if (name is not null) body["settings"] = new { name }; using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body)); await AssertErrorAsync(await s.Http.SendAsync(req), 400, "space_name_required"); } [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 OwnerCapabilityCanManageWithoutOccupyingAnotherSeat() { var ch = await s.CreateAsync(); using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode); var unseatedOwner = new Who("owner", ch.OwnerToken, RandomNumberGenerator.GetBytes(32)); async Task OwnerRequest(HttpMethod method, string path, HttpContent? body = null) { using var request = s.Req(method, path, unseatedOwner, body); request.Headers.Remove("X-WD-Endpoint"); return await s.Http.SendAsync(request); } using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 }))) Assert.Equal(HttpStatusCode.OK, patch.StatusCode); using (var seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats")) Assert.Equal(HttpStatusCode.OK, seats.StatusCode); using (var absentSeat = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}")) await AssertErrorAsync(absentSeat, 404, "not_found"); using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}")) Assert.Equal(HttpStatusCode.NoContent, deleted.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 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), expectContinue: true), 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(); using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode); 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(); 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(); 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", settings = new { name = "Rate limit space" }, 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 StartAsync(Dictionary overrides) { var f = ServerFixture.WithSettings(overrides); await f.InitializeAsync(); return f; } } public class ErrorResponseHeaderTests(ServerFixture s) : IClassFixture { [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 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()); } }