feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
@@ -0,0 +1,109 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class ChannelCryptoTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
|
||||
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Username = "owner", Payload = "forbidden-rotation" }] }));
|
||||
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "owner", Payload = "owner-key" },
|
||||
new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "member", Payload = "unauthorized-grant" }] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerPlansAndAtomicallyCommitsNoHistoryRotationAndDefaultAccess()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Equal(channelId, plan.ChannelId);
|
||||
Assert.Equal(new[] { CryptoDomain.Chat, CryptoDomain.Files }, plan.Batches.Select(b => b.Domain).Order());
|
||||
Assert.All(plan.Batches, batch => { Assert.Equal(2, batch.Epoch); Assert.Equal(new[] { "member", "owner" }, batch.Recipients.Order(StringComparer.Ordinal)); });
|
||||
|
||||
var incomplete = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
||||
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).Skip(1).ToList();
|
||||
Assert.Throws<WebDropException>(() => env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", incomplete));
|
||||
Assert.Empty(env.Service.Invitations(space.Id, owner));
|
||||
|
||||
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
||||
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
|
||||
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
|
||||
var updatedChannel = env.Service.GetWorkspace(space.Id, owner).Channels.Single();
|
||||
Assert.Equal(2, updatedChannel.ChatEpoch); Assert.Equal(2, updatedChannel.FilesEpoch);
|
||||
|
||||
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
var received = env.Service.ReadCrypto(space.Id, member);
|
||||
Assert.Equal(2, received.Envelopes.Count);
|
||||
Assert.All(received.Envelopes, e => Assert.Equal(2, e.Epoch));
|
||||
Assert.All(received.Access, a => { Assert.True(a.ChatRead); Assert.True(a.ChatWrite); Assert.True(a.FilesRead); Assert.True(a.FilesUpload); });
|
||||
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "old" }));
|
||||
var objectId = TestEnv.NewId().ToBase64Url();
|
||||
Assert.Equal("member", env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = objectId, ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, KeyEnvelope = "current" }).AuthorAccount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryEnabledPlanGrantsExistingEpochsOnlyToTheNewRecipient()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Equal(2, plan.Batches.Count);
|
||||
Assert.All(plan.Batches, batch => { Assert.Equal(1, batch.Epoch); Assert.Equal(new[] { "member" }, batch.Recipients); });
|
||||
var packages = plan.Batches.Select(batch => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = "member", Payload = $"wrapped-{batch.Domain}" }).ToList();
|
||||
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
|
||||
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
|
||||
var state = env.Service.ReadCrypto(space.Id, member);
|
||||
Assert.Equal(2, state.Envelopes.Count);
|
||||
Assert.All(state.Envelopes, e => Assert.Equal("member", e.Username));
|
||||
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().ChatEpoch);
|
||||
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().FilesEpoch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnencryptedWorkspaceStillUsesServerHistoryCutoffsAndAccessRules()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
|
||||
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
var oldFile = TestEnv.NewId();
|
||||
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
|
||||
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
|
||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Empty(plan.Batches);
|
||||
env.Service.InviteUser(space.Id, owner, "member", "account-grant", []);
|
||||
var member = space.Member(memberEndpoint) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
Assert.Empty(env.Service.GetWorkspace(space.Id, member).Locations);
|
||||
Assert.Empty((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||
|
||||
var newMessage = TestEnv.NewId();
|
||||
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = newMessage.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
|
||||
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(newMessage, 0, FrameFlags.Final, 16), null);
|
||||
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||
}
|
||||
|
||||
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
|
||||
{
|
||||
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest
|
||||
{
|
||||
ChannelId = channelId, Domain = domain, Epoch = 1,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = domain, Epoch = 1, Username = "owner", Payload = $"owner-{domain.ToString().ToLowerInvariant()}-key" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class ChannelServiceTests : IDisposable
|
||||
var dup = new CreateChannelRequest
|
||||
{
|
||||
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
|
||||
Settings = new ChannelSettings { Name = "Duplicate" },
|
||||
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
|
||||
};
|
||||
@@ -51,9 +52,9 @@ public class ChannelServiceTests : IDisposable
|
||||
public void CreateEnforcesModeSpecificHashes()
|
||||
{
|
||||
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
|
||||
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() };
|
||||
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
|
||||
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
|
||||
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "Raw space" }, ReadHash = h(), OwnerHash = h() };
|
||||
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"));
|
||||
@@ -106,6 +107,32 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 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); // 设备仍单独登记,但不计入成员人数
|
||||
|
||||
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
||||
AssertCode(ErrorCode.SeatsFull, () => 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() }));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataOperationsRequireASeat()
|
||||
{
|
||||
@@ -114,6 +141,28 @@ public class ChannelServiceTests : IDisposable
|
||||
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CreateRequiresSpaceName(string? name)
|
||||
{
|
||||
var request = new CreateChannelRequest
|
||||
{
|
||||
Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw,
|
||||
Settings = new ChannelSettings { Name = name },
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
|
||||
};
|
||||
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTrimsSpaceName()
|
||||
{
|
||||
var channel = _env.Create(_ownerEp, settings: new ChannelSettings { Name = " Team space " });
|
||||
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
|
||||
{
|
||||
|
||||
@@ -68,6 +68,8 @@ public sealed class TestEnv : IDisposable
|
||||
var write = Random(32);
|
||||
var owner = Random(32);
|
||||
|
||||
settings ??= new ChannelSettings();
|
||||
settings.Name ??= "Test space";
|
||||
Service.CreateChannel(new CreateChannelRequest
|
||||
{
|
||||
Id = id.ToBase64Url(),
|
||||
|
||||
@@ -67,6 +67,37 @@ public class VectorTests
|
||||
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureFramingAndEcdsaMatchTheVectors()
|
||||
{
|
||||
var vector = V.GetProperty("signatures");
|
||||
static byte[] B64Url(string value) => Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4));
|
||||
var x = B64Url(vector.GetProperty("x").GetString()!);
|
||||
var y = B64Url(vector.GetProperty("y").GetString()!);
|
||||
var payload = H(vector.GetProperty("payload"));
|
||||
var kind = vector.GetProperty("kind").GetString()!;
|
||||
var signature = H(vector.GetProperty("signature"));
|
||||
Assert.Equal(H(vector.GetProperty("message")), SignatureCodec.BuildMessage(kind, payload));
|
||||
Assert.True(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
|
||||
Assert.False(SignatureCodec.VerifyP256(x, y, "chat.edit", payload, signature));
|
||||
payload[0] ^= 1;
|
||||
Assert.False(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentEventPayloadAndFrameManifestMatchTheVectors()
|
||||
{
|
||||
var vector = V.GetProperty("content_event");
|
||||
var digests = vector.GetProperty("frame_digests").EnumerateArray().Select(H).ToList();
|
||||
var manifest = SignatureCodec.FrameManifestHash(digests);
|
||||
Assert.Equal(H(vector.GetProperty("manifest_hash")), manifest);
|
||||
byte[] id(string property) => H(vector.GetProperty(property));
|
||||
Assert.Equal(H(vector.GetProperty("payload")), SignatureCodec.ContentEventPayload(
|
||||
id("space_id"), vector.GetProperty("channel_id").GetString()!, vector.GetProperty("username").GetString()!,
|
||||
vector.GetProperty("device_id").GetString()!, id("event_id"), vector.GetProperty("kind").GetString()!, CryptoDomain.Chat,
|
||||
vector.GetProperty("epoch").GetInt32(), id("item_id"), vector.GetProperty("frame_count").GetInt32(), manifest, id("target_item_id")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerTokenHashMatchesTheStoredHashes()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class WorkspaceStructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void NewSpaceHasAnAtomicInitialChannelAndChangesPersist()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
|
||||
var state = env.Service.GetWorkspace(ch.Id, owner);
|
||||
Assert.Equal("常规", Assert.Single(state.Channels).Name);
|
||||
var group = env.Service.CreateWorkspaceGroup(ch.Id, owner, "项目").Groups.Single();
|
||||
var changed = env.Service.CreateWorkspaceChannel(ch.Id, owner, "讨论", group.Id);
|
||||
Assert.Equal(group.Id, changed.Channels.Last().GroupId);
|
||||
var reloaded = env.Service.GetWorkspace(ch.Id, owner);
|
||||
Assert.Equal(changed.Revision, reloaded.Revision); Assert.Equal(2, reloaded.Channels.Count);
|
||||
}
|
||||
[Fact]
|
||||
public void InvalidAndCrossSpaceAssociationsRollback()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var a = env.Create(endpoint); var b = env.Create(endpoint);
|
||||
var group = env.Service.CreateWorkspaceGroup(b.Id, b.Owner(endpoint), "其他空间").Groups.Single();
|
||||
var previous = env.Service.GetWorkspace(a.Id, a.Owner(endpoint));
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "讨论", group.Id));
|
||||
Assert.Equal(previous.Revision, env.Service.GetWorkspace(a.Id, a.Owner(endpoint)).Revision);
|
||||
var first = previous.Channels.Single().Id;
|
||||
var second = env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "频道2", null).Channels.Last().Id;
|
||||
var folder = env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), first, null, "目录").Folders.Single();
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), second, folder.Id, "跨频道目录"));
|
||||
}
|
||||
[Fact]
|
||||
public async Task MembersCannotMoveAnotherAccountsItemsAndTheServerSuppliesTheAuthor()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
|
||||
var member = ch.Member(memberEp) with { AccountName = "member" };
|
||||
env.Service.Join(ch.Id, member);
|
||||
var item = TestEnv.NewId();
|
||||
await env.Service.AppendFrameAsync(ch.Id, ch.Owner(ownerEp), TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
|
||||
var thread = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
|
||||
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, member, [item.ToBase64Url()], thread, null));
|
||||
var moved = env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [item.ToBase64Url()], thread, null);
|
||||
Assert.Equal("owner", moved.Locations.Single().AuthorAccount);
|
||||
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [TestEnv.NewId().ToBase64Url()], thread, null));
|
||||
}
|
||||
[Fact]
|
||||
public void AnExistingSpaceWithoutBackendStructureIsNotAutomaticallyMigrated()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint);
|
||||
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={Path.Combine(env.Dir, "test.db")}");
|
||||
connection.Open();
|
||||
using var delete = connection.CreateCommand(); delete.CommandText = "DELETE FROM workspace_structures WHERE space_id=$id";
|
||||
delete.Parameters.AddWithValue("$id", ch.Id.ToArray()); delete.ExecuteNonQuery();
|
||||
var empty = env.Service.GetWorkspace(ch.Id, ch.Owner(endpoint));
|
||||
Assert.Empty(empty.Channels); Assert.Equal(0, empty.Revision);
|
||||
var added = env.Service.CreateWorkspaceChannel(ch.Id, ch.Owner(endpoint), "新频道", null);
|
||||
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
|
||||
}
|
||||
[Fact]
|
||||
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user