From dd593bc77a9ab8a8501fb1c30e20cd2a39ddd647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:06:28 +0100 Subject: [PATCH 1/6] Add option to read & write app scoped permissions (grants) --- src/Models/AppSettings.cs | 1 + tests/AppClientTests.cs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Models/AppSettings.cs b/src/Models/AppSettings.cs index 27a0446..25bea7f 100644 --- a/src/Models/AppSettings.cs +++ b/src/Models/AppSettings.cs @@ -71,6 +71,7 @@ public abstract class AppSettingsBase public FileUploadConfig FileUploadConfig { get; set; } public DateTimeOffset? RevokeTokensIssuedBefore { get; set; } public UniqueUsernameEnforcementPolicy? EnforceUniqueUsernames { get; set; } + public Dictionary> Grants { get; set; } } public class AppSettingsRequest : AppSettingsBase diff --git a/tests/AppClientTests.cs b/tests/AppClientTests.cs index 60a6c26..5a61a69 100644 --- a/tests/AppClientTests.cs +++ b/tests/AppClientTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; @@ -114,5 +116,42 @@ public async Task TestTestSnsPushAsync() resp.Status.Should().Be(SnsCheckStatus.Error); } + + [Test] + public async Task TestReadingAppGrants() + { + var resp = await _appClient.GetAppSettingsAsync(); + resp.App.Grants.Should().NotBeNull(); + } + + [Test] + public async Task TestWritingAppGrants() + { + var getAppResponse = await _appClient.GetAppSettingsAsync(); + var userGrants = GetUserGrants(getAppResponse.App.Grants); + + // Remove permission + userGrants.Remove("delete-poll-owner"); + await _appClient.UpdateAppSettingsAsync(new AppSettingsRequest { Grants = getAppResponse.App.Grants }); + + // Assert permissions is removed + var getAppResponse2 = await _appClient.GetAppSettingsAsync(); + var userGrants2 = GetUserGrants(getAppResponse2.App.Grants); + userGrants2.Should().NotContain("delete-poll-owner"); + + // Add permission + userGrants2.Add("delete-poll-owner"); + await _appClient.UpdateAppSettingsAsync(new AppSettingsRequest { Grants = getAppResponse2.App.Grants }); + + // Assert permissions is added + var getAppResponse3 = await _appClient.GetAppSettingsAsync(); + var userGrants3 = GetUserGrants(getAppResponse3.App.Grants); + userGrants3.Should().Contain("delete-poll-owner"); + + return; + + List GetUserGrants(Dictionary> grants) + => grants.First(g => g.Key == "user").Value; + } } } \ No newline at end of file From 9aaaefcff2f3afc7951e2d9422cd25aae4375c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:51:29 +0100 Subject: [PATCH 2/6] fix naming --- tests/AppClientTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AppClientTests.cs b/tests/AppClientTests.cs index 5a61a69..b78a943 100644 --- a/tests/AppClientTests.cs +++ b/tests/AppClientTests.cs @@ -118,14 +118,14 @@ public async Task TestTestSnsPushAsync() } [Test] - public async Task TestReadingAppGrants() + public async Task TestReadingAppGrantsAsync() { var resp = await _appClient.GetAppSettingsAsync(); resp.App.Grants.Should().NotBeNull(); } [Test] - public async Task TestWritingAppGrants() + public async Task TestWritingAppGrantsAsync() { var getAppResponse = await _appClient.GetAppSettingsAsync(); var userGrants = GetUserGrants(getAppResponse.App.Grants); From 34c3d9bd089c33a735c1fd949aa2f5292c43fa3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:54:36 +0100 Subject: [PATCH 3/6] Add code examples related to user permissions + Change AddMembersAsync to return UpdateChannel + Add Members to UpdateChannelResponse + Add Grants to ChannelConfigBase + add ChannelRole to ChannelMember + add unit tests guaranteeing that the code examples work as stated --- samples/DocsExamples/UserPermissions.cs | 139 ++++++++++++++++ src/Clients/ChannelClient.Members.cs | 6 +- src/Clients/IChannelClient.cs | 4 +- src/Models/Channel.cs | 1 + src/Models/ChannelConfig.cs | 1 + src/Models/ChannelMember.cs | 1 + src/Models/ChannelType.cs | 9 ++ tests/BlocklistClientTests.cs | 8 +- tests/ChannelTypeClientTests.cs | 2 +- tests/CommandClientTests.cs | 4 +- tests/ImportClientTests.cs | 1 - tests/PermissionTests.cs | 203 +++++++++++++++++++++++- tests/TestBase.cs | 2 +- tests/UserRolesTests.cs | 58 +++++++ 14 files changed, 418 insertions(+), 21 deletions(-) create mode 100644 samples/DocsExamples/UserPermissions.cs create mode 100644 tests/UserRolesTests.cs diff --git a/samples/DocsExamples/UserPermissions.cs b/samples/DocsExamples/UserPermissions.cs new file mode 100644 index 0000000..c53ab82 --- /dev/null +++ b/samples/DocsExamples/UserPermissions.cs @@ -0,0 +1,139 @@ +using StreamChat.Clients; +using StreamChat.Models; + +namespace DocsExamples; + +/// +/// Code examples for +/// +internal class UserPermissions +{ + private readonly IUserClient _userClient; + private readonly IChannelClient _channelClient; + private readonly IPermissionClient _permissionClient; + private readonly IChannelTypeClient _channelTypeClient; + private readonly IAppClient _appClient; + + internal async Task ChangeUserRole() + { + var upsertResponse = await _userClient.UpdatePartialAsync(new UserPartialRequest + { + Id = "user-id", + Set = new Dictionary + { + { "role", "special_agent" } + } + }); + } + + internal async Task VerifyChannelMemberRoleAssigned() + { + var addMembersResponse + = await _channelClient.AddMembersAsync("channel-type", "channel-id", new[] { "user-id" }); + Console.WriteLine(addMembersResponse.Members[0].ChannelRole); // channel role is equal to "channel_member" + } + + internal async Task AssignRoles() + { + // User must be a member of the channel before you can assign channel role + var resp = await _channelClient.AssignRolesAsync("channel-type", "channel-id", new AssignRoleRequest + { + AssignRoles = new List + { + new RoleAssignment { UserId = "user-id", ChannelRole = Role.ChannelModerator } + } + }); + } + + internal async Task CreateRole() + { + await _permissionClient.CreateRoleAsync("special_agent"); + } + + internal async Task DeleteRole() + { + await _permissionClient.DeleteRoleAsync("special_agent"); + } + + internal async Task ListPermissions() + { + var response = await _permissionClient.ListPermissionsAsync(); + } + + internal async Task UpdateGrantedPermissions() + { + // observe current grants of the channel type + var channelType = await _channelTypeClient.GetChannelTypeAsync("messaging"); + Console.WriteLine(channelType.Grants); + + // update "channel_member" role grants in "messaging" scope + var update = new ChannelTypeWithStringCommandsRequest + { + Grants = new Dictionary> + { + { + // This will replace all existing grants of "channel_member" role + "channel_member", new List + { + "read-channel", // allow access to the channel + "create-message", // create messages in the channel + "update-message-owner", // update own user messages + "delete-message-owner", // delete own user messages + } + }, + } + }; + await _channelTypeClient.UpdateChannelTypeAsync("messaging", update); + } + + internal async Task RemoveGrantedPermissionsByCategory() + { + var update = new ChannelTypeWithStringCommandsRequest + { + Grants = new Dictionary> + { + { "guest", new List() }, // removes all grants of "guest" role + { "anonymous", new List() }, // removes all grants of "anonymous" role + } + }; + await _channelTypeClient.UpdateChannelTypeAsync("messaging", update); + } + + internal async Task ResetGrantsToDefaultSettings() + { + var update = new ChannelTypeWithStringCommandsRequest + { + Grants = new Dictionary>() + }; + await _channelTypeClient.UpdateChannelTypeAsync("messaging", update); + } + + internal async Task UpdateAppScopedGrants() + { + var settings = new AppSettingsRequest + { + Grants = new Dictionary> + { + { "anonymous", new List() }, + { "guest", new List() }, + { "user", new List { "search-user", "mute-user" } }, + { "admin", new List { "search-user", "mute-user", "ban-user" } }, + } + }; + await _appClient.UpdateAppSettingsAsync(settings); + } + + internal async Task UpdateChannelLevelPermissions() + { + var grants = new Dictionary { { "user", new List { "!add-links", "create-reaction" } } }; + var overrides = new Dictionary { { "grants", grants } }; + var request = new PartialUpdateChannelRequest + { + Set = new Dictionary + { + { "config_overrides", overrides } + } + }; + var resp = await _channelClient.PartialUpdateAsync("channel-type", "channel-id", request); + } +} \ No newline at end of file diff --git a/src/Clients/ChannelClient.Members.cs b/src/Clients/ChannelClient.Members.cs index e7f5504..fcc9a01 100644 --- a/src/Clients/ChannelClient.Members.cs +++ b/src/Clients/ChannelClient.Members.cs @@ -8,11 +8,11 @@ namespace StreamChat.Clients { public partial class ChannelClient { - public async Task AddMembersAsync(string channelType, string channelId, params string[] userIds) + public async Task AddMembersAsync(string channelType, string channelId, params string[] userIds) => await AddMembersAsync(channelType, channelId, userIds, null, null); - public async Task AddMembersAsync(string channelType, string channelId, IEnumerable userIds, MessageRequest msg, AddMemberOptions options) - => await ExecuteRequestAsync($"channels/{channelType}/{channelId}", + public async Task AddMembersAsync(string channelType, string channelId, IEnumerable userIds, MessageRequest msg, AddMemberOptions options) + => await ExecuteRequestAsync($"channels/{channelType}/{channelId}", HttpMethod.POST, HttpStatusCode.Created, new ChannelUpdateRequest diff --git a/src/Clients/IChannelClient.cs b/src/Clients/IChannelClient.cs index 021dc5b..eeb55b8 100644 --- a/src/Clients/IChannelClient.cs +++ b/src/Clients/IChannelClient.cs @@ -14,13 +14,13 @@ public interface IChannelClient /// Adds members to a channel. /// /// https://getstream.io/chat/docs/dotnet-csharp/channel_members/?language=csharp - Task AddMembersAsync(string channelType, string channelId, params string[] userIds); + Task AddMembersAsync(string channelType, string channelId, params string[] userIds); /// /// Adds members to a channel. /// /// https://getstream.io/chat/docs/dotnet-csharp/channel_members/?language=csharp - Task AddMembersAsync(string channelType, string channelId, IEnumerable userIds, + Task AddMembersAsync(string channelType, string channelId, IEnumerable userIds, MessageRequest msg, AddMemberOptions options); /// diff --git a/src/Models/Channel.cs b/src/Models/Channel.cs index 1f01d7f..a890afb 100644 --- a/src/Models/Channel.cs +++ b/src/Models/Channel.cs @@ -51,6 +51,7 @@ public class UpdateChannelResponse : ApiResponse { public ChannelWithConfig Channel { get; set; } public Message Message { get; set; } + public List Members { get; set; } } public class PartialUpdateChannelRequest diff --git a/src/Models/ChannelConfig.cs b/src/Models/ChannelConfig.cs index 3fe85dd..ffceb1b 100644 --- a/src/Models/ChannelConfig.cs +++ b/src/Models/ChannelConfig.cs @@ -18,6 +18,7 @@ public abstract class ChannelConfigBase public string MessageRetention { get; set; } public int MaxMessageLength { get; set; } public string Automod { get; set; } + public Dictionary> Grants { get; set; } } public class ChannelConfig : ChannelConfigBase diff --git a/src/Models/ChannelMember.cs b/src/Models/ChannelMember.cs index 8bfb8ac..8aeb43e 100644 --- a/src/Models/ChannelMember.cs +++ b/src/Models/ChannelMember.cs @@ -19,6 +19,7 @@ public class ChannelMember : CustomDataBase public DateTimeOffset? InviteAcceptedAt { get; set; } public DateTimeOffset? InviteRejectedAt { get; set; } public string Role { get; set; } + public string ChannelRole { get; set; } public DateTimeOffset? CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } public bool? Banned { get; set; } diff --git a/src/Models/ChannelType.cs b/src/Models/ChannelType.cs index e94c752..47a55ac 100644 --- a/src/Models/ChannelType.cs +++ b/src/Models/ChannelType.cs @@ -72,6 +72,15 @@ public class ChannelTypeWithCommandsRequest : ChannelTypeRequestBase public class ChannelTypeWithStringCommandsRequest : ChannelTypeRequestBase { + public ChannelTypeWithStringCommandsRequest() + { + } + + public ChannelTypeWithStringCommandsRequest(Dictionary> grants) + { + Grants = grants; + } + public List Commands { get; set; } } diff --git a/tests/BlocklistClientTests.cs b/tests/BlocklistClientTests.cs index 0391004..90f380f 100644 --- a/tests/BlocklistClientTests.cs +++ b/tests/BlocklistClientTests.cs @@ -36,7 +36,7 @@ public async Task TearDownAsync() } [Test] - public Task TestGetAsync() => TryMultiple(async () => + public Task TestGetAsync() => TryMultipleAsync(async () => { var resp = await _blocklistClient.GetAsync(_blocklistName); @@ -47,7 +47,7 @@ public Task TestGetAsync() => TryMultiple(async () => }); [Test] - public Task TestListAsync() => TryMultiple(async () => + public Task TestListAsync() => TryMultipleAsync(async () => { var resp = await _blocklistClient.ListAsync(); @@ -59,12 +59,12 @@ public async Task TestUpdateAsync() { var expectedWords = new[] { "test", "test2" }; - await TryMultiple(async () => + await TryMultipleAsync(async () => { await _blocklistClient.UpdateAsync(_blocklistName, expectedWords); }); - await TryMultiple(async () => + await TryMultipleAsync(async () => { var updated = await _blocklistClient.GetAsync(_blocklistName); updated.Blocklist.Words.Should().BeEquivalentTo(expectedWords); diff --git a/tests/ChannelTypeClientTests.cs b/tests/ChannelTypeClientTests.cs index 4f94b45..5cd6960 100644 --- a/tests/ChannelTypeClientTests.cs +++ b/tests/ChannelTypeClientTests.cs @@ -52,7 +52,7 @@ await WaitForAsync(async () => [Test] public Task TestGetChannelTypeAsync() - => TryMultiple(testBody: async () => + => TryMultipleAsync(testBody: async () => { var actualChannelType = await _channelTypeClient.GetChannelTypeAsync(_channelType.Name); actualChannelType.Name.Should().BeEquivalentTo(_channelType.Name); diff --git a/tests/CommandClientTests.cs b/tests/CommandClientTests.cs index 0fc4d04..0ee82ed 100644 --- a/tests/CommandClientTests.cs +++ b/tests/CommandClientTests.cs @@ -38,7 +38,7 @@ public async Task TeardownAsync() [Test] public Task TestGetCommandAsync() - => TryMultiple(async () => + => TryMultipleAsync(async () => { var command = await _commandClient.GetAsync(_command.Name); @@ -47,7 +47,7 @@ public Task TestGetCommandAsync() [Test] public Task TestListCommandsAsync() - => TryMultiple(async () => + => TryMultipleAsync(async () => { var resp = await _commandClient.ListAsync(); diff --git a/tests/ImportClientTests.cs b/tests/ImportClientTests.cs index 8e6bb51..e8183f9 100644 --- a/tests/ImportClientTests.cs +++ b/tests/ImportClientTests.cs @@ -1,7 +1,6 @@ using System; using System.Net.Http; using System.Net.Http.Headers; -using System.Text; using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; diff --git a/tests/PermissionTests.cs b/tests/PermissionTests.cs index 10d7965..6fd7820 100644 --- a/tests/PermissionTests.cs +++ b/tests/PermissionTests.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; -using StreamChat; +using StreamChat.Clients; using StreamChat.Exceptions; using StreamChat.Models; @@ -24,12 +24,27 @@ public class PermissionTests : TestBase { private const string TestPermissionDescription = "Test Permission"; + private UserRequest _user1; + private UserRequest _user2; + [OneTimeSetUp] [OneTimeTearDown] public async Task CleanupAsync() { await DeleteCustomRolesAsync(); - await DeleteCustomPermissonsAsync(); + await DeleteCustomPermissionsAsync(); + } + + [SetUp] + public async Task SetupAsync() + { + (_user1, _user2) = (await UpsertNewUserAsync(), await UpsertNewUserAsync()); + } + + [TearDown] + public async Task TeardownAsync() + { + await TryDeleteUsersAsync(_user1.Id, _user2.Id); } private async Task DeleteCustomRolesAsync() @@ -49,7 +64,7 @@ private async Task DeleteCustomRolesAsync() } } - private async Task DeleteCustomPermissonsAsync() + private async Task DeleteCustomPermissionsAsync() { var permResp = await _permissionClient.ListPermissionsAsync(); foreach (var perm in permResp.Permissions.Where(x => x.Description == TestPermissionDescription)) @@ -67,7 +82,7 @@ private async Task DeleteCustomPermissonsAsync() } [Test] - public async Task TestRolesEnd2endAsync() + public async Task TestRolesEnd2EndAsync() { // Test create var roleResp = await _permissionClient.CreateRoleAsync(Guid.NewGuid().ToString()); @@ -100,7 +115,7 @@ public async Task TestRolesEnd2endAsync() } [Test] - public async Task TestPermissionsEnd2endAsync() + public async Task TestPermissionsEnd2EndAsync() { var permission = new Permission { @@ -143,7 +158,7 @@ public async Task TestPermissionsEnd2endAsync() { if (ex.Message.Contains("not found")) { - // Unfortounatly, the backend is too slow to propagate the permission creation + // Unfortunately, the backend is too slow to propagate the permission creation // so this error message is expected. Facepalm. return; } @@ -151,5 +166,179 @@ public async Task TestPermissionsEnd2endAsync() throw; } } + + [Test] + public async Task WhenUpdatingChannelGrantsExpectChannelGrantsChanged() + { + ChannelTypeWithStringCommandsResponse tempChannelType = null; + try + { + tempChannelType = await _channelTypeClient.CreateChannelTypeAsync( + new ChannelTypeWithStringCommandsRequest() + { + Name = Guid.NewGuid().ToString(), + }); + + // Expect delete-message-owner to not be present by default + tempChannelType.Grants.First(g => g.Key == "channel_member").Value.Should() + .NotContain("delete-message-owner"); + + var update = new ChannelTypeWithStringCommandsRequest + { + Grants = new Dictionary> + { + { + "channel_member", new List + { + "delete-message-owner", + } + }, + }, + }; + await TryMultipleAsync(() => _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, update)); + + var getChannelType2 = await _channelTypeClient.GetChannelTypeAsync(tempChannelType.Name); + + // Expect delete-message-owner to not be present by default + var channelMemberGrants = getChannelType2.Grants.First(g => g.Key == "channel_member").Value; + channelMemberGrants.Should().HaveCount(1); + channelMemberGrants.Should().Contain("delete-message-owner"); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + finally + { + try + { + if (tempChannelType != null) + { + await _channelTypeClient.DeleteChannelTypeAsync(tempChannelType.Name); + } + } + catch (Exception e) + { + // ignored + } + } + } + + [Test] + public async Task WhenUpdatingGrantsWithEmptyListExpectResetToDefault() + { + var tempChannelType = await _channelTypeClient.CreateChannelTypeAsync( + new ChannelTypeWithStringCommandsRequest + { + Name = Guid.NewGuid().ToString(), + }); + + var channelMemberInitialGrantsCounts + = tempChannelType.Grants.First(g => g.Key == "channel_member").Value.Count; + + // We expect more than 1 grant by default + channelMemberInitialGrantsCounts.Should().NotBe(1); + + // Wait for data propagation - channel type is sometimes not present immediately after creation + await WaitForAsync(async () => + { + try + { + var channelType = await _channelTypeClient.GetChannelTypeAsync(tempChannelType.Name); + return true; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + }); + + // Override channel_members grants to replace with a single grant + var updateGrants = new ChannelTypeWithStringCommandsRequest + { + Grants = new Dictionary> + { + { + "channel_member", new List + { + "delete-message-owner", + } + }, + }, + }; + var updateChannelTypeResponse + = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, updateGrants); + + // Confirm a single grant is present + updateChannelTypeResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCount(1); + + // Restore grants + var restoreGrantsRequest + = new ChannelTypeWithStringCommandsRequest(grants: new Dictionary>()); + var restoreGrantsResponse + = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, restoreGrantsRequest); + + // Assert more than 1 grant is present + restoreGrantsResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCountGreaterThan(1); + } + + [Test] + public async Task WhenAssigningAppScopedPermissionsExpectAppGrantsMatchingAsync() + { + var settings = new AppSettingsRequest + { + Grants = new Dictionary> + { + { "anonymous", new List() }, + { "guest", new List() }, + { "user", new List { "search-user", "mute-user" } }, + { "admin", new List { "search-user", "mute-user", "ban-user" } }, + }, + }; + await _appClient.UpdateAppSettingsAsync(settings); + + var getAppResponse = await _appClient.GetAppSettingsAsync(); + getAppResponse.App.Grants.Should().NotBeNull(); + getAppResponse.App.Grants["anonymous"].Should().BeEmpty(); + getAppResponse.App.Grants["guest"].Should().BeEmpty(); + getAppResponse.App.Grants["user"].Should().BeEquivalentTo(new[] { "search-user", "mute-user" }); + getAppResponse.App.Grants["admin"].Should() + .BeEquivalentTo(new[] { "search-user", "mute-user", "ban-user" }); + } + + [Test] + public async Task WhenUpdatingChannelConfigGrantsOverridesExpectGrantsOverridenAsync() + { + var channel = await CreateChannelAsync(createdByUserId: _user1.Id); + await _channelClient.AddMembersAsync(channel.Type, channel.Id, new[] { _user2.Id }); + + var request = new PartialUpdateChannelRequest + { + Set = new Dictionary + { + { + "config_overrides", new Dictionary + { + { + "grants", new Dictionary + { + { + "user", new List { "!add-links", "create-reaction" } + }, + } + }, + } + }, + }, + }; + var partialUpdateChannelResponse + = await _channelClient.PartialUpdateAsync(channel.Type, channel.Id, request); + + var channelResp = await _channelClient.GetOrCreateAsync(channel.Type, channel.Id, new ChannelGetRequest()); + channelResp.Channel.Config.Grants["user"].Should() + .BeEquivalentTo(new List { "!add-links", "create-reaction" }); + } } -} +} \ No newline at end of file diff --git a/tests/TestBase.cs b/tests/TestBase.cs index 78c0c5d..fa8afda 100644 --- a/tests/TestBase.cs +++ b/tests/TestBase.cs @@ -128,7 +128,7 @@ await _userClient.DeleteManyAsync( /// How many times to try /// delay between a failed try /// Throws ArgumentException If max attempts or timeout exceeds the limit - protected async Task TryMultiple(Func testBody, + protected async Task TryMultipleAsync(Func testBody, int attempts = 5, int attemptTimeoutMs = 500) { const int maxAttempts = 20; diff --git a/tests/UserRolesTests.cs b/tests/UserRolesTests.cs new file mode 100644 index 0000000..1c49fa0 --- /dev/null +++ b/tests/UserRolesTests.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using StreamChat.Models; + +namespace StreamChatTests; + +internal class UserRolesTests : TestBase +{ + private UserRequest _user1; + private UserRequest _user2; + + [SetUp] + public async Task SetupAsync() + { + (_user1, _user2) = (await UpsertNewUserAsync(), await UpsertNewUserAsync()); + } + + [TearDown] + public async Task TeardownAsync() + { + await TryDeleteUsersAsync(_user1.Id, _user2.Id); + } + + [Test] + public async Task WhenUserIsAddedToChannelExpectChannelMemberRoleAssignedAsync() + { + var channel = await CreateChannelAsync(createdByUserId: _user1.Id); + + var addMembersResponse = await _channelClient.AddMembersAsync(channel.Type, channel.Id, new[] { _user2.Id }); + addMembersResponse.Members.First(m => m.UserId == _user2.Id).ChannelRole.Should() + .BeEquivalentTo("channel_member"); + + var getChannel = await _channelClient.GetOrCreateAsync(channel.Type, channel.Id, new ChannelGetRequest()); + + getChannel.Members.First(m => m.UserId == _user2.Id).ChannelRole.Should().BeEquivalentTo("channel_member"); + } + + [Test] + public async Task WhenAssigningARoleExpectRoleAssignedAsync() + { + var channel = await CreateChannelAsync(createdByUserId: _user1.Id); + await _channelClient.AddMembersAsync(channel.Type, channel.Id, new[] { _user2.Id }); + + var resp = await _channelClient.AssignRolesAsync(channel.Type, channel.Id, new AssignRoleRequest + { + AssignRoles = new List + { + new RoleAssignment { UserId = _user2.Id, ChannelRole = Role.ChannelModerator }, + }, + }); + resp.Members.First().ChannelRole.Should().BeEquivalentTo("channel_moderator"); + } + + +} \ No newline at end of file From 3ca439c2b3109206a23ff56db1d2ddeeefb45b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 30 Jan 2025 22:10:00 +0100 Subject: [PATCH 4/6] fix auto review errors --- samples/DocsExamples/UserPermissions.cs | 10 ++++++++++ tests/PermissionTests.cs | 2 +- tests/UserRolesTests.cs | 2 -- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/samples/DocsExamples/UserPermissions.cs b/samples/DocsExamples/UserPermissions.cs index c53ab82..233ec16 100644 --- a/samples/DocsExamples/UserPermissions.cs +++ b/samples/DocsExamples/UserPermissions.cs @@ -13,6 +13,16 @@ internal class UserPermissions private readonly IPermissionClient _permissionClient; private readonly IChannelTypeClient _channelTypeClient; private readonly IAppClient _appClient; + + public UserPermissions() + { + var factory = new StreamClientFactory("{{ api_key }}", "{{ api_secret }}"); + _userClient = factory.GetUserClient(); + _channelClient = factory.GetChannelClient(); + _permissionClient = factory.GetPermissionClient(); + _channelTypeClient = factory.GetChannelTypeClient(); + _appClient = factory.GetAppClient(); + } internal async Task ChangeUserRole() { diff --git a/tests/PermissionTests.cs b/tests/PermissionTests.cs index 6fd7820..578c911 100644 --- a/tests/PermissionTests.cs +++ b/tests/PermissionTests.cs @@ -218,7 +218,7 @@ public async Task WhenUpdatingChannelGrantsExpectChannelGrantsChanged() await _channelTypeClient.DeleteChannelTypeAsync(tempChannelType.Name); } } - catch (Exception e) + catch (Exception) { // ignored } diff --git a/tests/UserRolesTests.cs b/tests/UserRolesTests.cs index 1c49fa0..3974ec7 100644 --- a/tests/UserRolesTests.cs +++ b/tests/UserRolesTests.cs @@ -53,6 +53,4 @@ public async Task WhenAssigningARoleExpectRoleAssignedAsync() }); resp.Members.First().ChannelRole.Should().BeEquivalentTo("channel_moderator"); } - - } \ No newline at end of file From 13eaebcb740ab944929c41b261d3fc8eac7baf58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:09:12 +0100 Subject: [PATCH 5/6] Allow settings grants explicitly to NULL. This is a special case where API will reset grants to default settings --- src/Models/ChannelType.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Models/ChannelType.cs b/src/Models/ChannelType.cs index 47a55ac..d95f78a 100644 --- a/src/Models/ChannelType.cs +++ b/src/Models/ChannelType.cs @@ -62,7 +62,12 @@ public abstract class ChannelTypeRequestBase [Obsolete("Use V2 Permissions APIs instead. " + "See https://getstream.io/chat/docs/dotnet-csharp/migrating_from_legacy/?language=csharp")] public List Permissions { get; set; } - public Dictionary> Grants { get; set; } + + // JsonProperty is needed because passing NULL is a special case where API resets the grants to the default settings. + // Empty Dictionary as a default value is needed in order for the default object to not reset the grants + [JsonProperty(NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Include)] + public Dictionary> Grants { get; set; } = new Dictionary>(); } public class ChannelTypeWithCommandsRequest : ChannelTypeRequestBase @@ -72,15 +77,6 @@ public class ChannelTypeWithCommandsRequest : ChannelTypeRequestBase public class ChannelTypeWithStringCommandsRequest : ChannelTypeRequestBase { - public ChannelTypeWithStringCommandsRequest() - { - } - - public ChannelTypeWithStringCommandsRequest(Dictionary> grants) - { - Grants = grants; - } - public List Commands { get; set; } } From 30e25e473e9c7f03b246235c37e44994e2451f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:09:59 +0100 Subject: [PATCH 6/6] Change test to create temporary channel types only (mac count is restricted by the API) --- tests/PermissionTests.cs | 63 ++++++++++++++++++---------------------- tests/TestBase.cs | 51 ++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 37 deletions(-) diff --git a/tests/PermissionTests.cs b/tests/PermissionTests.cs index 578c911..fd58384 100644 --- a/tests/PermissionTests.cs +++ b/tests/PermissionTests.cs @@ -168,16 +168,12 @@ public async Task TestPermissionsEnd2EndAsync() } [Test] - public async Task WhenUpdatingChannelGrantsExpectChannelGrantsChanged() + public async Task WhenUpdatingChannelGrantsExpectChannelGrantsChangedAsync() { ChannelTypeWithStringCommandsResponse tempChannelType = null; try { - tempChannelType = await _channelTypeClient.CreateChannelTypeAsync( - new ChannelTypeWithStringCommandsRequest() - { - Name = Guid.NewGuid().ToString(), - }); + tempChannelType = await CreateChannelTypeAsync(); // Expect delete-message-owner to not be present by default tempChannelType.Grants.First(g => g.Key == "channel_member").Value.Should() @@ -226,13 +222,9 @@ public async Task WhenUpdatingChannelGrantsExpectChannelGrantsChanged() } [Test] - public async Task WhenUpdatingGrantsWithEmptyListExpectResetToDefault() + public async Task WhenUpdatingGrantsWithEmptyListExpectResetToDefaultAsync() { - var tempChannelType = await _channelTypeClient.CreateChannelTypeAsync( - new ChannelTypeWithStringCommandsRequest - { - Name = Guid.NewGuid().ToString(), - }); + var tempChannelType = await CreateChannelTypeAsync(); var channelMemberInitialGrantsCounts = tempChannelType.Grants.First(g => g.Key == "channel_member").Value.Count; @@ -241,18 +233,9 @@ var channelMemberInitialGrantsCounts channelMemberInitialGrantsCounts.Should().NotBe(1); // Wait for data propagation - channel type is sometimes not present immediately after creation - await WaitForAsync(async () => + await TryMultipleAsync(async () => { - try - { - var channelType = await _channelTypeClient.GetChannelTypeAsync(tempChannelType.Name); - return true; - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } + await _channelTypeClient.GetChannelTypeAsync(tempChannelType.Name); }); // Override channel_members grants to replace with a single grant @@ -268,20 +251,32 @@ await WaitForAsync(async () => }, }, }; - var updateChannelTypeResponse - = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, updateGrants); - // Confirm a single grant is present - updateChannelTypeResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCount(1); + // Try multiple times because it may fail due to data propagation + await TryMultipleAsync(async () => + { + var updateChannelTypeResponse + = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, updateGrants); + + // Confirm a single grant is present + updateChannelTypeResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCount(1); + }); - // Restore grants - var restoreGrantsRequest - = new ChannelTypeWithStringCommandsRequest(grants: new Dictionary>()); - var restoreGrantsResponse - = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, restoreGrantsRequest); + // Try multiple times because it may fail due to data propagation + await TryMultipleAsync(async () => + { + // Restore grants + var restoreGrantsRequest + = new ChannelTypeWithStringCommandsRequest + { + Grants = null, + }; + var restoreGrantsResponse + = await _channelTypeClient.UpdateChannelTypeAsync(tempChannelType.Name, restoreGrantsRequest); - // Assert more than 1 grant is present - restoreGrantsResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCountGreaterThan(1); + // Assert more than 1 grant is present + restoreGrantsResponse.Grants.First(g => g.Key == "channel_member").Value.Should().HaveCountGreaterThan(1); + }); } [Test] diff --git a/tests/TestBase.cs b/tests/TestBase.cs index fa8afda..b62b457 100644 --- a/tests/TestBase.cs +++ b/tests/TestBase.cs @@ -26,6 +26,7 @@ public abstract class TestBase protected static readonly ITaskClient _taskClient = TestClientFactory.GetTaskClient(); private readonly List _testChannels = new List(); + private readonly List _testChannelTypes = new List(); [OneTimeTearDown] public async Task OneTimeTearDown() @@ -33,13 +34,36 @@ public async Task OneTimeTearDown() const int chunkSize = 50; var cids = _testChannels.Select(x => x.Cid).ToArray(); - for (int i = 0; i < cids.Length; i += chunkSize) + for (var i = 0; i < cids.Length; i += chunkSize) { var chunk = cids.Skip(i).Take(chunkSize).ToArray(); - var resp = await _channelClient.DeleteChannelsAsync(chunk, hardDelete: true); - await WaitUntilTaskSucceedsAsync(resp.TaskId); + try + { + var resp = await _channelClient.DeleteChannelsAsync(chunk, hardDelete: true); + await WaitUntilTaskSucceedsAsync(resp.TaskId); + } + catch (Exception e) + { + Console.WriteLine($"Exception thrown while deleting channels: {e.Message}. Channels to delete: {string.Join(", ", chunk)}"); + } + } + + _testChannels.Clear(); + + foreach (var channelType in _testChannelTypes) + { + try + { + await _channelTypeClient.DeleteChannelTypeAsync(channelType); + } + catch (Exception e) + { + Console.WriteLine($"Exception thrown while deleting channel type: {e.Message}. Channel type to delete: {channelType}"); + } } + + _testChannelTypes.Clear(); } protected async Task WaitForAsync(Func> condition, int timeout = 5000, int delay = 500) @@ -105,6 +129,27 @@ protected async Task TryDeleteChannelAsync(string cid) await WaitUntilTaskSucceedsAsync(resp.TaskId); } + protected async Task CreateChannelTypeAsync(string name = null, bool autoDelete = true) + { + if (string.IsNullOrEmpty(name)) + { + name = Guid.NewGuid().ToString(); + } + + var channelType = await _channelTypeClient.CreateChannelTypeAsync( + new ChannelTypeWithStringCommandsRequest + { + Name = name, + }); + + if (autoDelete) + { + _testChannelTypes.Add(channelType.Name); + } + + return channelType; + } + protected async Task TryDeleteUsersAsync(params string[] userIds) { try