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:
@@ -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
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
12
src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs
Normal file
12
src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs
Normal 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();
|
||||
}
|
||||
21
src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs
Normal file
21
src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
2455
src/Umbraco.Core/EmbeddedResources/Lang/de_ch.xml
Normal file
2455
src/Umbraco.Core/EmbeddedResources/Lang/de_ch.xml
Normal file
File diff suppressed because it is too large
Load Diff
2357
src/Umbraco.Core/EmbeddedResources/Lang/fr_ch.xml
Normal file
2357
src/Umbraco.Core/EmbeddedResources/Lang/fr_ch.xml
Normal file
File diff suppressed because it is too large
Load Diff
3170
src/Umbraco.Core/EmbeddedResources/Lang/it_ch.xml
Normal file
3170
src/Umbraco.Core/EmbeddedResources/Lang/it_ch.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user