Merge remote-tracking branch 'origin/v9/dev' into v9/dev

This commit is contained in:
Bjarke Berg
2021-10-18 06:44:13 +02:00
18 changed files with 834 additions and 25 deletions

View File

@@ -3,15 +3,15 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
@@ -165,7 +165,15 @@ namespace Umbraco.Extensions
return ContentStatus.Unpublished;
}
/// <summary>
/// Gets a collection containing the ids of all ancestors.
/// </summary>
/// <param name="content"><see cref="IContent"/> to retrieve ancestors for</param>
/// <returns>An Enumerable list of integer ids</returns>
public static IEnumerable<int> GetAncestorIds(this IContent content) =>
content.Path.Split(Constants.CharArrays.Comma)
.Where(x => x != Constants.System.RootString && x != content.Id.ToString(CultureInfo.InvariantCulture)).Select(s =>
int.Parse(s, CultureInfo.InvariantCulture));
#endregion

View File

@@ -24,7 +24,7 @@ namespace Umbraco.Cms.Core.Security
/// <summary>
/// The user store for back office users
/// </summary>
public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, IdentityRole<string>>
public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, IdentityRole<string>>, IUserSessionStore<BackOfficeIdentityUser>
{
private readonly IScopeProvider _scopeProvider;
private readonly IUserService _userService;

View File

@@ -531,18 +531,20 @@ namespace Umbraco.Cms.Core.Services.Implement
public IEnumerable<IContent> GetAncestors(IContent content)
{
//null check otherwise we get exceptions
if (content.Path.IsNullOrWhiteSpace()) return Enumerable.Empty<IContent>();
var rootId = Cms.Core.Constants.System.RootString;
var ids = content.Path.Split(Constants.CharArrays.Comma)
.Where(x => x != rootId && x != content.Id.ToString(CultureInfo.InvariantCulture)).Select(s =>
int.Parse(s, CultureInfo.InvariantCulture)).ToArray();
if (ids.Any() == false)
return new List<IContent>();
using (var scope = ScopeProvider.CreateScope(autoComplete: true))
if (content.Path.IsNullOrWhiteSpace())
{
scope.ReadLock(Cms.Core.Constants.Locks.ContentTree);
return Enumerable.Empty<IContent>();
}
var ids = content.GetAncestorIds().ToArray();
if (ids.Any() == false)
{
return new List<IContent>();
}
using (IScope scope = ScopeProvider.CreateScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetMany(ids);
}
}

View File

@@ -16,6 +16,7 @@ namespace Umbraco.Cms.Tests.Common.Builders
public class ContentBuilder
: BuilderBase<Content>,
IBuildContentTypes,
IBuildContentCultureInfosCollection,
IWithIdBuilder,
IWithKeyBuilder,
IWithParentIdBuilder,
@@ -31,6 +32,7 @@ namespace Umbraco.Cms.Tests.Common.Builders
IWithPropertyValues
{
private ContentTypeBuilder _contentTypeBuilder;
private ContentCultureInfosCollectionBuilder _contentCultureInfosCollectionBuilder;
private GenericDictionaryBuilder<ContentBuilder, string, object> _propertyDataBuilder;
private int? _id;
@@ -48,6 +50,7 @@ namespace Umbraco.Cms.Tests.Common.Builders
private bool? _trashed;
private CultureInfo _cultureInfo;
private IContentType _contentType;
private ContentCultureInfosCollection _contentCultureInfosCollection;
private readonly IDictionary<string, string> _cultureNames = new Dictionary<string, string>();
private object _propertyValues;
private string _propertyValuesCulture;
@@ -73,6 +76,14 @@ namespace Umbraco.Cms.Tests.Common.Builders
return this;
}
public ContentBuilder WithContentCultureInfosCollection(
ContentCultureInfosCollection contentCultureInfosCollection)
{
_contentCultureInfosCollectionBuilder = null;
_contentCultureInfosCollection = contentCultureInfosCollection;
return this;
}
public ContentBuilder WithCultureName(string culture, string name = "")
{
if (string.IsNullOrWhiteSpace(name))
@@ -105,6 +116,14 @@ namespace Umbraco.Cms.Tests.Common.Builders
return builder;
}
public ContentCultureInfosCollectionBuilder AddContentCultureInfosCollection()
{
_contentCultureInfosCollection = null;
var builder = new ContentCultureInfosCollectionBuilder(this);
_contentCultureInfosCollectionBuilder = builder;
return builder;
}
public override Content Build()
{
var id = _id ?? 0;
@@ -176,6 +195,13 @@ namespace Umbraco.Cms.Tests.Common.Builders
content.ResetDirtyProperties(false);
}
if (_contentCultureInfosCollection is not null || _contentCultureInfosCollectionBuilder is not null)
{
ContentCultureInfosCollection contentCultureInfos =
_contentCultureInfosCollection ?? _contentCultureInfosCollectionBuilder.Build();
content.PublishCultureInfos = contentCultureInfos;
}
return content;
}

View File

@@ -0,0 +1,45 @@
using System;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Tests.Common.Builders.Interfaces;
namespace Umbraco.Cms.Tests.Common.Builders
{
public class ContentCultureInfosBuilder : ChildBuilderBase<ContentCultureInfosCollectionBuilder, ContentCultureInfos>,
IWithNameBuilder,
IWithDateBuilder
{
private string _name;
private string _cultureIso;
private DateTime? _date;
public ContentCultureInfosBuilder(ContentCultureInfosCollectionBuilder parentBuilder) : base(parentBuilder)
{
}
public ContentCultureInfosBuilder WithCultureIso(string cultureIso)
{
_cultureIso = cultureIso;
return this;
}
public override ContentCultureInfos Build()
{
var name = _name ?? Guid.NewGuid().ToString();
var cultureIso = _cultureIso ?? "en-us";
DateTime date = _date ?? DateTime.Now;
return new ContentCultureInfos(cultureIso) { Name = name, Date = date };
}
public string Name
{
get => _name;
set => _name = value;
}
public DateTime? Date
{
get => _date;
set => _date = value;
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Tests.Common.Builders.Interfaces;
namespace Umbraco.Cms.Tests.Common.Builders
{
public class ContentCultureInfosCollectionBuilder : ChildBuilderBase<ContentBuilder, ContentCultureInfosCollection>, IBuildContentCultureInfosCollection
{
private readonly List<ContentCultureInfosBuilder> _cultureInfosBuilders;
public ContentCultureInfosCollectionBuilder(ContentBuilder parentBuilder) : base(parentBuilder) => _cultureInfosBuilders = new List<ContentCultureInfosBuilder>();
public ContentCultureInfosBuilder AddCultureInfos()
{
var builder = new ContentCultureInfosBuilder(this);
_cultureInfosBuilders.Add(builder);
return builder;
}
public override ContentCultureInfosCollection Build()
{
if (_cultureInfosBuilders.Count < 1)
{
throw new InvalidOperationException("You must add at least one culture infos to the collection builder");
}
var cultureInfosCollection = new ContentCultureInfosCollection();
foreach (ContentCultureInfosBuilder cultureInfosBuilder in _cultureInfosBuilders)
{
cultureInfosCollection.Add(cultureInfosBuilder.Build());
}
return cultureInfosCollection;
}
}
}

View File

@@ -234,5 +234,11 @@ namespace Umbraco.Cms.Tests.Common.Builders.Extensions
builder.PropertyValuesSegment = segment;
return builder;
}
public static T WithDate<T>(this T builder, DateTime date) where T : IWithDateBuilder
{
builder.Date = date;
return builder;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces
{
public interface IBuildContentCultureInfosCollection
{
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces
{
public interface IWithDateBuilder
{
DateTime? Date { get; set; }
}
}

View File

@@ -0,0 +1,4 @@
using NUnit.Framework;
[assembly: SetCulture("en-US")]
[assembly: SetUICulture("en-US")]

View File

@@ -23,6 +23,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
[TestFixture]
public class ContentControllerTests : UmbracoTestServerTestBase
{
private const string UsIso = "en-US";
private const string DkIso = "da-DK";
/// <summary>
/// Returns 404 if the content wasn't found based on the ID specified
/// </summary>
@@ -33,7 +36,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -91,7 +94,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -160,7 +163,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -225,7 +228,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -286,7 +289,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -350,7 +353,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
// Add another language
localizationService.Save(new LanguageBuilder()
.WithCultureInfo("da-DK")
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
@@ -374,8 +377,8 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
Content content = new ContentBuilder()
.WithId(0)
.WithCultureName("en-US", "English")
.WithCultureName("da-DK", "Danish")
.WithCultureName(UsIso, "English")
.WithCultureName(DkIso, "Danish")
.WithContentType(contentType)
.AddPropertyData()
.WithKeyValue("title", "Cool invariant title")
@@ -406,5 +409,291 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers
CollectionAssert.Contains(display.Errors.Keys, "_content_variant_en-US_null_");
});
}
[Test]
public async Task PostSave_Validates_Domains_Exist()
{
ILocalizationService localizationService = GetRequiredService<ILocalizationService>();
localizationService.Save(new LanguageBuilder()
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
IContentTypeService contentTypeService = GetRequiredService<IContentTypeService>();
IContentType contentType = new ContentTypeBuilder().WithContentVariation(ContentVariation.Culture).Build();
contentTypeService.Save(contentType);
Content content = new ContentBuilder()
.WithId(1)
.WithContentType(contentType)
.WithCultureName(UsIso, "Root")
.WithCultureName(DkIso, "Rod")
.Build();
ContentItemSave model = new ContentItemSaveBuilder()
.WithContent(content)
.WithAction(ContentSaveAction.PublishNew)
.Build();
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
HttpResponseMessage response = await Client.PostAsync(url, new MultipartFormDataContent
{
{ new StringContent(JsonConvert.SerializeObject(model)), "contentItem" }
});
var body = await response.Content.ReadAsStringAsync();
body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix);
ContentItemDisplay display = JsonConvert.DeserializeObject<ContentItemDisplay>(body);
ILocalizedTextService localizedTextService = GetRequiredService<ILocalizedTextService>();
var expectedMessage = localizedTextService.Localize("speechBubbles", "publishWithNoDomains");
Assert.Multiple(() =>
{
Assert.IsNotNull(display);
Assert.AreEqual(1, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
Assert.AreEqual(expectedMessage, display.Notifications.FirstOrDefault(x => x.NotificationType == NotificationStyle.Warning)?.Message);
});
}
[Test]
public async Task PostSave_Validates_All_Ancestor_Cultures_Are_Considered()
{
var sweIso = "sv-SE";
ILocalizationService localizationService = GetRequiredService<ILocalizationService>();
//Create 2 new languages
localizationService.Save(new LanguageBuilder()
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
localizationService.Save(new LanguageBuilder()
.WithCultureInfo(sweIso)
.WithIsDefault(false)
.Build());
IContentTypeService contentTypeService = GetRequiredService<IContentTypeService>();
IContentType contentType = new ContentTypeBuilder().WithContentVariation(ContentVariation.Culture).Build();
contentTypeService.Save(contentType);
Content content = new ContentBuilder()
.WithoutIdentity()
.WithContentType(contentType)
.WithCultureName(UsIso, "Root")
.Build();
IContentService contentService = GetRequiredService<IContentService>();
contentService.SaveAndPublish(content);
Content childContent = new ContentBuilder()
.WithoutIdentity()
.WithContentType(contentType)
.WithParent(content)
.WithCultureName(DkIso, "Barn")
.WithCultureName(UsIso, "Child")
.Build();
contentService.SaveAndPublish(childContent);
Content grandChildContent = new ContentBuilder()
.WithoutIdentity()
.WithContentType(contentType)
.WithParent(childContent)
.WithCultureName(sweIso, "Bjarn")
.Build();
ContentItemSave model = new ContentItemSaveBuilder()
.WithContent(grandChildContent)
.WithParentId(childContent.Id)
.WithAction(ContentSaveAction.PublishNew)
.Build();
ILanguage enLanguage = localizationService.GetLanguageByIsoCode(UsIso);
IDomainService domainService = GetRequiredService<IDomainService>();
var enDomain = new UmbracoDomain("/en")
{
RootContentId = content.Id,
LanguageId = enLanguage.Id
};
domainService.Save(enDomain);
ILanguage dkLanguage = localizationService.GetLanguageByIsoCode(DkIso);
var dkDomain = new UmbracoDomain("/dk")
{
RootContentId = childContent.Id,
LanguageId = dkLanguage.Id
};
domainService.Save(dkDomain);
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
HttpResponseMessage response = await Client.PostAsync(url, new MultipartFormDataContent
{
{ new StringContent(JsonConvert.SerializeObject(model)), "contentItem" }
});
var body = await response.Content.ReadAsStringAsync();
body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix);
ContentItemDisplay display = JsonConvert.DeserializeObject<ContentItemDisplay>(body);
ILocalizedTextService localizedTextService = GetRequiredService<ILocalizedTextService>();
var expectedMessage = localizedTextService.Localize("speechBubbles", "publishWithMissingDomain", new []{"sv-SE"});
Assert.Multiple(() =>
{
Assert.NotNull(display);
Assert.AreEqual(1, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
Assert.AreEqual(expectedMessage, display.Notifications.FirstOrDefault(x => x.NotificationType == NotificationStyle.Warning)?.Message);
});
}
[Test]
public async Task PostSave_Validates_All_Cultures_Has_Domains()
{
ILocalizationService localizationService = GetRequiredService<ILocalizationService>();
localizationService.Save(new LanguageBuilder()
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
IContentTypeService contentTypeService = GetRequiredService<IContentTypeService>();
IContentType contentType = new ContentTypeBuilder().WithContentVariation(ContentVariation.Culture).Build();
contentTypeService.Save(contentType);
Content content = new ContentBuilder()
.WithoutIdentity()
.WithContentType(contentType)
.WithCultureName(UsIso, "Root")
.WithCultureName(DkIso, "Rod")
.Build();
IContentService contentService = GetRequiredService<IContentService>();
contentService.Save(content);
ContentItemSave model = new ContentItemSaveBuilder()
.WithContent(content)
.WithAction(ContentSaveAction.Publish)
.Build();
ILanguage dkLanguage = localizationService.GetLanguageByIsoCode(DkIso);
IDomainService domainService = GetRequiredService<IDomainService>();
var dkDomain = new UmbracoDomain("/")
{
RootContentId = content.Id,
LanguageId = dkLanguage.Id
};
domainService.Save(dkDomain);
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
HttpResponseMessage response = await Client.PostAsync(url, new MultipartFormDataContent
{
{ new StringContent(JsonConvert.SerializeObject(model)), "contentItem" }
});
var body = await response.Content.ReadAsStringAsync();
body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix);
ContentItemDisplay display = JsonConvert.DeserializeObject<ContentItemDisplay>(body);
ILocalizedTextService localizedTextService = GetRequiredService<ILocalizedTextService>();
var expectedMessage = localizedTextService.Localize("speechBubbles", "publishWithMissingDomain", new []{UsIso});
Assert.Multiple(() =>
{
Assert.NotNull(display);
Assert.AreEqual(1, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
Assert.AreEqual(expectedMessage, display.Notifications.FirstOrDefault(x => x.NotificationType == NotificationStyle.Warning)?.Message);
});
}
[Test]
public async Task PostSave_Checks_Ancestors_For_Domains()
{
ILocalizationService localizationService = GetRequiredService<ILocalizationService>();
localizationService.Save(new LanguageBuilder()
.WithCultureInfo(DkIso)
.WithIsDefault(false)
.Build());
IContentTypeService contentTypeService = GetRequiredService<IContentTypeService>();
IContentType contentType = new ContentTypeBuilder().WithContentVariation(ContentVariation.Culture).Build();
contentTypeService.Save(contentType);
Content rootNode = new ContentBuilder()
.WithoutIdentity()
.WithContentType(contentType)
.WithCultureName(UsIso, "Root")
.WithCultureName(DkIso, "Rod")
.Build();
IContentService contentService = GetRequiredService<IContentService>();
contentService.SaveAndPublish(rootNode);
Content childNode = new ContentBuilder()
.WithoutIdentity()
.WithParent(rootNode)
.WithContentType(contentType)
.WithCultureName(DkIso, "Barn")
.WithCultureName(UsIso, "Child")
.Build();
contentService.SaveAndPublish(childNode);
Content grandChild = new ContentBuilder()
.WithoutIdentity()
.WithParent(childNode)
.WithContentType(contentType)
.WithCultureName(DkIso, "BarneBarn")
.WithCultureName(UsIso, "GrandChild")
.Build();
contentService.Save(grandChild);
ILanguage dkLanguage = localizationService.GetLanguageByIsoCode(DkIso);
ILanguage usLanguage = localizationService.GetLanguageByIsoCode(UsIso);
IDomainService domainService = GetRequiredService<IDomainService>();
var dkDomain = new UmbracoDomain("/")
{
RootContentId = rootNode.Id,
LanguageId = dkLanguage.Id
};
var usDomain = new UmbracoDomain("/en")
{
RootContentId = childNode.Id,
LanguageId = usLanguage.Id
};
domainService.Save(dkDomain);
domainService.Save(usDomain);
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
ContentItemSave model = new ContentItemSaveBuilder()
.WithContent(grandChild)
.WithAction(ContentSaveAction.Publish)
.Build();
HttpResponseMessage response = await Client.PostAsync(url, new MultipartFormDataContent
{
{ new StringContent(JsonConvert.SerializeObject(model)), "contentItem" }
});
var body = await response.Content.ReadAsStringAsync();
body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix);
ContentItemDisplay display = JsonConvert.DeserializeObject<ContentItemDisplay>(body);
Assert.Multiple(() =>
{
Assert.NotNull(display);
// Assert all is good, a success notification for each culture published and no warnings.
Assert.AreEqual(2, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Success));
Assert.AreEqual(0, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
});
}
}
}

View File

@@ -0,0 +1,4 @@
using NUnit.Framework;
[assembly: SetCulture("en-US")]
[assembly: SetUICulture("en-US")]

View File

@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Dictionary;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Web.BackOffice.Controllers;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Controllers
{
[TestFixture]
public class ContentControllerTests
{
[Test]
public void Root_Node_With_Domains_Causes_No_Warning()
{
// Setup domain service
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(1060, It.IsAny<bool>()))
.Returns(new []{new UmbracoDomain("/", "da-dk"), new UmbracoDomain("/en", "en-us")});
// Create content, we need to specify and ID in order to be able to configure domain service
Content rootNode = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(1060)
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.AddCultureInfos()
.WithCultureIso("en-us")
.Done()
.Done()
.Build();
var culturesPublished = new []{ "en-us", "da-dk" };
var notifications = new SimpleNotificationModel();
ContentController contentController = CreateContentController(domainServiceMock.Object);
contentController.AddDomainWarnings(rootNode, culturesPublished, notifications);
Assert.IsEmpty(notifications.Notifications);
}
[Test]
public void Node_With_Single_Published_Culture_Causes_No_Warning()
{
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(It.IsAny<int>(), It.IsAny<bool>()))
.Returns(Enumerable.Empty<IDomain>());
Content rootNode = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(1060)
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.Done()
.Build();
var culturesPublished = new []{"da-dk" };
var notifications = new SimpleNotificationModel();
ContentController contentController = CreateContentController(domainServiceMock.Object);
contentController.AddDomainWarnings(rootNode, culturesPublished, notifications);
Assert.IsEmpty(notifications.Notifications);
}
[Test]
public void Root_Node_Without_Domains_Causes_SingleWarning()
{
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(It.IsAny<int>(), It.IsAny<bool>()))
.Returns(Enumerable.Empty<IDomain>());
Content rootNode = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(1060)
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.AddCultureInfos()
.WithCultureIso("en-us")
.Done()
.Done()
.Build();
var culturesPublished = new []{ "en-us", "da-dk" };
var notifications = new SimpleNotificationModel();
ContentController contentController = CreateContentController(domainServiceMock.Object);
contentController.AddDomainWarnings(rootNode, culturesPublished, notifications);
Assert.AreEqual(1, notifications.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
}
[Test]
public void One_Warning_Per_Culture_Being_Published()
{
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(It.IsAny<int>(), It.IsAny<bool>()))
.Returns(new []{new UmbracoDomain("/", "da-dk")});
Content rootNode = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(1060)
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.AddCultureInfos()
.WithCultureIso("en-us")
.Done()
.Done()
.Build();
var culturesPublished = new []{ "en-us", "da-dk", "nl-bk", "se-sv" };
var notifications = new SimpleNotificationModel();
ContentController contentController = CreateContentController(domainServiceMock.Object);
contentController.AddDomainWarnings(rootNode, culturesPublished, notifications);
Assert.AreEqual(3, notifications.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
}
[Test]
public void Ancestor_Domains_Counts()
{
var rootId = 1060;
var level1Id = 1061;
var level2Id = 1062;
var level3Id = 1063;
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(rootId, It.IsAny<bool>()))
.Returns(new[] { new UmbracoDomain("/", "da-dk") });
domainServiceMock.Setup(x => x.GetAssignedDomains(level1Id, It.IsAny<bool>()))
.Returns(new[] { new UmbracoDomain("/en", "en-us") });
domainServiceMock.Setup(x => x.GetAssignedDomains(level2Id, It.IsAny<bool>()))
.Returns(new[] { new UmbracoDomain("/se", "se-sv"), new UmbracoDomain("/nl", "nl-bk") });
Content level3Node = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(level3Id)
.WithPath($"-1,{rootId},{level1Id},{level2Id},{level3Id}")
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.AddCultureInfos()
.WithCultureIso("en-us")
.Done()
.AddCultureInfos()
.WithCultureIso("se-sv")
.Done()
.AddCultureInfos()
.WithCultureIso("nl-bk")
.Done()
.AddCultureInfos()
.WithCultureIso("de-de")
.Done()
.Done()
.Build();
var culturesPublished = new []{ "en-us", "da-dk", "nl-bk", "se-sv", "de-de" };
ContentController contentController = CreateContentController(domainServiceMock.Object);
var notifications = new SimpleNotificationModel();
contentController.AddDomainWarnings(level3Node, culturesPublished, notifications);
// We expect one error because all domains except "de-de" is registered somewhere in the ancestor path
Assert.AreEqual(1, notifications.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
}
[Test]
public void Only_Warns_About_Cultures_Being_Published()
{
var domainServiceMock = new Mock<IDomainService>();
domainServiceMock.Setup(x => x.GetAssignedDomains(It.IsAny<int>(), It.IsAny<bool>()))
.Returns(new []{new UmbracoDomain("/", "da-dk")});
Content rootNode = new ContentBuilder()
.WithContentType(CreateContentType())
.WithId(1060)
.AddContentCultureInfosCollection()
.AddCultureInfos()
.WithCultureIso("da-dk")
.Done()
.AddCultureInfos()
.WithCultureIso("en-us")
.Done()
.AddCultureInfos()
.WithCultureIso("se-sv")
.Done()
.AddCultureInfos()
.WithCultureIso("de-de")
.Done()
.Done()
.Build();
var culturesPublished = new []{ "en-us", "se-sv" };
var notifications = new SimpleNotificationModel();
ContentController contentController = CreateContentController(domainServiceMock.Object);
contentController.AddDomainWarnings(rootNode, culturesPublished, notifications);
// We only get two errors, one for each culture being published, so no errors from previously published cultures.
Assert.AreEqual(2, notifications.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
}
private ContentController CreateContentController(IDomainService domainService)
{
// We have to configure ILocalizedTextService to return a new string every time Localize is called
// Otherwise it won't add the notification because it skips dupes
var localizedTextServiceMock = new Mock<ILocalizedTextService>();
localizedTextServiceMock.Setup(x => x.Localize(It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CultureInfo>(), It.IsAny<IDictionary<string, string>>()))
.Returns(() => Guid.NewGuid().ToString());
var controller = new ContentController(
Mock.Of<ICultureDictionary>(),
NullLoggerFactory.Instance,
Mock.Of<IShortStringHelper>(),
Mock.Of<IEventMessagesFactory>(),
localizedTextServiceMock.Object,
new PropertyEditorCollection(new DataEditorCollection(() => null)),
Mock.Of<IContentService>(),
Mock.Of<IUserService>(),
Mock.Of<IBackOfficeSecurityAccessor>(),
Mock.Of<IContentTypeService>(),
Mock.Of<IUmbracoMapper>(),
Mock.Of<IPublishedUrlProvider>(),
domainService,
Mock.Of<IDataTypeService>(),
Mock.Of<ILocalizationService>(),
Mock.Of<IFileService>(),
Mock.Of<INotificationService>(),
new ActionCollection(() => null),
Mock.Of<ISqlContext>(),
Mock.Of<IJsonSerializer>(),
Mock.Of<IScopeProvider>(),
Mock.Of<IAuthorizationService>()
);
return controller;
}
private IContentType CreateContentType() =>
new ContentTypeBuilder().WithContentVariation(ContentVariation.Culture).Build();
}
}

View File

@@ -398,7 +398,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
[HttpPost]
public ActionResult<IDictionary<string, ContentItemDisplay>> GetEmptyByAliases(ContentTypesByAliases contentTypesByAliases)
{
// It's important to do this operation within a scope to reduce the amount of readlock queries.
// It's important to do this operation within a scope to reduce the amount of readlock queries.
using var scope = _scopeProvider.CreateScope(autoComplete: true);
var contentTypes = contentTypesByAliases.ContentTypeAliases.Select(alias => _contentTypeService.Get(alias));
return GetEmpties(contentTypes, contentTypesByAliases.ParentId).ToDictionary(x => x.ContentTypeAlias);
@@ -879,7 +879,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
case ContentSaveAction.Publish:
case ContentSaveAction.PublishNew:
{
var publishStatus = PublishInternal(contentItem, defaultCulture, cultureForInvariantErrors, out wasCancelled, out var successfulCultures);
PublishResult publishStatus = PublishInternal(contentItem, defaultCulture, cultureForInvariantErrors, out wasCancelled, out var successfulCultures);
// Add warnings if domains are not set up correctly
AddDomainWarnings(publishStatus.Content, successfulCultures, globalNotifications);
AddPublishStatusNotifications(new[] { publishStatus }, globalNotifications, notifications, successfulCultures);
}
break;
@@ -896,6 +898,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
}
var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
AddDomainWarnings(publishStatus, successfulCultures, globalNotifications);
AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures);
}
break;
@@ -1412,6 +1415,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
var publishStatus = _contentService.SaveAndPublish(contentItem.PersistedContent, culturesToPublish, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id);
wasCancelled = publishStatus.Result == PublishResultType.FailedPublishCancelledByEvent;
successfulCultures = culturesToPublish;
return publishStatus;
}
else
@@ -1425,6 +1429,73 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
}
}
private void AddDomainWarnings(IEnumerable<PublishResult> publishResults, string[] culturesPublished,
SimpleNotificationModel globalNotifications)
{
foreach (PublishResult publishResult in publishResults)
{
AddDomainWarnings(publishResult.Content, culturesPublished, globalNotifications);
}
}
/// <summary>
/// Verifies that there's an appropriate domain setup for the published cultures
/// </summary>
/// <remarks>
/// Adds a warning and logs a message if a node varies by culture, there's at least 1 culture already published,
/// and there's no domain added for the published cultures
/// </remarks>
/// <param name="persistedContent"></param>
/// <param name="culturesPublished"></param>
/// <param name="globalNotifications"></param>
internal void AddDomainWarnings(IContent persistedContent, string[] culturesPublished, SimpleNotificationModel globalNotifications)
{
// Don't try to verify if no cultures were published
if (culturesPublished is null)
{
return;
}
var publishedCultures = GetPublishedCulturesFromAncestors(persistedContent).ToList();
// If only a single culture is published we shouldn't have any routing issues
if (publishedCultures.Count < 2)
{
return;
}
// If more than a single culture is published we need to verify that there's a domain registered for each published culture
var assignedDomains = _domainService.GetAssignedDomains(persistedContent.Id, true).ToHashSet();
// We also have to check all of the ancestors, if any of those has the appropriate culture assigned we don't need to warn
foreach (var ancestorID in persistedContent.GetAncestorIds())
{
assignedDomains.UnionWith(_domainService.GetAssignedDomains(ancestorID, true));
}
// No domains at all, add a warning, to add domains.
if (assignedDomains.Count == 0)
{
globalNotifications.AddWarningNotification(
_localizedTextService.Localize("auditTrails", "publish"),
_localizedTextService.Localize("speechBubbles", "publishWithNoDomains"));
_logger.LogWarning("The root node {RootNodeName} was published with multiple cultures, but no domains are configured, this will cause routing and caching issues, please register domains for: {Cultures}",
persistedContent.Name, string.Join(", ", publishedCultures));
return;
}
// If there is some domains, verify that there's a domain for each of the published cultures
foreach (var culture in culturesPublished
.Where(culture => assignedDomains.Any(x => x.LanguageIsoCode.Equals(culture, StringComparison.OrdinalIgnoreCase)) is false))
{
globalNotifications.AddWarningNotification(
_localizedTextService.Localize("auditTrails", "publish"),
_localizedTextService.Localize("speechBubbles", "publishWithMissingDomain", new []{culture}));
_logger.LogWarning("The root node {RootNodeName} was published in culture {Culture}, but there's no domain configured for it, this will cause routing and caching issues, please register a domain for it",
persistedContent.Name, culture);
}
}
/// <summary>
/// Validate if publishing is possible based on the mandatory language requirements
/// </summary>
@@ -1512,6 +1583,27 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
return true;
}
private IEnumerable<string> GetPublishedCulturesFromAncestors(IContent content)
{
if (content.ParentId == -1)
{
return content.PublishedCultures;
}
HashSet<string> publishedCultures = new ();
publishedCultures.UnionWith(content.PublishedCultures);
IEnumerable<int> ancestorIds = content.GetAncestorIds();
foreach (var id in ancestorIds)
{
IEnumerable<string> cultures = _contentService.GetById(id).PublishedCultures;
publishedCultures.UnionWith(cultures);
}
return publishedCultures;
}
/// <summary>
/// Adds a generic culture error for use in displaying the culture validation error in the save/publish/etc... dialogs
/// </summary>

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Umbraco.Cms.Core;
@@ -93,6 +94,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
IUserService userService,
IUmbracoMapper umbracoMapper,
IBackOfficeUserManager backOfficeUserManager,
ILoggerFactory loggerFactory,
ILocalizedTextService localizedTextService,
AppCaches appCaches,
IShortStringHelper shortStringHelper,

View File

@@ -1245,6 +1245,8 @@ Mange hilsner fra Umbraco robotten
<key alias="scheduleErrReleaseDate3">Kan ikke planlægge dokumentes udgivelse da det krævet '%0%' har en senere udgivelses dato end et ikke krævet sprog</key>
<key alias="scheduleErrExpireDate1">Afpubliceringsdatoen kan ikke ligge i fortiden</key>
<key alias="scheduleErrExpireDate2">Afpubliceringsdatoen kan ikke være før udgivelsesdatoen</key>
<key alias="publishWithNoDomains">Domæner er ikke konfigureret for en flersproget side, kontakt vensligst en administrator, se loggen for mere information</key>
<key alias="publishWithMissingDomain">Der er ikke noget domæne konfigureret for %0%, kontakt vensligst en administrator, se loggen for mere information</key>
</area>
<area alias="stylesheet">
<key alias="addRule">Tilføj style</key>

View File

@@ -1441,6 +1441,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont
<key alias="resendInviteSuccess">Invitation has been re-sent to %0%</key>
<key alias="documentTypeExportedSuccess">Document Type was exported to file</key>
<key alias="documentTypeExportedError">An error occurred while exporting the Document Type</key>
<key alias="publishWithNoDomains">Domains are not configured for multilingual site, please contact an administrator, see log for more information</key>
<key alias="publishWithMissingDomain">There is no domain configured for %0%, please contact an administrator, see log for more information</key>
</area>
<area alias="stylesheet">
<key alias="addRule">Add style</key>

View File

@@ -1470,6 +1470,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont
<key alias="scheduleErrReleaseDate3">Cannot schedule the document for publishing since the required '%0%' has a publish date later than a non mandatory language</key>
<key alias="scheduleErrExpireDate1">The expire date cannot be in the past</key>
<key alias="scheduleErrExpireDate2">The expire date cannot be before the release date</key>
<key alias="publishWithNoDomains">Domains are not configured for multilingual site, please contact an administrator, see log for more information</key>
<key alias="publishWithMissingDomain">There is no domain configured for %0%, please contact an administrator, see log for more information</key>
</area>
<area alias="stylesheet">
<key alias="addRule">Add style</key>