diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs b/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs new file mode 100644 index 000000000000..8ab2041f7d46 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/OpenApi/RemoveSecuritySchemesDocumentFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Umbraco.Cms.Api.Common.OpenApi; + +/// +/// This filter explicitly removes all security schemes from a named OpenAPI document. +/// +public class RemoveSecuritySchemesDocumentFilter : IDocumentFilter +{ + private readonly string _documentName; + + public RemoveSecuritySchemesDocumentFilter(string documentName) + => _documentName = documentName; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (context.DocumentName != _documentName) + { + return; + } + + swaggerDoc.Components.SecuritySchemes.Clear(); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs b/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs new file mode 100644 index 000000000000..936cb72d2b9f --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Caching/NoOutputCachePolicy.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.OutputCaching; + +namespace Umbraco.Cms.Api.Delivery.Caching; + +internal sealed class NoOutputCachePolicy : IOutputCachePolicy +{ + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.EnableOutputCaching = false; + + return ValueTask.CompletedTask; + } + + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs index 7fb301c87e84..9b87166300dc 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs @@ -21,6 +21,7 @@ public void Configure(SwaggerGenOptions swaggerGenOptions) }); swaggerGenOptions.DocumentFilter(DeliveryApiConfiguration.ApiName); + swaggerGenOptions.DocumentFilter(DeliveryApiConfiguration.ApiName); swaggerGenOptions.OperationFilter(); swaggerGenOptions.OperationFilter(); diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs index 11e8070f4cb6..1c821f9681fa 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs @@ -17,33 +17,16 @@ namespace Umbraco.Cms.Api.Delivery.Configuration; /// public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : IConfigureOptions { - private const string AuthSchemeName = "Umbraco Member"; + private const string AuthSchemeName = "UmbracoMember"; public void Configure(SwaggerGenOptions options) { - options.AddSecurityDefinition( - AuthSchemeName, - new OpenApiSecurityScheme - { - In = ParameterLocation.Header, - Name = AuthSchemeName, - Type = SecuritySchemeType.OAuth2, - Description = "Umbraco Member Authentication", - Flows = new OpenApiOAuthFlows - { - AuthorizationCode = new OpenApiOAuthFlow - { - AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative), - TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative) - } - } - }); - // add security requirements for content API operations + options.DocumentFilter(); options.OperationFilter(); } - private class DeliveryApiSecurityFilter : SwaggerFilterBase, IOperationFilter + private class DeliveryApiSecurityFilter : SwaggerFilterBase, IOperationFilter, IDocumentFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { @@ -70,5 +53,31 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) } }; } + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (context.DocumentName != DeliveryApiConfiguration.ApiName) + { + return; + } + + swaggerDoc.Components.SecuritySchemes.Add( + AuthSchemeName, + new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Name = AuthSchemeName, + Type = SecuritySchemeType.OAuth2, + Description = "Umbraco Member Authentication", + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative), + TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative) + } + } + }); + } } } diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 34f8b058e64e..77f2cae93c3b 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -105,7 +105,7 @@ private static IUmbracoBuilder AddOutputCache(this IUmbracoBuilder builder) builder.Services.AddOutputCache(options => { - options.AddBasePolicy(_ => { }); + options.AddBasePolicy(build => build.AddPolicy()); if (outputCacheSettings.ContentDuration.TotalSeconds > 0) { diff --git a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs index 74376a3ed2c4..dfac63763cd0 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs @@ -6,6 +6,7 @@ namespace Umbraco.Cms.Core.Configuration; /// /// Typed configuration options for content dashboard settings. /// +[Obsolete("Scheduled for removal in v16, dashboard manipulation is now done trough frontend extensions.")] [UmbracoOptions(Constants.Configuration.ConfigContentDashboard)] public class ContentDashboardSettings { diff --git a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs index 2505183efa6d..5ca0fd47886b 100644 --- a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs +++ b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs @@ -18,7 +18,13 @@ public interface IBlockReference /// /// The content UDI. /// + [Obsolete("Use ContentKey instead. Will be removed in V18.")] Udi ContentUdi { get; } + + /// + /// Gets the content key. + /// + public Guid ContentKey { get; set; } } /// diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 2a7e49dbda3d..c1df8c2f7775 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; @@ -14,42 +14,13 @@ namespace Umbraco.Cms.Core.Services; internal sealed class ContentEditingService : ContentEditingServiceWithSortingBase, IContentEditingService { + private readonly PropertyEditorCollection _propertyEditorCollection; private readonly ITemplateService _templateService; private readonly ILogger _logger; private readonly IUserService _userService; private readonly ILocalizationService _localizationService; private readonly ILanguageService _languageService; - - [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 16.")] - public ContentEditingService( - IContentService contentService, - IContentTypeService contentTypeService, - PropertyEditorCollection propertyEditorCollection, - IDataTypeService dataTypeService, - ITemplateService templateService, - ILogger logger, - ICoreScopeProvider scopeProvider, - IUserIdKeyResolver userIdKeyResolver, - ITreeEntitySortingService treeEntitySortingService, - IContentValidationService contentValidationService) - : this( - contentService, - contentTypeService, - propertyEditorCollection, - dataTypeService, - templateService, - logger, - scopeProvider, - userIdKeyResolver, - treeEntitySortingService, - contentValidationService, - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService() - ) - { - - } + private readonly ContentSettings _contentSettings; public ContentEditingService( IContentService contentService, @@ -64,14 +35,17 @@ public ContentEditingService( IContentValidationService contentValidationService, IUserService userService, ILocalizationService localizationService, - ILanguageService languageService) + ILanguageService languageService, + IOptions contentSettings) : base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, contentValidationService, treeEntitySortingService) { + _propertyEditorCollection = propertyEditorCollection; _templateService = templateService; _logger = logger; _userService = userService; _localizationService = localizationService; _languageService = languageService; + _contentSettings = contentSettings.Value; } public async Task GetAsync(Guid key) @@ -154,6 +128,8 @@ private async Task EnsureOnlyAllowedFieldsAreUpdated(IContent contentW var allowedCultures = (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet(); + ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); + foreach (var culture in contentWithPotentialUnallowedChanges.EditedCultures ?? contentWithPotentialUnallowedChanges.PublishedCultures) { if (allowedCultures.Contains(culture)) @@ -161,21 +137,44 @@ private async Task EnsureOnlyAllowedFieldsAreUpdated(IContent contentW continue; } - // else override the updates values with the original values. foreach (IProperty property in contentWithPotentialUnallowedChanges.Properties) { - if (property.PropertyType.VariesByCulture() is false) + // if the property varies by culture, simply overwrite the edited property value with the current property value + if (property.PropertyType.VariesByCulture()) { + var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(culture, null, false); + property.SetValue(currentValue, culture, null); continue; } - var value = existingContent?.Properties.First(x=>x.Alias == property.Alias).GetValue(culture, null, false); - property.SetValue(value, culture, null); + // if the property does not vary by culture and the data editor supports variance within invariant property values, + // we need perform a merge between the edited property value and the current property value + if (_propertyEditorCollection.TryGet(property.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor) && dataEditor.CanMergePartialPropertyValues(property.PropertyType)) + { + var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false); + var editedValue = contentWithPotentialUnallowedChanges.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false); + var mergedValue = dataEditor.MergePartialPropertyValueForCulture(currentValue, editedValue, culture); + + // If we are not allowed to edit invariant properties, overwrite the edited property value with the current property value. + if (_contentSettings.AllowEditInvariantFromNonDefault is false && culture == defaultLanguage?.IsoCode) + { + mergedValue = dataEditor.MergePartialPropertyValueForCulture(currentValue, mergedValue, null); + } + + property.SetValue(mergedValue, null, null); + } + + // If property does not support merging, we still need to overwrite if we are not allowed to edit invariant properties. + else if (_contentSettings.AllowEditInvariantFromNonDefault is false && culture == defaultLanguage?.IsoCode) + { + var currentValue = existingContent?.Properties.First(x => x.Alias == property.Alias).GetValue(null, null, false); + property.SetValue(currentValue, null, null); + } } } - return contentWithPotentialUnallowedChanges; + return contentWithPotentialUnallowedChanges; } public async Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs index 0f1fd3070ad9..bd8d702efac1 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs @@ -151,25 +151,16 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l var progress = 0; - Parallel.ForEachAsync(updateBatch, async (update, token) => + void HandleUpdateBatch(UpdateBatch update) { - //Foreach here, but we need to suppress the flow before each task, but not the actual await of the task - Task task; - using (ExecutionContext.SuppressFlow()) - { - task = Task.Run( - () => - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.Complete(); + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); progress++; if (progress % 100 == 0) { - _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, - updateBatch.Count); + _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, updateBatch.Count); } PropertyDataDto propertyDataDto = update.Poco; @@ -265,11 +256,37 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l stringValue = UpdateDatabaseValue(stringValue); propertyDataDto.TextValue = stringValue; - }, token); - } + } - await task; - }).GetAwaiter().GetResult(); + if (DatabaseType == DatabaseType.SQLite) + { + // SQLite locks up if we run the migration in parallel, so... let's not. + foreach (UpdateBatch update in updateBatch) + { + HandleUpdateBatch(update); + } + } + else + { + Parallel.ForEachAsync(updateBatch, async (update, token) => + { + //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + HandleUpdateBatch(update); + }, + token); + } + + await task; + }).GetAwaiter().GetResult(); + } updateBatch.RemoveAll(updatesToSkip.Contains); diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 2d60a9361a2f..29583d3d34f5 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 2d60a9361a2f22e9552382f2af0ade3de732d45d +Subproject commit 29583d3d34f57e98052450128435fcb06a0c1984 diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs index 74e7ea77a9af..83bd01e0747d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockEditorElementVariationTestBase.cs @@ -37,6 +37,8 @@ public abstract class BlockEditorElementVariationTestBase : UmbracoIntegrationTe protected PropertyEditorCollection PropertyEditorCollection => GetRequiredService(); + protected IContentEditingService ContentEditingService => GetRequiredService(); + private IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService(); private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs new file mode 100644 index 000000000000..2cdadc5e4602 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs @@ -0,0 +1,501 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; + +public partial class BlockListElementLevelVariationTests +{ + [TestCase(true)] + [TestCase(false)] + public async Task Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess) + { + await LanguageService.CreateAsync( + new Language("de-DE", "German"), Constants.Security.SuperUserKey); + var userKey = updateWithLimitedUserAccess + ? (await CreateLimitedUser()).Key + : Constants.Security.SuperUserKey; + + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType); + var content = CreateContent(contentType, elementType, [], false); + content.SetCultureName("Home (de)", "de-DE"); + ContentService.Save(content); + + var blockListValue = BlockListPropertyValue( + elementType, + [ + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#1: The first invariant content value" }, + new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" } + }, + new List { + new() { Alias = "invariantText", Value = "#1: The first invariant settings value" }, + new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" } + }, + null, + null + ) + ), + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#2: The first invariant content value" }, + new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" } + }, + new List { + new() { Alias = "invariantText", Value = "#2: The first invariant settings value" }, + new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" } + }, + null, + null + ) + ) + ] + ); + + content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue)); + ContentService.Save(content); + + blockListValue.ContentData[0].Values[0].Value = "#1: The second invariant content value"; + blockListValue.ContentData[0].Values[1].Value = "#1: The second content value in English"; + blockListValue.ContentData[0].Values[2].Value = "#1: The second content value in Danish"; + blockListValue.ContentData[0].Values[3].Value = "#1: The second content value in German"; + blockListValue.SettingsData[0].Values[0].Value = "#1: The second invariant settings value"; + blockListValue.SettingsData[0].Values[1].Value = "#1: The second settings value in English"; + blockListValue.SettingsData[0].Values[2].Value = "#1: The second settings value in Danish"; + blockListValue.SettingsData[0].Values[3].Value = "#1: The second settings value in German"; + + blockListValue.ContentData[1].Values[0].Value = "#2: The second invariant content value"; + blockListValue.ContentData[1].Values[1].Value = "#2: The second content value in English"; + blockListValue.ContentData[1].Values[2].Value = "#2: The second content value in Danish"; + blockListValue.ContentData[1].Values[3].Value = "#2: The second content value in German"; + blockListValue.SettingsData[1].Values[0].Value = "#2: The second invariant settings value"; + blockListValue.SettingsData[1].Values[1].Value = "#2: The second settings value in English"; + blockListValue.SettingsData[1].Values[2].Value = "#2: The second settings value in Danish"; + blockListValue.SettingsData[1].Values[3].Value = "#2: The second settings value in German"; + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) } + }, + Variants = new[] + { + new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] }, + new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] }, + new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] } + } + }; + + var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey); + Assert.IsTrue(result.Success); + + content = ContentService.GetById(content.Key); + var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString(); + Assert.NotNull(savedBlocksValue); + blockListValue = JsonSerializer.Deserialize(savedBlocksValue); + + // the Danish and invariant values should be updated regardless of the executing user + Assert.Multiple(() => + { + Assert.AreEqual("#1: The second invariant content value", blockListValue.ContentData[0].Values[0].Value); + Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values[2].Value); + Assert.AreEqual("#1: The second invariant settings value", blockListValue.SettingsData[0].Values[0].Value); + Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values[2].Value); + + Assert.AreEqual("#2: The second invariant content value", blockListValue.ContentData[1].Values[0].Value); + Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values[2].Value); + Assert.AreEqual("#2: The second invariant settings value", blockListValue.SettingsData[1].Values[0].Value); + Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values[2].Value); + }); + + // limited user access means English and German should not have been updated - changes should be rolled back to the initial block values + if (updateWithLimitedUserAccess) + { + Assert.Multiple(() => + { + Assert.AreEqual("#1: The first content value in English", blockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("#1: The first settings value in English", blockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("#1: The first content value in German", blockListValue.ContentData[0].Values[3].Value); + Assert.AreEqual("#1: The first settings value in German", blockListValue.SettingsData[0].Values[3].Value); + + Assert.AreEqual("#2: The first content value in English", blockListValue.ContentData[1].Values[1].Value); + Assert.AreEqual("#2: The first settings value in English", blockListValue.SettingsData[1].Values[1].Value); + Assert.AreEqual("#2: The first content value in German", blockListValue.ContentData[1].Values[3].Value); + Assert.AreEqual("#2: The first settings value in German", blockListValue.SettingsData[1].Values[3].Value); + }); + } + else + { + Assert.Multiple(() => + { + Assert.AreEqual("#1: The second content value in English", blockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("#1: The second settings value in English", blockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("#1: The second content value in German", blockListValue.ContentData[0].Values[3].Value); + Assert.AreEqual("#1: The second settings value in German", blockListValue.SettingsData[0].Values[3].Value); + + Assert.AreEqual("#2: The second content value in English", blockListValue.ContentData[1].Values[1].Value); + Assert.AreEqual("#2: The second settings value in English", blockListValue.SettingsData[1].Values[1].Value); + Assert.AreEqual("#2: The second content value in German", blockListValue.ContentData[1].Values[3].Value); + Assert.AreEqual("#2: The second settings value in German", blockListValue.SettingsData[1].Values[3].Value); + }); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Handle_Limited_User_Access_To_Languages_Without_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess) + { + await LanguageService.CreateAsync( + new Language("de-DE", "German"), Constants.Security.SuperUserKey); + var userKey = updateWithLimitedUserAccess + ? (await CreateLimitedUser()).Key + : Constants.Security.SuperUserKey; + + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType); + var content = CreateContent(contentType, elementType, [], false); + content.SetCultureName("Home (de)", "de-DE"); + ContentService.Save(content); + + var blockListValue = BlockListPropertyValue( + elementType, + [ + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#1: The first invariant content value" }, + new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" } + }, + new List { + new() { Alias = "invariantText", Value = "#1: The first invariant settings value" }, + new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" } + }, + null, + null + ) + ), + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#2: The first invariant content value" }, + new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" } + }, + new List { + new() { Alias = "invariantText", Value = "#2: The first invariant settings value" }, + new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" } + }, + null, + null + ) + ) + ] + ); + + content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue)); + ContentService.Save(content); + + blockListValue.ContentData[0].Values[0].Value = "#1: The second invariant content value"; + blockListValue.ContentData[0].Values[1].Value = "#1: The second content value in English"; + blockListValue.ContentData[0].Values[2].Value = "#1: The second content value in Danish"; + blockListValue.ContentData[0].Values[3].Value = "#1: The second content value in German"; + blockListValue.SettingsData[0].Values[0].Value = "#1: The second invariant settings value"; + blockListValue.SettingsData[0].Values[1].Value = "#1: The second settings value in English"; + blockListValue.SettingsData[0].Values[2].Value = "#1: The second settings value in Danish"; + blockListValue.SettingsData[0].Values[3].Value = "#1: The second settings value in German"; + + blockListValue.ContentData[1].Values[0].Value = "#2: The second invariant content value"; + blockListValue.ContentData[1].Values[1].Value = "#2: The second content value in English"; + blockListValue.ContentData[1].Values[2].Value = "#2: The second content value in Danish"; + blockListValue.ContentData[1].Values[3].Value = "#2: The second content value in German"; + blockListValue.SettingsData[1].Values[0].Value = "#2: The second invariant settings value"; + blockListValue.SettingsData[1].Values[1].Value = "#2: The second settings value in English"; + blockListValue.SettingsData[1].Values[2].Value = "#2: The second settings value in Danish"; + blockListValue.SettingsData[1].Values[3].Value = "#2: The second settings value in German"; + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) } + }, + Variants = new[] + { + new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] }, + new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] }, + new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] } + } + }; + + var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey); + Assert.IsTrue(result.Success); + + content = ContentService.GetById(content.Key); + var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString(); + Assert.NotNull(savedBlocksValue); + blockListValue = JsonSerializer.Deserialize(savedBlocksValue); + + // the Danish values should be updated regardless of the executing user + Assert.Multiple(() => + { + Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values[2].Value); + Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values[2].Value); + + Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values[2].Value); + Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values[2].Value); + }); + + // limited user access means invariant, English and German should not have been updated - changes should be rolled back to the initial block values + if (updateWithLimitedUserAccess) + { + Assert.Multiple(() => + { + + Assert.AreEqual("#1: The first invariant content value", blockListValue.ContentData[0].Values[0].Value); + Assert.AreEqual("#1: The first invariant settings value", blockListValue.SettingsData[0].Values[0].Value); + Assert.AreEqual("#1: The first content value in English", blockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("#1: The first settings value in English", blockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("#1: The first content value in German", blockListValue.ContentData[0].Values[3].Value); + Assert.AreEqual("#1: The first settings value in German", blockListValue.SettingsData[0].Values[3].Value); + + Assert.AreEqual("#2: The first invariant content value", blockListValue.ContentData[1].Values[0].Value); + Assert.AreEqual("#2: The first invariant settings value", blockListValue.SettingsData[1].Values[0].Value); + Assert.AreEqual("#2: The first content value in English", blockListValue.ContentData[1].Values[1].Value); + Assert.AreEqual("#2: The first settings value in English", blockListValue.SettingsData[1].Values[1].Value); + Assert.AreEqual("#2: The first content value in German", blockListValue.ContentData[1].Values[3].Value); + Assert.AreEqual("#2: The first settings value in German", blockListValue.SettingsData[1].Values[3].Value); + }); + } + else + { + Assert.Multiple(() => + { + Assert.AreEqual("#1: The second invariant content value", blockListValue.ContentData[0].Values[0].Value); + Assert.AreEqual("#1: The second invariant settings value", blockListValue.SettingsData[0].Values[0].Value); + Assert.AreEqual("#1: The second content value in English", blockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("#1: The second settings value in English", blockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("#1: The second content value in German", blockListValue.ContentData[0].Values[3].Value); + Assert.AreEqual("#1: The second settings value in German", blockListValue.SettingsData[0].Values[3].Value); + + Assert.AreEqual("#2: The second invariant content value", blockListValue.ContentData[1].Values[0].Value); + Assert.AreEqual("#2: The second invariant settings value", blockListValue.SettingsData[1].Values[0].Value); + Assert.AreEqual("#2: The second content value in English", blockListValue.ContentData[1].Values[1].Value); + Assert.AreEqual("#2: The second settings value in English", blockListValue.SettingsData[1].Values[1].Value); + Assert.AreEqual("#2: The second content value in German", blockListValue.ContentData[1].Values[3].Value); + Assert.AreEqual("#2: The second settings value in German", blockListValue.SettingsData[1].Values[3].Value); + }); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess) + { + await LanguageService.CreateAsync( + new Language("de-DE", "German"), Constants.Security.SuperUserKey); + var userKey = updateWithLimitedUserAccess + ? (await CreateLimitedUser()).Key + : Constants.Security.SuperUserKey; + var nestedElementType = CreateElementType(ContentVariation.Culture); + var nestedBlockListDataType = await CreateBlockListDataType(nestedElementType); + + var rootElementType = new ContentTypeBuilder() + .WithAlias("myRootElementType") + .WithName("My Root Element Type") + .WithIsElement(true) + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("nestedBlocks") + .WithName("Nested blocks") + .WithDataTypeId(nestedBlockListDataType.Id) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.BlockList) + .WithValueStorageType(ValueStorageType.Ntext) + .WithVariations(ContentVariation.Nothing) + .Done() + .Build(); + ContentTypeService.Save(rootElementType); + var rootBlockListDataType = await CreateBlockListDataType(rootElementType); + var contentType = CreateContentType(ContentVariation.Culture, rootBlockListDataType); + + var nestedElementContentKey = Guid.NewGuid(); + var nestedElementSettingsKey = Guid.NewGuid(); + var content = CreateContent( + contentType, + rootElementType, + new List + { + new() + { + Alias = "nestedBlocks", + Value = BlockListPropertyValue( + nestedElementType, + nestedElementContentKey, + nestedElementSettingsKey, + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "The first nested invariant content value" }, + new() { Alias = "variantText", Value = "The first nested content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The first nested content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "The first nested content value in German", Culture = "de-DE" }, + }, + new List + { + new() { Alias = "invariantText", Value = "The first nested invariant settings value" }, + new() { Alias = "variantText", Value = "The first nested settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The first nested settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "The first nested settings value in German", Culture = "de-DE" }, + }, + null, + null)) + } + }, + [], + false); + content.SetCultureName("Home (de)", "de-DE"); + ContentService.Save(content); + + var blockListValue = JsonSerializer.Deserialize((string)content.Properties["blocks"]!.GetValue()!); + blockListValue.ContentData[0].Values[0].Value = BlockListPropertyValue( + nestedElementType, + nestedElementContentKey, + nestedElementSettingsKey, + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "The second nested invariant content value" }, + new() { Alias = "variantText", Value = "The second nested content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The second nested content value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "The second nested content value in German", Culture = "de-DE" }, + }, + new List + { + new() { Alias = "invariantText", Value = "The second nested invariant settings value" }, + new() { Alias = "variantText", Value = "The second nested settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The second nested settings value in Danish", Culture = "da-DK" }, + new() { Alias = "variantText", Value = "The second nested settings value in German", Culture = "de-DE" }, + }, + null, + null)); + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) } + }, + Variants = new[] + { + new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] }, + new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] }, + new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] } + } + }; + + var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey); + Assert.IsTrue(result.Success); + + content = ContentService.GetById(content.Key); + var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString(); + Assert.NotNull(savedBlocksValue); + blockListValue = JsonSerializer.Deserialize(savedBlocksValue); + + var nestedBlocksPropertyValue = blockListValue.ContentData + .FirstOrDefault()?.Values + .FirstOrDefault(v => v.Alias == "nestedBlocks")?.Value?.ToString(); + Assert.IsNotNull(nestedBlocksPropertyValue); + var nestedBlockListValue = JsonSerializer.Deserialize(nestedBlocksPropertyValue); + + + // the Danish and invariant values should be updated regardless of the executing user + Assert.Multiple(() => + { + Assert.AreEqual("The second nested invariant content value", nestedBlockListValue.ContentData[0].Values[0].Value); + Assert.AreEqual("The second nested content value in Danish", nestedBlockListValue.ContentData[0].Values[2].Value); + + Assert.AreEqual("The second nested invariant settings value", nestedBlockListValue.SettingsData[0].Values[0].Value); + Assert.AreEqual("The second nested settings value in Danish", nestedBlockListValue.SettingsData[0].Values[2].Value); + }); + + // limited user access means English and German should not have been updated - changes should be rolled back to the initial block values + if (updateWithLimitedUserAccess) + { + Assert.Multiple(() => + { + Assert.AreEqual("The first nested content value in English", nestedBlockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("The first nested content value in German", nestedBlockListValue.ContentData[0].Values[3].Value); + + Assert.AreEqual("The first nested settings value in English", nestedBlockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("The first nested settings value in German", nestedBlockListValue.SettingsData[0].Values[3].Value); + }); + } + else + { + Assert.Multiple(() => + { + Assert.AreEqual("The second nested content value in English", nestedBlockListValue.ContentData[0].Values[1].Value); + Assert.AreEqual("The second nested content value in German", nestedBlockListValue.ContentData[0].Values[3].Value); + + Assert.AreEqual("The second nested settings value in English", nestedBlockListValue.SettingsData[0].Values[1].Value); + Assert.AreEqual("The second nested settings value in German", nestedBlockListValue.SettingsData[0].Values[3].Value); + }); + } + } + + private async Task CreateLimitedUser() + { + var userGroupService = GetRequiredService(); + var userService = GetRequiredService(); + + var danish = await LanguageService.GetAsync("da-DK"); + Assert.IsNotNull(danish); + + var user = UserBuilder.CreateUser(); + userService.Save(user); + + var group = UserGroupBuilder.CreateUserGroup(); + group.ClearAllowedLanguages(); + group.AddAllowedLanguage(danish.Id); + + var userGroupResult = await userGroupService.CreateAsync(group, Constants.Security.SuperUserKey, [user.Key]); + Assert.IsTrue(userGroupResult.Success); + + return user; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs index 53af4af967b7..14fb9c5c72ef 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.cs @@ -16,6 +16,12 @@ public partial class BlockListElementLevelVariationTests : BlockEditorElementVar public void OneTimeSetUp() { TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Publish_Invariant_Properties_Without_Default_Culture_With_AllowEditInvariantFromNonDefault)); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault)); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault)); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault) + "(True)"); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_With_AllowEditInvariantFromNonDefault) + "(False)"); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault) + "(True)"); + TestsRequiringAllowEditInvariantFromNonDefault.Add(nameof(Can_Handle_Limited_User_Access_To_Languages_In_Nested_Blocks_Without_Access_With_AllowEditInvariantFromNonDefault) + "(False)"); } private IJsonSerializer JsonSerializer => GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index d17424352236..6c4eafafb690 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -167,6 +167,9 @@ BlockListElementLevelVariationTests.cs + + BlockListElementLevelVariationTests.cs + BlockListElementLevelVariationTests.cs diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs index 4cc94e8e03f5..ae54bd51dad4 100644 --- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs +++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs @@ -67,6 +67,7 @@ public class UmbracoCmsDefinition public required LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; } + [Obsolete("Scheduled for removal in v16, dashboard manipulation is now done trough frontend extensions.")] public required ContentDashboardSettings ContentDashboard { get; set; } public required HelpPageSettings HelpPage { get; set; }