feat(api)!: fold devices into users and report member presence

Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.

- rename seats -> max_members, seats_used -> members_used,
  seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
  stream control message -> `members`, and POST /channels/{id}/seats ->
  POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
  endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
  is optional for logged-in accounts, no seat row is written for them,
  and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
  online_devices per account; presence is tracked in memory from active
  WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
justme committed 2026-10-14 22:18:59 +08:00
1 parent 0fd2a269d9
commit d946fc5e8a
35 files changed
+517 -490

No files matched your search

@@ -18,7 +18,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
[Fact]
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
{
var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
var ch = await server.CreateAsync(settings: new { name = "Key package space", max_members = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username);
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
@@ -31,7 +31,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, token))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
{
@@ -90,7 +90,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", max_members = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
string channelId;
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
@@ -163,26 +163,27 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
var ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open", allow_guests = true });
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
{
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, token);
using var response = await server.Http.SendAsync(joined);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("members_used").GetInt32());
}
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("members_used").GetInt32());
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", third, unrelated))
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "members_full");
Assert.Equal(2, accounts.Devices(username, firstId).Count);
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required");
// 账号成员只凭账号会话鉴权,不再绑定到某个终端:同一账号的任一设备会话都能读取
using (var anyDevice = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(anyDevice)).StatusCode);
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
@@ -195,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
[Fact]
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
var request = JsonSerializer.Deserialize("{\"settings\":{\"max_members\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy);
Assert.True(request.Settings.AllowGuests);
request.Settings.Validate(new ServerLimits());
@@ -212,6 +213,32 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
await AssertErrorAsync(response, 400, "bad_request");
}
}
[Fact]
public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations()
{
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false });
var username = "member_" + Guid.NewGuid().ToString("N")[..8];
var account = await Register(username);
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
var who = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var join = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(join)).StatusCode);
// 登录账号不带终端密钥也能鉴权
using (var log = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", who, account))
{
log.Headers.Remove("X-WD-Endpoint");
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(log)).StatusCode);
}
// 账号成员不在访客清单里;成员清单里有它,设备数不小于在线设备数
using (var guests = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner))
Assert.Equal(0, (await ReadJsonAsync(await server.Http.SendAsync(guests))).GetArrayLength());
using var members = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner);
var list = await ReadJsonAsync(await server.Http.SendAsync(members));
var mine = list.EnumerateArray().Single(m => m.GetProperty("username").GetString() == username);
Assert.True(mine.GetProperty("devices").GetInt32() >= mine.GetProperty("online_devices").GetInt32());
}
private async Task<string> Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
@@ -290,32 +317,33 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { name = "Password space", seats = 8 });
var ch = await server.CreateAsync(mode, new { name = "Password space", max_members = 8 });
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest))
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest))
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" }))
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" }))
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" }))
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "channel-secret" }))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode);
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = await Register(username);
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
// 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined");
var writer = member with { Role = "write", Token = ch.WriteToken };
string channelId;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
@@ -335,7 +363,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
}
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
}
+27 -26
View File
@@ -44,8 +44,8 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
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(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());
@@ -124,13 +124,13 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
return await s.Http.SendAsync(request);
}
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 })))
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 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 guests = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests"))
Assert.Equal(HttpStatusCode.OK, guests.StatusCode);
using (var absentGuest = await OwnerRequest(HttpMethod.Delete,
$"/api/v1/channels/{ch.Id}/guests/{B64(RandomNumberGenerator.GetBytes(32))}"))
await AssertErrorAsync(absentGuest, 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");
@@ -232,33 +232,34 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
}
[Fact]
public async Task SeatsJoinListAndRelease()
public async Task JoinListGuestsAndRelease()
{
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()!;
Assert.Equal(2, j.GetProperty("members_used").GetInt32());
Assert.Equal(2, j.GetProperty("members_max").GetInt32());
Assert.False(j.TryGetProperty("endpoint_hash", out _)); // 终端不再对外暴露
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Reader.EndpointSecret));
// 第三个终端被拒绝
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.JoinAsync(ch, third), 403, "members_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());
// 访客清单只有所有者能看;账号成员不在其中
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Reader)), 403, "forbidden");
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner));
var guests = await ReadJsonAsync(list);
Assert.Equal(1, guests.GetArrayLength());
Assert.Equal(memberHash, guests[0].GetProperty("endpoint_hash").GetString());
// 成员上传加密设备名
// 设备名接口已移除
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]));
Assert.Equal(HttpStatusCode.NotFound, label.StatusCode);
// 所有者释放后,成员失去访问
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
// 所有者移除访客后,成员失去访问
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
}
@@ -269,17 +270,17 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
var ch = await s.CreateAsync();
await s.JoinAsync(ch, ch.Member);
var patch = () => JsonBody(new { seats = 4, members_can_upload = false });
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("seats").GetInt32());
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 { seats = 0 }))),
await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { max_members = 0 }))),
400, "bad_request");
}
@@ -118,7 +118,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
};
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open", allow_guests = true };
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
@@ -136,7 +136,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
}
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who));
public async Task<HttpResponseMessage> AppendAsync(
Chan ch,
@@ -116,13 +116,13 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
{
var ch = await s.CreateAsync();
var joined = await s.JoinAsync(ch, ch.Member);
string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!;
await s.JoinAsync(ch, ch.Member);
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Member.EndpointSecret));
using var member = await ConnectWs(ch, ch.Member);
// 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
bool closed = false;
@@ -137,7 +137,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
}
else if (type == WebSocketMessageType.Text)
{
Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
Assert.Equal("members", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
}
}
Assert.True(closed);
@@ -240,4 +240,39 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
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);
}
}
@@ -47,18 +47,18 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
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("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
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", seats = 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 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("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
Assert.Equal(defaults.GetProperty("max_members").GetInt32(), settings.GetProperty("max_members").GetInt32());
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
}
}
@@ -66,8 +66,8 @@ public class ChannelServiceTests : IDisposable
{
var ch = _env.Create(_ownerEp);
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
Assert.Equal(1, info.SeatsUsed);
Assert.Equal(2, info.Settings.Seats);
Assert.Equal(1, info.MembersUsed);
Assert.Equal(2, info.Settings.MaxMembers);
Assert.Equal(1, info.OldestSeq);
Assert.Equal(0, info.LastSeq);
}
@@ -99,38 +99,36 @@ public class ChannelServiceTests : IDisposable
var ch = _env.Create(_ownerEp);
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
Assert.Equal(a.EndpointHash, again.EndpointHash);
Assert.Equal(2, again.SeatsUsed);
Assert.Equal(2, again.SeatsMax);
Assert.Equal(a.JoinedAt, again.JoinedAt);
Assert.Equal(2, again.MembersUsed);
Assert.Equal(2, again.MembersMax);
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
Assert.Equal(1, _env.Events.Members); // 只有真正新增时才通知
}
[Fact]
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 2 });
var memberOne = TestEnv.Endpoint();
var memberTwo = TestEnv.Endpoint();
var ownerTwo = TestEnv.Endpoint();
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).MembersUsed);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).MembersUsed);
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).MembersUsed);
Assert.Empty(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 账号的设备不对外列出,也不计入成员人数
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob));
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), memberOne)); // 账号设备不能按设备移除,只能撤销用户
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed);
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
}
[Fact]
@@ -209,29 +207,25 @@ public class ChannelServiceTests : IDisposable
public async Task ReleasedSeatLosesAccess()
{
var ch = _env.Create(_ownerEp);
var joined = Svc.Join(ch.Id, ch.Member(_memberEp));
Svc.Join(ch.Id, ch.Member(_memberEp));
await AppendSmall(ch, ch.Member(_memberEp));
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash));
Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp);
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
}
private static byte[] Codec_Decode(string b64) =>
Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException();
[Fact]
public void OnlyTheOwnerManagesSeatsAndSettings()
public void OnlyTheOwnerManagesGuestsAndSettings()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Member(_memberEp));
AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 }));
AssertCode(ErrorCode.Forbidden, () => Svc.ListGuests(ch.Id, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { MaxMembers = 5 }));
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp));
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp));
var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp));
Assert.Equal(2, seats.Count);
Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台
}
[Fact]
@@ -243,25 +237,13 @@ public class ChannelServiceTests : IDisposable
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { MaxMembers = 3 }).Settings.MaxMembers);
Assert.Single(Svc.ListGuests(ch.Id, unseatedOwner));
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, unseatedOwner, TestEnv.Endpoint()));
Svc.DeleteChannel(ch.Id, unseatedOwner);
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
}
[Fact]
public void SeatLabelIsStoredAndBounded()
{
var ch = _env.Create(_ownerEp);
Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]);
var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single();
Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label);
AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257]));
AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), []));
}
[Fact]
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
{
@@ -269,15 +251,15 @@ public class ChannelServiceTests : IDisposable
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
_env.Clock.Advance(TimeSpan.FromMinutes(1));
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 });
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 3, TtlSeconds = 3600 });
Assert.Equal(3, after.Settings.Seats);
Assert.Equal(3, after.Settings.MaxMembers);
Assert.Equal(3600, after.Settings.TtlSeconds);
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
Assert.True(after.ExpiresAt < before.ExpiresAt);
Assert.Equal(1, _env.Events.Settings);
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 }));
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 0 }));
}
// ───── 追加:幂等、形状、权限 ─────
@@ -524,7 +506,7 @@ public class ChannelServiceTests : IDisposable
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
{
var otherEndpoint = TestEnv.Endpoint();
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 3, FileMaxDownloads = 1 });
Svc.Join(ch.Id, ch.Reader(_memberEp));
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
@@ -155,9 +155,9 @@ public class PolicyTests
[Fact]
public void SettingsPatchOnlyChangesProvidedFields()
{
var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 };
var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before);
Assert.Equal(5, after.Seats);
var before = new ChannelSettings { MaxMembers = 2, TtlSeconds = 100 };
var after = new ChannelSettingsPatch { MaxMembers = 5 }.ApplyTo(before);
Assert.Equal(5, after.MaxMembers);
Assert.Equal(100, after.TtlSeconds);
}
@@ -169,7 +169,7 @@ public class PolicyTests
[InlineData(2, 86400, 0)]
public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems)
{
var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems };
var s = new ChannelSettings { MaxMembers = seats, TtlSeconds = ttl, MaxItems = maxItems };
var ex = Assert.Throws<WebDropException>(() => s.Validate(new ServerLimits()));
Assert.Equal(ErrorCode.BadRequest, ex.Code);
}
+2 -2
View File
@@ -14,10 +14,10 @@ public sealed class FakeClock : IClock
public sealed class RecordingEvents : IChannelEvents
{
public List<(Id16 Channel, long Seq)> Advances { get; } = [];
public int Settings, Seats, Deleted;
public int Settings, Members, Deleted;
public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); }
public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings);
public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats);
public void MembersChanged(Id16 channelId) => Interlocked.Increment(ref Members);
public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted);
}