Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions NGitLab.Mock.Tests/ProjectsMockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,9 @@ public void UpdateAsync_WhenProjectNotFound_ItThrows()
}

[Test]
public async Task DeleteAsync_WhenProjectExists_ItIsDeleted()
public async Task DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion()
{
var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsDeleted)}";
var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion)}";
using var server = new GitLabConfig()
.WithUser("Test", isDefault: true)
.WithProjectOfFullPath(projectFullPath)
Expand All @@ -455,8 +455,9 @@ public async Task DeleteAsync_WhenProjectExists_ItIsDeleted()
// Act
await projectClient.DeleteAsync(projectFullPath);

// Assert
Assert.CatchAsync<GitLabException>((Func<Task>)(() => 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]
Expand All @@ -475,6 +476,80 @@ 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);
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);

// First call: soft-mark
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<GitLabException>((Func<Task>)(() => 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);

// Soft-mark first
await projectClient.DeleteAsync(project.Id);

// Act: wrong full_path
var ex = Assert.CatchAsync<GitLabException>((Func<Task>)(() =>
projectClient.DeleteAsync(project.Id, new ProjectDelete
{
PermanentlyRemove = true,
FullPath = "wrong/path",
})));

// Assert
Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}

[Test]
public async Task GetAndSetProjectJobTokenScope()
{
Expand Down
30 changes: 29 additions & 1 deletion NGitLab.Mock/Clients/ProjectClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public void Delete(long id)
using (Context.BeginOperationScope())
{
var project = GetProject(id, ProjectPermission.Delete);
project.Remove();
DeleteCore(project, options: null);
}
}

Expand All @@ -149,8 +149,36 @@ 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);
}
}

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)
Expand Down
3 changes: 3 additions & 0 deletions NGitLab.Mock/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ public string[] Tags

public SquashOption SquashOption { get; set; }

public DateTime? MarkedForDeletionOn { get; set; }

public void Remove()
{
Group.Projects.Remove(this);
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions NGitLab.Mock/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> tags = null) -> NGitLab.Mock.Config.GitLabProject
NGitLab.Mock.Project.MarkedForDeletionOn.get -> System.DateTime?
NGitLab.Mock.Project.MarkedForDeletionOn.set -> void
28 changes: 28 additions & 0 deletions NGitLab.Tests/ProjectsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitLabException>((Func<Task>)(() => 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)]
Expand Down
5 changes: 5 additions & 0 deletions NGitLab/IProjectClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;
Expand Down Expand Up @@ -48,8 +49,12 @@ 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);

void Archive(long id);

void Unarchive(long id);
Expand Down
21 changes: 20 additions & 1 deletion NGitLab/Impl/ProjectClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Net;
Expand Down Expand Up @@ -39,8 +40,26 @@ public Task<Project> 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)
{
var @operator = url.Contains('?') ? "&" : "?";
url += $"{@operator}full_path={options.FullPath}";
}
}

return _api.Delete().ExecuteAsync(url, cancellationToken);
}

public void Archive(long id) => _api.Post().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}/archive");

Expand Down
8 changes: 8 additions & 0 deletions NGitLab/Models/ProjectDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace NGitLab.Models;

public sealed class ProjectDelete
{
public bool? PermanentlyRemove { get; set; }

public string FullPath { get; set; }
}
8 changes: 8 additions & 0 deletions NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5278,3 +5278,11 @@ 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.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
8 changes: 8 additions & 0 deletions NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5279,3 +5279,11 @@ 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.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
8 changes: 8 additions & 0 deletions NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5278,3 +5278,11 @@ 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.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
8 changes: 8 additions & 0 deletions NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5279,3 +5279,11 @@ 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.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
Loading