Merge branch 'v14/dev' into v15/dev

# Conflicts:
#	Directory.Packages.props
#	src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs
#	src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs
#	src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs
#	src/Umbraco.Cms.Api.Management/OpenApi.json
#	src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs
#	src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs
#	src/Umbraco.PublishedCache.NuCache/ContentStore.cs
#	tests/Directory.Packages.props
This commit is contained in:
Sven Geusens
2025-01-21 10:17:46 +01:00
40 changed files with 11506 additions and 34 deletions

View File

@@ -46,7 +46,9 @@ public static class UmbracoBuilderAuthExtensions
Paths.BackOfficeApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetRevocationEndpointUris(
Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash),
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetUserInfoEndpointUris(
Paths.MemberApi.UserinfoEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
// Enable authorization code flow with PKCE
options
@@ -62,7 +64,8 @@ public static class UmbracoBuilderAuthExtensions
.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableEndSessionEndpointPassthrough();
.EnableEndSessionEndpointPassthrough()
.EnableUserInfoEndpointPassthrough();
// Enable reference tokens
// - see https://documentation.openiddict.com/configuration/token-storage.html

View File

@@ -31,6 +31,8 @@ public static class Paths
public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke");
public static readonly string UserinfoEndpoint = EndpointPath($"{EndpointTemplate}/userinfo");
// NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs
private static string EndpointPath(string relativePath) => $"/umbraco/delivery/api/v1/{relativePath}";
}

View File

@@ -15,6 +15,7 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
[ApiExplorerSettings(GroupName = "Content")]
[LocalizeFromAcceptLanguageHeader]
[ValidateStartItem]
[AddVaryHeader]
[OutputCache(PolicyName = Constants.DeliveryApi.OutputCache.ContentCachePolicy)]
public abstract class ContentApiControllerBase : DeliveryApiControllerBase
{

View File

@@ -0,0 +1,28 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Server.AspNetCore;
using Umbraco.Cms.Api.Delivery.Routing;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Controllers.Security;
[ApiVersion("1.0")]
[ApiController]
[VersionedDeliveryApiRoute(Common.Security.Paths.MemberApi.EndpointTemplate)]
[ApiExplorerSettings(IgnoreApi = true)]
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
public class CurrentMemberController : DeliveryApiControllerBase
{
private readonly ICurrentMemberClaimsProvider _currentMemberClaimsProvider;
public CurrentMemberController(ICurrentMemberClaimsProvider currentMemberClaimsProvider)
=> _currentMemberClaimsProvider = currentMemberClaimsProvider;
[HttpGet("userinfo")]
public async Task<IActionResult> Userinfo()
{
Dictionary<string, object> claims = await _currentMemberClaimsProvider.GetClaimsAsync();
return Ok(claims);
}
}

View File

@@ -61,6 +61,7 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IApiMediaQueryService, ApiMediaQueryService>();
builder.Services.AddTransient<IMemberApplicationManager, MemberApplicationManager>();
builder.Services.AddTransient<IRequestMemberAccessService, RequestMemberAccessService>();
builder.Services.AddTransient<ICurrentMemberClaimsProvider, CurrentMemberClaimsProvider>();
builder.Services.AddScoped<IMemberClientCredentialsManager, MemberClientCredentialsManager>();
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace Umbraco.Cms.Api.Delivery.Filters;
public sealed class AddVaryHeaderAttribute : ActionFilterAttribute
{
private const string Vary = "Accept-Language, Preview, Start-Item";
public override void OnResultExecuting(ResultExecutingContext context)
=> context.HttpContext.Response.Headers.Vary = context.HttpContext.Response.Headers.Vary.Count > 0
? $"{context.HttpContext.Response.Headers.Vary}, {Vary}"
: Vary;
}

View File

@@ -0,0 +1,88 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Json;
public abstract class DeliveryApiVersionAwareJsonConverterBase<T> : JsonConverter<T>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JsonConverter<T> _defaultConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
public DeliveryApiVersionAwareJsonConverterBase(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> _defaultConverter.Read(ref reader, typeToConvert, options);
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
Type type = typeof(T);
var apiVersion = GetApiVersion();
// Get the properties in the specified order
PropertyInfo[] properties = type.GetProperties().OrderBy(GetPropertyOrder).ToArray();
writer.WriteStartObject();
foreach (PropertyInfo property in properties)
{
// Filter out properties based on the API version
var include = apiVersion is null || ShouldIncludeProperty(property, apiVersion.Value);
if (include is false)
{
continue;
}
var propertyName = property.Name;
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);
JsonSerializer.Serialize(writer, property.GetValue(value), options);
}
writer.WriteEndObject();
}
private int? GetApiVersion()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
return apiVersion?.MajorVersion;
}
private int GetPropertyOrder(PropertyInfo prop)
{
var attribute = prop.GetCustomAttribute<JsonPropertyOrderAttribute>();
return attribute?.Order ?? 0;
}
/// <summary>
/// Determines whether a property should be included based on version bounds.
/// </summary>
/// <param name="propertyInfo">The property info.</param>
/// <param name="version">An integer representing an API version.</param>
/// <returns><c>true</c> if the property should be included; otherwise, <c>false</c>.</returns>
private bool ShouldIncludeProperty(PropertyInfo propertyInfo, int version)
{
var attribute = propertyInfo
.GetCustomAttributes(typeof(IncludeInApiVersionAttribute), false)
.FirstOrDefault();
if (attribute is not IncludeInApiVersionAttribute apiVersionAttribute)
{
return true; // No attribute means include the property
}
// Check if the version is within the specified bounds
var isWithinMinVersion = apiVersionAttribute.MinVersion.HasValue is false || version >= apiVersionAttribute.MinVersion.Value;
var isWithinMaxVersion = apiVersionAttribute.MaxVersion.HasValue is false || version <= apiVersionAttribute.MaxVersion.Value;
return isWithinMinVersion && isWithinMaxVersion;
}
}

View File

@@ -1,6 +1,5 @@
using Umbraco.Cms.Api.Delivery.Indexing.Filters;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Querying.Filters;
@@ -15,15 +14,15 @@ public sealed class ContentTypeFilter : IFilterHandler
/// <inheritdoc/>
public FilterOption BuildFilterOption(string filter)
{
var alias = filter.Substring(ContentTypeSpecifier.Length);
var filterValue = filter.Substring(ContentTypeSpecifier.Length);
var negate = filterValue.StartsWith('!');
var aliases = filterValue.TrimStart('!').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
return new FilterOption
{
FieldName = ContentTypeFilterIndexer.FieldName,
Values = alias.IsNullOrWhiteSpace() == false
? new[] { alias.TrimStart('!') }
: Array.Empty<string>(),
Operator = alias.StartsWith('!')
Values = aliases,
Operator = negate
? FilterOperation.IsNot
: FilterOperation.Is
};

View File

@@ -0,0 +1,44 @@
using OpenIddict.Abstractions;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Delivery.Services;
// NOTE: this is public and unsealed to allow overriding the default claims with minimal effort.
public class CurrentMemberClaimsProvider : ICurrentMemberClaimsProvider
{
private readonly IMemberManager _memberManager;
public CurrentMemberClaimsProvider(IMemberManager memberManager)
=> _memberManager = memberManager;
public virtual async Task<Dictionary<string, object>> GetClaimsAsync()
{
MemberIdentityUser? memberIdentityUser = await _memberManager.GetCurrentMemberAsync();
return memberIdentityUser is not null
? await GetClaimsForMemberIdentityAsync(memberIdentityUser)
: throw new InvalidOperationException("Could not retrieve the current member. This method should only ever be invoked when a member has been authorized.");
}
protected virtual async Task<Dictionary<string, object>> GetClaimsForMemberIdentityAsync(MemberIdentityUser memberIdentityUser)
{
var claims = new Dictionary<string, object>
{
[OpenIddictConstants.Claims.Subject] = memberIdentityUser.Key
};
if (memberIdentityUser.Name is not null)
{
claims[OpenIddictConstants.Claims.Name] = memberIdentityUser.Name;
}
if (memberIdentityUser.Email is not null)
{
claims[OpenIddictConstants.Claims.Email] = memberIdentityUser.Email;
}
claims[OpenIddictConstants.Claims.Role] = await _memberManager.GetRolesAsync(memberIdentityUser);
return claims;
}
}

View File

@@ -275,9 +275,12 @@ public class BackOfficeController : SecurityControllerBase
[MapToApiVersion("1.0")]
public async Task<IActionResult> Signout(CancellationToken cancellationToken)
{
var userName = await GetUserNameFromAuthCookie();
AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
var userName = cookieAuthResult.Principal?.Identity?.Name;
var userId = cookieAuthResult.Principal?.Identity?.GetUserId();
await _backOfficeSignInManager.SignOutAsync();
_backOfficeUserManager.NotifyLogoutSuccess(cookieAuthResult.Principal ?? User, userId);
_logger.LogInformation(
"User {UserName} from IP address {RemoteIpAddress} has logged out",

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Api.Management.DependencyInjection;
@@ -9,6 +11,13 @@ internal static class AuditLogBuilderExtensions
internal static IUmbracoBuilder AddAuditLogs(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IAuditLogPresentationFactory, AuditLogPresentationFactory>();
builder.AddNotificationHandler<UserLoginSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserLogoutSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserLoginFailedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserForgotPasswordRequestedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserForgotPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserPasswordResetNotification, BackOfficeUserManagerAuditer>();
return builder;
}

View File

@@ -0,0 +1,142 @@
using System.Globalization;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security;
/// <summary>
/// Binds to notifications to write audit logs for the <see cref="BackOfficeUserManager" />
/// </summary>
internal sealed class BackOfficeUserManagerAuditer :
INotificationHandler<UserLoginSuccessNotification>,
INotificationHandler<UserLogoutSuccessNotification>,
INotificationHandler<UserLoginFailedNotification>,
INotificationHandler<UserForgotPasswordRequestedNotification>,
INotificationHandler<UserForgotPasswordChangedNotification>,
INotificationHandler<UserPasswordChangedNotification>,
INotificationHandler<UserPasswordResetNotification>
{
private readonly IAuditService _auditService;
private readonly IUserService _userService;
public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService)
{
_auditService = auditService;
_userService = userService;
}
public void Handle(UserForgotPasswordChangedNotification notification) =>
WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/password/forgot/change",
"password forgot/change");
public void Handle(UserForgotPasswordRequestedNotification notification) =>
WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/password/forgot/request",
"password forgot/request");
public void Handle(UserLoginFailedNotification notification) =>
WriteAudit(
notification.PerformingUserId,
null,
notification.IpAddress,
"umbraco/user/sign-in/failed",
"login failed");
public void Handle(UserLoginSuccessNotification notification)
=> WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/sign-in/login",
"login success");
public void Handle(UserLogoutSuccessNotification notification)
=> WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/sign-in/logout",
"logout success");
public void Handle(UserPasswordChangedNotification notification) =>
WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/password/change",
"password change");
public void Handle(UserPasswordResetNotification notification) =>
WriteAudit(
notification.PerformingUserId,
notification.AffectedUserId,
notification.IpAddress,
"umbraco/user/password/reset",
"password reset");
private static string FormatEmail(IMembershipUser? user) =>
user is null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? string.Empty : $"<{user.Email}>";
private void WriteAudit(
string performingId,
string? affectedId,
string ipAddress,
string eventType,
string eventDetails)
{
int? performingIdAsInt = ParseUserId(performingId);
int? affectedIdAsInt = ParseUserId(affectedId);
WriteAudit(performingIdAsInt, affectedIdAsInt, ipAddress, eventType, eventDetails);
}
private static int? ParseUserId(string? id)
=> int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var isAsInt) ? isAsInt : null;
private void WriteAudit(
int? performingId,
int? affectedId,
string ipAddress,
string eventType,
string eventDetails)
{
var performingDetails = "User UNKNOWN:0";
if (performingId.HasValue)
{
IUser? performingUser = _userService.GetUserById(performingId.Value);
performingDetails = performingUser is null
? $"User UNKNOWN:{performingId.Value}"
: $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}";
}
var affectedDetails = "User UNKNOWN:0";
if (affectedId.HasValue)
{
IUser? affectedUser = _userService.GetUserById(affectedId.Value);
affectedDetails = affectedUser is null
? $"User UNKNOWN:{affectedId.Value}"
: $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}";
}
_auditService.Write(
performingId ?? 0,
performingDetails,
ipAddress,
DateTime.UtcNow,
affectedId ?? 0,
affectedDetails,
eventType,
eventDetails);
}
}

View File

@@ -140,7 +140,9 @@ public static class DistributedCacheExtensions
Id = x.Item.Id,
Key = x.Item.Key,
ChangeTypes = x.ChangeTypes,
Blueprint = x.Item.Blueprint
Blueprint = x.Item.Blueprint,
PublishedCultures = x.PublishedCultures?.ToArray(),
UnpublishedCultures = x.UnpublishedCultures?.ToArray()
});
dc.RefreshByPayload(ContentCacheRefresher.UniqueId, payloads);

View File

@@ -380,6 +380,10 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
public TreeChangeTypes ChangeTypes { get; init; }
public bool Blueprint { get; init; }
public string[]? PublishedCultures { get; init; }
public string[]? UnpublishedCultures { get; init; }
}
#endregion

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface ICurrentMemberClaimsProvider
{
/// <summary>
/// Retrieves the claims for the currently logged in member.
/// </summary>
/// <remarks>
/// This is used by the OIDC user info endpoint to supply "current user" info.
/// </remarks>
Task<Dictionary<string, object>> GetClaimsAsync();
}

View File

@@ -0,0 +1,21 @@
namespace Umbraco.Cms.Core.DeliveryApi;
[AttributeUsage(AttributeTargets.Property)]
public class IncludeInApiVersionAttribute : Attribute
{
public int? MinVersion { get; }
public int? MaxVersion { get; }
/// <summary>
/// Initializes a new instance of the <see cref="IncludeInApiVersionAttribute"/> class.
/// Specifies that the property should be included in the API response if the API version falls within the specified bounds.
/// </summary>
/// <param name="minVersion">The minimum API version (inclusive) for which the property should be included.</param>
/// <param name="maxVersion">The maximum API version (inclusive) for which the property should be included.</param>
public IncludeInApiVersionAttribute(int minVersion = -1, int maxVersion = -1)
{
MinVersion = minVersion >= 0 ? minVersion : null;
MaxVersion = maxVersion >= 0 ? maxVersion : null;
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public class NoopCurrentMemberClaimsProvider : ICurrentMemberClaimsProvider
{
public Task<Dictionary<string, object>> GetClaimsAsync() => Task.FromResult(new Dictionary<string, object>());
}

View File

@@ -34,6 +34,8 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
using Umbraco.Cms.Core.DynamicRoot;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.PublishedCache.Internal;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services.FileSystem;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -384,7 +384,10 @@ public static class ClaimsIdentityExtensions
var firstValue = identity.FindFirstValue(ClaimTypes.NameIdentifier);
if (firstValue is not null)
{
return int.Parse(firstValue, CultureInfo.InvariantCulture);
if (int.TryParse(firstValue, CultureInfo.InvariantCulture, out var id))
{
return id;
}
}
return null;

View File

@@ -32,4 +32,14 @@ public class ContentTreeChangeNotification : TreeChangeNotification<IContent>
: base(new TreeChange<IContent>(target, changeTypes), messages)
{
}
public ContentTreeChangeNotification(
IContent target,
TreeChangeTypes changeTypes,
IEnumerable<string>? publishedCultures,
IEnumerable<string>? unpublishedCultures,
EventMessages messages)
: base(new TreeChange<IContent>(target, changeTypes, publishedCultures, unpublishedCultures), messages)
{
}
}

View File

@@ -37,6 +37,12 @@ public class DecimalValueConverter : PropertyValueConverterBase
return Convert.ToDecimal(sourceDouble);
}
// is it an integer?
if (source is int sourceInteger)
{
return Convert.ToDecimal(sourceInteger);
}
// is it a string?
if (source is string sourceString)
{

View File

@@ -8,10 +8,22 @@ public class TreeChange<TItem>
ChangeTypes = changeTypes;
}
public TreeChange(TItem changedItem, TreeChangeTypes changeTypes, IEnumerable<string>? publishedCultures, IEnumerable<string>? unpublishedCultures)
{
Item = changedItem;
ChangeTypes = changeTypes;
PublishedCultures = publishedCultures;
UnpublishedCultures = unpublishedCultures;
}
public TItem Item { get; }
public TreeChangeTypes ChangeTypes { get; }
public IEnumerable<string>? PublishedCultures { get; }
public IEnumerable<string>? UnpublishedCultures { get; }
public EventArgs ToEventArgs() => new EventArgs(this);
public class EventArgs : System.EventArgs

View File

@@ -1641,7 +1641,12 @@ public class ContentService : RepositoryService, IContentService
// events and audit
scope.Notifications.Publish(
new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState));
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages));
scope.Notifications.Publish(new ContentTreeChangeNotification(
content,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null,
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"],
eventMessages));
if (culturesUnpublishing != null)
{
@@ -1700,7 +1705,12 @@ public class ContentService : RepositoryService, IContentService
if (!branchOne)
{
scope.Notifications.Publish(
new ContentTreeChangeNotification(content, changeType, eventMessages));
new ContentTreeChangeNotification(
content,
changeType,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"],
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null,
eventMessages));
scope.Notifications.Publish(
new ContentPublishedNotification(content, eventMessages).WithState(notificationState));
}
@@ -2149,7 +2159,6 @@ public class ContentService : RepositoryService, IContentService
var results = new List<PublishResult>();
var publishedDocuments = new List<IContent>();
IDictionary<string, object?>? initialNotificationState = null;
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
@@ -2168,7 +2177,8 @@ public class ContentService : RepositoryService, IContentService
}
// deal with the branch root - if it fails, abort
PublishResult? result = PublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out initialNotificationState);
HashSet<string>? culturesToPublish = shouldPublish(document);
PublishResult? result = PublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary<string, object?>? notificationState);
if (result != null)
{
results.Add(result);
@@ -2178,6 +2188,8 @@ public class ContentService : RepositoryService, IContentService
}
}
HashSet<string> culturesPublished = culturesToPublish ?? [];
// deal with descendants
// if one fails, abort its branch
var exclude = new HashSet<int>();
@@ -2203,12 +2215,14 @@ public class ContentService : RepositoryService, IContentService
}
// no need to check path here, parent has to be published here
result = PublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _);
culturesToPublish = shouldPublish(d);
result = PublishBranchItem(scope, d, culturesToPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _);
if (result != null)
{
results.Add(result);
if (result.Success)
{
culturesPublished.UnionWith(culturesToPublish ?? []);
continue;
}
}
@@ -2225,9 +2239,15 @@ public class ContentService : RepositoryService, IContentService
// trigger events for the entire branch
// (SaveAndPublishBranchOne does *not* do it)
var variesByCulture = document.ContentType.VariesByCulture();
scope.Notifications.Publish(
new ContentTreeChangeNotification(document, TreeChangeTypes.RefreshBranch, eventMessages));
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(initialNotificationState));
new ContentTreeChangeNotification(
document,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"],
null,
eventMessages));
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages).WithState(notificationState));
scope.Complete();
}
@@ -2241,7 +2261,7 @@ public class ContentService : RepositoryService, IContentService
private PublishResult? PublishBranchItem(
ICoreScope scope,
IContent document,
Func<IContent, HashSet<string>?> shouldPublish,
HashSet<string>? culturesToPublish,
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>,
bool> publishCultures,
bool isRoot,
@@ -2251,9 +2271,7 @@ public class ContentService : RepositoryService, IContentService
IReadOnlyCollection<ILanguage> allLangs,
out IDictionary<string, object?>? initialNotificationState)
{
HashSet<string>? culturesToPublish = shouldPublish(document);
initialNotificationState = null;
initialNotificationState = new Dictionary<string, object?>();
// we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications
if (HasUnsavedChanges(document))
@@ -2796,6 +2814,13 @@ public class ContentService : RepositoryService, IContentService
GetPagedDescendants(content.Id, page++, pageSize, out total);
foreach (IContent descendant in descendants)
{
// when copying a branch into itself, the copy of a root would be seen as a descendant
// and would be copied again => filter it out.
if (descendant.Id == copy.Id)
{
continue;
}
// if parent has not been copied, skip, else gets its copy id
if (idmap.TryGetValue(descendant.ParentId, out parentId) == false)
{

View File

@@ -22,6 +22,7 @@
<!-- Take top-level depedendency on System.Security.Cryptography.Xml, because Examine depends on a vulnerable version -->
<PackageReference Include="System.Security.Cryptography.Xml" />
<!-- Take top-level depedendency on Lucene.Net.Replicator, because Examine depends on a vulnerable version -->
<!-- Take top-level depedendency on Lucene.Net.Replicator-->
<PackageReference Include="Lucene.Net.Replicator" />
</ItemGroup>

View File

@@ -102,8 +102,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
// - non-#comment nodes
// - non-#text nodes
// - non-empty #text nodes
// - empty #text between inline elements (see #17037)
HtmlNode[] childNodes = element.ChildNodes
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || string.IsNullOrWhiteSpace(c.InnerText) is false))
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || c.NextSibling is not null || string.IsNullOrWhiteSpace(c.InnerText) is false))
.ToArray();
var tag = TagName(element);

View File

@@ -438,6 +438,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IRequestRedirectService, NoopRequestRedirectService>();
builder.Services.AddSingleton<IRequestPreviewService, NoopRequestPreviewService>();
builder.Services.AddSingleton<IRequestMemberAccessService, NoopRequestMemberAccessService>();
builder.Services.AddTransient<ICurrentMemberClaimsProvider, NoopCurrentMemberClaimsProvider>();
builder.Services.AddSingleton<IApiAccessService, NoopApiAccessService>();
builder.Services.AddSingleton<IApiContentQueryService, NoopApiContentQueryService>();
builder.Services.AddSingleton<IApiMediaQueryService, NoopApiMediaQueryService>();

View File

@@ -187,7 +187,7 @@ public class ExamineIndexRebuilder : IIndexRebuilder
{
// If an index exists but it has zero docs we'll consider it empty and rebuild
IIndex[] indexes = (onlyEmptyIndexes
? _examineManager.Indexes.Where(x => ShouldRebuild(x))
? _examineManager.Indexes.Where(ShouldRebuild)
: _examineManager.Indexes).ToArray();
if (indexes.Length == 0)

View File

@@ -223,6 +223,19 @@ internal class DictionaryRepository : EntityRepositoryBase<int, IDictionaryItem>
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, Guid>(GlobalIsolatedCache, ScopeAccessor,
options);
}
protected override IEnumerable<IDictionaryItem> PerformGetAll(params Guid[]? ids)
{
Sql<ISqlContext> sql = GetBaseQuery(false).Where<DictionaryDto>(x => x.PrimaryKey > 0);
if (ids?.Any() ?? false)
{
sql.WhereIn<DictionaryDto>(x => x.UniqueId, ids);
}
return Database
.FetchOneToMany<DictionaryDto>(x => x.LanguageTextDtos, sql)
.Select(ConvertToEntity);
}
}
private class DictionaryByKeyRepository : SimpleGetRepository<string, IDictionaryItem, DictionaryDto>
@@ -266,6 +279,19 @@ internal class DictionaryRepository : EntityRepositoryBase<int, IDictionaryItem>
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, string>(GlobalIsolatedCache, ScopeAccessor,
options);
}
protected override IEnumerable<IDictionaryItem> PerformGetAll(params string[]? ids)
{
Sql<ISqlContext> sql = GetBaseQuery(false).Where<DictionaryDto>(x => x.PrimaryKey > 0);
if (ids?.Any() ?? false)
{
sql.WhereIn<DictionaryDto>(x => x.Key, ids);
}
return Database
.FetchOneToMany<DictionaryDto>(x => x.LanguageTextDtos, sql)
.Select(ConvertToEntity);
}
}
protected override IEnumerable<IDictionaryItem> PerformGetAll(params int[]? ids)

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
@@ -29,6 +30,7 @@ public class UmbLoginStatusController : SurfaceController
=> _signInManager = signInManager;
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[ValidateUmbracoFormRouteString]
public async Task<IActionResult> HandleLogout([Bind(Prefix = "logoutModel")] PostRedirectModel model)

View File

@@ -0,0 +1,87 @@
import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers';
import {expect} from "@playwright/test";
const contentName = 'TestContent';
const documentTypeName = 'TestDocumentTypeForContent';
const dataTypeName = 'Approved Color';
test.beforeEach(async ({umbracoApi, umbracoUi}) => {
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoUi.goToBackOffice();
});
test.afterEach(async ({umbracoApi}) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can create content with the approved color data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const expectedState = 'Draft';
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveButton();
// Assert
await umbracoUi.content.isSuccessNotificationVisible();
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.variants[0].state).toBe(expectedState);
expect(contentData.values).toEqual([]);
});
test('can publish content with the approved color data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const expectedState = 'Published';
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id);
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickSaveAndPublishButton();
// Assert
await umbracoUi.content.doesSuccessNotificationsHaveCount(2);
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.variants[0].state).toBe(expectedState);
expect(contentData.values).toEqual([]);
});
test('can create content with the custom approved color data type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const customDataTypeName = 'CustomApprovedColor';
const colorValue = 'd73737';
const colorLabel = 'Test Label';
const customDataTypeId = await umbracoApi.dataType.createApprovedColorDataTypeWithOneItem(customDataTypeName, colorLabel, colorValue);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickApprovedColorByValue(colorValue);
await umbracoUi.content.clickSaveAndPublishButton();
// Assert
await umbracoUi.content.doesSuccessNotificationsHaveCount(2);
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values[0].alias).toEqual(AliasHelper.toAlias(customDataTypeName));
expect(contentData.values[0].value.label).toEqual(colorLabel);
expect(contentData.values[0].value.value).toEqual('#' + colorValue);
// Clean
await umbracoApi.dataType.ensureNameNotExists(customDataTypeName);
});

File diff suppressed because it is too large Load Diff

View File

@@ -198,6 +198,48 @@ public partial class ContentEditingServiceTests
}
}
[TestCase(true)]
[TestCase(false)]
public async Task Can_Copy_Onto_Self(bool includeDescendants)
{
var contentType = await CreateTextPageContentTypeAsync();
(IContent root, IContent child) = await CreateRootAndChildAsync(contentType);
var result = await ContentEditingService.CopyAsync(root.Key, root.Key, false, includeDescendants, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status);
VerifyCopy(result.Result);
// re-get and re-test
VerifyCopy(await ContentEditingService.GetAsync(result.Result!.Key));
void VerifyCopy(IContent? copiedRoot)
{
Assert.IsNotNull(copiedRoot);
Assert.AreEqual(root.Id, copiedRoot.ParentId);
Assert.IsTrue(copiedRoot.HasIdentity);
Assert.AreNotEqual(root.Key, copiedRoot.Key);
Assert.AreEqual(root.Name, copiedRoot.Name);
var copiedChildren = ContentService.GetPagedChildren(copiedRoot.Id, 0, 100, out var total).ToArray();
if (includeDescendants)
{
Assert.AreEqual(1, copiedChildren.Length);
Assert.AreEqual(1, total);
var copiedChild = copiedChildren.First();
Assert.AreNotEqual(child.Id, copiedChild.Id);
Assert.AreNotEqual(child.Key, copiedChild.Key);
Assert.AreEqual(child.Name, copiedChild.Name);
}
else
{
Assert.AreEqual(0, copiedChildren.Length);
Assert.AreEqual(0, total);
}
}
}
[Test]
public async Task Can_Relate_Copy_To_Original()
{

View File

@@ -54,7 +54,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
.AddNotificationHandler<ContentPublishingNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentPublishedNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentUnpublishingNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentUnpublishedNotification, ContentNotificationHandler>();
.AddNotificationHandler<ContentUnpublishedNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentNotificationHandler>();
private void CreateTestData()
{
@@ -176,6 +177,69 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
}
}
[Test]
public void Publishing_Invariant()
{
IContent document = new Content("content", -1, _contentType);
ContentService.Save(document);
var treeChangeWasCalled = false;
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var publishedCultures = change?.PublishedCultures?.ToArray();
Assert.IsNotNull(publishedCultures);
Assert.AreEqual(1, publishedCultures.Length);
Assert.IsTrue(publishedCultures.InvariantContains("*"));
Assert.IsNull(change.UnpublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.Publish(document, ["*"]);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.TreeChange = null;
}
}
[Test]
public void Unpublishing_Invariant()
{
IContent document = new Content("content", -1, _contentType);
ContentService.Save(document);
ContentService.Publish(document, ["*"]);
var treeChangeWasCalled = false;
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
Assert.IsNull(change?.PublishedCultures);
var unpublishedCultures = change?.UnpublishedCultures?.ToArray();
Assert.IsNotNull(unpublishedCultures);
Assert.AreEqual(1, unpublishedCultures.Length);
Assert.IsTrue(unpublishedCultures.InvariantContains("*"));
treeChangeWasCalled = true;
};
try
{
ContentService.Unpublish(document);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.TreeChange = null;
}
}
[Test]
public async Task Publishing_Culture()
{
@@ -202,6 +266,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
var publishingWasCalled = false;
var publishedWasCalled = false;
var treeChangeWasCalled = false;
ContentNotificationHandler.PublishingContent += notification =>
{
@@ -227,16 +292,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
publishedWasCalled = true;
};
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var publishedCultures = change?.PublishedCultures?.ToArray();
Assert.IsNotNull(publishedCultures);
Assert.AreEqual(1, publishedCultures.Length);
Assert.IsTrue(publishedCultures.InvariantContains("fr-FR"));
Assert.IsNull(change.UnpublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.Publish(document, new[] { "fr-FR" });
Assert.IsTrue(publishingWasCalled);
Assert.IsTrue(publishedWasCalled);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.PublishingContent = null;
ContentNotificationHandler.PublishedContent = null;
ContentNotificationHandler.TreeChange = null;
}
document = ContentService.GetById(document.Id);
@@ -399,6 +478,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
var publishingWasCalled = false;
var publishedWasCalled = false;
var treeChangeWasCalled = false;
// TODO: revisit this - it was migrated when removing static events, but the expected result seems illogic - why does this test bind to Published and not Unpublished?
@@ -432,16 +512,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
publishedWasCalled = true;
};
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var unpublishedCultures = change?.UnpublishedCultures?.ToArray();
Assert.IsNotNull(unpublishedCultures);
Assert.AreEqual(1, unpublishedCultures.Length);
Assert.IsTrue(unpublishedCultures.InvariantContains("fr-FR"));
Assert.IsNull(change.PublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.CommitDocumentChanges(document);
Assert.IsTrue(publishingWasCalled);
Assert.IsTrue(publishedWasCalled);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.PublishingContent = null;
ContentNotificationHandler.PublishedContent = null;
ContentNotificationHandler.TreeChange = null;
}
document = ContentService.GetById(document.Id);
@@ -456,7 +550,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
INotificationHandler<ContentPublishingNotification>,
INotificationHandler<ContentPublishedNotification>,
INotificationHandler<ContentUnpublishingNotification>,
INotificationHandler<ContentUnpublishedNotification>
INotificationHandler<ContentUnpublishedNotification>,
INotificationHandler<ContentTreeChangeNotification>
{
public static Action<ContentSavingNotification> SavingContent { get; set; }
@@ -470,6 +565,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
public static Action<ContentUnpublishedNotification> UnpublishedContent { get; set; }
public static Action<ContentTreeChangeNotification> TreeChange { get; set; }
public void Handle(ContentPublishedNotification notification) => PublishedContent?.Invoke(notification);
public void Handle(ContentPublishingNotification notification) => PublishingContent?.Invoke(notification);
@@ -480,5 +577,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
public void Handle(ContentUnpublishedNotification notification) => UnpublishedContent?.Invoke(notification);
public void Handle(ContentUnpublishingNotification notification) => UnpublishingContent?.Invoke(notification);
public void Handle(ContentTreeChangeNotification notification) => TreeChange?.Invoke(notification);
}
}

View File

@@ -0,0 +1,212 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Delivery.Json;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Json;
[TestFixture]
public class DeliveryApiVersionAwareJsonConverterBaseTests
{
private Mock<IHttpContextAccessor> _httpContextAccessorMock;
private Mock<IApiVersioningFeature> _apiVersioningFeatureMock;
private void SetUpMocks(int apiVersion)
{
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
_apiVersioningFeatureMock
.SetupGet(feature => feature.RequestedApiVersion)
.Returns(new ApiVersion(apiVersion));
var httpContext = new DefaultHttpContext();
httpContext.Features.Set(_apiVersioningFeatureMock.Object);
_httpContextAccessorMock
.SetupGet(accessor => accessor.HttpContext)
.Returns(httpContext);
}
[Test]
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
[TestCase(2, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
[TestCase(3, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
public void Can_Include_All_Properties_When_HttpContext_Is_Not_Available(int apiVersion, string[] expectedPropertyNames)
{
// Arrange
using var memoryStream = new MemoryStream();
using var jsonWriter = new Utf8JsonWriter(memoryStream);
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
_apiVersioningFeatureMock
.SetupGet(feature => feature.RequestedApiVersion)
.Returns(new ApiVersion(apiVersion));
_httpContextAccessorMock
.SetupGet(accessor => accessor.HttpContext)
.Returns((HttpContext)null);
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
// Act
sut.Write(jsonWriter, new TestResponseModel(), new JsonSerializerOptions());
jsonWriter.Flush();
memoryStream.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(memoryStream);
var output = reader.ReadToEnd();
// Assert
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
}
[Test]
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" }, new[] { "PropertyV2Min", "PropertyV2Only" })]
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" }, new[] { "PropertyV1Max" })]
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" }, new[] { "PropertyV1Max", "PropertyV2Only", "PropertyV2Max" })]
public void Can_Include_Correct_Properties_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
{
var jsonOptions = new JsonSerializerOptions();
var output = GetJsonOutput(apiVersion, jsonOptions);
// Assert
Assert.Multiple(() =>
{
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
});
}
[Test]
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" })]
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" })]
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" })]
public void Can_Serialize_Properties_Correctly_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames)
{
var jsonOptions = new JsonSerializerOptions();
var output = GetJsonOutput(apiVersion, jsonOptions);
// Verify values correspond to properties
var jsonDoc = JsonDocument.Parse(output);
var root = jsonDoc.RootElement;
// Assert
foreach (var propertyName in expectedPropertyNames)
{
var expectedValue = GetPropertyValue(propertyName);
Assert.AreEqual(expectedValue, root.GetProperty(propertyName).GetString());
}
}
[Test]
[TestCase(1, new[] { "propertyAll", "propertyV1Max", "propertyV2Max" }, new[] { "propertyV2Min", "propertyV2Only" })]
[TestCase(2, new[] { "propertyAll", "propertyV2Min", "propertyV2Only", "propertyV2Max" }, new[] { "propertyV1Max" })]
[TestCase(3, new[] { "propertyAll", "propertyV2Min" }, new[] { "propertyV1Max", "propertyV2Only", "propertyV2Max" })]
public void Can_Respect_Property_Naming_Policy_On_Json_Options(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
{
// Set up CamelCase naming policy
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
var output = GetJsonOutput(apiVersion, jsonOptions);
// Assert
Assert.Multiple(() =>
{
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
});
}
[Test]
[TestCase(1, "PropertyV1Max", "PropertyAll")]
[TestCase(2, "PropertyV2Min", "PropertyAll")]
public void Can_Respect_Property_Order(int apiVersion, string expectedFirstPropertyName, string expectedLastPropertyName)
{
var jsonOptions = new JsonSerializerOptions();
var output = GetJsonOutput(apiVersion, jsonOptions);
// Parse the JSON to verify the order of properties
using var jsonDocument = JsonDocument.Parse(output);
var rootElement = jsonDocument.RootElement;
var properties = rootElement.EnumerateObject().ToList();
var firstProperty = properties.First();
var lastProperty = properties.Last();
// Assert
Assert.Multiple(() =>
{
Assert.AreEqual(expectedFirstPropertyName, firstProperty.Name);
Assert.AreEqual(expectedLastPropertyName, lastProperty.Name);
});
}
private string GetJsonOutput(int apiVersion, JsonSerializerOptions jsonOptions)
{
// Arrange
using var memoryStream = new MemoryStream();
using var jsonWriter = new Utf8JsonWriter(memoryStream);
SetUpMocks(apiVersion);
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
// Act
sut.Write(jsonWriter, new TestResponseModel(), jsonOptions);
jsonWriter.Flush();
memoryStream.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(memoryStream);
return reader.ReadToEnd();
}
private string GetPropertyValue(string propertyName)
{
var model = new TestResponseModel();
return propertyName switch
{
nameof(TestResponseModel.PropertyAll) => model.PropertyAll,
nameof(TestResponseModel.PropertyV1Max) => model.PropertyV1Max,
nameof(TestResponseModel.PropertyV2Max) => model.PropertyV2Max,
nameof(TestResponseModel.PropertyV2Min) => model.PropertyV2Min,
nameof(TestResponseModel.PropertyV2Only) => model.PropertyV2Only,
_ => throw new ArgumentException($"Unknown property name: {propertyName}"),
};
}
}
internal class TestJsonConverter : DeliveryApiVersionAwareJsonConverterBase<TestResponseModel>
{
public TestJsonConverter(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
{
}
}
internal class TestResponseModel
{
[JsonPropertyOrder(100)]
public string PropertyAll { get; init; } = "all";
[IncludeInApiVersion(maxVersion: 1)]
public string PropertyV1Max { get; init; } = "v1";
[IncludeInApiVersion(2)]
public string PropertyV2Min { get; init; } = "v2+";
[IncludeInApiVersion(2, 2)]
public string PropertyV2Only { get; init; } = "v2";
[IncludeInApiVersion(maxVersion: 2)]
public string PropertyV2Max { get; init; } = "up to v2";
}

View File

@@ -27,26 +27,68 @@ public class RefresherTests
Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes);
}
[Test]
public void ContentCacheRefresherCanDeserializeJsonPayload()
[TestCase(TreeChangeTypes.None, false)]
[TestCase(TreeChangeTypes.RefreshAll, true)]
[TestCase(TreeChangeTypes.RefreshBranch, false)]
[TestCase(TreeChangeTypes.Remove, true)]
[TestCase(TreeChangeTypes.RefreshNode, false)]
public void ContentCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes changeTypes, bool blueprint)
{
var key = Guid.NewGuid();
ContentCacheRefresher.JsonPayload[] source =
{
new ContentCacheRefresher.JsonPayload()
{
Id = 1234,
Key = Guid.NewGuid(),
ChangeTypes = TreeChangeTypes.None
Key = key,
ChangeTypes = changeTypes,
Blueprint = blueprint
}
};
var json = JsonSerializer.Serialize(source);
var payload = JsonSerializer.Deserialize<ContentCacheRefresher.JsonPayload[]>(json);
Assert.AreEqual(source[0].Id, payload[0].Id);
Assert.AreEqual(source[0].Key, payload[0].Key);
Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes);
Assert.AreEqual(source[0].Blueprint, payload[0].Blueprint);
Assert.AreEqual(1234, payload[0].Id);
Assert.AreEqual(key, payload[0].Key);
Assert.AreEqual(changeTypes, payload[0].ChangeTypes);
Assert.AreEqual(blueprint, payload[0].Blueprint);
Assert.IsNull(payload[0].PublishedCultures);
Assert.IsNull(payload[0].UnpublishedCultures);
}
[Test]
public void ContentCacheRefresherCanDeserializeJsonPayloadWithCultures()
{
var key = Guid.NewGuid();
ContentCacheRefresher.JsonPayload[] source =
{
new ContentCacheRefresher.JsonPayload()
{
Id = 1234,
Key = key,
PublishedCultures = ["en-US", "da-DK"],
UnpublishedCultures = ["de-DE"]
}
};
var json = JsonSerializer.Serialize(source);
var payload = JsonSerializer.Deserialize<ContentCacheRefresher.JsonPayload[]>(json);
Assert.IsNotNull(payload[0].PublishedCultures);
Assert.Multiple(() =>
{
Assert.AreEqual(2, payload[0].PublishedCultures.Length);
Assert.AreEqual("en-US", payload[0].PublishedCultures.First());
Assert.AreEqual("da-DK", payload[0].PublishedCultures.Last());
});
Assert.IsNotNull(payload[0].UnpublishedCultures);
Assert.Multiple(() =>
{
Assert.AreEqual(1, payload[0].UnpublishedCultures.Length);
Assert.AreEqual("de-DE", payload[0].UnpublishedCultures.First());
});
}
[Test]

View File

@@ -357,6 +357,48 @@ public class RichTextParserTests : PropertyValueConverterTests
Assert.IsEmpty(blockLevelBlock.Elements);
}
[Test]
public void ParseElement_CanHandleWhitespaceAroundInlineElemements()
{
var parser = CreateRichTextElementParser();
var element = parser.Parse("<p>What follows from <strong>here</strong> <em>is</em> <a href=\"#\">just</a> a bunch of text.</p>") as RichTextRootElement;
Assert.IsNotNull(element);
var paragraphElement = element.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(paragraphElement);
var childElements = paragraphElement.Elements.ToArray();
Assert.AreEqual(7, childElements.Length);
var childElementCounter = 0;
void AssertNextChildElementIsText(string expectedText)
{
var textElement = childElements[childElementCounter++] as RichTextTextElement;
Assert.IsNotNull(textElement);
Assert.AreEqual(expectedText, textElement.Text);
}
void AssertNextChildElementIsGeneric(string expectedTag, string expectedInnerText)
{
var genericElement = childElements[childElementCounter++] as RichTextGenericElement;
Assert.IsNotNull(genericElement);
Assert.AreEqual(expectedTag, genericElement.Tag);
Assert.AreEqual(1, genericElement.Elements.Count());
var textElement = genericElement.Elements.First() as RichTextTextElement;
Assert.IsNotNull(textElement);
Assert.AreEqual(expectedInnerText, textElement.Text);
}
AssertNextChildElementIsText("What follows from ");
AssertNextChildElementIsGeneric("strong", "here");
AssertNextChildElementIsText(" ");
AssertNextChildElementIsGeneric("em", "is");
AssertNextChildElementIsText(" ");
AssertNextChildElementIsGeneric("a", "just");
AssertNextChildElementIsText(" a bunch of text.");
}
[Test]
public void ParseMarkup_CanParseContentLink()
{

View File

@@ -78,8 +78,21 @@ internal class UmbracoCmsSchema
public required DataTypesSettings DataTypes { get; set; }
public required MarketplaceSettings Marketplace { get; set; }
public InstallDefaultDataNamedOptions InstallDefaultData { get; set; } = null!;
public required WebhookSettings Webhook { get; set; }
public required CacheSettings Cache { get; set; }
}
public class InstallDefaultDataNamedOptions
{
public InstallDefaultDataSettings Languages { get; set; } = null!;
public InstallDefaultDataSettings DataTypes { get; set; } = null!;
public InstallDefaultDataSettings MediaTypes { get; set; } = null!;
public InstallDefaultDataSettings MemberTypes { get; set; } = null!;
}
}