From c2af43d9d9a4fc8340f4acaf25df4e0f116e3a35 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 14 Apr 2023 09:44:52 +0200 Subject: [PATCH] Copy and move API for content and media (#14040) * Copy and Move API for Content and Media * Update OpenAPI JSON schema * Update OpenApi JSON file after merge * Rename key to id --------- Co-authored-by: Bjarke Berg --- .../Content/ContentControllerBase.cs | 4 + .../Document/CopyDocumentController.cs | 41 +++ .../Document/MoveDocumentController.cs | 39 +++ .../Controllers/Media/MoveMediaController.cs | 39 +++ src/Umbraco.Cms.Api.Management/OpenApi.json | 285 ++++++++++++++++++ .../Document/CopyDocumentRequestModel.cs | 10 + .../Document/MoveDocumentRequestModel.cs | 6 + .../ViewModels/Media/MoveMediaRequestModel.cs | 6 + .../Services/ContentEditingService.cs | 29 +- .../Services/ContentEditingServiceBase.cs | 93 +++++- src/Umbraco.Core/Services/ContentService.cs | 16 +- .../Services/IContentEditingService.cs | 4 + src/Umbraco.Core/Services/IContentService.cs | 2 +- .../Services/IMediaEditingService.cs | 2 + .../Services/MediaEditingService.cs | 17 +- .../ContentEditingOperationStatus.cs | 1 + .../ContentEditingServiceTests.Copy.cs | 248 +++++++++++++++ .../ContentEditingServiceTests.Create.cs | 36 ++- .../ContentEditingServiceTests.Move.cs | 199 ++++++++++++ .../Services/ContentEditingServiceTests.cs | 44 ++- .../Umbraco.Tests.Integration.csproj | 6 + 21 files changed, 1094 insertions(+), 33 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/CopyDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/MoveDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/MoveMediaController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/CopyDocumentRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/MoveDocumentRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Media/MoveMediaRequestModel.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Copy.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Move.cs diff --git a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs index 48e0b9cf8a..79ace9c8d5 100644 --- a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs @@ -22,6 +22,10 @@ public class ContentControllerBase : ManagementApiControllerBase .Build()), ContentEditingOperationStatus.NotFound => NotFound("The content could not be found"), ContentEditingOperationStatus.ParentNotFound => NotFound("The targeted content parent could not be found"), + ContentEditingOperationStatus.ParentInvalid => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid parent") + .WithDetail("The targeted parent was not valid for the operation.") + .Build()), ContentEditingOperationStatus.NotAllowed => BadRequest(new ProblemDetailsBuilder() .WithTitle("Operation not permitted") .WithDetail("The attempted operation was not permitted, likely due to a permission/configuration mismatch with the operation.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CopyDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CopyDocumentController.cs new file mode 100644 index 0000000000..44d0aa77dd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CopyDocumentController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class CopyDocumentController : DocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CopyDocumentController(IContentEditingService contentEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _contentEditingService = contentEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("{id:guid}/copy")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Copy(Guid id, CopyDocumentRequestModel copyDocumentRequestModel) + { + Attempt result = await _contentEditingService.CopyAsync( + id, + copyDocumentRequestModel.TargetId, + copyDocumentRequestModel.RelateToOriginal, + copyDocumentRequestModel.IncludeDescendants, + CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), result.Result!.Key) + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/MoveDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/MoveDocumentController.cs new file mode 100644 index 0000000000..e1d34fbffa --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/MoveDocumentController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class MoveDocumentController : DocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public MoveDocumentController(IContentEditingService contentEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _contentEditingService = contentEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/move")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Move(Guid id, MoveDocumentRequestModel moveDocumentRequestModel) + { + Attempt result = await _contentEditingService.MoveAsync( + id, + moveDocumentRequestModel.TargetId, + CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/MoveMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/MoveMediaController.cs new file mode 100644 index 0000000000..19f2f0a091 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/MoveMediaController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Media; + +public class MoveMediaController : MediaControllerBase +{ + private readonly IMediaEditingService _mediaEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public MoveMediaController(IMediaEditingService mediaEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _mediaEditingService = mediaEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/move")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Move(Guid id, MoveMediaRequestModel moveDocumentRequestModel) + { + Attempt result = await _mediaEditingService.MoveAsync( + id, + moveDocumentRequestModel.TargetId, + CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 9f6158ddde..4a5c017256 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1706,6 +1706,59 @@ } } }, + "/umbraco/management/api/v1/document/{id}/copy": { + "post": { + "tags": [ + "Document" + ], + "operationId": "PostDocumentByIdCopy", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CopyDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/umbraco/management/api/v1/document/{id}/domains": { "get": { "tags": [ @@ -1765,6 +1818,49 @@ } } }, + "/umbraco/management/api/v1/document/{id}/move": { + "put": { + "tags": [ + "Document" + ], + "operationId": "PutDocumentByIdMove", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/umbraco/management/api/v1/document/{id}/notifications": { "get": { "tags": [ @@ -3599,6 +3695,49 @@ } } }, + "/umbraco/management/api/v1/media/{id}/move": { + "put": { + "tags": [ + "Media" + ], + "operationId": "PutMediaByIdMove", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveMediaRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/umbraco/management/api/v1/media/item": { "get": { "tags": [ @@ -5837,6 +5976,67 @@ } } }, + "/umbraco/management/api/v1/tag": { + "get": { + "tags": [ + "Tag" + ], + "operationId": "GetTag", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tagGroup", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedTagResponseModel" + } + } + } + } + } + } + }, "/umbraco/management/api/v1/telemetry": { "get": { "tags": [ @@ -7893,6 +8093,23 @@ }, "additionalProperties": false }, + "CopyDocumentRequestModel": { + "type": "object", + "properties": { + "targetId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "relateToOriginal": { + "type": "boolean" + }, + "includeDescendants": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "CreateContentRequestModelBaseDocumentValueModelDocumentVariantRequestModel": { "type": "object", "properties": { @@ -9654,6 +9871,28 @@ }, "additionalProperties": false }, + "MoveDocumentRequestModel": { + "type": "object", + "properties": { + "targetId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "MoveMediaRequestModel": { + "type": "object", + "properties": { + "targetId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, "ObjectTypeResponseModel": { "type": "object", "properties": { @@ -10530,6 +10769,30 @@ }, "additionalProperties": false }, + "PagedTagResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TagResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedTelemetryResponseModel": { "required": [ "items", @@ -11136,6 +11399,28 @@ ], "additionalProperties": false }, + "TagResponseModel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "text": { + "type": "string", + "nullable": true + }, + "group": { + "type": "string", + "nullable": true + }, + "nodeCount": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "TelemetryLevelModel": { "enum": [ "Minimal", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/CopyDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CopyDocumentRequestModel.cs new file mode 100644 index 0000000000..238d4acdda --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CopyDocumentRequestModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class CopyDocumentRequestModel +{ + public Guid? TargetId { get; set; } + + public bool RelateToOriginal { get; set; } + + public bool IncludeDescendants { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/MoveDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/MoveDocumentRequestModel.cs new file mode 100644 index 0000000000..ad3b2d02be --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/MoveDocumentRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class MoveDocumentRequestModel +{ + public Guid? TargetId { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Media/MoveMediaRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MoveMediaRequestModel.cs new file mode 100644 index 0000000000..137a745c76 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MoveMediaRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Media; + +public class MoveMediaRequestModel +{ + public Guid? TargetId { get; set; } +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 43dde51ce9..ba2bb04c16 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -7,12 +7,12 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; +// FIXME: add granular permissions check (for inspiration, check how the old ContentController utilizes IAuthorizationService) internal sealed class ContentEditingService : ContentEditingServiceBase, IContentEditingService { private readonly ITemplateService _templateService; private readonly ILogger _logger; - private readonly ICoreScopeProvider _scopeProvider; private readonly IUserIdKeyResolver _userIdKeyResolver; public ContentEditingService( @@ -28,7 +28,6 @@ internal sealed class ContentEditingService { _templateService = templateService; _logger = logger; - _scopeProvider = scopeProvider; _userIdKeyResolver = userIdKeyResolver; } @@ -53,7 +52,7 @@ internal sealed class ContentEditingService return Attempt.FailWithStatus(operationStatus, content); } - operationStatus = Save(content, userKey); + operationStatus = await Save(content, userKey); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content) : Attempt.FailWithStatus(operationStatus, content); @@ -73,7 +72,7 @@ internal sealed class ContentEditingService return Attempt.FailWithStatus(operationStatus, content); } - operationStatus = Save(content, userKey); + operationStatus = await Save(content, userKey); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content) : Attempt.FailWithStatus(operationStatus, content); @@ -81,16 +80,28 @@ internal sealed class ContentEditingService public async Task> MoveToRecycleBinAsync(Guid id, Guid userKey) { - var currentUserId = await _userIdKeyResolver.GetAsync(id) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); return await HandleDeletionAsync(id, content => ContentService.MoveToRecycleBin(content, currentUserId), false); } public async Task> DeleteAsync(Guid id, Guid userKey) { - var currentUserId = await _userIdKeyResolver.GetAsync(id) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); return await HandleDeletionAsync(id, content => ContentService.Delete(content, currentUserId), false); } + public async Task> MoveAsync(Guid id, Guid? parentId, Guid userKey) + { + var currentUserId = await GetUserIdAsync(userKey); + return await HandleMoveAsync(id, parentId, (content, newParentId) => ContentService.Move(content, newParentId, currentUserId)); + } + + public async Task> CopyAsync(Guid id, Guid? parentId, bool relateToOriginal, bool includeDescendants, Guid userKey) + { + var currentUserId = await GetUserIdAsync(userKey); + return await HandleCopyAsync(id, parentId, (content, newParentId) => ContentService.Copy(content, newParentId, relateToOriginal, includeDescendants, currentUserId)); + } + protected override IContent Create(string? name, int parentId, IContentType contentType) => new Content(name, parentId, contentType); private async Task UpdateTemplateAsync(IContent content, Guid? templateKey) @@ -118,11 +129,11 @@ internal sealed class ContentEditingService return ContentEditingOperationStatus.Success; } - private ContentEditingOperationStatus Save(IContent content, Guid userKey) + private async Task Save(IContent content, Guid userKey) { try { - var currentUserId = _userIdKeyResolver.GetAsync(userKey).GetAwaiter().GetResult() ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); OperationResult saveResult = ContentService.Save(content, currentUserId); return saveResult.Result switch { @@ -140,4 +151,6 @@ internal sealed class ContentEditingService return ContentEditingOperationStatus.Unknown; } } + + private async Task GetUserIdAsync(Guid userKey) => await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; } diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index ed680dd3e1..2ff9232cb5 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -85,7 +85,7 @@ public abstract class ContentEditingServiceBase> HandleDeletionAsync(Guid id, Func performDelete, bool allowForTrashed) { - using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete:true); + using ICoreScope scope = _scopeProvider.CreateCoreScope(); TContent? content = ContentService.GetById(id); if (content == null) { @@ -98,17 +98,86 @@ public abstract class ContentEditingServiceBase Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content), - OperationResultType.FailedCancelledByEvent => Attempt.FailWithStatus(ContentEditingOperationStatus.CancelledByNotification, content), - // for any other state we'll return "unknown" so we know that we need to amend this - _ => Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, content) - }; + scope.Complete(); + + return OperationResultToAttempt(content, deleteResult); } + protected async Task> HandleMoveAsync(Guid id, Guid? parentId, Func performMove) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + TContent? content = ContentService.GetById(id); + if (content is null) + { + return await Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, content)); + } + + TContentType contentType = ContentTypeService.Get(content.ContentType.Key)!; + + TContent? parent = TryGetAndValidateParent(parentId, contentType, out ContentEditingOperationStatus operationStatus); + if (operationStatus != ContentEditingOperationStatus.Success) + { + return Attempt.FailWithStatus(operationStatus, content); + } + + // special case for move: short circuit the operation if the content is already under the correct parent. + if ((parent == null && content.ParentId == Constants.System.Root) || (parent != null && parent.Id == content.ParentId)) + { + return Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content); + } + + // special case for move: do not allow moving an item beneath itself. + if (parent?.Path.Split(Constants.CharArrays.Comma).Select(int.Parse).Contains(content.Id) is true) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.ParentInvalid, content); + } + + OperationResult? moveResult = performMove(content, parent?.Id ?? Constants.System.Root); + + scope.Complete(); + + return OperationResultToAttempt(content, moveResult); + } + + protected async Task> HandleCopyAsync(Guid id, Guid? parentId, Func performCopy) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + TContent? content = ContentService.GetById(id); + if (content is null) + { + return await Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, content)); + } + + TContentType contentType = ContentTypeService.Get(content.ContentType.Key)!; + + TContent? parent = TryGetAndValidateParent(parentId, contentType, out ContentEditingOperationStatus operationStatus); + if (operationStatus != ContentEditingOperationStatus.Success) + { + return Attempt.FailWithStatus(operationStatus, content); + } + + TContent? copy = performCopy(content, parent?.Id ?? Constants.System.Root); + scope.Complete(); + + // we'll assume that we have performed all validations for unsuccessful scenarios above, so a null result here + // means the copy operation was cancelled by a notification handler + return copy != null + ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, copy) + : Attempt.FailWithStatus(ContentEditingOperationStatus.CancelledByNotification, null); + } + + private Attempt OperationResultToAttempt(TContent? content, OperationResult? operationResult) => + operationResult?.Result switch + { + // these are the only result states currently expected from the invoked IContentService operations + OperationResultType.Success => Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content), + OperationResultType.FailedCancelledByEvent => Attempt.FailWithStatus(ContentEditingOperationStatus.CancelledByNotification, content), + + // for any other state we'll return "unknown" so we know that we need to amend this switch statement + _ => Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, content) + }; + private TContentType? TryGetAndValidateContentType(Guid contentTypeKey, ContentEditingModelBase contentEditingModelBase, out ContentEditingOperationStatus operationStatus) { TContentType? contentType = ContentTypeService.Get(contentTypeKey); @@ -190,6 +259,12 @@ public abstract class ContentEditingServiceBase c.Key).ToArray() ?? Array.Empty(); diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index fb9889d3e4..53a24dab3f 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -2430,22 +2430,21 @@ public class ContentService : RepositoryService, IContentService /// The to move /// Id of the Content's new Parent /// Optional Id of the User moving the Content - public void Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId) + public OperationResult Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId) { + EventMessages eventMessages = EventMessagesFactory.Get(); + if(content.ParentId == parentId) { - return; + return OperationResult.Succeed(eventMessages); } // if moving to the recycle bin then use the proper method if (parentId == Constants.System.RecycleBinContent) { - MoveToRecycleBin(content, userId); - return; + return MoveToRecycleBin(content, userId); } - EventMessages eventMessages = EventMessagesFactory.Get(); - var moves = new List<(IContent, string)>(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) @@ -2465,7 +2464,7 @@ public class ContentService : RepositoryService, IContentService if (scope.Notifications.PublishCancelable(movingNotification)) { scope.Complete(); - return; // causes rollback + return OperationResult.Cancel(eventMessages); // causes rollback } // if content was trashed, and since we're not moving to the recycle bin, @@ -2500,6 +2499,7 @@ public class ContentService : RepositoryService, IContentService Audit(AuditType.Move, userId, content.Id); scope.Complete(); + return OperationResult.Succeed(eventMessages); } } @@ -2952,7 +2952,7 @@ public class ContentService : RepositoryService, IContentService { scope.Notifications.Publish(new ContentPublishedNotification(published, eventMessages)); } - + return OperationResult.Succeed(eventMessages); } diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index 7018571af6..e1a7541310 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -15,4 +15,8 @@ public interface IContentEditingService Task> MoveToRecycleBinAsync(Guid id, Guid userKey); Task> DeleteAsync(Guid id, Guid userKey); + + Task> MoveAsync(Guid id, Guid? parentId, Guid userKey); + + Task> CopyAsync(Guid id, Guid? parentId, bool relateToOriginal, bool includeDescendants, Guid userKey); } diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 1eb2db83bf..955d56022a 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -317,7 +317,7 @@ public interface IContentService : IContentServiceBase /// /// Moves a document under a new parent. /// - void Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId); + OperationResult Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId); /// /// Copies a document. diff --git a/src/Umbraco.Core/Services/IMediaEditingService.cs b/src/Umbraco.Core/Services/IMediaEditingService.cs index ca3fe5ddc3..ea75caf678 100644 --- a/src/Umbraco.Core/Services/IMediaEditingService.cs +++ b/src/Umbraco.Core/Services/IMediaEditingService.cs @@ -15,4 +15,6 @@ public interface IMediaEditingService Task> MoveToRecycleBinAsync(Guid id, Guid userKey); Task> DeleteAsync(Guid id, Guid userKey); + + Task> MoveAsync(Guid id, Guid? parentId, Guid userKey); } diff --git a/src/Umbraco.Core/Services/MediaEditingService.cs b/src/Umbraco.Core/Services/MediaEditingService.cs index 5ca0b830b4..67fda1c859 100644 --- a/src/Umbraco.Core/Services/MediaEditingService.cs +++ b/src/Umbraco.Core/Services/MediaEditingService.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; +// FIXME: add granular permissions check (for inspiration, check how the old MediaController utilizes IAuthorizationService) internal sealed class MediaEditingService : ContentEditingServiceBase, IMediaEditingService { @@ -43,7 +44,7 @@ internal sealed class MediaEditingService IMedia media = result.Result!; - var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); ContentEditingOperationStatus operationStatus = Save(media, currentUserId); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, media) @@ -58,7 +59,7 @@ internal sealed class MediaEditingService return Attempt.FailWithStatus(result.Result, content); } - var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); ContentEditingOperationStatus operationStatus = Save(content, currentUserId); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content) @@ -67,16 +68,22 @@ internal sealed class MediaEditingService public async Task> MoveToRecycleBinAsync(Guid id, Guid userKey) { - var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); return await HandleDeletionAsync(id, media => ContentService.MoveToRecycleBin(media, currentUserId).Result, false); } public async Task> DeleteAsync(Guid id, Guid userKey) { - var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; + var currentUserId = await GetUserIdAsync(userKey); return await HandleDeletionAsync(id, media => ContentService.Delete(media, currentUserId).Result, true); } + public async Task> MoveAsync(Guid id, Guid? parentId, Guid userKey) + { + var currentUserId = await GetUserIdAsync(userKey); + return await HandleMoveAsync(id, parentId, (content, newParentId) => ContentService.Move(content, newParentId, currentUserId).Result); + } + protected override IMedia Create(string? name, int parentId, IMediaType contentType) => new Models.Media(name, parentId, contentType); @@ -101,4 +108,6 @@ internal sealed class MediaEditingService return ContentEditingOperationStatus.Unknown; } } + + private async Task GetUserIdAsync(Guid userKey) => await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; } diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs index 5555a9938f..db4f7b19cf 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs @@ -8,6 +8,7 @@ public enum ContentEditingOperationStatus ContentTypeCultureVarianceMismatch, NotFound, ParentNotFound, + ParentInvalid, NotAllowed, TemplateNotFound, TemplateNotAllowed, diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Copy.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Copy.cs new file mode 100644 index 0000000000..364b60674f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Copy.cs @@ -0,0 +1,248 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +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_Copy_To_Root(bool allowedAtRoot) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + contentType.AllowedAsRoot = allowedAtRoot; + ContentTypeService.Save(contentType); + + var result = await ContentEditingService.CopyAsync(child.Key, Constants.System.RootKey, false, false, Constants.Security.SuperUserKey); + + if (allowedAtRoot) + { + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyCopy(result.Result); + + // re-get and re-test + VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyCopy(IContent? copiedContent) + { + Assert.IsNotNull(copiedContent); + Assert.AreEqual(Constants.System.Root, copiedContent.ParentId); + Assert.IsTrue(copiedContent.HasIdentity); + Assert.AreNotEqual(child.Id, copiedContent.Id); + Assert.AreNotEqual(child.Key, copiedContent.Key); + } + } + else + { + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Copy_To_Another_Parent(bool allowedAtParent) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType, "Root 1", "Child 1"); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType, "Root 2", "Child 2"); + + if (allowedAtParent is false) + { + contentType.AllowedContentTypes = Enumerable.Empty(); + } + + ContentTypeService.Save(contentType); + + var result = await ContentEditingService.CopyAsync(child1.Key, root2.Key, false, false, Constants.Security.SuperUserKey); + + if (allowedAtParent) + { + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyCopy(result.Result); + + // re-get and re-test + VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyCopy(IContent? copiedContent) + { + Assert.IsNotNull(copiedContent); + Assert.AreEqual(root2.Id, copiedContent.ParentId); + Assert.IsTrue(copiedContent.HasIdentity); + Assert.AreNotEqual(child1.Id, copiedContent.Id); + Assert.AreNotEqual(child1.Key, copiedContent.Key); + } + } + else + { + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Copy_Entire_Structure(bool includeDescendants) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType, "Root 1", "Child 1"); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType, "Root 2", "Child 2"); + + var result = await ContentEditingService.CopyAsync(root1.Key, root2.Key, false, includeDescendants, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyCopy(result.Result); + + // re-get and re-test + VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyCopy(IContent? copiedRoot) + { + Assert.IsNotNull(copiedRoot); + Assert.AreEqual(root2.Id, copiedRoot.ParentId); + Assert.AreNotEqual(root1.Id, copiedRoot.Id); + Assert.AreNotEqual(root1.Key, copiedRoot.Key); + Assert.AreEqual(root1.Name, copiedRoot.Name); + + var copiedChildren = ContentService.GetPagedChildren(copiedRoot.Id, 0, 100, out var total).ToArray(); + + if (includeDescendants) + { + Assert.AreEqual(1, copiedChildren.Length); + Assert.AreEqual(1, total); + var copiedChild = copiedChildren.First(); + Assert.AreNotEqual(child1.Id, copiedChild.Id); + Assert.AreNotEqual(child1.Key, copiedChild.Key); + Assert.AreEqual(child1.Name, copiedChild.Name); + } + else + { + Assert.AreEqual(0, copiedChildren.Length); + Assert.AreEqual(0, total); + } + } + } + + [Test] + public async Task Can_Copy_To_Existing_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.CopyAsync(child.Key, root.Key, false, false, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyCopy(result.Result); + + // re-get and re-test + VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyCopy(IContent? copiedContent) + { + Assert.IsNotNull(copiedContent); + Assert.AreEqual(root.Id, copiedContent.ParentId); + Assert.IsTrue(copiedContent.HasIdentity); + Assert.AreNotEqual(child.Key, copiedContent.Key); + Assert.AreNotEqual(child.Name, copiedContent.Name); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Copy_Beneath_Self(bool includeDescendants) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.CopyAsync(root.Key, child.Key, false, includeDescendants, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyCopy(result.Result); + + // re-get and re-test + VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyCopy(IContent? copiedRoot) + { + Assert.IsNotNull(copiedRoot); + Assert.AreEqual(child.Id, copiedRoot.ParentId); + Assert.IsTrue(copiedRoot.HasIdentity); + Assert.AreNotEqual(root.Key, copiedRoot.Key); + Assert.AreEqual(root.Name, copiedRoot.Name); + var copiedChildren = ContentService.GetPagedChildren(copiedRoot.Id, 0, 100, out var total).ToArray(); + + if (includeDescendants) + { + Assert.AreEqual(1, copiedChildren.Length); + Assert.AreEqual(1, total); + var copiedChild = copiedChildren.First(); + Assert.AreNotEqual(child.Id, copiedChild.Id); + Assert.AreNotEqual(child.Key, copiedChild.Key); + Assert.AreEqual(child.Name, copiedChild.Name); + } + else + { + Assert.AreEqual(0, copiedChildren.Length); + Assert.AreEqual(0, total); + } + } + } + + [Test] + public async Task Can_Relate_Copy_To_Original() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.CopyAsync(child.Key, root.Key, true, false, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + var relationService = GetRequiredService(); + var relations = relationService.GetByParentId(child.Id)!.ToArray(); + Assert.AreEqual(1, relations.Length); + Assert.AreEqual(result.Result!.Id, relations.First().ChildId); + } + + [Test] + public async Task Cannot_Copy_Non_Existing_Content() + { + var result = await ContentEditingService.CopyAsync(Guid.NewGuid(), Constants.System.RootKey, false, false, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Cannot_Copy_To_Non_Existing_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.CopyAsync(child.Key, Guid.NewGuid(), false, false, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ParentNotFound, result.Status); + } + + [Test] + public async Task Cannot_Copy_To_Trashed_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType); + await ContentEditingService.MoveToRecycleBinAsync(root1.Key, Constants.Security.SuperUserKey); + + var result = await ContentEditingService.CopyAsync(root2.Key, root1.Key, false, false, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.InTrash, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs index 50d84a0de1..79f6430c53 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs @@ -11,7 +11,7 @@ public partial class ContentEditingServiceTests { [TestCase(true)] [TestCase(false)] - public async Task Create_At_Root(bool allowedAtRoot) + public async Task Can_Create_At_Root(bool allowedAtRoot) { var template = TemplateBuilder.CreateTextPageTemplate(); await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); @@ -64,7 +64,7 @@ public partial class ContentEditingServiceTests [TestCase(true)] [TestCase(false)] - public async Task Create_As_Child(bool allowedAsChild) + public async Task Can_Create_As_Child(bool allowedAsChild) { var template = TemplateBuilder.CreateTextPageTemplate(); await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); @@ -427,4 +427,36 @@ public partial class ContentEditingServiceTests Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); Assert.IsNull(result.Result); } + + [Test] + public async Task Cannot_Create_Under_Trashed_Parent() + { + var contentType = ContentTypeBuilder.CreateBasicContentType(); + contentType.AllowedAsRoot = true; + contentType.AllowedContentTypes = new[] + { + new ContentTypeSort(new Lazy(() => contentType.Id), contentType.Key, 1, contentType.Alias) + }; + ContentTypeService.Save(contentType); + + var rootKey = (await ContentEditingService.CreateAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, InvariantName = "Root", ParentKey = Constants.System.RootKey + }, + Constants.Security.SuperUserKey)).Result.Key; + + await ContentEditingService.MoveToRecycleBinAsync(rootKey, Constants.Security.SuperUserKey); + + var result = await ContentEditingService.CreateAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, InvariantName = "Child", ParentKey = rootKey, + }, + Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.InTrash, result.Status); + Assert.IsNull(result.Result); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Move.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Move.cs new file mode 100644 index 0000000000..0e5ac0543b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Move.cs @@ -0,0 +1,199 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +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_Move_To_Root(bool allowedAtRoot) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + contentType.AllowedAsRoot = allowedAtRoot; + ContentTypeService.Save(contentType); + + var result = await ContentEditingService.MoveAsync(child.Key, Constants.System.RootKey, Constants.Security.SuperUserKey); + + if (allowedAtRoot) + { + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyMove(result.Result); + + // re-get and re-test + VerifyMove(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyMove(IContent? movedContent) + { + Assert.IsNotNull(movedContent); + Assert.AreEqual(Constants.System.Root, movedContent.ParentId); + } + } + else + { + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Move_To_Another_Parent(bool allowedAtParent) + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType, "Root 1", "Child 1"); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType, "Root 2", "Child 2"); + + if (allowedAtParent is false) + { + contentType.AllowedContentTypes = Enumerable.Empty(); + } + + ContentTypeService.Save(contentType); + + var result = await ContentEditingService.MoveAsync(child1.Key, root2.Key, Constants.Security.SuperUserKey); + + if (allowedAtParent) + { + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyMove(result.Result); + + // re-get and re-test + VerifyMove(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyMove(IContent? movedContent) + { + Assert.IsNotNull(movedContent); + Assert.AreEqual(root2.Id, movedContent.ParentId); + } + } + else + { + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } + } + + [Test] + public async Task Can_Move_Entire_Structure() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType, "Root 1", "Child 1"); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType, "Root 2", "Child 2"); + + var result = await ContentEditingService.MoveAsync(root1.Key, root2.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyMove(result.Result); + + // re-get and re-test + VerifyMove(await ContentEditingService.GetAsync(result.Result!.Key)); + + child1 = await ContentEditingService.GetAsync(child1.Key); + Assert.IsNotNull(child1); + var ancestorIds = child1.GetAncestorIds()!.ToArray(); + Assert.AreEqual(2, ancestorIds.Length); + Assert.AreEqual(root2.Id, ancestorIds.First()); + Assert.AreEqual(root1.Id, ancestorIds.Last()); + + void VerifyMove(IContent? movedContent) + { + Assert.IsNotNull(movedContent); + Assert.AreEqual(root2.Id, movedContent.ParentId); + } + } + + [Test] + public async Task Can_Move_To_Existing_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.MoveAsync(child.Key, root.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyMove(result.Result); + + // re-get and re-test + VerifyMove(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyMove(IContent? movedContent) + { + Assert.IsNotNull(movedContent); + Assert.AreEqual(root.Id, movedContent.ParentId); + } + } + + [Test] + public async Task Can_Move_From_Root_To_Root() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.MoveAsync(root.Key, Constants.System.RootKey, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + VerifyMove(result.Result); + + // re-get and re-test + VerifyMove(await ContentEditingService.GetAsync(result.Result!.Key)); + + void VerifyMove(IContent? movedContent) + { + Assert.IsNotNull(movedContent); + Assert.AreEqual(Constants.System.Root, movedContent.ParentId); + } + } + + [Test] + public async Task Cannot_Move_Non_Existing_Content() + { + var result = await ContentEditingService.MoveAsync(Guid.NewGuid(), Constants.System.RootKey, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Cannot_Move_To_Non_Existing_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.MoveAsync(child.Key, Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ParentNotFound, result.Status); + } + + [Test] + public async Task Cannot_Move_To_Trashed_Parent() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root1, IContent child1) = await CreateRootAndChildAsync(contentType); + (IContent root2, IContent child2) = await CreateRootAndChildAsync(contentType); + await ContentEditingService.MoveToRecycleBinAsync(root1.Key, Constants.Security.SuperUserKey); + + var result = await ContentEditingService.MoveAsync(root2.Key, root1.Key, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.InTrash, result.Status); + } + + [Test] + public async Task Cannot_Move_Beneath_Self() + { + var contentType = await CreateTextPageContentTypeAsync(); + (IContent root, IContent child) = await CreateRootAndChildAsync(contentType); + + var result = await ContentEditingService.MoveAsync(root.Key, child.Key, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ParentInvalid, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 6cbba9f3d0..59d699e37b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -1,9 +1,10 @@ using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -22,6 +23,9 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont [SetUp] public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + protected override void CustomTestSetup(IUmbracoBuilder builder) => + builder.AddNotificationHandler(); + private ITemplateService TemplateService => GetRequiredService(); private ILanguageService LanguageService => GetRequiredService(); @@ -155,4 +159,42 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont Assert.IsTrue(result.Success); return result.Result!; } + + private async Task CreateTextPageContentTypeAsync() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id); + contentType.AllowedAsRoot = true; + ContentTypeService.Save(contentType); + + return contentType; + } + + private async Task<(IContent root, IContent child)> CreateRootAndChildAsync(IContentType contentType, string rootName = "The Root", string childName = "The Child") + { + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = rootName + }; + + var root = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result!; + + contentType.AllowedContentTypes = new List + { + new (new Lazy(() => contentType.Id), contentType.Key, 1, contentType.Alias) + }; + ContentTypeService.Save(contentType); + + createModel.ParentKey = root.Key; + createModel.InvariantName = childName; + + var child = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result!; + Assert.AreEqual(root.Id, child.ParentId); + + return (root, child); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 6f9ba8ad10..0db665dd74 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -53,12 +53,18 @@ ContentEditingServiceTests.cs + + ContentEditingServiceTests.cs + ContentEditingServiceTests.cs ContentEditingServiceTests.cs + + ContentEditingServiceTests.cs + ContentEditingServiceTests.cs