From e46467da0909e7cf902e434481d63a3e2172d556 Mon Sep 17 00:00:00 2001 From: John Belcher Date: Wed, 27 Mar 2024 08:59:15 -0400 Subject: [PATCH] Add async functions to `IMembersClient` and other changes (#652) - Improve Create Group and Create User mock to better emulate GitLab. - Add support for explicit Group Path in mock helpers. - Add `RemoveMemberFrom*Async` and Implement async versions of the other Group and Project methods. - Add `IUserClient.GetByUserNameAsync` - Add unit and integrations tests to verify behavior of both the GitHub and Mock `IMembersClient`. --- NGitLab.Mock.Tests/MembersMockTests.cs | 98 +++++++++- NGitLab.Mock/Clients/ClientBase.cs | 5 + NGitLab.Mock/Clients/GroupClient.cs | 5 + NGitLab.Mock/Clients/MembersClient.cs | 252 ++++++++++++++++++------- NGitLab.Mock/Clients/UserClient.cs | 19 +- NGitLab.Mock/Config/GitLabGroup.cs | 5 + NGitLab.Mock/Config/GitLabHelpers.cs | 30 +++ NGitLab.Mock/GroupCollection.cs | 32 ++++ NGitLab.Mock/PublicAPI.Unshipped.txt | 3 + NGitLab.Mock/UserCollection.cs | 27 ++- NGitLab.Tests/MembersClientTests.cs | 92 +++++++++ NGitLab/IMembersClient.cs | 36 +++- NGitLab/IUserClient.cs | 2 + NGitLab/Impl/MembersClient.cs | 103 ++++++++-- NGitLab/Impl/UserClient.cs | 15 ++ NGitLab/Models/GroupMemberCreate.cs | 6 + NGitLab/Models/GroupMemberUpdate.cs | 6 + NGitLab/PublicAPI.Unshipped.txt | 24 +++ 18 files changed, 664 insertions(+), 96 deletions(-) diff --git a/NGitLab.Mock.Tests/MembersMockTests.cs b/NGitLab.Mock.Tests/MembersMockTests.cs index df389007..d2fd7726 100644 --- a/NGitLab.Mock.Tests/MembersMockTests.cs +++ b/NGitLab.Mock.Tests/MembersMockTests.cs @@ -1,4 +1,7 @@ -using System.Linq; +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; using NGitLab.Mock.Config; using NUnit.Framework; @@ -82,4 +85,97 @@ public void Test_members_project_all_inherited() Assert.That(members.Count(), Is.EqualTo(3), "Membership found are invalid"); } + + [Test] + public async Task Test_members_async_methods_simulate_gitlab_behavior() + { + // This test emulates GitLab's behavior. See `MembersClientTests.AsyncMethodsBehaveAsExpected()` + + // Arrange + var user1Name = "user1"; + var ownerName = "owner"; + + using var server = new GitLabConfig() + .WithUser(ownerName, isDefault: true) + .WithUser(user1Name) + .WithGroupOfFullPath("G1", configure: g => + g.WithUserPermission(ownerName, Models.AccessLevel.Owner) + .WithUserPermission(user1Name, Models.AccessLevel.Maintainer)) + .WithGroupOfFullPath("G1/G2", configure: g => + g.WithUserPermission(ownerName, Models.AccessLevel.Owner)) + .WithProject("Project", 1, @namespace: "G1") + .BuildServer(); + + var client = server.CreateClient(ownerName); + + var user1 = await client.Users.GetByUserNameAsync(user1Name); + var user1Id = user1.Id.ToString(); + + // Act + // Assert + const string projectId = "G1/Project"; + const string groupId = "G1/G2"; + + // Does NOT search inherited permission by default... + AssertThrowsGitLabException(() => client.Members.GetMemberOfProjectAsync(projectId, user1.Id), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.GetMemberOfGroupAsync(groupId, user1.Id), System.Net.HttpStatusCode.NotFound); + client.Members.OfProjectAsync(projectId).Select(m => m.UserName).Should().BeEmpty(); + client.Members.OfGroupAsync(groupId).Select(m => m.UserName).Should().BeEquivalentTo(new[] { ownerName }); + + // Does search inherited permission when asked... + (await client.Members.GetMemberOfProjectAsync(projectId, user1.Id, includeInheritedMembers: true)).UserName.Should().Be(user1Name); + (await client.Members.GetMemberOfGroupAsync(groupId, user1.Id, includeInheritedMembers: true)).UserName.Should().Be(user1Name); + client.Members.OfProjectAsync(projectId, includeInheritedMembers: true).Select(m => m.UserName).Should().BeEquivalentTo(new[] { ownerName, user1Name }); + client.Members.OfGroupAsync(groupId, includeInheritedMembers: true).Select(m => m.UserName).Should().BeEquivalentTo(new[] { ownerName, user1Name }); + + // Cannot update non-existent membership... + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), System.Net.HttpStatusCode.NotFound); + + // Cannot add membership with an access-level lower than inherited... + AssertThrowsGitLabException(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + AssertThrowsGitLabException(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + + // Can add membership with greater than or equal access-level... + await AssertReturnsMembership(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Maintainer }), Models.AccessLevel.Maintainer); + await AssertReturnsMembership(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Maintainer }), Models.AccessLevel.Maintainer); + + // Cannot add duplicate membership... + AssertThrowsGitLabException(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), System.Net.HttpStatusCode.Conflict); + AssertThrowsGitLabException(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), System.Net.HttpStatusCode.Conflict); + + // Can raise access-level above inherited... + await AssertReturnsMembership(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), Models.AccessLevel.Owner); + await AssertReturnsMembership(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Owner }), Models.AccessLevel.Owner); + + // Can decrease access-level to inherited... + await AssertReturnsMembership(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Maintainer }), Models.AccessLevel.Maintainer); + await AssertReturnsMembership(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Maintainer }), Models.AccessLevel.Maintainer); + + // Cannot decrease access-level lower than inherited... + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = Models.AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + + // Can delete... + await client.Members.RemoveMemberFromProjectAsync(projectId, user1.Id); + await client.Members.RemoveMemberFromGroupAsync(groupId, user1.Id); + + // Delete fails when not exist... + AssertThrowsGitLabException(() => client.Members.RemoveMemberFromProjectAsync(projectId, user1.Id), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.RemoveMemberFromGroupAsync(groupId, user1.Id), System.Net.HttpStatusCode.NotFound); + } + + private static async Task AssertReturnsMembership(Func> code, Models.AccessLevel expectedAccessLevel) + { + var membership = await code.Invoke().ConfigureAwait(false); + Assert.That(membership, Is.Not.Null); + Assert.That(membership.AccessLevel, Is.EqualTo((int)expectedAccessLevel)); + } + + private static void AssertThrowsGitLabException(AsyncTestDelegate code, System.Net.HttpStatusCode expectedStatusCode) + { + var ex = Assert.CatchAsync(typeof(GitLabException), code) as GitLabException; + Assert.That(ex, Is.Not.Null); + Assert.That(ex.StatusCode, Is.EqualTo(expectedStatusCode)); + } } diff --git a/NGitLab.Mock/Clients/ClientBase.cs b/NGitLab.Mock/Clients/ClientBase.cs index 8b90ce93..80c72af7 100644 --- a/NGitLab.Mock/Clients/ClientBase.cs +++ b/NGitLab.Mock/Clients/ClientBase.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using NGitLab.Models; namespace NGitLab.Mock.Clients; @@ -31,6 +32,8 @@ protected Group GetGroup(object id, GroupPermission permissions) { int idInt => Server.AllGroups.FindById(idInt), string idStr => Server.AllGroups.FindGroup(idStr), + IIdOrPathAddressable gid when gid.Path != null => Server.AllGroups.FindByNamespacedPath(gid.Path), + IIdOrPathAddressable gid => Server.AllGroups.FindById(gid.Id), _ => throw new ArgumentException($"Id of type '{id.GetType()}' is not supported"), }; @@ -77,6 +80,8 @@ protected Project GetProject(object id, ProjectPermission permissions) { int idInt => Server.AllProjects.FindById(idInt), string idStr => Server.AllProjects.FindProject(idStr), + IIdOrPathAddressable pid when pid.Path != null => Server.AllProjects.FindByNamespacedPath(pid.Path), + IIdOrPathAddressable pid => Server.AllProjects.FindById(pid.Id), _ => throw new ArgumentException($"Id of type '{id.GetType()}' is not supported"), }; diff --git a/NGitLab.Mock/Clients/GroupClient.cs b/NGitLab.Mock/Clients/GroupClient.cs index c60eb3f9..c2917147 100644 --- a/NGitLab.Mock/Clients/GroupClient.cs +++ b/NGitLab.Mock/Clients/GroupClient.cs @@ -34,8 +34,13 @@ public Models.Group Create(GroupCreate group) var newGroup = new Group { Name = group.Name, + Path = group.Path, Description = group.Description, Visibility = group.Visibility, + LfsEnabled = group.LfsEnabled, + RequestAccessEnabled = group.RequestAccessEnabled, + SharedRunnersLimit = TimeSpan.FromMinutes(group.SharedRunnersMinutesLimit ?? 0), + Permissions = { new Permission(Context.User, AccessLevel.Owner), diff --git a/NGitLab.Mock/Clients/MembersClient.cs b/NGitLab.Mock/Clients/MembersClient.cs index 8e13059d..c0cbd26e 100644 --- a/NGitLab.Mock/Clients/MembersClient.cs +++ b/NGitLab.Mock/Clients/MembersClient.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NGitLab.Extensions; +using NGitLab.Mock.Internals; using NGitLab.Models; namespace NGitLab.Mock.Clients; @@ -14,6 +17,26 @@ public MembersClient(ClientContext context) { } + public IEnumerable OfProject(string projectId) + { + return OfProject(projectId, includeInheritedMembers: false); + } + + public IEnumerable OfProject(string projectId, bool includeInheritedMembers) + { + using (Context.BeginOperationScope()) + { + var project = GetProject(projectId, ProjectPermission.View); + var members = project.GetEffectivePermissions(includeInheritedMembers).Permissions; + return members.Select(member => member.ToMembershipClient()); + } + } + + public GitLabCollectionResponse OfProjectAsync(ProjectId projectId, bool includeInheritedMembers = false) + { + return GitLabCollectionResponse.Create(OfProject(projectId.ValueAsString(), includeInheritedMembers)); + } + public Membership AddMemberToProject(string projectId, ProjectMemberCreate projectMemberCreate) { using (Context.BeginOperationScope()) @@ -21,15 +44,30 @@ public Membership AddMemberToProject(string projectId, ProjectMemberCreate proje var project = GetProject(projectId, ProjectPermission.Edit); var user = Server.Users.GetById(projectMemberCreate.UserId); - CheckUserPermissionOfProject(projectMemberCreate.AccessLevel, user, project); + if (project.Permissions.Any(p => p.User.Id == user.Id)) + { + throw new GitLabException("Member already exists") + { + // actual GitLab error + StatusCode = HttpStatusCode.Conflict, + ErrorMessage = "Member already exists", + }; + } + + ValidateNewProjectPermission(projectMemberCreate.AccessLevel, user, project); - var permission = new Permission(user, projectMemberCreate.AccessLevel); - project.Permissions.Add(permission); + project.Permissions.Add(new(user, projectMemberCreate.AccessLevel)); return project.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); } } + public async Task AddMemberToProjectAsync(ProjectId projectId, ProjectMemberCreate user, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return AddMemberToProject(projectId.ValueAsString(), user); + } + public Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate projectMemberUpdate) { using (Context.BeginOperationScope()) @@ -37,55 +75,60 @@ public Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate pr var project = GetProject(projectId, ProjectPermission.Edit); var user = Server.Users.GetById(projectMemberUpdate.UserId); - CheckUserPermissionOfProject(projectMemberUpdate.AccessLevel, user, project); + var curPermission = project.Permissions.SingleOrDefault(p => p.User.Id == user.Id) + ?? throw new GitLabNotFoundException(); + + ValidateNewProjectPermission(projectMemberUpdate.AccessLevel, user, project); + + project.Permissions.Remove(curPermission); + project.Permissions.Add(new(user, projectMemberUpdate.AccessLevel)); + return project.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); } } - public Membership AddMemberToGroup(string groupId, GroupMemberCreate groupMemberCreate) + public async Task UpdateMemberOfProjectAsync(ProjectId projectId, ProjectMemberUpdate projectMemberUpdate, CancellationToken cancellationToken = default) { - using (Context.BeginOperationScope()) - { - var group = GetGroup(groupId, GroupPermission.Edit); - var user = Server.Users.GetById(groupMemberCreate.UserId); - - CheckUserPermissionOfGroup(groupMemberCreate.AccessLevel, user, group); - - var permission = new Permission(user, groupMemberCreate.AccessLevel); - group.Permissions.Add(permission); - - return group.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); - } + await Task.Yield(); + return UpdateMemberOfProject(projectId.ValueAsString(), projectMemberUpdate); } - public Membership UpdateMemberOfGroup(string groupId, GroupMemberUpdate groupMemberUpdate) + public Membership GetMemberOfProject(string projectId, string userId) { - using (Context.BeginOperationScope()) - { - var group = GetGroup(groupId, GroupPermission.Edit); - var user = Server.Users.GetById(groupMemberUpdate.UserId); + return GetMemberOfProject(projectId, userId, includeInheritedMembers: false); + } - CheckUserPermissionOfGroup(groupMemberUpdate.AccessLevel, user, group); - return group.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); - } + public Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) + { + return OfProject(projectId, includeInheritedMembers) + .FirstOrDefault(u => string.Equals(u.Id.ToStringInvariant(), userId, StringComparison.Ordinal)) + ?? throw new GitLabNotFoundException(); } - public Membership GetMemberOfGroup(string groupId, string userId) + public async Task GetMemberOfProjectAsync(ProjectId projectId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default) { - return OfGroup(groupId, includeInheritedMembers: false) - .FirstOrDefault(u => string.Equals(u.Id.ToString(CultureInfo.InvariantCulture), userId, StringComparison.Ordinal)); + await Task.Yield(); + return GetMemberOfProject(projectId.ValueAsString(), userId.ToStringInvariant(), includeInheritedMembers); } - public Membership GetMemberOfProject(string projectId, string userId) + public async Task RemoveMemberFromProjectAsync(ProjectId projectId, long userId, CancellationToken cancellationToken = default) { - return OfProject(projectId, includeInheritedMembers: false) - .FirstOrDefault(u => string.Equals(u.Id.ToString(CultureInfo.InvariantCulture), userId, StringComparison.Ordinal)); + await Task.Yield(); + using (Context.BeginOperationScope()) + { + var project = GetProject(projectId, ProjectPermission.Edit); + + var permission = project.Permissions.SingleOrDefault(p => p.User.Id == userId) + ?? throw new GitLabNotFoundException(); + + project.Permissions.Remove(permission); + } } - public Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) + [Obsolete("Use OfGroup")] + public IEnumerable OfNamespace(string groupId) { - return OfProject(projectId, includeInheritedMembers) - .FirstOrDefault(u => string.Equals(u.Id.ToString(CultureInfo.InvariantCulture), userId, StringComparison.Ordinal)); + return OfGroup(groupId); } public IEnumerable OfGroup(string groupId) @@ -103,63 +146,134 @@ public IEnumerable OfGroup(string groupId, bool includeInheritedMemb } } - public IEnumerable OfNamespace(string groupId) + public GitLabCollectionResponse OfGroupAsync(GroupId groupId, bool includeInheritedMembers = false) { - return OfGroup(groupId); + return GitLabCollectionResponse.Create(OfGroup(groupId.ValueAsString(), includeInheritedMembers)); } - public IEnumerable OfProject(string projectId) + public Membership AddMemberToGroup(string groupId, GroupMemberCreate groupMemberCreate) { - return OfProject(projectId, includeInheritedMembers: false); + using (Context.BeginOperationScope()) + { + var group = GetGroup(groupId, GroupPermission.Edit); + var user = Server.Users.GetById(groupMemberCreate.UserId); + + if (group.Permissions.Any(p => p.User.Id == user.Id)) + { + throw new GitLabException("Member already exists") + { + // actual GitLab error + StatusCode = HttpStatusCode.Conflict, + ErrorMessage = "Member already exists", + }; + } + + ValidateNewGroupPermission(groupMemberCreate.AccessLevel, user, group); + + group.Permissions.Add(new(user, groupMemberCreate.AccessLevel)); + + return group.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); + } } - public IEnumerable OfProject(string projectId, bool includeInheritedMembers) + public async Task AddMemberToGroupAsync(GroupId groupId, GroupMemberCreate user, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return AddMemberToGroup(groupId.ValueAsString(), user); + } + + public Membership UpdateMemberOfGroup(string groupId, GroupMemberUpdate groupMemberUpdate) { using (Context.BeginOperationScope()) { - var project = GetProject(projectId, ProjectPermission.View); - var members = project.GetEffectivePermissions(includeInheritedMembers).Permissions; - return members.Select(member => member.ToMembershipClient()); + var group = GetGroup(groupId, GroupPermission.Edit); + var user = Server.Users.GetById(groupMemberUpdate.UserId); + + var curPermission = group.Permissions.SingleOrDefault(p => p.User.Id == user.Id) + ?? throw new GitLabNotFoundException(); + + ValidateNewGroupPermission(groupMemberUpdate.AccessLevel, user, group); + + group.Permissions.Remove(curPermission); + group.Permissions.Add(new(user, groupMemberUpdate.AccessLevel)); + + return group.GetEffectivePermissions().GetEffectivePermission(user).ToMembershipClient(); } } - private static void CheckUserPermissionOfProject(AccessLevel accessLevel, User user, Project project) + public async Task UpdateMemberOfGroupAsync(GroupId groupId, GroupMemberUpdate groupMemberUpdate, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return UpdateMemberOfGroup(groupId.ValueAsString(), groupMemberUpdate); + } + + public Membership GetMemberOfGroup(string groupId, string userId) + { + return GetMemberOfGroup(groupId, userId, false); + } + + public Membership GetMemberOfGroup(string groupId, string userId, bool includeInheritedMembers) { - var existingPermission = project.GetEffectivePermissions().GetEffectivePermission(user); - if (existingPermission != null) + return OfGroup(groupId, includeInheritedMembers: includeInheritedMembers) + .FirstOrDefault(u => string.Equals(u.Id.ToStringInvariant(), userId, StringComparison.Ordinal)) + ?? throw new GitLabNotFoundException(); + } + + public async Task GetMemberOfGroupAsync(GroupId groupId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return GetMemberOfGroup(groupId.ValueAsString(), userId.ToStringInvariant(), includeInheritedMembers); + } + + public async Task RemoveMemberFromGroupAsync(GroupId groupId, long userId, CancellationToken cancellationToken = default) + { + await Task.Yield(); + using (Context.BeginOperationScope()) { - if (existingPermission.AccessLevel > accessLevel) - { - throw new GitLabException("{\"access_level\":[\"should be greater than or equal to Owner inherited membership from group Runners\"]}.") - { - StatusCode = HttpStatusCode.BadRequest, - }; - } + var group = GetGroup(groupId, GroupPermission.Edit); - if (existingPermission.AccessLevel == accessLevel) - { - throw new GitLabException { StatusCode = HttpStatusCode.Conflict }; - } + var permission = group.Permissions.SingleOrDefault(p => p.User.Id == userId) + ?? throw new GitLabNotFoundException(); + + group.Permissions.Remove(permission); } } - private static void CheckUserPermissionOfGroup(AccessLevel accessLevel, User user, Group group) + /// + /// Checks if the given permission can be applied to the project and throws an exception if it is invalid. + /// + /// The new access level is less than an inherited membership role. + private static void ValidateNewProjectPermission(AccessLevel newAccessLevel, User user, Project project) { - var existingPermission = group.GetEffectivePermissions().GetEffectivePermission(user); - if (existingPermission != null) + // Get the existing permission from the parent, if it exists... + var permission = project.Group?.GetEffectivePermissions().GetEffectivePermission(user); + if (permission?.AccessLevel > newAccessLevel) { - if (existingPermission.AccessLevel > accessLevel) + throw new GitLabException("access_level should be greater than or equal to inherited membership.") { - throw new GitLabException("{\"access_level\":[\"should be greater than or equal to Owner inherited membership from group Runners\"]}.") - { - StatusCode = HttpStatusCode.BadRequest, - }; - } + // actual GitLab error + StatusCode = HttpStatusCode.BadRequest, + ErrorMessage = $$"""{"access_level":["should be greater than or equal to {{permission.AccessLevel}} inherited membership from project {{project.PathWithNamespace}}"]}.""", + }; + } + } - if (existingPermission.AccessLevel == accessLevel) + /// + /// Checks if the given permission can be applied to the group and throws an exception if it is invalid. + /// + /// The new access level is less than an inherited membership role. + private static void ValidateNewGroupPermission(AccessLevel newAccessLevel, User user, Group group) + { + // Get the existing permission from the parent, if it exists... + var permission = group.Parent?.GetEffectivePermissions().GetEffectivePermission(user); + if (permission?.AccessLevel > newAccessLevel) + { + throw new GitLabException("access_level should be greater than or equal to inherited membership.") { - throw new GitLabException { StatusCode = HttpStatusCode.Conflict }; - } + // actual GitLab error + StatusCode = HttpStatusCode.BadRequest, + ErrorMessage = $$"""{"access_level":["should be greater than or equal to {{permission.AccessLevel}} inherited membership from group {{group.PathWithNameSpace}}"]}.""", + }; } } } diff --git a/NGitLab.Mock/Clients/UserClient.cs b/NGitLab.Mock/Clients/UserClient.cs index 5cb1a13e..ca19a09c 100644 --- a/NGitLab.Mock/Clients/UserClient.cs +++ b/NGitLab.Mock/Clients/UserClient.cs @@ -57,6 +57,8 @@ public Models.User Create(UserUpsert user) { Name = user.Name, Email = user.Email, + State = UserState.active, + IsAdmin = user.IsAdmin ?? false, }; Server.Users.Add(u); @@ -158,8 +160,10 @@ public Models.User Update(int id, UserUpsert userUpsert) var user = Server.Users.GetById(id); if (user != null) { - user.Name = userUpsert.Name; - user.Email = userUpsert.Email; + // user.UserName = userUpsert.Username ?? user.UserName; // TODO + user.Name = userUpsert.Name ?? user.Name; + user.Email = userUpsert.Email ?? user.Email; + user.IsAdmin = userUpsert.IsAdmin ?? user.IsAdmin; return user.ToClientUser(); } @@ -174,6 +178,17 @@ public Models.User Update(int id, UserUpsert userUpsert) return this[id]; } + public async Task GetByUserNameAsync(string username, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return Get(username).SingleOrDefault() + ?? throw new GitLabException("User not found.") + { + StatusCode = System.Net.HttpStatusCode.NotFound, + ErrorMessage = "User not found.", + }; + } + public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default) { await Task.Yield(); diff --git a/NGitLab.Mock/Config/GitLabGroup.cs b/NGitLab.Mock/Config/GitLabGroup.cs index 5aa137f9..bd05d0e4 100644 --- a/NGitLab.Mock/Config/GitLabGroup.cs +++ b/NGitLab.Mock/Config/GitLabGroup.cs @@ -19,6 +19,11 @@ public GitLabGroup() /// public string Name { get; set; } + /// + /// Path/slug. Defaults to . + /// + public string Path { get; set; } + /// /// Parent namespace /// diff --git a/NGitLab.Mock/Config/GitLabHelpers.cs b/NGitLab.Mock/Config/GitLabHelpers.cs index 5b5b52a1..2437dd86 100644 --- a/NGitLab.Mock/Config/GitLabHelpers.cs +++ b/NGitLab.Mock/Config/GitLabHelpers.cs @@ -219,6 +219,35 @@ public static GitLabConfig WithGroup(this GitLabConfig config, string? name = nu }); } + /// + /// Add a group with the given full path, i.e., "{namespace}/{path}". + /// The namespace is optional, and leading or trailing slashes are ignored. + /// + /// Config. + /// The fully qualified path of the group. + /// Optional name. Defaults to the path. + /// Optional explicit ID (config increment) + /// Optional description. + /// Optional visibility. + /// Optionally define default user as maintainer. + /// Optional configuration method + public static GitLabConfig WithGroupOfFullPath(this GitLabConfig config, string fullPath, string? name = null, int id = default, string? description = null, VisibilityLevel? visibility = null, bool addDefaultUserAsMaintainer = false, Action? configure = null) + { + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentNullException(nameof(fullPath)); + + var span = fullPath.AsSpan().Trim('/'); + var slash = span.LastIndexOf('/'); + var path = slash == -1 ? span.ToString() : span.Slice(slash + 1).ToString(); + var @namespace = slash == -1 ? null : span.Slice(0, slash).ToString(); + + return WithGroup(config, name ?? path, id, @namespace, description, visibility, addDefaultUserAsMaintainer, configure: group => + { + group.Path = path; + configure?.Invoke(group); + }); + } + /// /// Add a project description in config /// @@ -1141,6 +1170,7 @@ private static void CreateGroup(GitLabServer server, GitLabGroup group) var grp = new Group(group.Name ?? throw new ArgumentException(@"group.Name == null", nameof(group))) { Id = group.Id, + Path = group.Path, Description = group.Description, Visibility = group.Visibility ?? group.Parent.DefaultVisibility, }; diff --git a/NGitLab.Mock/GroupCollection.cs b/NGitLab.Mock/GroupCollection.cs index fd15e88e..415b2568 100644 --- a/NGitLab.Mock/GroupCollection.cs +++ b/NGitLab.Mock/GroupCollection.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace NGitLab.Mock; @@ -18,6 +19,37 @@ public override void Add(Group group) { group.Id = Server.GetNewGroupId(); } + else if (this.Any(g => StringComparer.OrdinalIgnoreCase.Equals(g.Id, group.Id))) + { + // Cannot do this in GitLab + throw new NotSupportedException("Duplicate group id"); + } + + if (group.Name == null && group.Path == null) + { + throw new GitLabException("Missing name and path") + { + // actual GitLab error + StatusCode = System.Net.HttpStatusCode.BadRequest, + ErrorMessage = """name is missing, path is missing""", + }; + } + + // Auto-generate the Path or Name... + group.Path ??= Slug.Create(group.Name); + group.Name ??= group.Path; + + // Check for conflicts. + // Mimics GitLab behavior... + if (this.Any(g => g.Parent == group.Parent && StringComparer.OrdinalIgnoreCase.Equals(g.Path, group.Path))) + { + throw new GitLabException("Duplicate group path") + { + // actual GitLab error + StatusCode = System.Net.HttpStatusCode.BadRequest, + ErrorMessage = """Failed to save group {:path=>["has already been taken"]}""", + }; + } base.Add(group); } diff --git a/NGitLab.Mock/PublicAPI.Unshipped.txt b/NGitLab.Mock/PublicAPI.Unshipped.txt index 5953b669..a6009480 100644 --- a/NGitLab.Mock/PublicAPI.Unshipped.txt +++ b/NGitLab.Mock/PublicAPI.Unshipped.txt @@ -135,6 +135,8 @@ NGitLab.Mock.Config.GitLabConfig.Serialize() -> string NGitLab.Mock.Config.GitLabConfig.Url.get -> string NGitLab.Mock.Config.GitLabConfig.Url.set -> void NGitLab.Mock.Config.GitLabGroup.Milestones.get -> NGitLab.Mock.Config.GitLabMilestonesCollection +NGitLab.Mock.Config.GitLabGroup.Path.get -> string +NGitLab.Mock.Config.GitLabGroup.Path.set -> void NGitLab.Mock.Config.GitLabGroup.Visibility.get -> NGitLab.Models.VisibilityLevel? NGitLab.Mock.Config.GitLabIssue.Comments.get -> NGitLab.Mock.Config.GitLabCommentsCollection NGitLab.Mock.Config.GitLabIssue.CreatedAt.get -> System.DateTime? @@ -1234,6 +1236,7 @@ static NGitLab.Mock.Config.GitLabHelpers.WithComment(this NGitLab.Mock.Config.Gi static NGitLab.Mock.Config.GitLabHelpers.WithCommit(this NGitLab.Mock.Config.GitLabProject project, string message = null, string user = null, string sourceBranch = null, string targetBranch = null, string fromBranch = null, System.Collections.Generic.IEnumerable tags = null, string alias = null, System.Action configure = null) -> NGitLab.Mock.Config.GitLabProject static NGitLab.Mock.Config.GitLabHelpers.WithCommit(this NGitLab.Mock.Config.GitLabProject project, string message, string user, System.Action configure) -> NGitLab.Mock.Config.GitLabProject static NGitLab.Mock.Config.GitLabHelpers.WithGroup(this NGitLab.Mock.Config.GitLabConfig config, string name = null, int id = 0, string namespace = null, string description = null, NGitLab.Models.VisibilityLevel? visibility = null, bool addDefaultUserAsMaintainer = false, System.Action configure = null) -> NGitLab.Mock.Config.GitLabConfig +static NGitLab.Mock.Config.GitLabHelpers.WithGroupOfFullPath(this NGitLab.Mock.Config.GitLabConfig config, string fullPath, string name = null, int id = 0, string description = null, NGitLab.Models.VisibilityLevel? visibility = null, bool addDefaultUserAsMaintainer = false, System.Action configure = null) -> NGitLab.Mock.Config.GitLabConfig static NGitLab.Mock.Config.GitLabHelpers.WithGroupPermission(this NGitLab.Mock.Config.GitLabGroup grp, string groupName, NGitLab.Models.AccessLevel level) -> NGitLab.Mock.Config.GitLabGroup static NGitLab.Mock.Config.GitLabHelpers.WithGroupPermission(this NGitLab.Mock.Config.GitLabProject project, string groupName, NGitLab.Models.AccessLevel level) -> NGitLab.Mock.Config.GitLabProject static NGitLab.Mock.Config.GitLabHelpers.WithIssue(this NGitLab.Mock.Config.GitLabProject project, string title = null, int id = 0, string description = null, string author = null, string assignee = null, string milestone = null, System.DateTime? createdAt = null, System.DateTime? updatedAt = null, System.DateTime? closedAt = null, System.Collections.Generic.IEnumerable labels = null, System.Action configure = null) -> NGitLab.Mock.Config.GitLabProject diff --git a/NGitLab.Mock/UserCollection.cs b/NGitLab.Mock/UserCollection.cs index 3deed171..69d7a50d 100644 --- a/NGitLab.Mock/UserCollection.cs +++ b/NGitLab.Mock/UserCollection.cs @@ -17,7 +17,7 @@ public User GetById(string id) if (int.TryParse(id, NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return GetById(value); - return null; + return this.FirstOrDefault(u => StringComparer.OrdinalIgnoreCase.Equals(u.UserName, id)); } public User GetById(int id) => this.FirstOrDefault(user => user.Id == id); @@ -46,7 +46,30 @@ public override void Add(User user) } else if (GetById(user.Id) != null) { - throw new GitLabException("User already exists"); + // Cannot do this in GitLab + throw new NotSupportedException("Duplicate user id"); + } + + // Check for conflicts. + // Mimics GitLab behavior by checking email first, then username... + if (this.Any(u => StringComparer.OrdinalIgnoreCase.Equals(u.Email, user.Email))) + { + throw new GitLabException("Duplicate user email") + { + // actual GitLab error + StatusCode = System.Net.HttpStatusCode.Conflict, + ErrorMessage = "Email has already been taken", + }; + } + + if (this.Any(u => StringComparer.OrdinalIgnoreCase.Equals(u.UserName, user.UserName))) + { + throw new GitLabException("Duplicate user name") + { + // actual GitLab error + StatusCode = System.Net.HttpStatusCode.Conflict, + ErrorMessage = "Username has already been taken", + }; } Server.Groups.Add(new Group(user)); diff --git a/NGitLab.Tests/MembersClientTests.cs b/NGitLab.Tests/MembersClientTests.cs index 5b7173f0..2bcc8286 100644 --- a/NGitLab.Tests/MembersClientTests.cs +++ b/NGitLab.Tests/MembersClientTests.cs @@ -151,4 +151,96 @@ public async Task GetAccessLevelMemberOfGroup() var groupUser = context.Client.Members.GetMemberOfGroup(groupId, user.Id.ToString(CultureInfo.InvariantCulture)); Assert.That((AccessLevel)groupUser.AccessLevel, Is.EqualTo(AccessLevel.Developer)); } + + [Test] + public async Task AsyncMethodsBehaveAsExpected() + { + // Arrange + using var context = await GitLabTestContext.CreateAsync(); + var client = context.Client; + var ownerName = client.Users.Current.Username; + + // Create a user, a top-level group, a group project, and a subgroup... + context.CreateNewUser(out var user1); + var group1 = context.CreateGroup(); + var group2 = context.CreateSubgroup(group1.Id); + var project = context.CreateProject(group1.Id); + + var user1Name = user1.Username; + var user1Id = user1.Id.ToString(); + + // Add the user to the top-level group as a Maintainer... + await client.Members.AddMemberToGroupAsync(group1, new() + { + AccessLevel = AccessLevel.Maintainer, + UserId = user1Id, + }); + + // Act + // Assert + var projectId = project.PathWithNamespace!; + var groupId = group2.FullPath!; + + // Does NOT search inherited permission by default... + AssertThrowsGitLabException(() => client.Members.GetMemberOfProjectAsync(projectId, user1.Id), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.GetMemberOfGroupAsync(groupId, user1.Id), System.Net.HttpStatusCode.NotFound); + Assert.That(client.Members.OfProjectAsync(projectId).Select(m => m.UserName), Is.Empty); + Assert.That(client.Members.OfGroupAsync(groupId).Select(m => m.UserName), Is.EquivalentTo(new[] { ownerName })); + + // Does search inherited permission when asked... + await client.Members.GetMemberOfProjectAsync(projectId, user1.Id, includeInheritedMembers: true); + await client.Members.GetMemberOfGroupAsync(groupId, user1.Id, includeInheritedMembers: true); + Assert.That(client.Members.OfProjectAsync(projectId, includeInheritedMembers: true).Select(m => m.UserName), Is.EquivalentTo(new[] { ownerName, user1Name })); + Assert.That(client.Members.OfGroupAsync(groupId, includeInheritedMembers: true).Select(m => m.UserName), Is.EquivalentTo(new[] { ownerName, user1Name })); + + // Cannot update non-existent membership... + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), System.Net.HttpStatusCode.NotFound); + + // Cannot add membership with an access-level lower than inherited... + AssertThrowsGitLabException(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + AssertThrowsGitLabException(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + + // Can add membership with greater than or equal access-level... + await AssertReturnsMembership(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Maintainer }), AccessLevel.Maintainer); + await AssertReturnsMembership(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Maintainer }), AccessLevel.Maintainer); + + // Cannot add duplicate membership... + AssertThrowsGitLabException(() => client.Members.AddMemberToProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), System.Net.HttpStatusCode.Conflict); + AssertThrowsGitLabException(() => client.Members.AddMemberToGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), System.Net.HttpStatusCode.Conflict); + + // Can raise access-level above inherited... + await AssertReturnsMembership(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), AccessLevel.Owner); + await AssertReturnsMembership(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Owner }), AccessLevel.Owner); + + // Can decrease access-level to inherited... + await AssertReturnsMembership(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Maintainer }), AccessLevel.Maintainer); + await AssertReturnsMembership(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Maintainer }), AccessLevel.Maintainer); + + // Cannot decrease access-level lower than inherited... + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfProjectAsync(projectId, new() { UserId = user1Id, AccessLevel = AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + AssertThrowsGitLabException(() => client.Members.UpdateMemberOfGroupAsync(groupId, new() { UserId = user1Id, AccessLevel = AccessLevel.Reporter }), System.Net.HttpStatusCode.BadRequest); + + // Can delete... + await client.Members.RemoveMemberFromProjectAsync(projectId, user1.Id); + await client.Members.RemoveMemberFromGroupAsync(groupId, user1.Id); + + // Delete fails when not exist... + AssertThrowsGitLabException(() => client.Members.RemoveMemberFromProjectAsync(projectId, user1.Id), System.Net.HttpStatusCode.NotFound); + AssertThrowsGitLabException(() => client.Members.RemoveMemberFromGroupAsync(groupId, user1.Id), System.Net.HttpStatusCode.NotFound); + } + + private static async Task AssertReturnsMembership(Func> code, AccessLevel expectedAccessLevel) + { + var membership = await code.Invoke().ConfigureAwait(false); + Assert.That(membership, Is.Not.Null); + Assert.That(membership.AccessLevel, Is.EqualTo((int)expectedAccessLevel)); + } + + private static void AssertThrowsGitLabException(AsyncTestDelegate code, System.Net.HttpStatusCode expectedStatusCode) + { + var ex = Assert.CatchAsync(typeof(GitLabException), code) as GitLabException; + Assert.That(ex, Is.Not.Null); + Assert.That(ex.StatusCode, Is.EqualTo(expectedStatusCode)); + } } diff --git a/NGitLab/IMembersClient.cs b/NGitLab/IMembersClient.cs index f03a95b7..96b14ac0 100644 --- a/NGitLab/IMembersClient.cs +++ b/NGitLab/IMembersClient.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using NGitLab.Models; namespace NGitLab; @@ -13,6 +15,24 @@ public interface IMembersClient IEnumerable OfProject(string projectId, bool includeInheritedMembers); + GitLabCollectionResponse OfProjectAsync(ProjectId projectId, bool includeInheritedMembers = false); + + Membership GetMemberOfProject(string projectId, string userId); + + Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers); + + Task GetMemberOfProjectAsync(ProjectId projectId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default); + + Membership AddMemberToProject(string projectId, ProjectMemberCreate user); + + Task AddMemberToProjectAsync(ProjectId projectId, ProjectMemberCreate user, CancellationToken cancellationToken = default); + + Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate user); + + Task UpdateMemberOfProjectAsync(ProjectId projectId, ProjectMemberUpdate user, CancellationToken cancellationToken = default); + + Task RemoveMemberFromProjectAsync(ProjectId projectId, long userId, CancellationToken cancellationToken = default); + [Obsolete("Use OfGroup")] IEnumerable OfNamespace(string groupId); @@ -20,17 +40,21 @@ public interface IMembersClient IEnumerable OfGroup(string groupId, bool includeInheritedMembers); - Membership GetMemberOfGroup(string groupId, string userId); + GitLabCollectionResponse OfGroupAsync(GroupId groupId, bool includeInheritedMembers = false); - Membership GetMemberOfProject(string projectId, string userId); + Membership GetMemberOfGroup(string groupId, string userId); - Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers); + Membership GetMemberOfGroup(string groupId, string userId, bool includeInheritedMembers); - Membership AddMemberToProject(string projectId, ProjectMemberCreate user); - - Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate user); + Task GetMemberOfGroupAsync(GroupId groupId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default); Membership AddMemberToGroup(string groupId, GroupMemberCreate user); + Task AddMemberToGroupAsync(GroupId groupId, GroupMemberCreate user, CancellationToken cancellationToken = default); + Membership UpdateMemberOfGroup(string groupId, GroupMemberUpdate user); + + Task UpdateMemberOfGroupAsync(GroupId groupId, GroupMemberUpdate user, CancellationToken cancellationToken = default); + + Task RemoveMemberFromGroupAsync(GroupId groupId, long userId, CancellationToken cancellationToken = default); } diff --git a/NGitLab/IUserClient.cs b/NGitLab/IUserClient.cs index ac99b2b3..15fb84d2 100644 --- a/NGitLab/IUserClient.cs +++ b/NGitLab/IUserClient.cs @@ -16,6 +16,8 @@ public interface IUserClient Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task GetByUserNameAsync(string username, CancellationToken cancellationToken = default); + User Create(UserUpsert user); Task CreateAsync(UserUpsert user, CancellationToken cancellationToken = default); diff --git a/NGitLab/Impl/MembersClient.cs b/NGitLab/Impl/MembersClient.cs index ab609074..3da6eaf4 100644 --- a/NGitLab/Impl/MembersClient.cs +++ b/NGitLab/Impl/MembersClient.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NGitLab.Extensions; using NGitLab.Models; namespace NGitLab.Impl; @@ -25,6 +28,17 @@ private IEnumerable GetAll(string url, bool includeInheritedMembers) return _api.Get().GetAll(url); } + private GitLabCollectionResponse GetAllAsync(string url, bool includeInheritedMembers) + { + url += "/members"; + if (includeInheritedMembers) + { + url += "/all"; + } + + return _api.Get().GetAllAsync(url); + } + public IEnumerable OfProject(string projectId) { return OfProject(projectId, includeInheritedMembers: false); @@ -32,7 +46,54 @@ public IEnumerable OfProject(string projectId) public IEnumerable OfProject(string projectId, bool includeInheritedMembers) { - return GetAll(Project.Url + "/" + WebUtility.UrlEncode(projectId), includeInheritedMembers); + return GetAll($"{Project.Url}/{WebUtility.UrlEncode(projectId)}", includeInheritedMembers); + } + + public GitLabCollectionResponse OfProjectAsync(ProjectId projectId, bool includeInheritedMembers = false) + { + return GetAllAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}", includeInheritedMembers); + } + + public Membership GetMemberOfProject(string projectId, string userId) + { + return GetMemberOfProject(projectId, userId, includeInheritedMembers: false); + } + + public Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) + { + var url = $"{Project.Url}/{WebUtility.UrlEncode(projectId)}/members/{(includeInheritedMembers ? "all/" : string.Empty)}{WebUtility.UrlEncode(userId)}"; + return _api.Get().To(url); + } + + public Task GetMemberOfProjectAsync(ProjectId projectId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default) + { + var url = $"{Project.Url}/{projectId.ValueAsUriParameter()}/members/{(includeInheritedMembers ? "all/" : string.Empty)}{userId.ToStringInvariant()}"; + return _api.Get().ToAsync(url, cancellationToken); + } + + public Membership AddMemberToProject(string projectId, ProjectMemberCreate user) + { + return _api.Post().With(user).To($"{Project.Url}/{WebUtility.UrlEncode(projectId)}/members"); + } + + public Task AddMemberToProjectAsync(ProjectId projectId, ProjectMemberCreate user, CancellationToken cancellationToken = default) + { + return _api.Post().With(user).ToAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}/members", cancellationToken); + } + + public Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate user) + { + return _api.Put().With(user).To($"{Project.Url}/{WebUtility.UrlEncode(projectId)}/members/{WebUtility.UrlEncode(user.UserId)}"); + } + + public Task UpdateMemberOfProjectAsync(ProjectId projectId, ProjectMemberUpdate user, CancellationToken cancellationToken = default) + { + return _api.Put().With(user).ToAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}/members/{WebUtility.UrlEncode(user.UserId)}", cancellationToken); + } + + public Task RemoveMemberFromProjectAsync(ProjectId projectId, long userId, CancellationToken cancellationToken = default) + { + return _api.Delete().ExecuteAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}/members/{userId.ToStringInvariant()}", cancellationToken); } [Obsolete("Use OfGroup")] @@ -48,43 +109,53 @@ public IEnumerable OfGroup(string groupId) public IEnumerable OfGroup(string groupId, bool includeInheritedMembers) { - return GetAll(GroupsClient.Url + "/" + WebUtility.UrlEncode(groupId), includeInheritedMembers); + return GetAll($"{Group.Url}/{WebUtility.UrlEncode(groupId)}", includeInheritedMembers); + } + + public GitLabCollectionResponse OfGroupAsync(GroupId groupId, bool includeInheritedMembers = false) + { + return GetAllAsync($"{Group.Url}/{groupId.ValueAsUriParameter()}", includeInheritedMembers); } public Membership GetMemberOfGroup(string groupId, string userId) { - return _api.Get().To(GroupsClient.Url + "/" + WebUtility.UrlEncode(groupId) + "/members/" + WebUtility.UrlEncode(userId)); + return GetMemberOfGroup(groupId, userId, includeInheritedMembers: false); } - public Membership GetMemberOfProject(string projectId, string userId) + public Membership GetMemberOfGroup(string groupId, string userId, bool includeInheritedMembers) { - return GetMemberOfProject(projectId, userId, includeInheritedMembers: false); + var tailAPIUrl = $"{Group.Url}/{WebUtility.UrlEncode(groupId)}/members/{(includeInheritedMembers ? "all/" : string.Empty)}{WebUtility.UrlEncode(userId)}"; + return _api.Get().To(tailAPIUrl); } - public Membership GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) + public Task GetMemberOfGroupAsync(GroupId groupId, long userId, bool includeInheritedMembers = false, CancellationToken cancellationToken = default) { - var url = $"{Project.Url}/{WebUtility.UrlEncode(projectId)}/members/{(includeInheritedMembers ? "all/" : string.Empty)}{WebUtility.UrlEncode(userId)}"; + var tailAPIUrl = $"{Group.Url}/{groupId.ValueAsUriParameter()}/members/{(includeInheritedMembers ? "all/" : string.Empty)}{userId.ToStringInvariant()}"; + return _api.Get().ToAsync(tailAPIUrl, cancellationToken); + } - return _api.Get().To(url); + public Membership AddMemberToGroup(string groupId, GroupMemberCreate user) + { + return _api.Post().With(user).To($"{Group.Url}/{WebUtility.UrlEncode(groupId)}/members"); } - public Membership AddMemberToProject(string projectId, ProjectMemberCreate user) + public Task AddMemberToGroupAsync(GroupId groupId, GroupMemberCreate user, CancellationToken cancellationToken = default) { - return _api.Post().With(user).To(Project.Url + "/" + WebUtility.UrlEncode(projectId) + "/members"); + return _api.Post().With(user).ToAsync($"{Group.Url}/{groupId.ValueAsUriParameter()}/members", cancellationToken); } - public Membership UpdateMemberOfProject(string projectId, ProjectMemberUpdate user) + public Membership UpdateMemberOfGroup(string groupId, GroupMemberUpdate user) { - return _api.Put().With(user).To(Project.Url + "/" + WebUtility.UrlEncode(projectId) + "/members/" + WebUtility.UrlEncode(user.UserId)); + return _api.Put().With(user).To($"{Group.Url}/{WebUtility.UrlEncode(groupId)}/members/{WebUtility.UrlEncode(user.UserId)}"); } - public Membership AddMemberToGroup(string groupId, GroupMemberCreate user) + public Task UpdateMemberOfGroupAsync(GroupId groupId, GroupMemberUpdate user, CancellationToken cancellationToken = default) { - return _api.Post().With(user).To(Group.Url + "/" + WebUtility.UrlEncode(groupId) + "/members"); + return _api.Put().With(user).ToAsync($"{Group.Url}/{groupId.ValueAsUriParameter()}/members/{WebUtility.UrlEncode(user.UserId)}", cancellationToken); } - public Membership UpdateMemberOfGroup(string groupId, GroupMemberUpdate user) + public Task RemoveMemberFromGroupAsync(GroupId groupId, long userId, CancellationToken cancellationToken = default) { - return _api.Put().With(user).To(Group.Url + "/" + WebUtility.UrlEncode(groupId) + "/members/" + WebUtility.UrlEncode(user.UserId)); + return _api.Delete().ExecuteAsync($"{Group.Url}/{groupId.ValueAsUriParameter()}/members/{userId.ToStringInvariant()}", cancellationToken); } } diff --git a/NGitLab/Impl/UserClient.cs b/NGitLab/Impl/UserClient.cs index 5c7145fd..c69b9e66 100644 --- a/NGitLab/Impl/UserClient.cs +++ b/NGitLab/Impl/UserClient.cs @@ -27,6 +27,21 @@ public Task GetByIdAsync(int id, CancellationToken cancellationToken = def return _api.Get().ToAsync(User.Url + "/" + id.ToStringInvariant(), cancellationToken); } + public async Task GetByUserNameAsync(string username, CancellationToken cancellationToken = default) + { + // This query returns 0 or 1 user. + await foreach (var u in _api.Get().GetAllAsync(User.Url + "?username=" + username).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + return u; + } + + throw new GitLabException("User not found.") + { + StatusCode = System.Net.HttpStatusCode.NotFound, + ErrorMessage = "User not found.", + }; + } + public IEnumerable Get(string username) => _api.Get().GetAll(User.Url + "?username=" + username); public IEnumerable Get(UserQuery query) diff --git a/NGitLab/Models/GroupMemberCreate.cs b/NGitLab/Models/GroupMemberCreate.cs index 071912b3..f6376dec 100644 --- a/NGitLab/Models/GroupMemberCreate.cs +++ b/NGitLab/Models/GroupMemberCreate.cs @@ -4,12 +4,18 @@ namespace NGitLab.Models; public class GroupMemberCreate { + /// + /// The Id of the user. Must be an integer value. + /// [JsonPropertyName("user_id")] public string UserId; [JsonPropertyName("access_level")] public AccessLevel AccessLevel; + /// + /// The optional expiration date. Must be null or a value like "yyyy-MM-dd". + /// [JsonPropertyName("expires_at")] public string ExpiresAt; } diff --git a/NGitLab/Models/GroupMemberUpdate.cs b/NGitLab/Models/GroupMemberUpdate.cs index 110e1bb4..41e6573f 100644 --- a/NGitLab/Models/GroupMemberUpdate.cs +++ b/NGitLab/Models/GroupMemberUpdate.cs @@ -4,12 +4,18 @@ namespace NGitLab.Models; public class GroupMemberUpdate { + /// + /// The Id of the user. Must be an integer value. + /// [JsonPropertyName("user_id")] public string UserId; [JsonPropertyName("access_level")] public AccessLevel AccessLevel; + /// + /// The optional expiration date. Must be null or a value like "yyyy-MM-dd". + /// [JsonPropertyName("expires_at")] public string ExpiresAt; } diff --git a/NGitLab/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI.Unshipped.txt index d7d8c5df..c0a663c2 100644 --- a/NGitLab/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI.Unshipped.txt @@ -379,17 +379,28 @@ NGitLab.ILintClient.ValidateCIYamlContentAsync(string projectId, string yamlCont NGitLab.ILintClient.ValidateProjectCIConfigurationAsync(string projectId, NGitLab.Models.LintCIOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient NGitLab.IMembersClient.AddMemberToGroup(string groupId, NGitLab.Models.GroupMemberCreate user) -> NGitLab.Models.Membership +NGitLab.IMembersClient.AddMemberToGroupAsync(NGitLab.Models.GroupId groupId, NGitLab.Models.GroupMemberCreate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.AddMemberToProject(string projectId, NGitLab.Models.ProjectMemberCreate user) -> NGitLab.Models.Membership +NGitLab.IMembersClient.AddMemberToProjectAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectMemberCreate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.GetMemberOfGroup(string groupId, string userId) -> NGitLab.Models.Membership +NGitLab.IMembersClient.GetMemberOfGroup(string groupId, string userId, bool includeInheritedMembers) -> NGitLab.Models.Membership +NGitLab.IMembersClient.GetMemberOfGroupAsync(NGitLab.Models.GroupId groupId, long userId, bool includeInheritedMembers = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.GetMemberOfProject(string projectId, string userId) -> NGitLab.Models.Membership NGitLab.IMembersClient.GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) -> NGitLab.Models.Membership +NGitLab.IMembersClient.GetMemberOfProjectAsync(NGitLab.Models.ProjectId projectId, long userId, bool includeInheritedMembers = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.OfGroup(string groupId) -> System.Collections.Generic.IEnumerable NGitLab.IMembersClient.OfGroup(string groupId, bool includeInheritedMembers) -> System.Collections.Generic.IEnumerable +NGitLab.IMembersClient.OfGroupAsync(NGitLab.Models.GroupId groupId, bool includeInheritedMembers = false) -> NGitLab.GitLabCollectionResponse NGitLab.IMembersClient.OfNamespace(string groupId) -> System.Collections.Generic.IEnumerable NGitLab.IMembersClient.OfProject(string projectId) -> System.Collections.Generic.IEnumerable NGitLab.IMembersClient.OfProject(string projectId, bool includeInheritedMembers) -> System.Collections.Generic.IEnumerable +NGitLab.IMembersClient.OfProjectAsync(NGitLab.Models.ProjectId projectId, bool includeInheritedMembers = false) -> NGitLab.GitLabCollectionResponse +NGitLab.IMembersClient.RemoveMemberFromGroupAsync(NGitLab.Models.GroupId groupId, long userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IMembersClient.RemoveMemberFromProjectAsync(NGitLab.Models.ProjectId projectId, long userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.UpdateMemberOfGroup(string groupId, NGitLab.Models.GroupMemberUpdate user) -> NGitLab.Models.Membership +NGitLab.IMembersClient.UpdateMemberOfGroupAsync(NGitLab.Models.GroupId groupId, NGitLab.Models.GroupMemberUpdate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMembersClient.UpdateMemberOfProject(string projectId, NGitLab.Models.ProjectMemberUpdate user) -> NGitLab.Models.Membership +NGitLab.IMembersClient.UpdateMemberOfProjectAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectMemberUpdate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IMergeRequestApprovalClient NGitLab.IMergeRequestApprovalClient.Approvals.get -> NGitLab.Models.MergeRequestApprovals NGitLab.IMergeRequestApprovalClient.ApproveMergeRequest(NGitLab.Models.MergeRequestApproveRequest request = null) -> NGitLab.Models.MergeRequestApprovals @@ -658,18 +669,29 @@ NGitLab.Impl.LintClient.ValidateCIYamlContentAsync(string projectId, string yaml NGitLab.Impl.LintClient.ValidateProjectCIConfigurationAsync(string projectId, NGitLab.Models.LintCIOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient NGitLab.Impl.MembersClient.AddMemberToGroup(string groupId, NGitLab.Models.GroupMemberCreate user) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.AddMemberToGroupAsync(NGitLab.Models.GroupId groupId, NGitLab.Models.GroupMemberCreate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.AddMemberToProject(string projectId, NGitLab.Models.ProjectMemberCreate user) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.AddMemberToProjectAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectMemberCreate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.GetMemberOfGroup(string groupId, string userId) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.GetMemberOfGroup(string groupId, string userId, bool includeInheritedMembers) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.GetMemberOfGroupAsync(NGitLab.Models.GroupId groupId, long userId, bool includeInheritedMembers = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.GetMemberOfProject(string projectId, string userId) -> NGitLab.Models.Membership NGitLab.Impl.MembersClient.GetMemberOfProject(string projectId, string userId, bool includeInheritedMembers) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.GetMemberOfProjectAsync(NGitLab.Models.ProjectId projectId, long userId, bool includeInheritedMembers = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.MembersClient(NGitLab.Impl.API api) -> void NGitLab.Impl.MembersClient.OfGroup(string groupId) -> System.Collections.Generic.IEnumerable NGitLab.Impl.MembersClient.OfGroup(string groupId, bool includeInheritedMembers) -> System.Collections.Generic.IEnumerable +NGitLab.Impl.MembersClient.OfGroupAsync(NGitLab.Models.GroupId groupId, bool includeInheritedMembers = false) -> NGitLab.GitLabCollectionResponse NGitLab.Impl.MembersClient.OfNamespace(string groupId) -> System.Collections.Generic.IEnumerable NGitLab.Impl.MembersClient.OfProject(string projectId) -> System.Collections.Generic.IEnumerable NGitLab.Impl.MembersClient.OfProject(string projectId, bool includeInheritedMembers) -> System.Collections.Generic.IEnumerable +NGitLab.Impl.MembersClient.OfProjectAsync(NGitLab.Models.ProjectId projectId, bool includeInheritedMembers = false) -> NGitLab.GitLabCollectionResponse +NGitLab.Impl.MembersClient.RemoveMemberFromGroupAsync(NGitLab.Models.GroupId groupId, long userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.MembersClient.RemoveMemberFromProjectAsync(NGitLab.Models.ProjectId projectId, long userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.UpdateMemberOfGroup(string groupId, NGitLab.Models.GroupMemberUpdate user) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.UpdateMemberOfGroupAsync(NGitLab.Models.GroupId groupId, NGitLab.Models.GroupMemberUpdate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MembersClient.UpdateMemberOfProject(string projectId, NGitLab.Models.ProjectMemberUpdate user) -> NGitLab.Models.Membership +NGitLab.Impl.MembersClient.UpdateMemberOfProjectAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectMemberUpdate user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.MergeRequestApprovalClient NGitLab.Impl.MergeRequestApprovalClient.Approvals.get -> NGitLab.Models.MergeRequestApprovals NGitLab.Impl.MergeRequestApprovalClient.ApproveMergeRequest(NGitLab.Models.MergeRequestApproveRequest request = null) -> NGitLab.Models.MergeRequestApprovals @@ -931,6 +953,7 @@ NGitLab.Impl.UserClient.Delete(int userId) -> void NGitLab.Impl.UserClient.Get(NGitLab.UserQuery query) -> System.Collections.Generic.IEnumerable NGitLab.Impl.UserClient.Get(string username) -> System.Collections.Generic.IEnumerable NGitLab.Impl.UserClient.GetByIdAsync(int id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.UserClient.GetByUserNameAsync(string username, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.UserClient.GetCurrentUserAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Impl.UserClient.GetLastActivityDatesAsync(System.DateTimeOffset? from = null) -> NGitLab.GitLabCollectionResponse NGitLab.Impl.UserClient.Search(string query) -> System.Collections.Generic.IEnumerable @@ -1141,6 +1164,7 @@ NGitLab.IUserClient.Delete(int id) -> void NGitLab.IUserClient.Get(NGitLab.UserQuery query) -> System.Collections.Generic.IEnumerable NGitLab.IUserClient.Get(string username) -> System.Collections.Generic.IEnumerable NGitLab.IUserClient.GetByIdAsync(int id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IUserClient.GetByUserNameAsync(string username, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IUserClient.GetCurrentUserAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.IUserClient.GetLastActivityDatesAsync(System.DateTimeOffset? from = null) -> NGitLab.GitLabCollectionResponse NGitLab.IUserClient.Search(string query) -> System.Collections.Generic.IEnumerable