Skip to content
Merged
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
103 changes: 99 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_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)
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,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));
Comment thread
louis-z marked this conversation as resolved.
}

[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);
Comment thread
louis-z marked this conversation as resolved.

// 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);

// GitLab requires the project to be soft-deleted before permanently_remove can be used.
await projectClient.DeleteAsync(project.Id);
Comment thread
louis-z marked this conversation as resolved.

// 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 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<GitLabException>((Func<Task>)(() => projectClient.GetAsync(project.Id)));
}

[Test]
public async Task GetAndSetProjectJobTokenScope()
{
Expand Down
37 changes: 36 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,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)
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
12 changes: 12 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,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);

/// <summary>
/// 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.
/// </summary>
Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default);

void Archive(long id);

void Unarchive(long id);
Expand Down
29 changes: 28 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,34 @@ 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)
{
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");

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; }
}
10 changes: 10 additions & 0 deletions NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading