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

279 lines
11 KiB
C#

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)}");
string account = who.AccountToken ?? (who.Role == "owner" ? s.OwnerAccountToken : s.MemberAccountToken);
ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(account))}");
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 WebSocketNotifiesMemberChangesAndClosesWhenRevoked()
{
var ch = await s.CreateAsync();
await s.JoinAsync(ch, ch.Member);
using var member = await ConnectWs(ch, ch.Member);
// 所有者撤销成员:订阅者先收到 members 控制消息,随后被以 not_joined 关闭
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", 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("members", 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");
}
[Fact]
public async Task MembersReportAccountPresenceFromActiveSubscriptions()
{
var ch = await s.CreateAsync();
async Task<(bool Online, int Devices, int OnlineDevices)> Owner()
{
using var response = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var owner = doc.RootElement.EnumerateArray().First(m => m.GetProperty("role").GetString() == "owner");
return (owner.GetProperty("online").GetBoolean(), owner.GetProperty("devices").GetInt32(), owner.GetProperty("online_devices").GetInt32());
}
async Task<(bool Online, int Devices, int OnlineDevices)> Eventually(Func<(bool Online, int Devices, int OnlineDevices), bool> done)
{
for (int i = 0; i < 50; i++)
{
var state = await Owner();
if (done(state)) return state;
await Task.Delay(100);
}
return await Owner();
}
Assert.False((await Owner()).Online);
using var ws = await ConnectWs(ch, ch.Owner);
var online = await Eventually(state => state.Online);
Assert.True(online.Online);
Assert.Equal(1, online.OnlineDevices);
Assert.True(online.Devices >= 1);
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
var offline = await Eventually(state => !state.Online);
Assert.False(offline.Online);
Assert.Equal(0, offline.OnlineDevices);
}
}