diff --git a/NGitLab.Mock.Tests/ProjectsMockTests.cs b/NGitLab.Mock.Tests/ProjectsMockTests.cs index 126e3454..00a2a929 100644 --- a/NGitLab.Mock.Tests/ProjectsMockTests.cs +++ b/NGitLab.Mock.Tests/ProjectsMockTests.cs @@ -442,9 +442,9 @@ public void UpdateAsync_WhenProjectNotFound_ItThrows() } [Test] - public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() + public async Task DeleteAsync_WhenProjectExists_MarksProjectForDeletion() { - var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsDeleted)}"; + var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_MarksProjectForDeletion)}"; using var server = new GitLabConfig() .WithUser("Test", isDefault: true) .WithProjectOfFullPath(projectFullPath) @@ -455,8 +455,9 @@ public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() // Act await projectClient.DeleteAsync(projectFullPath); - // Assert - Assert.CatchAsync((Func)(() => projectClient.GetAsync(projectFullPath))); + // Assert: project is still accessible but marked for deletion + var markedProject = await projectClient.GetAsync(projectFullPath); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null); } [Test] @@ -475,6 +476,100 @@ public void DeleteAsync_WhenProjectNotFound_ItThrows() Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + [Test] + public async Task DeleteAsync_WithoutPermanentlyRemove_MarksProjectForDeletion() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithoutPermanentlyRemove_MarksProjectForDeletion)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // Act + await projectClient.DeleteAsync(project.Id); + + // Assert + var markedProject = await projectClient.GetAsync(project.Id); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null); + // The mock sets MarkedForDeletionOn to UtcNow at the time of soft-deletion. + Assert.That(markedProject.MarkedForDeletionOn!.Value.Date, Is.EqualTo(DateTime.UtcNow.Date)); + } + + [Test] + public async Task DeleteAsync_WithPermanentlyRemove_AndMatchingFullPath_HardDeletes() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithPermanentlyRemove_AndMatchingFullPath_HardDeletes)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // GitLab requires the project to be soft-deleted before permanently_remove can be used. + await projectClient.DeleteAsync(project.Id); + + // Second call: permanently remove + await projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = projectFullPath, + }); + + // Assert: project is now gone + Assert.CatchAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + + [Test] + public async Task DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // GitLab requires the project to be soft-deleted before permanently_remove can be used. + await projectClient.DeleteAsync(project.Id); + + // Act: wrong full_path + var ex = Assert.CatchAsync((Func)(() => + projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = "wrong/path", + }))); + + // Assert + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PermanentlyDeleteAsync_HardDeletesProjectInOneCall() + { + var projectFullPath = $"Test/{nameof(PermanentlyDeleteAsync_HardDeletesProjectInOneCall)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // Act + await projectClient.PermanentlyDeleteAsync(project.Id); + + // Assert: project is gone + Assert.CatchAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + [Test] public async Task GetAndSetProjectJobTokenScope() { diff --git a/NGitLab.Mock/Clients/ProjectClient.cs b/NGitLab.Mock/Clients/ProjectClient.cs index 3c77ca73..2bfdf2c3 100644 --- a/NGitLab.Mock/Clients/ProjectClient.cs +++ b/NGitLab.Mock/Clients/ProjectClient.cs @@ -139,7 +139,7 @@ public void Delete(long id) using (Context.BeginOperationScope()) { var project = GetProject(id, ProjectPermission.Delete); - project.Remove(); + DeleteCore(project, options: null); } } @@ -149,8 +149,43 @@ public async Task DeleteAsync(ProjectId projectId, CancellationToken cancellatio using (Context.BeginOperationScope()) { var project = GetProject(projectId, ProjectPermission.Delete); + DeleteCore(project, options: null); + } + } + + public async Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default) + { + await Task.Yield(); + using (Context.BeginOperationScope()) + { + var project = GetProject(projectId, ProjectPermission.Delete); + DeleteCore(project, options); + } + } + + public async Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + await DeleteAsync(projectId, cancellationToken).ConfigureAwait(false); + var markedProject = await GetAsync(projectId, cancellationToken: cancellationToken).ConfigureAwait(false); + await DeleteAsync(projectId, new ProjectDelete { PermanentlyRemove = true, FullPath = markedProject.PathWithNamespace, }, cancellationToken).ConfigureAwait(false); + } + + private static void DeleteCore(Project project, ProjectDelete options) + { + if (options?.PermanentlyRemove == true) + { + if (!string.Equals(options.FullPath, project.PathWithNamespace, StringComparison.Ordinal)) + throw GitLabException.BadRequest("Project full_path does not match"); + + if (project.MarkedForDeletionOn is null) + throw GitLabException.BadRequest("Project is not marked for deletion"); + project.Remove(); } + else + { + project.MarkedForDeletionOn ??= DateTime.UtcNow; + } } public void Archive(long id) diff --git a/NGitLab.Mock/Project.cs b/NGitLab.Mock/Project.cs index 4813cba4..4abe69a3 100644 --- a/NGitLab.Mock/Project.cs +++ b/NGitLab.Mock/Project.cs @@ -170,6 +170,8 @@ public string[] Tags public SquashOption SquashOption { get; set; } + public DateTime? MarkedForDeletionOn { get; set; } + public void Remove() { Group.Projects.Remove(this); @@ -495,6 +497,7 @@ public Models.Project ToClientProject(User currentUser) OnlyMirrorProtectedBranch = OnlyMirrorProtectedBranch, MirrorOverwritesDivergedBranches = MirrorOverwritesDivergedBranches, Permissions = GetProjectPermissions(currentUser), + MarkedForDeletionOn = MarkedForDeletionOn, }; #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/NGitLab.Mock/PublicAPI.Unshipped.txt b/NGitLab.Mock/PublicAPI.Unshipped.txt index a66180f3..743881ac 100644 --- a/NGitLab.Mock/PublicAPI.Unshipped.txt +++ b/NGitLab.Mock/PublicAPI.Unshipped.txt @@ -1408,3 +1408,5 @@ NGitLab.Mock.Config.GitLabContainerRepository.Tags.get -> System.Collections.Gen NGitLab.Mock.Config.GitLabContainerRepositoriesCollection NGitLab.Mock.Config.GitLabProject.ContainerRepositories.get -> NGitLab.Mock.Config.GitLabContainerRepositoriesCollection static NGitLab.Mock.Config.GitLabHelpers.WithContainerRepository(this NGitLab.Mock.Config.GitLabProject project, string name, System.Collections.Generic.IEnumerable tags = null) -> NGitLab.Mock.Config.GitLabProject +NGitLab.Mock.Project.MarkedForDeletionOn.get -> System.DateTime? +NGitLab.Mock.Project.MarkedForDeletionOn.set -> void diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index d01e68c1..70256e44 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -594,6 +594,34 @@ public async Task DeleteAsync_WhenProjectNotFound_ItThrows() Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + [Test] + [NGitLabRetry] + public async Task DeleteAsync_WhenPermanentlyRemoveOnMarkedProject_ItIsDeleted() + { + using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[18.0,)")); + + var group = context.CreateGroup(); + var project = context.CreateProject(group.Id); + var projectClient = context.Client.Projects; + + // Soft-mark first + await projectClient.DeleteAsync(project.Id); + + var markedProject = await projectClient.GetAsync(project.Id); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null, "Project should be marked for deletion before permanently removing"); + + // Act: permanently remove + await projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = markedProject.PathWithNamespace, + }); + + // Assert: project no longer accessible + Assert.ThrowsAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + // No owner level (50) for project! See https://docs.gitlab.com/ee/api/members.html [TestCase(AccessLevel.Guest)] [TestCase(AccessLevel.Reporter)] diff --git a/NGitLab/IProjectClient.cs b/NGitLab/IProjectClient.cs index 5d186e21..ea8c3d05 100644 --- a/NGitLab/IProjectClient.cs +++ b/NGitLab/IProjectClient.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using NGitLab.Models; @@ -48,8 +49,19 @@ public interface IProjectClient void Delete(long id); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] Task DeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] + Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default); + + /// + /// Permanently deletes a project. Soft-deletes it first (if not already marked for deletion), + /// then fetches the post-deletion path (GitLab renames the project on soft-delete) and issues + /// the permanent removal. + /// + Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default); + void Archive(long id); void Unarchive(long id); diff --git a/NGitLab/Impl/ProjectClient.cs b/NGitLab/Impl/ProjectClient.cs index 25223b3f..d6fdf474 100644 --- a/NGitLab/Impl/ProjectClient.cs +++ b/NGitLab/Impl/ProjectClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net; @@ -39,8 +40,34 @@ public Task CreateAsync(ProjectCreate project, CancellationToken cancel public void Delete(long id) => _api.Delete().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}"); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] public Task DeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) => - _api.Delete().ExecuteAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}", cancellationToken); + DeleteAsync(projectId, options: null, cancellationToken); + + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] + public Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default) + { + var url = $"{Project.Url}/{projectId.ValueAsUriParameter()}"; + if (options is not null) + { + url = Utils.AddParameter(url, "permanently_remove", options.PermanentlyRemove); + if (options.FullPath is not null) + { + url = Utils.AddParameter(url, "full_path", options.FullPath); + } + } + + return _api.Delete().ExecuteAsync(url, cancellationToken); + } + + public async Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + await DeleteAsync(projectId, cancellationToken).ConfigureAwait(false); + // After soft-delete GitLab renames the project (appends a deletion-schedule suffix), + // so we must re-fetch PathWithNamespace rather than using the pre-deletion path. + var markedProject = await GetAsync(projectId, cancellationToken: cancellationToken).ConfigureAwait(false); + await DeleteAsync(projectId, new ProjectDelete { PermanentlyRemove = true, FullPath = markedProject.PathWithNamespace, }, cancellationToken).ConfigureAwait(false); + } public void Archive(long id) => _api.Post().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}/archive"); diff --git a/NGitLab/Models/ProjectDelete.cs b/NGitLab/Models/ProjectDelete.cs new file mode 100644 index 00000000..de31cce5 --- /dev/null +++ b/NGitLab/Models/ProjectDelete.cs @@ -0,0 +1,8 @@ +namespace NGitLab.Models; + +public sealed class ProjectDelete +{ + public bool? PermanentlyRemove { get; set; } + + public string FullPath { get; set; } +} diff --git a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt index cc092230..ce3c6c09 100644 --- a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -5297,3 +5297,13 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt index 94b454b6..f9ee7e11 100644 --- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -5298,3 +5298,13 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt index cc092230..ce3c6c09 100644 --- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -5297,3 +5297,13 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 94b454b6..f9ee7e11 100644 --- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -5298,3 +5298,13 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task