Files
WebDrop/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs
T

170 lines
15 KiB
C#

using System.Net;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<ServerFixture>
{
[Fact]
public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault()
{
var store = server.App.Services.GetRequiredService<SqliteAccountStore>();
var revision = store.GetVault("fixture_owner")!.Value.Revision;
using var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "资料测试", avatar = "data:image/png;base64,AA==" }) };
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var response = await server.Http.SendAsync(patch);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response); Assert.Equal("资料测试", body.GetProperty("displayName").GetString());
Assert.Equal(revision, store.GetVault("fixture_owner")!.Value.Revision);
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/profile"); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var loaded = await server.Http.SendAsync(read);
Assert.Equal("资料测试", (await ReadJsonAsync(loaded)).GetProperty("displayName").GetString());
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "test", avatar = "https://tracker.test/avatar.png" }) };
bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var rejected = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
// 动图头像:必须真是 GIF(文件头 GIF8…),只改了 data: 前缀的不行
foreach (var (avatar, expected) in new[] { ("data:image/gif;base64," + Convert.ToBase64String("GIF89a"u8.ToArray()), HttpStatusCode.OK), ("data:image/gif;base64,AAAAAAAAAAAA", HttpStatusCode.BadRequest) })
{
using var gif = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "动图", avatar }) };
gif.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var gifResponse = await server.Http.SendAsync(gif); Assert.Equal(expected, gifResponse.StatusCode);
}
// 资料接口不内联头像(动图可能几 MB),只说有没有;头像单独取,带 ETag,没变时回 304
Assert.False(body.TryGetProperty("avatar", out _)); Assert.True(body.GetProperty("hasAvatar").GetBoolean());
using var avatarRead = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/users/fixture_owner/avatar"); avatarRead.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var avatarResponse = await server.Http.SendAsync(avatarRead);
Assert.Equal(HttpStatusCode.OK, avatarResponse.StatusCode); Assert.Equal("image/gif", avatarResponse.Content.Headers.ContentType?.MediaType);
var etag = avatarResponse.Headers.ETag; Assert.NotNull(etag);
using var again = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/users/fixture_owner/avatar"); again.Headers.Add("X-WD-Account", server.OwnerAccountToken); again.Headers.IfNoneMatch.Add(etag);
using var notModified = await server.Http.SendAsync(again); Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
using var noAccount = await server.Http.GetAsync("/api/v1/accounts/users/fixture_owner/avatar"); Assert.NotEqual(HttpStatusCode.OK, noAccount.StatusCode);
using var anonymous = await server.Http.GetAsync("/api/v1/accounts/profile"); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task SpaceAvatarIsOwnerSetValidatedAndReturnedWithTheSpaceInfo()
{
var ch = await server.CreateAsync();
var gif = "data:image/gif;base64," + Convert.ToBase64String("GIF89a"u8.ToArray());
async Task<HttpStatusCode> Patch(string avatar) { using var r = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { avatar })); using var res = await server.Http.SendAsync(r); return res.StatusCode; }
Assert.Equal(HttpStatusCode.OK, await Patch(gif));
using (var read = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner, null)) using (var res = await server.Http.SendAsync(read))
Assert.Equal(gif, (await ReadJsonAsync(res)).GetProperty("avatar").GetString());
Assert.Equal(HttpStatusCode.BadRequest, await Patch("https://tracker.test/a.png"));
Assert.Equal(HttpStatusCode.BadRequest, await Patch("data:image/gif;base64,AAAAAAAAAAAA")); // 不是真的 GIF
Assert.Equal(HttpStatusCode.OK, await Patch("")); // 空字符串 = 移除
using (var read = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner, null)) using (var res = await server.Http.SendAsync(read))
Assert.False((await ReadJsonAsync(res)).TryGetProperty("avatar", out _)); // 没有头像时不输出该字段
}
[Fact]
public async Task AvatarsServeTheLossyThumbByDefaultAndTheOriginalOnRequestAndAcceptGzip()
{
var gif = "data:image/gif;base64," + Convert.ToBase64String("GIF89a\u0001\u0000\u0001\u0000"u8.ToArray());
var thumb = "data:image/webp;base64," + Convert.ToBase64String("RIFFxxxxWEBP"u8.ToArray());
static byte[] Gzip(string text)
{
using var output = new MemoryStream();
using (var zip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) zip.Write(System.Text.Encoding.UTF8.GetBytes(text));
return output.ToArray();
}
// 账号头像:gzip 上传,原图 + 缩略图
using (var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile"))
{
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
patch.Content = new ByteArrayContent(Gzip(System.Text.Json.JsonSerializer.Serialize(new { displayName = "压缩", avatar = gif, avatarThumb = thumb })));
patch.Content.Headers.ContentType = new("application/json"); patch.Content.Headers.ContentEncoding.Add("gzip");
using var res = await server.Http.SendAsync(patch); Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
async Task<(string? Type, int Length)> Get(string query)
{
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/users/fixture_owner/avatar" + query); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var res = await server.Http.SendAsync(read); Assert.Equal(HttpStatusCode.OK, res.StatusCode);
return (res.Content.Headers.ContentType?.MediaType, (await res.Content.ReadAsByteArrayAsync()).Length);
}
Assert.Equal("image/webp", (await Get("")).Type); // 默认给有损的小图
Assert.Equal("image/gif", (await Get("?size=full")).Type); // 预览才给原图
// 只改名字:keepAvatar 保留头像和缩略图
using (var rename = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "只改名", keepAvatar = true }) })
{
rename.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var res = await server.Http.SendAsync(rename); Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
Assert.Equal("image/webp", (await Get("")).Type); Assert.Equal("image/gif", (await Get("?size=full")).Type);
// 缩略图必须跟着原图一起上传;不合法的缩略图被拒绝
foreach (var body in new object[] { new { displayName = "x", avatarThumb = thumb }, new { displayName = "x", avatar = gif, avatarThumb = "https://tracker.test/a.png" } })
{
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(body) }; bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var res = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
// 解压后的体积有上限:一个声称很小、解开很大的请求不能把内存吃光
using (var bomb = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile"))
{
bomb.Headers.Add("X-WD-Account", server.OwnerAccountToken);
bomb.Content = new ByteArrayContent(Gzip("{\"displayName\":\"x\",\"avatar\":\"" + new string('A', 20 * 1024 * 1024) + "\"}"));
bomb.Content.Headers.ContentType = new("application/json"); bomb.Content.Headers.ContentEncoding.Add("gzip");
using var res = await server.Http.SendAsync(bomb); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
// 空间头像:信息里是缩略图,原图走单独接口
var ch = await server.CreateAsync();
using (var set = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { avatar = gif, avatar_thumb = thumb }))) { using var res = await server.Http.SendAsync(set); Assert.Equal(HttpStatusCode.OK, res.StatusCode); }
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner, null)) { using var res = await server.Http.SendAsync(info); Assert.Equal(thumb, (await ReadJsonAsync(res)).GetProperty("avatar").GetString()); }
using (var full = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/avatar", ch.Owner, null)) { using var res = await server.Http.SendAsync(full); Assert.Equal(HttpStatusCode.OK, res.StatusCode); Assert.Equal("image/gif", res.Content.Headers.ContentType?.MediaType); }
}
[Fact]
public async Task StructureEndpointsPersistAndRejectCrossSpaceGroups()
{
var a = await server.CreateAsync(); var b = await server.CreateAsync();
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{b.Id}/groups", b.Owner, JsonBody(new { name = "另一空间" }));
using var groupResponse = await server.Http.SendAsync(createGroup); Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
var groupBody = await ReadJsonAsync(groupResponse);
var groupId = groupBody.GetProperty("group").GetProperty("id").GetString();
using var wrong = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "频道", groupId }));
using var wrongResponse = await server.Http.SendAsync(wrong); Assert.Equal(HttpStatusCode.BadRequest, wrongResponse.StatusCode);
using var valid = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "讨论" }));
using var response = await server.Http.SendAsync(valid); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var createdBody = await ReadJsonAsync(response);
Assert.Equal("讨论", createdBody.GetProperty("channel").GetProperty("name").GetString());
using var read = server.Req(HttpMethod.Get, $"/api/v1/spaces/{a.Id}/structure", a.Owner);
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
}
[Fact]
public async Task DeletingAGroupMovesItsChannelsBackToUngrouped()
{
var a = await server.CreateAsync();
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/groups", a.Owner, JsonBody(new { name = "待删分组" }));
using var groupResponse = await server.Http.SendAsync(createGroup);
var groupId = (await ReadJsonAsync(groupResponse)).GetProperty("group").GetProperty("id").GetString();
using var createChannel = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "组内频道", groupId }));
using var channelResponse = await server.Http.SendAsync(createChannel); Assert.Equal(HttpStatusCode.Created, channelResponse.StatusCode);
using var delete = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
using var deleted = await server.Http.SendAsync(delete); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
var structure = (await ReadJsonAsync(deleted)).GetProperty("structure");
Assert.DoesNotContain(structure.GetProperty("groups").EnumerateArray(), g => g.GetProperty("id").GetString() == groupId);
var moved = structure.GetProperty("channels").EnumerateArray().Single(ch => ch.GetProperty("name").GetString() == "组内频道");
Assert.True(!moved.TryGetProperty("groupId", out var remaining) || remaining.ValueKind == System.Text.Json.JsonValueKind.Null);
using var again = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
using var missing = await server.Http.SendAsync(again); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
}
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
await isolated.InitializeAsync();
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
var defaults = info.GetProperty("defaults").GetProperty("channel");
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
Assert.Equal(3, defaults.GetProperty("max_members").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
var ch = new Chan(NewId(), ChannelMode.Enc);
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", max_members = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
Assert.Equal(defaults.GetProperty("max_members").GetInt32(), settings.GetProperty("max_members").GetInt32());
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
}
}