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.
This commit is contained in:
justme committed 2026-10-16 06:46:15 +08:00
1 parent b1ac11e1d7
commit c858bd2a09
31 files changed
+194 -358

No files matched your search

@@ -163,7 +163,7 @@ 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", max_members = 2, join_policy = "open", allow_guests = true });
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open" });
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) })
@@ -198,7 +198,6 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
{
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());
var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!;
Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits());
@@ -214,9 +213,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
}
}
[Fact]
public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations()
public async Task AccountMembersNeedNoTerminalKey()
{
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false });
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite" });
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);
@@ -230,9 +229,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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);
@@ -321,17 +318,17 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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}/join", guest))
var joiner = await Register("joiner_" + Guid.NewGuid().ToString("N")[..8]);
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner))
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" }))
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest, body: new { password = "channel-secret" }))
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
// 没有账号会话的请求一律被拒绝,哪怕链接令牌和密码都对
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, 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 })))
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = await Register(username);
@@ -341,9 +338,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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}/join", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
// 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记
// 只有链接令牌与终端密钥、没有账号会话的请求没有任何身份
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined");
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 401, "account_required");
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))
+14 -21
View File
@@ -126,11 +126,6 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
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 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,7 +227,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
}
[Fact]
public async Task JoinListGuestsAndRelease()
public async Task JoinMembersAndRevoke()
{
var ch = await s.CreateAsync();
@@ -241,26 +236,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
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));
// 第三个账号被拒绝:成员已满
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");
// 访客清单只有所有者能看;账号成员不在其中
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 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 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.NotFound, label.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 release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
// 所有者撤销成员后,成员失去访问
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");
}
@@ -23,6 +23,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public HttpClient Http { get; private set; } = null!;
public Uri BaseUri { get; private set; } = null!;
public string OwnerAccountToken { get; private set; } = null!;
/// <summary>非所有者的默认账号。频道只接受登录账号,所以测试里的「成员」「读者」都是这个账号。</summary>
public string MemberAccountToken { get; private set; } = null!;
public ServerFixture() : this([]) { }
@@ -47,12 +49,20 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseUri = new Uri(address);
Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) };
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
OwnerAccountToken = NewAccountToken("fixture_owner");
MemberAccountToken = NewAccountToken("fixture_member");
}
/// <summary>注册一个账号并返回它的会话令牌。</summary>
public string NewAccountToken(string username)
{
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
OwnerAccountToken = accounts.Authenticate("fixture_owner", verifier)!;
accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
return accounts.Authenticate(username, verifier)!;
}
public async Task DisposeAsync()
@@ -82,7 +92,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public Who Reader => new("read", ReadToken, MemberSecret);
}
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret);
/// <summary>AccountToken 为空时:所有者用 OwnerAccountToken,其余用 MemberAccountToken。</summary>
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret, string? AccountToken = null);
public static string B64(byte[] bytes) => Codec.Encode(bytes);
public static string NewId() => B64(RandomNumberGenerator.GetBytes(16));
@@ -93,7 +104,10 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
req.Headers.Add("X-WD-Role", who.Role);
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
// 频道和空间只接受登录账号;便捷分享(/shares)仍由未登录终端使用
if (path.StartsWith("/api/v1/channels", StringComparison.Ordinal) || path.StartsWith("/api/v1/spaces", StringComparison.Ordinal))
req.Headers.Add("X-WD-Account", who.AccountToken ?? (who.Role == "owner" ? OwnerAccountToken : MemberAccountToken));
else if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
return req;
}
@@ -118,7 +132,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", max_members = 2, join_policy = "open", allow_guests = true };
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open" };
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);
@@ -20,7 +20,8 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
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)}");
if (who.Role == "owner") ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(s.OwnerAccountToken))}");
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);
@@ -106,23 +107,22 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
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 WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
public async Task WebSocketNotifiesMemberChangesAndClosesWhenRevoked()
{
var ch = await s.CreateAsync();
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}/guests/{memberHash}", ch.Owner));
// 所有者撤销成员:订阅者先收到 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;
@@ -45,7 +45,7 @@ public class ChannelServiceTests : IDisposable
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner"));
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, "owner"));
}
[Fact]
@@ -56,9 +56,9 @@ public class ChannelServiceTests : IDisposable
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, "owner"));
}
[Fact]
@@ -78,10 +78,10 @@ public class ChannelServiceTests : IDisposable
public void WrongTokenIsUnauthorized()
{
var ch = _env.Create(_ownerEp);
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp)));
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp)));
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp, "owner")));
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp, "owner")));
// 用读令牌冒充所有者
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp)));
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp, "owner")));
}
[Fact]
@@ -119,13 +119,10 @@ public class ChannelServiceTests : IDisposable
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.MembersFull, () => Svc.Join(ch.Id, bob));
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)).MembersUsed);
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
@@ -151,7 +148,7 @@ public class ChannelServiceTests : IDisposable
Settings = new ChannelSettings { Name = name },
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
};
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, "owner"));
}
[Fact]
@@ -204,28 +201,36 @@ public class ChannelServiceTests : IDisposable
}
[Fact]
public async Task ReleasedSeatLosesAccess()
public async Task RevokedMemberLosesAccess()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Member(_memberEp));
await AppendSmall(ch, ch.Member(_memberEp));
Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp);
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), ch.AccountOf(_memberEp));
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.Join(ch.Id, ch.Member(_memberEp))); // 被移除的账号不能凭链接再进来
}
[Fact]
public void OnlyTheOwnerManagesGuestsAndSettings()
public async Task ChannelsRequireALoggedInAccount()
{
var ch = _env.Create(_ownerEp);
var anonymous = new Credentials(Role.Read, ch.ReadToken, _memberEp);
AssertCode(ErrorCode.AccountRequired, () => Svc.GetChannel(ch.Id, anonymous));
AssertCode(ErrorCode.AccountRequired, () => Svc.Join(ch.Id, anonymous));
await AssertCode(ErrorCode.AccountRequired, () => LogOf(ch, anonymous));
await AssertCode(ErrorCode.AccountRequired, () => AppendSmall(ch, anonymous with { ClaimedRole = Role.Write, Token = ch.WriteToken }));
}
[Fact]
public void OnlyTheOwnerManagesSettings()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Member(_memberEp));
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.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp));
Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台
}
[Fact]
@@ -238,8 +243,6 @@ 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 { 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)));
}
@@ -346,7 +349,7 @@ public class ChannelServiceTests : IDisposable
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10));
var anonymous = new Credentials(Role.Read, null, _memberEp);
var anonymous = new Credentials(Role.Read, null, _memberEp, "reader"); // raw 频道知道 id 即可读,但仍要登录
Svc.Join(raw.Id, anonymous);
Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records);
@@ -116,8 +116,8 @@ public class PolicyTests
var first = TestEnv.Endpoint(); var second = TestEnv.Endpoint();
var item = ItemBy(first) with { AuthorAccount = "member" };
var channel = Chan(new ChannelSettings { DeletePolicy = DeletePolicy.Own });
Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, second, "member")));
Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, first, "other-account")));
Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "member")));
Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "other-account")));
}
private static Channel Chan(ChannelSettings s, bool writeHash = true) => new(
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0, "owner");
@@ -129,12 +129,12 @@ public class PolicyTests
public void WritePermission()
{
var ep = TestEnv.Endpoint();
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep)));
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep)));
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep)));
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, "someone")));
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, "someone")));
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, "someone")));
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, "someone")));
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, "someone")));
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, "someone")));
}
[Theory]
@@ -146,10 +146,9 @@ public class PolicyTests
[InlineData(DeletePolicy.Anyone, Role.Read, false, true)]
public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected)
{
var me = TestEnv.Endpoint();
var item = ItemBy(isAuthor ? me : TestEnv.Endpoint());
var item = ItemBy(TestEnv.Endpoint()) with { AuthorAccount = isAuthor ? "me" : "someone-else" };
var channel = Chan(new() { DeletePolicy = policy });
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me)));
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, "me")));
}
[Fact]
+5 -3
View File
@@ -30,9 +30,11 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w
public byte[] WriteToken { get; } = write;
public byte[] OwnerToken { get; } = owner;
/// <summary>测试里用 endpoint 当作「哪一个人」:同一个 endpoint 对应同一个账号。</summary>
public string AccountOf(byte[] endpoint) => endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : "u" + Convert.ToHexString(endpoint)[..8].ToLowerInvariant();
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner");
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, AccountOf(endpoint));
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, AccountOf(endpoint));
}
public sealed class TestEnv : IDisposable
@@ -78,7 +80,7 @@ public sealed class TestEnv : IDisposable
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
OwnerHash = Codec.Encode(TokenHash.Compute(owner)),
}, ownerEndpoint, "owner");
}, "owner");
return new TestChannel(id, mode, read, write, owner, ownerEndpoint);
}
@@ -57,15 +57,15 @@ public sealed class WorkspaceStructureTests
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
}
[Fact]
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
public async Task MembersCanPlaceTheirUploadsButCannotCreateBusinessStructure()
{
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest);
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var guest = ch.Member(memberEp); env.Service.Join(ch.Id, guest);
var item = TestEnv.NewId();
await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations);
Assert.Equal(ErrorCode.AccountRequired, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code);
Assert.Equal(ErrorCode.Forbidden, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "成员分组")).Code);
}
}