Add management api delete document endpoints (#15600)

* Added Delete document endpoint

* Added delete from recylebin endpoint

Added/restructured unittests

* Clarifications

* Moved document specific mapping from base recyclebin to document recyclebin

* Refactor OperationStatusResult mapping trough inheritance

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
This commit is contained in:
Sven Geusens
2024-01-22 15:58:18 +01:00
committed by GitHub
parent bb0395349f
commit acc71b6d45
11 changed files with 224 additions and 28 deletions

View File

@@ -0,0 +1,57 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization.Content;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class DeleteDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public DeleteDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpDelete("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionDelete.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
Attempt<IContent?, ContentEditingOperationStatus> result = await _contentEditingService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor));
return result.Success
? Ok()
: ContentEditingOperationStatusResult(result.Status);
}
}

View File

@@ -0,0 +1,59 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization.Content;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin;
[ApiVersion("1.0")]
public class DeleteDocumentRecycleBinController : DocumentRecycleBinControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IContentEditingService _contentEditingService;
public DeleteDocumentRecycleBinController(
IEntityService entityService,
IAuthorizationService authorizationService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IContentEditingService contentEditingService)
: base(entityService)
{
_authorizationService = authorizationService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_contentEditingService = contentEditingService;
}
[HttpDelete("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionDelete.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
Attempt<IContent?, ContentEditingOperationStatus> result = await _contentEditingService.DeleteFromRecycleBinAsync(id, CurrentUserKey(_backOfficeSecurityAccessor));
return result.Success
? Ok()
: ContentEditingOperationStatusResult(result.Status);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
@@ -9,6 +10,7 @@ using Umbraco.Cms.Api.Management.Controllers.RecycleBin;
using Umbraco.Cms.Api.Management.Filters;
using Umbraco.Cms.Api.Management.ViewModels.RecycleBin;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin;

View File

@@ -6,11 +6,12 @@ using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Services.Paging;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Content;
using Umbraco.Cms.Api.Management.ViewModels.RecycleBin;
namespace Umbraco.Cms.Api.Management.Controllers.RecycleBin;
public abstract class RecycleBinControllerBase<TItem> : ManagementApiControllerBase
public abstract class RecycleBinControllerBase<TItem> : ContentControllerBase
where TItem : RecycleBinItemResponseModel, new()
{
private readonly IEntityService _entityService;

View File

@@ -80,8 +80,11 @@ internal sealed class ContentEditingService
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey)
=> await HandleMoveToRecycleBinAsync(key, userKey);
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> DeleteFromRecycleBinAsync(Guid key, Guid userKey)
=> await HandleDeleteAsync(key, userKey, true);
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> DeleteAsync(Guid key, Guid userKey)
=> await HandleDeleteAsync(key, userKey);
=> await HandleDeleteAsync(key, userKey, false);
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveAsync(Guid key, Guid? parentKey, Guid userKey)
=> await HandleMoveAsync(key, parentKey, userKey);

View File

@@ -103,13 +103,17 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
}
protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleMoveToRecycleBinAsync(Guid key, Guid userKey)
=> await HandleDeletionAsync(key, userKey, false, MoveToRecycleBin);
=> await HandleDeletionAsync(key, userKey, ContentTrashStatusRequirement.MustNotBeTrashed, MoveToRecycleBin);
protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleDeleteAsync(Guid key, Guid userKey)
=> await HandleDeletionAsync(key, userKey, true, Delete);
protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleDeleteAsync(Guid key, Guid userKey, bool mustBeTrashed = true)
=> await HandleDeletionAsync(key, userKey, mustBeTrashed ? ContentTrashStatusRequirement.MustBeTrashed : ContentTrashStatusRequirement.Irrelevant, Delete);
// helper method to perform move-to-recycle-bin and delete for content as they are very much handled in the same way
private async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleDeletionAsync(Guid key, Guid userKey, bool mustBeTrashed, Func<TContent, int, OperationResult?> performDelete)
// helper method to perform move-to-recycle-bin, delete-from-recycle-bin and delete for content as they are very much handled in the same way
// IContentEditingService methods hitting this (ContentTrashStatusRequirement, calledFunction):
// DeleteAsync (irrelevant, Delete)
// MoveToRecycleBinAsync (MustNotBeTrashed, MoveToRecycleBin)
// DeleteFromRecycleBinAsync (MustBeTrashed, Delete)
private async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleDeletionAsync(Guid key, Guid userKey, ContentTrashStatusRequirement trashStatusRequirement, Func<TContent, int, OperationResult?> performDelete)
{
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
TContent? content = ContentService.GetById(key);
@@ -118,9 +122,11 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return await Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, content));
}
if (content.Trashed != mustBeTrashed)
// checking the trash status is not done when it is irrelevant
if ((trashStatusRequirement is ContentTrashStatusRequirement.MustBeTrashed && content.Trashed is false)
|| (trashStatusRequirement is ContentTrashStatusRequirement.MustNotBeTrashed && content.Trashed is true))
{
ContentEditingOperationStatus status = mustBeTrashed
ContentEditingOperationStatus status = trashStatusRequirement is ContentTrashStatusRequirement.MustBeTrashed
? ContentEditingOperationStatus.NotInTrash
: ContentEditingOperationStatus.InTrash;
return await Task.FromResult(Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(status, content));
@@ -470,4 +476,14 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
private static Dictionary<string, IPropertyType> GetPropertyTypesByAlias(TContentType contentType)
=> contentType.CompositionPropertyTypes.ToDictionary(pt => pt.Alias);
/// <summary>
/// Should never be made public, serves the purpose of a nullable bool but more readable.
/// </summary>
private enum ContentTrashStatusRequirement
{
Irrelevant,
MustBeTrashed,
MustNotBeTrashed
}
}

View File

@@ -14,11 +14,19 @@ public interface IContentEditingService
Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey);
Task<Attempt<IContent?, ContentEditingOperationStatus>> DeleteAsync(Guid key, Guid userKey);
/// <summary>
/// Deletes a Content Item if it is in the recycle bin.
/// </summary>
Task<Attempt<IContent?, ContentEditingOperationStatus>> DeleteFromRecycleBinAsync(Guid key, Guid userKey);
Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveAsync(Guid key, Guid? parentKey, Guid userKey);
Task<Attempt<IContent?, ContentEditingOperationStatus>> CopyAsync(Guid key, Guid? parentKey, bool relateToOriginal, bool includeDescendants, Guid userKey);
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Deletes a Content Item whether it is in the recycle bin or not.
/// </summary>
Task<Attempt<IContent?, ContentEditingOperationStatus>> DeleteAsync(Guid key, Guid userKey);
}

View File

@@ -8,11 +8,26 @@ public partial class ContentEditingServiceTests
{
[TestCase(true)]
[TestCase(false)]
public async Task Can_Delete(bool variant)
public async Task Can_Delete_FromRecycleBin(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey);
var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status);
// re-get and verify deletion
content = await ContentEditingService.GetAsync(content.Key);
Assert.IsNull(content);
}
[TestCase(true)]
[TestCase(false)]
public async Task Can_Delete_FromOutsideOfRecycleBin(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
var result = await ContentEditingService.DeleteAsync(content.Key, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status);
@@ -29,19 +44,4 @@ public partial class ContentEditingServiceTests
Assert.IsFalse(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status);
}
[TestCase(true)]
[TestCase(false)]
public async Task Cannot_Delete_If_Not_In_Recycle_Bin(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
var result = await ContentEditingService.DeleteAsync(content.Key, Constants.Security.SuperUserKey);
Assert.IsFalse(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.NotInTrash, result.Status);
// re-get and verify that deletion did not happen
content = await ContentEditingService.GetAsync(content.Key);
Assert.IsNotNull(content);
}
}

View File

@@ -0,0 +1,47 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
public partial class ContentEditingServiceTests
{
[TestCase(true)]
[TestCase(false)]
public async Task Can_DeleteFromRecycleBin_If_InsideRecycleBin(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey);
var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status);
// re-get and verify deletion
content = await ContentEditingService.GetAsync(content.Key);
Assert.IsNull(content);
}
[Test]
public async Task Cannot_Delete_FromRecycleBin_Non_Existing()
{
var result = await ContentEditingService.DeleteFromRecycleBinAsync(Guid.NewGuid(), Constants.Security.SuperUserKey);
Assert.IsFalse(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status);
}
[TestCase(true)]
[TestCase(false)]
public async Task Cannot_Delete_FromRecycleBin_If_Not_In_Recycle_Bin(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey);
Assert.IsFalse(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.NotInTrash, result.Status);
// re-get and verify that deletion did not happen
content = await ContentEditingService.GetAsync(content.Key);
Assert.IsNotNull(content);
}
}

View File

@@ -25,7 +25,7 @@ public partial class ContentEditingServiceTests
[Test]
public async Task Cannot_Move_Non_Existing_To_Recycle_Bin()
{
var result = await ContentEditingService.DeleteAsync(Guid.NewGuid(), Constants.Security.SuperUserKey);
var result = await ContentEditingService.MoveToRecycleBinAsync(Guid.NewGuid(), Constants.Security.SuperUserKey);
Assert.IsFalse(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status);
}

View File

@@ -58,7 +58,7 @@
<Compile Update="Umbraco.Infrastructure\Services\ContentEditingServiceTests.Copy.cs">
<DependentUpon>ContentEditingServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Infrastructure\Services\ContentEditingServiceTests.Delete.cs">
<Compile Update="Umbraco.Infrastructure\Services\ContentEditingServiceTests.DeleteFromRecycleBin.cs">
<DependentUpon>ContentEditingServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Infrastructure\Services\ContentEditingServiceTests.Get.cs">
@@ -121,6 +121,9 @@
<Compile Update="Umbraco.Core\Services\MediaTypeEditingServiceTests.Update.cs">
<DependentUpon>MediaTypeEditingServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Infrastructure\Services\ContentEditingServiceTests.MoveToRecyleBin.cs">
<DependentUpon>ContentEditingServiceTests.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>