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,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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user