Files
justme c858bd2a09 feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the
/guests endpoints and the guest seat table are gone, and requests without an
account session get 401 account_required. Share links are unaffected.
Contract version is now 8.0.0.
2026-10-16 06:46:15 +08:00

475 lines
21 KiB
C#

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<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.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("members_used").GetInt32());
Assert.Equal(2, info.GetProperty("settings").GetProperty("max_members").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",
["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<string, object?>
{
["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<HttpResponseMessage> 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 { max_members = 3 })))
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
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 JoinMembersAndRevoke()
{
var ch = await s.CreateAsync();
using var joined = await s.JoinAsync(ch, ch.Reader);
var j = await ReadJsonAsync(joined);
Assert.Equal(2, j.GetProperty("members_used").GetInt32());
Assert.Equal(2, j.GetProperty("members_max").GetInt32());
Assert.False(j.TryGetProperty("endpoint_hash", out _)); // 终端不再对外暴露
// 第三个账号被拒绝:成员已满
var thirdAccount = s.NewAccountToken("third_" + Guid.NewGuid().ToString("N")[..8]);
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32), thirdAccount);
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "members_full");
// 访客接口已移除
using var guests = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner));
Assert.Equal(HttpStatusCode.NotFound, guests.StatusCode);
// 没有账号会话的请求被拒绝
using var anonymous = s.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", ch.Reader);
anonymous.Headers.Remove("X-WD-Account");
await AssertErrorAsync(await s.Http.SendAsync(anonymous), 401, "account_required");
// 所有者撤销成员后,成员失去访问
using var revoke = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, revoke.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 { max_members = 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("max_members").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 { max_members = 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<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", 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<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());
}
}