Files
WebDrop/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs
T
justme 17638b6e20 refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration
- all authorization rules (owner/creator, per-channel read/write grants, who may
  remove whom, delete rules, structure management) live in ChannelPolicy; a test
  fails if they are reimplemented elsewhere
- ChannelInfo carries what the caller may do (can_*), member and invitation lists
  say who can be removed; clients display it and never derive it
- the host no longer stores or calls the file service: it only signs short-lived
  tickets from space permissions (create/put/get/delete/domain), and keeps an opaque
  fileDomain reference on the channel
- a whole file is registered in one request (last frame header + per-frame length and
  digest) instead of one request per 1 MiB frame; the signed manifest still has to
  match every digest
- drop file quota/threshold, receipts, reconcile and delete-queue code from the host
- contract generator emits constants for enums, error codes, token roles, permissions
  and share states
2026-10-19 13:38:40 +08:00

202 lines
16 KiB
C#

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 MembersCanPlaceTheirUploadsButCannotCreateBusinessStructure()
{
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.Forbidden, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "成员分组")).Code);
}
// ───── 身份组 ─────
private static (TestEnv Env, TestChannel Ch, Credentials Owner, Credentials Alice, Credentials Bob, Credentials Carol) RoleSpace()
{
var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp, settings: new ChannelSettings { MaxMembers = 10 });
Credentials Join(string name) { var creds = ch.Member(TestEnv.Endpoint()) with { AccountName = name }; env.Service.Join(ch.Id, creds); return creds; }
return (env, ch, ch.Owner(ownerEp), Join("alice"), Join("bob"), Join("carol"));
}
[Fact]
public void OnlyTheOwnerManagesRolesAndMembersMustHaveJoined()
{
var (env, ch, owner, alice, _, _) = RoleSpace(); using var _env = env;
var created = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, "管理员", "blue", [WorkspacePermission.RemoveMembers]);
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "越权", null, null, null, []));
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "坏权限", null, null, null, ["nope"]));
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "坏颜色", null, null, "pink", []));
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceRoleMembers(ch.Id, owner, created.Role.Id, ["stranger"]));
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceRoleMembers(ch.Id, alice, created.Role.Id, ["alice"]));
var state = env.Service.SetWorkspaceRoleMembers(ch.Id, owner, created.Role.Id, ["Alice", "alice"]);
var role = state.Roles.Single(r => r.Id == created.Role.Id);
Assert.Equal("管理员", role.Suffix); Assert.Equal(["alice"], role.Members);
// 默认的「管理组」一直在;删掉自己建的不影响它,删掉默认组后也不会再自动出现
var remaining = env.Service.DeleteWorkspaceRole(ch.Id, owner, created.Role.Id);
Assert.Equal([WorkspaceRole.OwnerId, WorkspaceRole.DefaultId], remaining.Roles.Select(r => r.Id));
Assert.Equal(WorkspaceRole.OwnerId, Assert.Single(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles).Id);
// Owner 组删不掉、不能加成员,但前后缀和颜色可以改,名称和权限保持不变
Assert.Throws<WebDropException>(() => env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId));
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceRoleMembers(ch.Id, owner, WorkspaceRole.OwnerId, ["alice"]));
var edited = env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "改名", "**主**", "*Owner*", "g:#ff0000,#0000ff", []).Roles.Single();
Assert.Equal("Owner 组", edited.Name); Assert.Equal("**主**", edited.Prefix); Assert.Equal("g:#ff0000,#0000ff", edited.Color); Assert.Equal(WorkspacePermission.All, edited.Permissions);
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#12345", []));
Assert.Equal("g:#ff0000,#0000ff|marquee", env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "g:#ff0000,#0000ff|marquee", []).Roles.Single().Color); // 动画效果跟在颜色后面
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#ff0000|spin", []));
Assert.Single(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles);
}
[Fact]
public void StructureStoredBeforeRolesExistedStillLoadsWithAnEmptyRoleList()
{
var old = System.Text.Json.JsonSerializer.Deserialize("{\"revision\":3,\"channels\":[],\"groups\":[],\"folders\":[],\"locations\":[]}", WorkspaceJson.Default.WorkspaceState)!;
Assert.Empty(old.Roles); Assert.False(old.RolesSeeded);
}
[Fact]
public void EverySpaceStartsWithADefaultAdminGroupThatCanHoldMembers()
{
var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env;
var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles, r => r.Id == WorkspaceRole.DefaultId);
Assert.Equal(WorkspaceRole.DefaultId, admin.Id); Assert.Equal("管理组", admin.Name); Assert.Empty(admin.Members);
Assert.Contains(WorkspacePermission.RemoveMembers, admin.Permissions); Assert.Contains(WorkspacePermission.ManageStructure, admin.Permissions);
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admin.Id, ["alice"]);
env.Service.RevokeInvitation(ch.Id, alice, "bob"); // 加入默认管理组即可移除成员
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, bob, "alice"));
}
[Fact]
public void ARoleWithRemovePermissionCanKickOrdinaryMembersOnly()
{
var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env;
var peer = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, "管理员", "blue", [WorkspacePermission.RemoveMembers]).Role;
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, peer.Id, ["alice", "carol"]);
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, bob, "alice")); // 没有权限
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "owner")); // 不能踢空间主
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "carol")); // 不能踢同样有权限的人
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "alice")); // 不能踢自己
env.Service.RevokeInvitation(ch.Id, alice, "bob");
Assert.DoesNotContain(env.Service.Members(ch.Id, owner), member => member.Username == "bob");
_ = carol;
}
[Fact]
public void ARoleWithStructurePermissionManagesGroupsButCannotCreateChannels()
{
var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env;
var role = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "整理组", "【", "整理】", "green", [WorkspacePermission.ManageStructure]).Role;
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, role.Id, ["alice"]);
var channel = env.Service.GetWorkspace(ch.Id, owner).Channels.Single();
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, bob, "越权"));
var group = env.Service.CreateWorkspaceGroup(ch.Id, alice, "项目").Groups.Single();
env.Service.RenameWorkspaceChannel(ch.Id, alice, channel.Id, "改名");
env.Service.GroupWorkspaceChannel(ch.Id, alice, channel.Id, group.Id);
Assert.Empty(env.Service.DeleteWorkspaceGroup(ch.Id, alice, group.Id).Groups);
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, []));
}
[Fact]
public void ChannelFileDomainIsAnOpaqueReferenceSetOnceAndNeverInterpreted()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
Assert.Null(env.Service.GetWorkspace(ch.Id, owner).Channels.Single().FileDomain);
var created = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "文件", null, "ab12-CD_34").Channel;
Assert.Equal("ab12-CD_34", created.FileDomain);
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "乱", null, "bad domain!"));
// 创建时没带的,以后可以补一次;补了就不能换成别的(文件已经存在那里了)
var later = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "稍后", null).Channel;
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-2"));
}
[Fact]
public void ChannelInfoStatesWhatTheCallerMayDoSoClientsNeverDeriveIt()
{
var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env;
var admins = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, null, null, [WorkspacePermission.RemoveMembers]).Role;
var movers = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "结构组", null, null, null, [WorkspacePermission.ManageStructure]).Role;
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admins.Id, ["alice", "bob"]);
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, movers.Id, ["carol"]);
// 所有者:全部能力
var asOwner = env.Service.GetChannel(ch.Id, owner);
Assert.True(asOwner.IsOwner); Assert.True(asOwner.CanManageStructure); Assert.True(asOwner.CanDeleteAny); Assert.True(asOwner.CanDeleteOwn);
Assert.True(asOwner.CanCreateChannel && asOwner.CanManageFolders && asOwner.CanInvite && asOwner.CanEditSettings && asOwner.CanRemoveMembers);
// 身份组授予的权限:被授予「管理频道和分组」的不是所有者,却能管理结构;其他所有者专属的操作它仍然做不了
var asCarol = env.Service.GetChannel(ch.Id, carol);
Assert.False(asCarol.IsOwner); Assert.True(asCarol.CanManageStructure); Assert.False(asCarol.CanRemoveMembers);
Assert.False(asCarol.CanCreateChannel || asCarol.CanManageFolders || asCarol.CanInvite || asCarol.CanEditSettings);
var asAlice = env.Service.GetChannel(ch.Id, alice);
Assert.False(asAlice.IsOwner); Assert.False(asAlice.CanManageStructure); Assert.True(asAlice.CanRemoveMembers);
// 默认删除策略:成员只能删自己的
Assert.False(asAlice.CanDeleteAny); Assert.True(asAlice.CanDeleteOwn);
// 谁能移除谁:所有者能移除除自己外的人;被授权的不能移除自己、所有者、同样被授权的人
var byOwner = env.Service.MembersView(ch.Id, owner).ToDictionary(v => v.Member.Username, v => v.CanRemove);
Assert.True(byOwner["alice"]); Assert.True(byOwner["carol"]);
Assert.False(byOwner[env.Service.GetChannel(ch.Id, owner).CreatorAccount!]);
var byAlice = env.Service.MembersView(ch.Id, alice).ToDictionary(v => v.Member.Username, v => v.CanRemove);
Assert.False(byAlice["alice"]); Assert.False(byAlice["bob"]); Assert.True(byAlice["carol"]);
Assert.All(env.Service.MembersView(ch.Id, carol), view => Assert.False(view.CanRemove)); // 没有移除权限
// 展示和执行用同一条规则:展示说不能移除的,真去移除也会被拒绝
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "bob"));
}
}