From a44495e16c7675d212787c26819f4f1ca4f9cc14 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:05:04 +0200 Subject: [PATCH 1/8] Revert production mode validation for templates and partial views at the service layer, and move to management API. --- .../CreatePartialViewController.cs | 29 ++++- .../DeletePartialViewController.cs | 27 +++- .../RenamePartialViewController.cs | 32 ++++- .../UpdatePartialViewController.cs | 29 ++++- .../Template/CreateTemplateController.cs | 27 +++- .../Template/DeleteTemplateController.cs | 29 ++++- .../Template/UpdateTemplateController.cs | 30 ++++- .../Services/PartialViewService.cs | 69 +++-------- src/Umbraco.Core/Services/TemplateService.cs | 41 +------ .../Services/PartialViewServiceTests.cs | 104 ---------------- .../Services/TemplateServiceTests.cs | 116 ------------------ 11 files changed, 218 insertions(+), 315 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs index 3ab1692a19a9..9992eaf1ee56 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs @@ -1,9 +1,13 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -21,6 +25,7 @@ public class CreatePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class with the specified services. @@ -28,14 +33,31 @@ public class CreatePartialViewController : PartialViewControllerBase /// The service used to manage partial views. /// The mapper used for Umbraco model mapping. /// Provides access to back office security information. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public CreatePartialViewController( IPartialViewService partialViewService, IUmbracoMapper umbracoMapper, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions runtimeSettings) { _partialViewService = partialViewService; _umbracoMapper = umbracoMapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public CreatePartialViewController( + IPartialViewService partialViewService, + IUmbracoMapper umbracoMapper, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + partialViewService, + umbracoMapper, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -55,6 +77,11 @@ public async Task Create( CancellationToken cancellationToken, CreatePartialViewRequestModel requestModel) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); + } + PartialViewCreateModel createModel = _umbracoMapper.Map(requestModel)!; Attempt createAttempt = await _partialViewService.CreateAsync(createModel, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs index b4fb1dadf472..7cc55be33285 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs @@ -1,7 +1,11 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -16,18 +20,34 @@ public class DeletePartialViewController : PartialViewControllerBase { private readonly IPartialViewService _partialViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, responsible for handling requests to delete partial views. /// /// Service used to manage partial views. /// Accessor for back office security context. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public DeletePartialViewController( IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions runtimeSettings) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public DeletePartialViewController( + IPartialViewService partialViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + partialViewService, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService>()) + { } [HttpDelete("{*path}")] @@ -39,6 +59,11 @@ public DeletePartialViewController( [EndpointDescription("Deletes a partial view identified by the provided Id.")] public async Task Delete(CancellationToken cancellationToken, string path) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); + } + path = DecodePath(path).VirtualPathToSystemPath(); PartialViewOperationStatus operationStatus = await _partialViewService.DeleteAsync(path, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs index 271b7e680727..a8ff9546c370 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs @@ -1,9 +1,13 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -21,6 +25,7 @@ public class RenamePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class. @@ -28,11 +33,31 @@ public class RenamePartialViewController : PartialViewControllerBase /// Service used to manage and manipulate partial views. /// Accessor for back office security context and authentication. /// Mapper used to convert between Umbraco domain models and API models. - public RenamePartialViewController(IPartialViewService partialViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IUmbracoMapper umbracoMapper) + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] + public RenamePartialViewController( + IPartialViewService partialViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper umbracoMapper, + IOptions runtimeSettings) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _umbracoMapper = umbracoMapper; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public RenamePartialViewController( + IPartialViewService partialViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper umbracoMapper) + : this( + partialViewService, + backOfficeSecurityAccessor, + umbracoMapper, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -61,6 +86,11 @@ public async Task Rename( string path, RenamePartialViewRequestModel requestModel) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); + } + PartialViewRenameModel renameModel = _umbracoMapper.Map(requestModel)!; path = DecodePath(path).VirtualPathToSystemPath(); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs index b8817af5d150..9e3a85af73e6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs @@ -1,9 +1,13 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -21,6 +25,7 @@ public class UpdatePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IUmbracoMapper _mapper; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, which handles requests for updating partial views in the Umbraco backoffice. @@ -28,14 +33,31 @@ public class UpdatePartialViewController : PartialViewControllerBase /// Service used to manage partial view files. /// Accessor for back office security context. /// The Umbraco object mapper for mapping between models. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public UpdatePartialViewController( IPartialViewService partialViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUmbracoMapper mapper) + IUmbracoMapper mapper, + IOptions runtimeSettings) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _mapper = mapper; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public UpdatePartialViewController( + IPartialViewService partialViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper) + : this( + partialViewService, + backOfficeSecurityAccessor, + mapper, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -57,6 +79,11 @@ public async Task Update( string path, UpdatePartialViewRequestModel updateViewModel) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); + } + path = DecodePath(path).VirtualPathToSystemPath(); PartialViewUpdateModel updateModel = _mapper.Map(updateViewModel)!; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs index 3da8e20e3db2..896ee99a7a63 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/CreateTemplateController.cs @@ -1,8 +1,12 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.ViewModels.Template; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -18,18 +22,34 @@ public class CreateTemplateController : TemplateControllerBase { private readonly ITemplateService _templateService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class. /// /// An instance of used to manage templates. /// An instance of used to access back office security information. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public CreateTemplateController( ITemplateService templateService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions runtimeSettings) { _templateService = templateService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public CreateTemplateController( + ITemplateService templateService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + templateService, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -47,6 +67,11 @@ public CreateTemplateController( [EndpointDescription("Creates a new template with the configuration specified in the request model.")] public async Task Create(CancellationToken cancellationToken, CreateTemplateRequestModel requestModel) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return TemplateOperationStatusResult(TemplateOperationStatus.NotAllowedInProductionMode); + } + Attempt result = await _templateService.CreateAsync( requestModel.Name, requestModel.Alias, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs index f6e8d3058f5c..c41c156127dc 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/DeleteTemplateController.cs @@ -1,7 +1,11 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -17,16 +21,34 @@ public class DeleteTemplateController : TemplateControllerBase { private readonly ITemplateService _templateService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, responsible for handling template deletion operations. /// /// The service used to manage templates. /// Provides access to back office security features. - public DeleteTemplateController(ITemplateService templateService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] + public DeleteTemplateController( + ITemplateService templateService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions runtimeSettings) { _templateService = templateService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public DeleteTemplateController( + ITemplateService templateService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + templateService, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -44,6 +66,11 @@ public DeleteTemplateController(ITemplateService templateService, IBackOfficeSec [EndpointDescription("Deletes a template identified by the provided Id.")] public async Task Delete(CancellationToken cancellationToken, Guid id) { + if (_runtimeSettings.Value.Mode == RuntimeMode.Production) + { + return TemplateOperationStatusResult(TemplateOperationStatus.NotAllowedInProductionMode); + } + Attempt result = await _templateService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); return result.Success ? Ok() diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs index 701bd320f6d9..84bbac19daf6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/UpdateTemplateController.cs @@ -1,8 +1,12 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.ViewModels.Template; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -20,6 +24,7 @@ public class UpdateTemplateController : TemplateControllerBase private readonly ITemplateService _templateService; private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, which manages update operations for templates in the Umbraco CMS. @@ -27,14 +32,31 @@ public class UpdateTemplateController : TemplateControllerBase /// Service used to perform operations on templates. /// Mapper used to convert between domain models and API models. /// Accessor for back office security context. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public UpdateTemplateController( ITemplateService templateService, IUmbracoMapper umbracoMapper, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions runtimeSettings) { _templateService = templateService; _umbracoMapper = umbracoMapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _runtimeSettings = runtimeSettings; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public UpdateTemplateController( + ITemplateService templateService, + IUmbracoMapper umbracoMapper, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + templateService, + umbracoMapper, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -62,7 +84,13 @@ public async Task Update( return TemplateNotFound(); } + // In production mode, block updates if the content is being changed. + var existingContent = template.Content; template = _umbracoMapper.Map(requestModel, template); + if (_runtimeSettings.Value.Mode == RuntimeMode.Production && existingContent != template.Content) + { + return TemplateOperationStatusResult(TemplateOperationStatus.ContentChangeNotAllowedInProductionMode); + } Attempt result = await _templateService.UpdateAsync(template, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Core/Services/PartialViewService.cs b/src/Umbraco.Core/Services/PartialViewService.cs index 7227da47f326..f78e933b5e0a 100644 --- a/src/Umbraco.Core/Services/PartialViewService.cs +++ b/src/Umbraco.Core/Services/PartialViewService.cs @@ -25,9 +25,8 @@ namespace Umbraco.Cms.Core.Services; public class PartialViewService : FileServiceOperationBase, IPartialViewService { private readonly PartialViewSnippetCollection _snippetCollection; - private readonly IOptions _runtimeSettings; - // TODO (V18): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. + // TODO (V19): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. // Also update UmbracoBuilder where this service is registered using: // Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); // We do this to allow the ActivatorUtilitiesConstructor to be used (it's otherwise ignored by AddUnique). @@ -45,7 +44,6 @@ public class PartialViewService : FileServiceOperationBaseThe resolver for converting user keys to IDs. /// The service for audit logging. /// The collection of available partial view snippets. - /// The runtime configuration settings. [ActivatorUtilitiesConstructor] public PartialViewService( ICoreScopeProvider provider, @@ -55,15 +53,13 @@ public PartialViewService( ILogger logger, IUserIdKeyResolver userIdKeyResolver, IAuditService auditService, - PartialViewSnippetCollection snippetCollection, - IOptions runtimeSettings) + PartialViewSnippetCollection snippetCollection) : base(provider, loggerFactory, eventMessagesFactory, repository, logger, userIdKeyResolver, auditService) { _snippetCollection = snippetCollection; - _runtimeSettings = runtimeSettings; } - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] + [Obsolete("Use the constructor without the runtimeSettings parameter. Scheduled for removal in Umbraco 19.")] public PartialViewService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -71,8 +67,9 @@ public PartialViewService( IPartialViewRepository repository, ILogger logger, IUserIdKeyResolver userIdKeyResolver, - IAuditRepository auditRepository, - PartialViewSnippetCollection snippetCollection) + IAuditService auditService, + PartialViewSnippetCollection snippetCollection, + IOptions runtimeSettings) : this( provider, loggerFactory, @@ -80,9 +77,8 @@ public PartialViewService( repository, logger, userIdKeyResolver, - StaticServiceProvider.Instance.GetRequiredService(), - snippetCollection, - StaticServiceProvider.Instance.GetRequiredService>()) + auditService, + snippetCollection) { } @@ -94,7 +90,6 @@ public PartialViewService( IPartialViewRepository repository, ILogger logger, IUserIdKeyResolver userIdKeyResolver, - IAuditService auditService, IAuditRepository auditRepository, PartialViewSnippetCollection snippetCollection) : this( @@ -104,9 +99,8 @@ public PartialViewService( repository, logger, userIdKeyResolver, - auditService, - snippetCollection, - StaticServiceProvider.Instance.GetRequiredService>()) + StaticServiceProvider.Instance.GetRequiredService(), + snippetCollection) { } @@ -119,6 +113,7 @@ public PartialViewService( ILogger logger, IUserIdKeyResolver userIdKeyResolver, IAuditService auditService, + IAuditRepository auditRepository, PartialViewSnippetCollection snippetCollection) : this( provider, @@ -128,17 +123,13 @@ public PartialViewService( logger, userIdKeyResolver, auditService, - snippetCollection, - StaticServiceProvider.Instance.GetRequiredService>()) + snippetCollection) { } /// protected override string[] AllowedFileExtensions { get; } = { ".cshtml" }; - /// - private bool IsProductionMode => _runtimeSettings.Value.Mode == RuntimeMode.Production; - /// protected override PartialViewOperationStatus Success => PartialViewOperationStatus.Success; @@ -210,45 +201,17 @@ public Task> GetSnippetsAsync(int skip, int t /// public async Task DeleteAsync(string path, Guid userKey) - { - if (IsProductionMode) - { - return PartialViewOperationStatus.NotAllowedInProductionMode; - } - - return await HandleDeleteAsync(path, userKey); - } + => await HandleDeleteAsync(path, userKey); /// public async Task> CreateAsync(PartialViewCreateModel createModel, Guid userKey) - { - if (IsProductionMode) - { - return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); - } - - return await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); - } + => await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); /// public async Task> UpdateAsync(string path, PartialViewUpdateModel updateModel, Guid userKey) - { - if (IsProductionMode) - { - return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); - } - - return await HandleUpdateAsync(path, updateModel.Content, userKey); - } + => await HandleUpdateAsync(path, updateModel.Content, userKey); /// public async Task> RenameAsync(string path, PartialViewRenameModel renameModel, Guid userKey) - { - if (IsProductionMode) - { - return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); - } - - return await HandleRenameAsync(path, renameModel.Name, userKey); - } + => await HandleRenameAsync(path, renameModel.Name, userKey); } diff --git a/src/Umbraco.Core/Services/TemplateService.cs b/src/Umbraco.Core/Services/TemplateService.cs index d87ec0e29b74..9bdfbd108dc4 100644 --- a/src/Umbraco.Core/Services/TemplateService.cs +++ b/src/Umbraco.Core/Services/TemplateService.cs @@ -24,9 +24,8 @@ public class TemplateService : RepositoryService, ITemplateService private readonly ITemplateRepository _templateRepository; private readonly IAuditService _auditService; private readonly ITemplateContentParserService _templateContentParserService; - private readonly IOptions _runtimeSettings; - // TODO (V18): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. + // TODO (V19): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. // Also update UmbracoBuilder where this service is registered using: // Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); // We do this to allow the ActivatorUtilitiesConstructor to be used (it's otherwise ignored by AddUnique). @@ -43,7 +42,6 @@ public class TemplateService : RepositoryService, ITemplateService /// The repository for template data access. /// The audit service for recording audit entries. /// The service for parsing template content. - /// The runtime configuration settings. [ActivatorUtilitiesConstructor] public TemplateService( ICoreScopeProvider provider, @@ -52,18 +50,16 @@ public TemplateService( IShortStringHelper shortStringHelper, ITemplateRepository templateRepository, IAuditService auditService, - ITemplateContentParserService templateContentParserService, - IOptions runtimeSettings) + ITemplateContentParserService templateContentParserService) : base(provider, loggerFactory, eventMessagesFactory) { _shortStringHelper = shortStringHelper; _templateRepository = templateRepository; _auditService = auditService; _templateContentParserService = templateContentParserService; - _runtimeSettings = runtimeSettings; } - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] + [Obsolete("Use the constructor without the runtimeSettings parameter. Scheduled for removal in Umbraco 19.")] public TemplateService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -71,7 +67,8 @@ public TemplateService( IShortStringHelper shortStringHelper, ITemplateRepository templateRepository, IAuditService auditService, - ITemplateContentParserService templateContentParserService) + ITemplateContentParserService templateContentParserService, + IOptions runtimeSettings) : this( provider, loggerFactory, @@ -79,8 +76,7 @@ public TemplateService( shortStringHelper, templateRepository, auditService, - templateContentParserService, - StaticServiceProvider.Instance.GetRequiredService>()) + templateContentParserService) { } @@ -129,8 +125,6 @@ public TemplateService( { } - private bool IsProductionMode => _runtimeSettings.Value.Mode == RuntimeMode.Production; - /// [Obsolete("Use the overload that includes name and alias parameters instead. Scheduled for removal in Umbraco 19.")] public async Task> CreateForContentTypeAsync( @@ -218,11 +212,6 @@ public async Task> CreateAsync(ITemp /// The operation status indicating the result of the validation. private async Task ValidateCreateAsync(ITemplate templateToCreate) { - if (IsProductionMode) - { - return TemplateOperationStatus.NotAllowedInProductionMode; - } - ITemplate? existingTemplate = await GetAsync(templateToCreate.Alias); if (existingTemplate is not null) { @@ -319,19 +308,6 @@ private async Task ValidateUpdateAsync(ITemplate templa return TemplateOperationStatus.TemplateNotFound; } - // In production mode, block updates if the content is being changed. - if (IsProductionMode) - { - // Reuse existingTemplate if keys match (same template), otherwise fetch by key. - ITemplate? existingByKey = existingTemplate?.Key == templateToUpdate.Key - ? existingTemplate - : await GetAsync(templateToUpdate.Key); - if (existingByKey is not null && existingByKey.Content != templateToUpdate.Content) - { - return TemplateOperationStatus.ContentChangeNotAllowedInProductionMode; - } - } - return TemplateOperationStatus.Success; } @@ -580,11 +556,6 @@ private async Task> CreateAsync(ITem /// An attempt result containing the deleted template and operation status. private async Task> DeleteAsync(Func> getTemplate, Guid userKey) { - if (IsProductionMode) - { - return Attempt.FailWithStatus(TemplateOperationStatus.NotAllowedInProductionMode, null); - } - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { ITemplate? template = await getTemplate(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs index dd6bc857dde8..4974a9164385 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs @@ -1,15 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Attributes; using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; @@ -118,107 +115,6 @@ public async Task Can_Rename_PartialView() Assert.AreEqual("RenamedPartialView.cshtml", result.Result.Name); } - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Create_PartialView_In_Production_Mode() - { - var createModel = new PartialViewCreateModel - { - Name = "TestPartialView.cshtml", - Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" - }; - - var result = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); - Assert.IsNull(result.Result); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Update_PartialView_In_Production_Mode() - { - // Create file directly via filesystem since service blocks creation in production mode - var fileSystems = GetRequiredService(); - var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; - const string fileName = "ExistingPartialView.cshtml"; - const string originalContent = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

"; - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(originalContent))) - { - partialViewFileSystem.AddFile(fileName, stream); - } - - var updateModel = new PartialViewUpdateModel - { - Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Updated

" - }; - - var result = await PartialViewService.UpdateAsync(fileName, updateModel, Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); - - // Verify the file was not changed - using var fileStream = partialViewFileSystem.OpenFile(fileName); - using var reader = new StreamReader(fileStream); - var content = await reader.ReadToEndAsync(); - Assert.That(content, Does.Contain("Original")); - Assert.That(content, Does.Not.Contain("Updated")); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Delete_PartialView_In_Production_Mode() - { - var fileSystems = GetRequiredService(); - var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; - const string fileName = "PartialViewToDelete.cshtml"; - const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) - { - partialViewFileSystem.AddFile(fileName, stream); - } - - Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should exist before delete attempt"); - - var result = await PartialViewService.DeleteAsync(fileName, Constants.Security.SuperUserKey); - - Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result); - Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should still exist after blocked delete"); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Rename_PartialView_In_Production_Mode() - { - var fileSystems = GetRequiredService(); - var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; - const string originalFileName = "OriginalName.cshtml"; - const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) - { - partialViewFileSystem.AddFile(originalFileName, stream); - } - - Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should exist"); - - var renameModel = new PartialViewRenameModel - { - Name = "RenamedFile.cshtml" - }; - - var result = await PartialViewService.RenameAsync(originalFileName, renameModel, Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); - Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should still exist"); - Assert.IsFalse(partialViewFileSystem.FileExists("RenamedFile.cshtml"), "Renamed file should not exist"); - } - private void DeleteAllPartialViewFiles() { var fileSystems = GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs index bfeafd89ecc4..45db3edf875a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs @@ -1,15 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Attributes; using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -20,14 +17,6 @@ internal sealed class TemplateServiceTests : UmbracoIntegrationTest { private ITemplateService TemplateService => GetRequiredService(); - /// - /// Configures the runtime mode to Production for tests decorated with [ConfigureBuilder]. - /// - public static void ConfigureProductionMode(IUmbracoBuilder builder) - { - builder.Services.Configure(settings => settings.Mode = RuntimeMode.Production); - } - [SetUp] public void SetUp() => DeleteAllTemplateViewFiles(); @@ -305,109 +294,4 @@ public async Task Can_Create_Template_With_Key() }); } - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Create_Template_In_Production_Mode() - { - var result = await TemplateService.CreateAsync("Template", "template", "test", Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(TemplateOperationStatus.NotAllowedInProductionMode, result.Status); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Delete_Template_In_Production_Mode() - { - // The production mode check happens before the template lookup, - // so we don't need an actual template in the database for this test. - var result = await TemplateService.DeleteAsync("AnyTemplateAlias", Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(TemplateOperationStatus.NotAllowedInProductionMode, result.Status); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Cannot_Update_Template_Content_In_Production_Mode() - { - // Create template directly via repository to bypass the service's production mode check, - // allowing us to have an existing template to test the update behavior against. - var fileSystems = GetRequiredService(); - var viewFileSystem = fileSystems.MvcViewsFileSystem!; - const string fileName = "ExistingTemplate.cshtml"; - const string originalContent = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

"; - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(originalContent))) - { - viewFileSystem.AddFile(fileName, stream); - } - - // Create the template in the database. - var shortStringHelper = GetRequiredService(); - var template = new Template(shortStringHelper, "ExistingTemplate", "ExistingTemplate") - { - Content = originalContent - }; - - // Save via scope to bypass service production check. - using (var scope = ScopeProvider.CreateScope()) - { - var templateRepository = GetRequiredService(); - templateRepository.Save(template); - scope.Complete(); - } - - // Now try to update the content in production mode. - template.Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Modified

"; - var result = await TemplateService.UpdateAsync(template, Constants.Security.SuperUserKey); - - Assert.IsFalse(result.Success); - Assert.AreEqual(TemplateOperationStatus.ContentChangeNotAllowedInProductionMode, result.Status); - } - - [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] - public async Task Can_Update_Template_Metadata_In_Production_Mode() - { - // Create template directly via repository to bypass the service's production mode check. - var fileSystems = GetRequiredService(); - var viewFileSystem = fileSystems.MvcViewsFileSystem!; - const string fileName = "MetadataTemplate.cshtml"; - const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) - { - viewFileSystem.AddFile(fileName, stream); - } - - var shortStringHelper = GetRequiredService(); - var template = new Template(shortStringHelper, "MetadataTemplate", "MetadataTemplate") - { - Content = content - }; - - using (var scope = ScopeProvider.CreateScope()) - { - var templateRepository = GetRequiredService(); - templateRepository.Save(template); - scope.Complete(); - } - - // Reload to get the saved version. - template = (Template?)await TemplateService.GetAsync(template.Key); - Assert.IsNotNull(template); - - // Now update only the name (metadata), keeping content the same. - template.Name = "Updated Template Name"; - var result = await TemplateService.UpdateAsync(template, Constants.Security.SuperUserKey); - - Assert.IsTrue(result.Success); - Assert.AreEqual(TemplateOperationStatus.Success, result.Status); - - // Verify the name was updated. - var updatedTemplate = await TemplateService.GetAsync(template.Key); - Assert.IsNotNull(updatedTemplate); - Assert.AreEqual("Updated Template Name", updatedTemplate.Name); - } } From 00a2ca0194eca840a11d1f5041bf8968ac140731 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:06:57 +0200 Subject: [PATCH 2/8] Remove unused ConfigureProductionMode helper from PartialViewServiceTests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Umbraco.Core/Services/PartialViewServiceTests.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs index 4974a9164385..0dd476f25eb6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs @@ -17,14 +17,6 @@ internal sealed class PartialViewServiceTests : UmbracoIntegrationTest { private IPartialViewService PartialViewService => GetRequiredService(); - /// - /// Configures the runtime mode to Production for tests decorated with [ConfigureBuilder]. - /// - public static void ConfigureProductionMode(IUmbracoBuilder builder) - { - builder.Services.Configure(settings => settings.Mode = RuntimeMode.Production); - } - [SetUp] public void SetUp() => DeleteAllPartialViewFiles(); From 6db17cc2140afd18be1531d672f95aec8f517186 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:28:31 +0200 Subject: [PATCH 3/8] Add integration tests for UpdateTemplateController production mode behavior Tests verify that the Management API correctly blocks template content changes while allowing metadata-only updates in production mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...teTemplateControllerProductionModeTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs new file mode 100644 index 000000000000..7a19013fa62e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Template; +using Umbraco.Cms.Api.Management.ViewModels.Template; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Template; + +public class UpdateTemplateControllerProductionModeTests : ManagementApiTest +{ + private ITemplateService TemplateService => GetRequiredService(); + + private ITemplate _template = null!; + + protected override Expression> MethodSelector { get; set; } + + [SetUp] + public override void Setup() + { + InMemoryConfiguration[Constants.Configuration.ConfigRuntimeMode] = "Production"; + base.Setup(); + } + + [SetUp] + public async Task CreateTemplate() + { + // Create template via the service layer (allowed in production mode). + var alias = "test" + Guid.NewGuid().ToString("N"); + var result = await TemplateService.CreateAsync(alias, alias, "

Original

", Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + _template = result.Result; + + MethodSelector = x => x.Update(CancellationToken.None, _template.Key, null); + + await AuthenticateClientAsync(Client, "admin@test.com", "1234567890", true); + } + + [Test] + public async Task Content_Change_Returns_Bad_Request() + { + UpdateTemplateRequestModel updateModel = new() + { + Name = _template.Name!, + Alias = _template.Alias, + Content = "

Modified

", + }; + + var response = await Client.PutAsync(Url, JsonContent.Create(updateModel)); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Test] + public async Task Metadata_Change_Returns_Ok() + { + // Re-fetch the template to get the actual resolved content (lazy-loaded from disk). + var current = await TemplateService.GetAsync(_template.Key); + + UpdateTemplateRequestModel updateModel = new() + { + Name = "Updated Name", + Alias = current!.Alias, + Content = current.Content, + }; + + var response = await Client.PutAsync(Url, JsonContent.Create(updateModel)); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } +} From 0ca1d8af61ac863158840fbe4488bc481cbef26a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:34:27 +0200 Subject: [PATCH 4/8] Restore partial view service checks. Add integration tests for template controllers with production mode. --- .../CreatePartialViewController.cs | 29 +------- .../DeletePartialViewController.cs | 27 +------- .../RenamePartialViewController.cs | 32 +-------- .../UpdatePartialViewController.cs | 29 +------- .../Services/PartialViewService.cs | 68 ++++++++++++++----- ...teTemplateControllerProductionModeTests.cs | 41 +++++++++++ ...teTemplateControllerProductionModeTests.cs | 46 +++++++++++++ .../Services/PartialViewServiceTests.cs | 11 +++ 8 files changed, 154 insertions(+), 129 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs index 9992eaf1ee56..3ab1692a19a9 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/CreatePartialViewController.cs @@ -1,13 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -25,7 +21,6 @@ public class CreatePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class with the specified services. @@ -33,31 +28,14 @@ public class CreatePartialViewController : PartialViewControllerBase /// The service used to manage partial views. /// The mapper used for Umbraco model mapping. /// Provides access to back office security information. - /// The runtime configuration settings. - [ActivatorUtilitiesConstructor] public CreatePartialViewController( IPartialViewService partialViewService, IUmbracoMapper umbracoMapper, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IOptions runtimeSettings) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { _partialViewService = partialViewService; _umbracoMapper = umbracoMapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _runtimeSettings = runtimeSettings; - } - - [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] - public CreatePartialViewController( - IPartialViewService partialViewService, - IUmbracoMapper umbracoMapper, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) - : this( - partialViewService, - umbracoMapper, - backOfficeSecurityAccessor, - StaticServiceProvider.Instance.GetRequiredService>()) - { } /// @@ -77,11 +55,6 @@ public async Task Create( CancellationToken cancellationToken, CreatePartialViewRequestModel requestModel) { - if (_runtimeSettings.Value.Mode == RuntimeMode.Production) - { - return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); - } - PartialViewCreateModel createModel = _umbracoMapper.Map(requestModel)!; Attempt createAttempt = await _partialViewService.CreateAsync(createModel, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs index 7cc55be33285..b4fb1dadf472 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/DeletePartialViewController.cs @@ -1,11 +1,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -20,34 +16,18 @@ public class DeletePartialViewController : PartialViewControllerBase { private readonly IPartialViewService _partialViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, responsible for handling requests to delete partial views. /// /// Service used to manage partial views. /// Accessor for back office security context. - /// The runtime configuration settings. - [ActivatorUtilitiesConstructor] public DeletePartialViewController( IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IOptions runtimeSettings) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _runtimeSettings = runtimeSettings; - } - - [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] - public DeletePartialViewController( - IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) - : this( - partialViewService, - backOfficeSecurityAccessor, - StaticServiceProvider.Instance.GetRequiredService>()) - { } [HttpDelete("{*path}")] @@ -59,11 +39,6 @@ public DeletePartialViewController( [EndpointDescription("Deletes a partial view identified by the provided Id.")] public async Task Delete(CancellationToken cancellationToken, string path) { - if (_runtimeSettings.Value.Mode == RuntimeMode.Production) - { - return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); - } - path = DecodePath(path).VirtualPathToSystemPath(); PartialViewOperationStatus operationStatus = await _partialViewService.DeleteAsync(path, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs index a8ff9546c370..271b7e680727 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/RenamePartialViewController.cs @@ -1,13 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -25,7 +21,6 @@ public class RenamePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class. @@ -33,31 +28,11 @@ public class RenamePartialViewController : PartialViewControllerBase /// Service used to manage and manipulate partial views. /// Accessor for back office security context and authentication. /// Mapper used to convert between Umbraco domain models and API models. - /// The runtime configuration settings. - [ActivatorUtilitiesConstructor] - public RenamePartialViewController( - IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUmbracoMapper umbracoMapper, - IOptions runtimeSettings) + public RenamePartialViewController(IPartialViewService partialViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IUmbracoMapper umbracoMapper) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _umbracoMapper = umbracoMapper; - _runtimeSettings = runtimeSettings; - } - - [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] - public RenamePartialViewController( - IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUmbracoMapper umbracoMapper) - : this( - partialViewService, - backOfficeSecurityAccessor, - umbracoMapper, - StaticServiceProvider.Instance.GetRequiredService>()) - { } /// @@ -86,11 +61,6 @@ public async Task Rename( string path, RenamePartialViewRequestModel requestModel) { - if (_runtimeSettings.Value.Mode == RuntimeMode.Production) - { - return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); - } - PartialViewRenameModel renameModel = _umbracoMapper.Map(requestModel)!; path = DecodePath(path).VirtualPathToSystemPath(); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs index 9e3a85af73e6..b8817af5d150 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/UpdatePartialViewController.cs @@ -1,13 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.PartialView; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -25,7 +21,6 @@ public class UpdatePartialViewController : PartialViewControllerBase private readonly IPartialViewService _partialViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IUmbracoMapper _mapper; - private readonly IOptions _runtimeSettings; /// /// Initializes a new instance of the class, which handles requests for updating partial views in the Umbraco backoffice. @@ -33,31 +28,14 @@ public class UpdatePartialViewController : PartialViewControllerBase /// Service used to manage partial view files. /// Accessor for back office security context. /// The Umbraco object mapper for mapping between models. - /// The runtime configuration settings. - [ActivatorUtilitiesConstructor] public UpdatePartialViewController( IPartialViewService partialViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUmbracoMapper mapper, - IOptions runtimeSettings) + IUmbracoMapper mapper) { _partialViewService = partialViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _mapper = mapper; - _runtimeSettings = runtimeSettings; - } - - [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] - public UpdatePartialViewController( - IPartialViewService partialViewService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUmbracoMapper mapper) - : this( - partialViewService, - backOfficeSecurityAccessor, - mapper, - StaticServiceProvider.Instance.GetRequiredService>()) - { } /// @@ -79,11 +57,6 @@ public async Task Update( string path, UpdatePartialViewRequestModel updateViewModel) { - if (_runtimeSettings.Value.Mode == RuntimeMode.Production) - { - return PartialViewOperationStatusResult(PartialViewOperationStatus.NotAllowedInProductionMode); - } - path = DecodePath(path).VirtualPathToSystemPath(); PartialViewUpdateModel updateModel = _mapper.Map(updateViewModel)!; diff --git a/src/Umbraco.Core/Services/PartialViewService.cs b/src/Umbraco.Core/Services/PartialViewService.cs index f78e933b5e0a..476e6d93dd0c 100644 --- a/src/Umbraco.Core/Services/PartialViewService.cs +++ b/src/Umbraco.Core/Services/PartialViewService.cs @@ -25,8 +25,9 @@ namespace Umbraco.Cms.Core.Services; public class PartialViewService : FileServiceOperationBase, IPartialViewService { private readonly PartialViewSnippetCollection _snippetCollection; + private readonly IOptions _runtimeSettings; - // TODO (V19): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. + // TODO (V18): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. // Also update UmbracoBuilder where this service is registered using: // Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); // We do this to allow the ActivatorUtilitiesConstructor to be used (it's otherwise ignored by AddUnique). @@ -44,6 +45,7 @@ public class PartialViewService : FileServiceOperationBaseThe resolver for converting user keys to IDs. /// The service for audit logging. /// The collection of available partial view snippets. + /// The runtime configuration settings. [ActivatorUtilitiesConstructor] public PartialViewService( ICoreScopeProvider provider, @@ -53,13 +55,15 @@ public PartialViewService( ILogger logger, IUserIdKeyResolver userIdKeyResolver, IAuditService auditService, - PartialViewSnippetCollection snippetCollection) + PartialViewSnippetCollection snippetCollection, + IOptions runtimeSettings) : base(provider, loggerFactory, eventMessagesFactory, repository, logger, userIdKeyResolver, auditService) { _snippetCollection = snippetCollection; + _runtimeSettings = runtimeSettings; } - [Obsolete("Use the constructor without the runtimeSettings parameter. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] public PartialViewService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -67,9 +71,8 @@ public PartialViewService( IPartialViewRepository repository, ILogger logger, IUserIdKeyResolver userIdKeyResolver, - IAuditService auditService, - PartialViewSnippetCollection snippetCollection, - IOptions runtimeSettings) + IAuditRepository auditRepository, + PartialViewSnippetCollection snippetCollection) : this( provider, loggerFactory, @@ -77,8 +80,9 @@ public PartialViewService( repository, logger, userIdKeyResolver, - auditService, - snippetCollection) + StaticServiceProvider.Instance.GetRequiredService(), + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) { } @@ -90,6 +94,7 @@ public PartialViewService( IPartialViewRepository repository, ILogger logger, IUserIdKeyResolver userIdKeyResolver, + IAuditService auditService, IAuditRepository auditRepository, PartialViewSnippetCollection snippetCollection) : this( @@ -99,8 +104,9 @@ public PartialViewService( repository, logger, userIdKeyResolver, - StaticServiceProvider.Instance.GetRequiredService(), - snippetCollection) + auditService, + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) { } @@ -113,7 +119,6 @@ public PartialViewService( ILogger logger, IUserIdKeyResolver userIdKeyResolver, IAuditService auditService, - IAuditRepository auditRepository, PartialViewSnippetCollection snippetCollection) : this( provider, @@ -123,13 +128,16 @@ public PartialViewService( logger, userIdKeyResolver, auditService, - snippetCollection) + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) { } /// protected override string[] AllowedFileExtensions { get; } = { ".cshtml" }; + private bool IsProductionMode => _runtimeSettings.Value.Mode == RuntimeMode.Production; + /// protected override PartialViewOperationStatus Success => PartialViewOperationStatus.Success; @@ -201,17 +209,45 @@ public Task> GetSnippetsAsync(int skip, int t /// public async Task DeleteAsync(string path, Guid userKey) - => await HandleDeleteAsync(path, userKey); + { + if (IsProductionMode) + { + return PartialViewOperationStatus.NotAllowedInProductionMode; + } + + return await HandleDeleteAsync(path, userKey); + } /// public async Task> CreateAsync(PartialViewCreateModel createModel, Guid userKey) - => await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); + } /// public async Task> UpdateAsync(string path, PartialViewUpdateModel updateModel, Guid userKey) - => await HandleUpdateAsync(path, updateModel.Content, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleUpdateAsync(path, updateModel.Content, userKey); + } /// public async Task> RenameAsync(string path, PartialViewRenameModel renameModel, Guid userKey) - => await HandleRenameAsync(path, renameModel.Name, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleRenameAsync(path, renameModel.Name, userKey); + } } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs new file mode 100644 index 000000000000..bb53a08bbac8 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Template; +using Umbraco.Cms.Api.Management.ViewModels.Template; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Template; + +public class CreateTemplateControllerProductionModeTests : ManagementApiTest +{ + protected override Expression> MethodSelector { get; set; } + = x => x.Create(CancellationToken.None, null); + + [SetUp] + public override void Setup() + { + InMemoryConfiguration[Constants.Configuration.ConfigRuntimeMode] = "Production"; + base.Setup(); + } + + [SetUp] + public async Task Authenticate() + => await AuthenticateClientAsync(Client, "admin@test.com", "1234567890", true); + + [Test] + public async Task Create_Returns_Bad_Request() + { + CreateTemplateRequestModel createModel = new() + { + Name = Guid.NewGuid().ToString(), + Alias = Guid.NewGuid().ToString("N"), + Content = "

Test

", + }; + + var response = await Client.PostAsync(Url, JsonContent.Create(createModel)); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs new file mode 100644 index 000000000000..d32a2fa8a045 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Template; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Template; + +public class DeleteTemplateControllerProductionModeTests : ManagementApiTest +{ + private ITemplateService TemplateService => GetRequiredService(); + + private ITemplate _template = null!; + + protected override Expression> MethodSelector { get; set; } + + [SetUp] + public override void Setup() + { + InMemoryConfiguration[Constants.Configuration.ConfigRuntimeMode] = "Production"; + base.Setup(); + } + + [SetUp] + public async Task CreateTemplate() + { + var alias = "test" + Guid.NewGuid().ToString("N"); + var result = await TemplateService.CreateAsync(alias, alias, "

Test

", Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + _template = result.Result; + + MethodSelector = x => x.Delete(CancellationToken.None, _template.Key); + + await AuthenticateClientAsync(Client, "admin@test.com", "1234567890", true); + } + + [Test] + public async Task Delete_Returns_Bad_Request() + { + var response = await Client.DeleteAsync(Url); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs index 0dd476f25eb6..6f928a29aa9a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs @@ -1,12 +1,15 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Attributes; using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; @@ -17,6 +20,14 @@ internal sealed class PartialViewServiceTests : UmbracoIntegrationTest { private IPartialViewService PartialViewService => GetRequiredService(); + /// + /// Configures the runtime mode to Production for tests decorated with [ConfigureBuilder]. + /// + public static void ConfigureProductionMode(IUmbracoBuilder builder) + { + builder.Services.Configure(settings => settings.Mode = RuntimeMode.Production); + } + [SetUp] public void SetUp() => DeleteAllPartialViewFiles(); From f7f6abdc6244b4507502004714b25a1d966e13d2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:38:11 +0200 Subject: [PATCH 5/8] Align delete with create/update for file system changes in production mode. --- .../Repositories/Implement/TemplateRepository.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs index a7796327c496..749f69a09439 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TemplateRepository.cs @@ -690,8 +690,12 @@ protected override void PersistDeletedItem(ITemplate entity) Database.Execute(delete, new { id = GetEntityId(entity) }); } - var viewName = string.Concat(entity.Alias, ".cshtml"); - _viewsFileSystem?.DeleteFile(viewName); + // Only delete file when not in production runtime mode + if (_runtimeSettings.CurrentValue.Mode != RuntimeMode.Production) + { + var viewName = string.Concat(entity.Alias, ".cshtml"); + _viewsFileSystem?.DeleteFile(viewName); + } entity.DeleteDate = DateTime.UtcNow; } From e868d41747ebb52cac9516fa5a563611b946b12e Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:40:31 +0200 Subject: [PATCH 6/8] Restore partial view service tests. --- .../Services/PartialViewServiceTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs index 6f928a29aa9a..dd6bc857dde8 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs @@ -118,6 +118,107 @@ public async Task Can_Rename_PartialView() Assert.AreEqual("RenamedPartialView.cshtml", result.Result.Name); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Create_PartialView_In_Production_Mode() + { + var createModel = new PartialViewCreateModel + { + Name = "TestPartialView.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" + }; + + var result = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + Assert.IsNull(result.Result); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Update_PartialView_In_Production_Mode() + { + // Create file directly via filesystem since service blocks creation in production mode + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string fileName = "ExistingPartialView.cshtml"; + const string originalContent = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(originalContent))) + { + partialViewFileSystem.AddFile(fileName, stream); + } + + var updateModel = new PartialViewUpdateModel + { + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Updated

" + }; + + var result = await PartialViewService.UpdateAsync(fileName, updateModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + + // Verify the file was not changed + using var fileStream = partialViewFileSystem.OpenFile(fileName); + using var reader = new StreamReader(fileStream); + var content = await reader.ReadToEndAsync(); + Assert.That(content, Does.Contain("Original")); + Assert.That(content, Does.Not.Contain("Updated")); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Delete_PartialView_In_Production_Mode() + { + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string fileName = "PartialViewToDelete.cshtml"; + const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) + { + partialViewFileSystem.AddFile(fileName, stream); + } + + Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should exist before delete attempt"); + + var result = await PartialViewService.DeleteAsync(fileName, Constants.Security.SuperUserKey); + + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result); + Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should still exist after blocked delete"); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Rename_PartialView_In_Production_Mode() + { + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string originalFileName = "OriginalName.cshtml"; + const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) + { + partialViewFileSystem.AddFile(originalFileName, stream); + } + + Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should exist"); + + var renameModel = new PartialViewRenameModel + { + Name = "RenamedFile.cshtml" + }; + + var result = await PartialViewService.RenameAsync(originalFileName, renameModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should still exist"); + Assert.IsFalse(partialViewFileSystem.FileExists("RenamedFile.cshtml"), "Renamed file should not exist"); + } + private void DeleteAllPartialViewFiles() { var fileSystems = GetRequiredService(); From cad545cd2b49a9cc3933dec295ad7f1325519cce Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 8 Apr 2026 18:52:16 +0200 Subject: [PATCH 7/8] Add test for update to delete template repository. --- .../Repositories/TemplateRepositoryTest.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs index c2b1a11c523c..84336cacdb49 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs @@ -885,6 +885,36 @@ public void Save_In_Production_Mode_Does_Not_Update_Existing_File() } } + [Test] + public void Delete_In_Production_Mode_Does_Not_Remove_File() + { + // Arrange - create template in development mode first. + var provider = ScopeProvider; + + using (provider.CreateScope()) + { + var developmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Development); + var developmentRepository = CreateRepository(provider, developmentRuntimeSettings); + + var template = new Template(ShortStringHelper, "productionTestDelete", "productionTestDelete") { Content = "mock-content" }; + developmentRepository.Save(template); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("productionTestDelete.cshtml"), Is.True); + + // Act - try to delete in production mode. + var productionRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Production); + var productionRepository = CreateRepository(provider, productionRuntimeSettings); + + var existingTemplate = productionRepository.Get("productionTestDelete"); + Assert.IsNotNull(existingTemplate); + + productionRepository.Delete(existingTemplate); + + // Assert - database record should be removed but file should still exist. + Assert.That(productionRepository.Get("productionTestDelete"), Is.Null); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("productionTestDelete.cshtml"), Is.True); + } + } + [Test] public void Save_In_Development_Mode_Writes_File() { From 628d4567e128db12518fe5cfc1e823c9f7305da3 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 9 Apr 2026 11:50:43 +0200 Subject: [PATCH 8/8] Refactored to use single test setup method. --- .../Template/CreateTemplateControllerProductionModeTests.cs | 5 +++-- .../Template/DeleteTemplateControllerProductionModeTests.cs | 5 +++-- .../Template/UpdateTemplateControllerProductionModeTests.cs | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs index bb53a08bbac8..a53ca2db5b87 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/CreateTemplateControllerProductionModeTests.cs @@ -17,11 +17,12 @@ public class CreateTemplateControllerProductionModeTests : ManagementApiTest await AuthenticateClientAsync(Client, "admin@test.com", "1234567890", true); [Test] diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs index d32a2fa8a045..2cdc15102582 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/DeleteTemplateControllerProductionModeTests.cs @@ -20,11 +20,12 @@ public class DeleteTemplateControllerProductionModeTests : ManagementApiTestTest", Constants.Security.SuperUserKey); diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs index 7a19013fa62e..1f92230a6f36 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Template/UpdateTemplateControllerProductionModeTests.cs @@ -22,11 +22,12 @@ public class UpdateTemplateControllerProductionModeTests : ManagementApiTest