Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9505011d71 | ||
|
|
2579aaf2db | ||
|
|
2b7784a226 | ||
|
|
8555a97b39 | ||
|
|
c2dd685a4b | ||
|
|
66fc819379 | ||
|
|
4f1f7e15c4 | ||
|
|
a826c52e2e | ||
|
|
8b2c22aaf1 | ||
|
|
aecfee4469 | ||
|
|
9c785a9c5b | ||
|
|
2fe10387ee | ||
|
|
8642b9e615 | ||
|
|
2a604c8719 | ||
|
|
9c0a0a1086 | ||
|
|
1a4256f997 | ||
|
|
80ae0380a2 | ||
|
|
fd01282798 | ||
|
|
f7ba2eaa62 | ||
|
|
9485a95c0e | ||
|
|
f1ab605bb9 | ||
|
|
3472ff9ba3 | ||
|
|
577dc06d55 | ||
|
|
f4771d1495 | ||
|
|
4b3ce53acf | ||
|
|
0543163817 | ||
|
|
72f43a5821 | ||
|
|
d7231c5435 | ||
|
|
aed7505e4b | ||
|
|
15c6ca7628 | ||
|
|
4e74dbf218 | ||
|
|
76fed82e91 |
@@ -4,12 +4,11 @@ pr: none
|
||||
trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 0 * * *'
|
||||
displayName: Daily midnight build
|
||||
- cron: '0 6 * * *'
|
||||
displayName: Daily 6 AM build (v16/dev)
|
||||
branches:
|
||||
include:
|
||||
- v15/dev
|
||||
- main
|
||||
- v16/dev
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
@@ -294,7 +293,8 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: Integration
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -475,7 +475,8 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: DefaultConfigE2E
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
@@ -674,4 +675,4 @@ stages:
|
||||
--data "$PAYLOAD" \
|
||||
"$SLACK_WEBHOOK_URL"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
|
||||
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
|
||||
|
||||
@@ -1,31 +1,60 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class DomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public DomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpGet("{id:guid}/domains")]
|
||||
[ProducesResponseType(typeof(DomainsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Domains(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(id, true))
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.ToArray();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
@@ -15,17 +21,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateDomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
private readonly IDomainPresentationFactory _domainPresentationFactory;
|
||||
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateDomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_domainPresentationFactory = domainPresentationFactory;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper,
|
||||
domainPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/domains")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
@@ -37,6 +56,16 @@ public class UpdateDomainsController : DocumentControllerBase
|
||||
Guid id,
|
||||
UpdateDomainsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionAssignDomain.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
|
||||
|
||||
Attempt<DomainUpdateResult, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(id, domainsUpdateModel);
|
||||
|
||||
+31
-1
@@ -1,33 +1,63 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateNotificationsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateNotificationsController(IAuthorizationService authorizationService, IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_notificationService = notificationService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
contentEditingService,
|
||||
notificationService,
|
||||
backOfficeSecurityAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/notifications")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateNotifications(CancellationToken cancellationToken, Guid id, UpdateDocumentNotificationsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IContent? content = await _contentEditingService.GetAsync(id);
|
||||
if (content == null)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.User;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -25,11 +28,26 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IUserGroupService _userGroupService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateUserGroupsUserController(
|
||||
IAuthorizationService authorizationService,
|
||||
IUserGroupService userGroupService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_userGroupService = userGroupService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
: this(
|
||||
authorizationService,
|
||||
userGroupService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[HttpPost("set-user-groups")]
|
||||
@@ -51,7 +69,8 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
|
||||
Attempt<UserGroupOperationStatus> result = await _userGroupService.UpdateUserGroupsOnUsersAsync(
|
||||
requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet());
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet(),
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result.Success
|
||||
? Ok()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Umbraco.Cms.Api.Common.Accessors;
|
||||
using Umbraco.Cms.Api.Common.Rendering;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
@@ -16,9 +16,10 @@ internal static class WebhooksBuilderExtensions
|
||||
builder.Services.AddUnique<IWebhookPresentationFactory, WebhookPresentationFactory>();
|
||||
builder.AddMapDefinition<WebhookEventMapDefinition>();
|
||||
|
||||
// deliveryApi will overwrite these more basic ones.
|
||||
builder.Services.AddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
// We have to use TryAdd here, as if they are registered by the delivery API, we don't want to register them
|
||||
// Delivery API will also overwrite these IF it is enabled.
|
||||
builder.Services.TryAddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.TryAddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -81,46 +81,23 @@ public class UserEditorAuthorizationHelper
|
||||
return Attempt<string?>.Succeed();
|
||||
}
|
||||
|
||||
// d) a non-admin user can remove any groups but can only add groups they themselves belong to
|
||||
if (userGroupAliases != null)
|
||||
{
|
||||
var savingGroupAliases = userGroupAliases.ToArray();
|
||||
var existingGroupAliases = savingUser == null
|
||||
IEnumerable<string> requestedGroupAliases = userGroupAliases.ToArray();
|
||||
IEnumerable<string> existingGroupAliases = savingUser == null
|
||||
? []
|
||||
: savingUser.Groups.Select(x => x.Alias).ToArray();
|
||||
: savingUser.Groups.Select(x => x.Alias);
|
||||
IEnumerable<string> performingUserGroupAliases = currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>();
|
||||
|
||||
IEnumerable<string> addedGroupAliases = savingGroupAliases.Except(existingGroupAliases);
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
// As we know the current user is not admin, it is only allowed to use groups that the user do have themselves.
|
||||
var savingGroupAliasesNotAllowed = addedGroupAliases
|
||||
.Except(currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>()).ToArray();
|
||||
if (savingGroupAliasesNotAllowed.Any())
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", savingGroupAliasesNotAllowed) +
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", unauthorized) +
|
||||
"', the current user is not part of them or admin");
|
||||
}
|
||||
|
||||
// only validate any groups that have changed.
|
||||
// a non-admin user can remove groups and add groups that they have access to
|
||||
// but they cannot add a group that they do not have access to or that grants them
|
||||
// path or section access that they don't have access to.
|
||||
var newGroups = savingUser == null
|
||||
? savingGroupAliases
|
||||
: savingGroupAliases.Except(savingUser.Groups.Select(x => x.Alias)).ToArray();
|
||||
|
||||
var userGroupsChanged = savingUser != null && newGroups.Length > 0;
|
||||
|
||||
if (userGroupsChanged)
|
||||
{
|
||||
// d) A user cannot assign a group to another user that they do not belong to
|
||||
var currentUserGroups = currentUser?.Groups.Select(x => x.Alias).ToArray();
|
||||
foreach (var group in newGroups)
|
||||
{
|
||||
if (currentUserGroups?.Contains(group) == false)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group " + group + ", the current user is not a member");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Attempt<string?>.Succeed();
|
||||
|
||||
@@ -12,7 +12,7 @@ public class X : OEmbedProviderBase
|
||||
{
|
||||
}
|
||||
|
||||
public override string ApiEndpoint => "http://publish.twitter.com/oembed";
|
||||
public override string ApiEndpoint => "https://publish.x.com/oembed";
|
||||
|
||||
public override string[] UrlSchemeRegex => new[] { @"(https?:\/\/(www\.)?)(twitter|x)\.com\/.*\/status\/.*" };
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Umbraco.Cms.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Shared authorization logic for user group assignment.
|
||||
/// </summary>
|
||||
public static class UserGroupAssignmentAuthorization
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the group aliases that the performing user is not authorized to assign.
|
||||
/// </summary>
|
||||
/// <param name="performingUserGroupAliases">The group aliases the performing user belongs to.</param>
|
||||
/// <param name="requestedGroupAliases">The group aliases being assigned to the target user.</param>
|
||||
/// <param name="existingGroupAliases">The group aliases the target user currently belongs to.</param>
|
||||
/// <returns>
|
||||
/// Group aliases that are being added but the performing user does not belong to.
|
||||
/// An empty collection means the assignment is authorized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can remove any groups but can only add groups they themselves belong to.
|
||||
/// Callers should check for admin status before calling this method, as admins bypass this check.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> GetUnauthorizedGroupAssignments(
|
||||
IEnumerable<string> performingUserGroupAliases,
|
||||
IEnumerable<string> requestedGroupAliases,
|
||||
IEnumerable<string> existingGroupAliases)
|
||||
{
|
||||
var performingGroups = performingUserGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
var existingGroups = existingGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
return requestedGroupAliases
|
||||
.Where(alias => existingGroups.Contains(alias) is false && performingGroups.Contains(alias) is false)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,22 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service for asynchronously retrieving embeddable HTML markup for a specified resource using the oEmbed
|
||||
/// protocol.
|
||||
/// </summary>
|
||||
public interface IOEmbedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the embeddable HTML markup for the specified resource.
|
||||
/// </summary>
|
||||
/// <remarks>The returned markup is suitable for embedding in web pages. The width and height parameters
|
||||
/// may be ignored by some providers depending on their capabilities.</remarks>
|
||||
/// <param name="url">The URI of the resource to retrieve markup for. Must be a valid, absolute URI.</param>
|
||||
/// <param name="width">The optional maximum width, in pixels, for the embedded content. If null, the default width is used.</param>
|
||||
/// <param name="height">The optional maximum height, in pixels, for the embedded content. If null, the default height is used.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests. The operation is canceled if the token is triggered.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The result contains an Attempt with the HTML markup if
|
||||
/// successful, or an oEmbed operation status indicating the reason for failure.</returns>
|
||||
Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? width, int? height, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
@@ -107,8 +107,25 @@ public interface IUserGroupService
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
[Obsolete("Please use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the users to have the groups specified, with authorization checks based on the performing user.
|
||||
/// </summary>
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <param name="performingUserKey">The key of the user performing the operation.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can only add groups they themselves belong to. Removing groups is always allowed.
|
||||
/// </remarks>
|
||||
// TODO (V18): Remove default implementation.
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys, Guid performingUserKey)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> UpdateUserGroupsOnUsersAsync(userGroupKeys, userKeys);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
Task<Attempt<UserGroupOperationStatus>> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
|
||||
Task<Attempt<UserGroupOperationStatus>> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,30 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IOEmbedService"/> for retrieving embeddable HTML markup using the oEmbed protocol.
|
||||
/// </summary>
|
||||
public class OEmbedService : IOEmbedService
|
||||
{
|
||||
private readonly EmbedProvidersCollection _embedProvidersCollection;
|
||||
private readonly ILogger<OEmbedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OEmbedService"/> class.
|
||||
/// </summary>
|
||||
public OEmbedService(EmbedProvidersCollection embedProvidersCollection, ILogger<OEmbedService> logger)
|
||||
{
|
||||
_embedProvidersCollection = embedProvidersCollection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? maxWidth, int? maxHeight, CancellationToken cancellationToken)
|
||||
{
|
||||
// Find the first provider that supports the URL
|
||||
IEmbedProvider? matchedProvider = _embedProvidersCollection
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex.Any(regex=>new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex
|
||||
.Any(regex => new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
|
||||
if (matchedProvider is null)
|
||||
{
|
||||
@@ -39,8 +47,8 @@ public class OEmbedService : IOEmbedService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oembed markup. Provider: {Provider}",matchedProvider.GetType().Name);
|
||||
Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oEmbed markup. Provider: {Provider}", matchedProvider.GetType().Name);
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
}
|
||||
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.ProviderReturnedInvalidResult, string.Empty);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Persistence;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services.AuthorizationStatus;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Extensions;
|
||||
@@ -210,9 +211,24 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
return Attempt.Succeed(UserGroupOperationStatus.Success);
|
||||
}
|
||||
|
||||
public async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
// TODO (V19): Collapse the following three methods into a single one, once the obsolete overload
|
||||
// of UpdateUserGroupsOnUsersAsync is removed from the interface.
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey: null);
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid performingUserKey)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey);
|
||||
|
||||
private async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersInternalAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid? performingUserKey)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
@@ -222,6 +238,40 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
.Select(x => x.ToReadOnlyGroup())
|
||||
.ToArray();
|
||||
|
||||
// Authorize the performing user if provided.
|
||||
if (performingUserKey.HasValue)
|
||||
{
|
||||
IUser? performingUser = await _userService.GetAsync(performingUserKey.Value);
|
||||
if (performingUser is null)
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.MissingUser);
|
||||
}
|
||||
|
||||
if (performingUser.IsAdmin() is false)
|
||||
{
|
||||
string[] performingUserGroupAliases = performingUser.Groups.Select(g => g.Alias).ToArray();
|
||||
string[] requestedGroupAliases = userGroups.Select(g => g.Alias).ToArray();
|
||||
|
||||
foreach (IUser user in users)
|
||||
{
|
||||
IEnumerable<string> existingGroupAliases = user.Groups.Select(g => g.Alias);
|
||||
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"The performing user is not allowed to assign user group(s) '{GroupAliases}' because they do not belong to them.",
|
||||
string.Join(", ", unauthorized));
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
|
||||
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
namespace Umbraco.Cms.Infrastructure.Examine.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the index options to construct the Examine indexes
|
||||
/// Configures the index options to construct the Examine indexes.
|
||||
/// </summary>
|
||||
public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
|
||||
{
|
||||
@@ -18,6 +18,9 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
private readonly IUmbracoIndexConfig _umbracoIndexConfig;
|
||||
private readonly IDeliveryApiContentIndexFieldDefinitionBuilder _deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureIndexOptions"/> class.
|
||||
/// </summary>
|
||||
public ConfigureIndexOptions(
|
||||
IUmbracoIndexConfig umbracoIndexConfig,
|
||||
IOptions<IndexCreatorSettings> settings,
|
||||
@@ -28,24 +31,27 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
_deliveryApiContentIndexFieldDefinitionBuilder = deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(string? name, LuceneDirectoryIndexOptions options)
|
||||
{
|
||||
// When creating FieldDefinitions with Umbraco defaults, pass in any already defined to avoid overwriting
|
||||
// those added via a package or custom code.
|
||||
switch (name)
|
||||
{
|
||||
case Constants.UmbracoIndexes.InternalIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.ExternalIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
options.Validator = _umbracoIndexConfig.GetPublishedContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.MembersIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetMemberValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.DeliveryApiContentIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
@@ -64,6 +70,7 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(LuceneDirectoryIndexOptions options)
|
||||
=> throw new NotImplementedException("This is never called and is just part of the interface");
|
||||
}
|
||||
|
||||
@@ -30,11 +30,29 @@ public class UmbracoFieldDefinitionCollection : FieldDefinitionCollection
|
||||
new(UmbracoExamineFieldNames.VariesByCultureFieldName, FieldDefinitionTypes.Raw),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing
|
||||
/// the default Umbraco field definitions.
|
||||
/// </summary>
|
||||
public UmbracoFieldDefinitionCollection()
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing the containing
|
||||
/// the default Umbraco field definitions, augmented or overridden by the provided definitions.
|
||||
/// </summary>
|
||||
/// <param name="definitions">Existing collection of field definitions.</param>
|
||||
public UmbracoFieldDefinitionCollection(FieldDefinitionCollection definitions)
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
foreach (FieldDefinition definition in definitions)
|
||||
{
|
||||
AddOrUpdate(definition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overridden to dynamically add field definitions for culture variations
|
||||
/// </summary>
|
||||
|
||||
+7
-3
@@ -1381,9 +1381,13 @@ AND umbracoNode.id <> @id",
|
||||
}
|
||||
else if (ev.Key.langId.HasValue)
|
||||
{
|
||||
// This should never happen! If a property culture is flagged as edited then the culture must exist at the document level
|
||||
throw new PanicException(
|
||||
$"The existing DocumentCultureVariationDto was not found for node {ev.Key.nodeId} and language {ev.Key.langId}");
|
||||
// This can happen when a property changes from invariant to variant and the content
|
||||
// was only created in non-default languages. The invariant property data gets migrated
|
||||
// to the default language, but no DocumentCultureVariationDto exists for the default
|
||||
// language because the content was never created in that language.
|
||||
// In this case, we simply skip updating the edited flag since there's no document
|
||||
// culture variation record to update.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Repositories;
|
||||
@@ -11,21 +14,35 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
|
||||
private const string QueryName = "umbDebug";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ICookieManager _cookieManager;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor)
|
||||
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor, ICookieManager cookieManager, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_cookieManager = cookieManager;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void SetStatus(int userId, bool status)
|
||||
{
|
||||
if (status)
|
||||
{
|
||||
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Append(CookieName, "1", new CookieOptions { Expires = DateTime.Now.AddYears(1) });
|
||||
// This cookie enables debug profiling on the front-end without needing query strings or headers.
|
||||
// It uses SameSite=Strict, so it only works when the BackOffice and front-end share the same domain.
|
||||
// It's marked httpOnly to prevent JavaScript access (the server reads it, not client-side code).
|
||||
// No expiration is set, so it's a session cookie and will be deleted when the browser closes.
|
||||
// For cross-site setups, use the query string (?umbDebug=true) or header (X-UMB-DEBUG) instead.
|
||||
_cookieManager.SetCookieValue(
|
||||
CookieName,
|
||||
"1",
|
||||
httpOnly: true,
|
||||
secure: _globalSettings.UseHttps,
|
||||
sameSiteMode: "Strict");
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Delete(CookieName);
|
||||
_cookieManager.ExpireCookie(CookieName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +60,6 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
|
||||
return xUmbDebug;
|
||||
}
|
||||
|
||||
return request.Cookies.ContainsKey(CookieName);
|
||||
return _cookieManager.HasCookie(CookieName);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "16.4.0-rc3",
|
||||
"version": "16.5.0-rc",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "16.4.0-rc3",
|
||||
"version": "16.5.0-rc",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./src/packages/*",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"license": "MIT",
|
||||
"version": "16.4.0",
|
||||
"version": "16.6.0-rc",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": null,
|
||||
|
||||
@@ -2525,13 +2525,19 @@ export default {
|
||||
profiling: {
|
||||
performanceProfiling: 'Performance profiling',
|
||||
performanceProfilingDescription:
|
||||
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p>",
|
||||
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p><p><strong>Note:</strong> This will only work if the Backoffice is currently located on the same URL as the front-end website.</p>",
|
||||
activateByDefault: 'Activate the profiler by default',
|
||||
reminder: 'Friendly reminder',
|
||||
reminderDescription:
|
||||
'<p>You should never let a production site run in debug mode. Debug mode is turned off by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>false</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>',
|
||||
profilerEnabledDescription:
|
||||
"<p>Umbraco currently does not run in debug mode, so you can't use the built-in profiler. This is how it should be for a production site.</p><p>Debug mode is turned on by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>true</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>",
|
||||
errorEnablingProfilerTitle: 'Error enabling profiler',
|
||||
errorEnablingProfilerDescription:
|
||||
'It was not possible to enable the profiler. Check that you are accessing the Backoffice on the same URL as the front-end website, and try again. If the problem persists, please check the log for more details.',
|
||||
errorDisablingProfilerTitle: 'Error disabling profiler',
|
||||
errorDisablingProfilerDescription:
|
||||
'It was not possible to disable the profiler. Try again, and if the problem persists, please check the log for more details.',
|
||||
},
|
||||
settingsDashboardVideos: {
|
||||
trainingHeadline: 'Hours of Umbraco training videos are only a click away',
|
||||
|
||||
+5
@@ -198,6 +198,9 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
@state()
|
||||
private _isReadOnly: boolean = false;
|
||||
|
||||
@state()
|
||||
private _limitMax?: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -294,6 +297,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
}
|
||||
|
||||
async #setupRangeValidation(rangeLimit: UmbNumberRangeValueType | undefined) {
|
||||
this._limitMax = rangeLimit?.max;
|
||||
if (this.#rangeUnderflowValidator) {
|
||||
this.removeValidator(this.#rangeUnderflowValidator);
|
||||
this.#rangeUnderflowValidator = undefined;
|
||||
@@ -408,6 +412,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
}
|
||||
|
||||
#renderCreateButtonGroup() {
|
||||
if (this._limitMax === 1 && this._layoutEntries.length > 0) return nothing;
|
||||
if (this._areaKey === null || this._layoutEntries.length === 0) {
|
||||
return html` <uui-button-group id="createButton">
|
||||
${this.#renderCreateButton()} ${this.#renderPasteButton()}
|
||||
|
||||
+2
-5
@@ -396,11 +396,8 @@ export class UmbPropertyEditorUIBlockListElement
|
||||
}
|
||||
|
||||
#renderCreateButtonGroup() {
|
||||
if (this.readonly && this._layouts.length > 0) {
|
||||
return nothing;
|
||||
} else {
|
||||
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
|
||||
}
|
||||
if (this._layouts.length > 0 && (this._limitMax === 1 || this.readonly)) return nothing;
|
||||
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
|
||||
}
|
||||
|
||||
#renderInlineCreateButton(index: number) {
|
||||
|
||||
+5
-2
@@ -420,6 +420,8 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
const repo = new UmbDataTypeDetailRepository(this);
|
||||
|
||||
const propertyTypes = await this.structure.getContentTypeProperties();
|
||||
const contentTypeVariesByCulture = this.structure.getVariesByCulture();
|
||||
const contentTypeVariesBySegment = this.structure.getVariesBySegment();
|
||||
const valueDefinitions = await Promise.all(
|
||||
propertyTypes.map(async (property) => {
|
||||
// TODO: Implement caching for data-type requests. [NL]
|
||||
@@ -438,8 +440,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
propertyEditorSchemaAlias: dataType.editorAlias,
|
||||
config: dataType.values,
|
||||
typeArgs: {
|
||||
variesByCulture: property.variesByCulture,
|
||||
variesBySegment: property.variesBySegment,
|
||||
// Only vary if the content type varies:
|
||||
variesByCulture: contentTypeVariesByCulture ? property.variesByCulture : false,
|
||||
variesBySegment: contentTypeVariesBySegment ? property.variesBySegment : false,
|
||||
} as UmbPropertyTypePresetModelTypeModel,
|
||||
} as UmbPropertyTypePresetModel;
|
||||
}),
|
||||
|
||||
+1
@@ -221,6 +221,7 @@ export class UmbInputMultipleTextStringElement extends UmbFormControlMixin<undef
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.disabled || this.readonly) return nothing;
|
||||
if (this.max === 1 && this._items.length > 0) return nothing;
|
||||
return html`
|
||||
<uui-button
|
||||
color="default"
|
||||
|
||||
+26
-20
@@ -81,26 +81,7 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectIcon')}>
|
||||
<div id="container">
|
||||
${this.renderSearch()}
|
||||
<hr />
|
||||
<uui-color-swatches
|
||||
value=${ifDefined(this.value.color)}
|
||||
label=${this.localize.term('defaultdialogs_colorSwitcher')}
|
||||
@change=${this.#onColorChange}>
|
||||
${
|
||||
// TODO: Missing localization for the color aliases. [NL]
|
||||
this._colorList.map(
|
||||
(color) => html`
|
||||
<uui-color-swatch
|
||||
label=${color.alias}
|
||||
title=${color.alias}
|
||||
value=${color.alias}
|
||||
style="--uui-swatch-color: var(${color.varName})">
|
||||
</uui-color-swatch>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</uui-color-swatches>
|
||||
<hr />
|
||||
${this.renderColors()}
|
||||
<uui-scroll-container id="icons">
|
||||
${this.data?.showEmptyOption && !this._isSearching
|
||||
? html`
|
||||
@@ -144,9 +125,34 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
${umbFocus()}>
|
||||
<uui-icon name="search" slot="prepend" id="search_icon"></uui-icon>
|
||||
</uui-input>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
renderColors() {
|
||||
return this.data?.hideColors === true
|
||||
? nothing
|
||||
: html`<uui-color-swatches
|
||||
value=${ifDefined(this.value.color)}
|
||||
label=${this.localize.term('defaultdialogs_colorSwitcher')}
|
||||
@change=${this.#onColorChange}>
|
||||
${
|
||||
this._colorList.map(
|
||||
(color) => html`
|
||||
<uui-color-swatch
|
||||
label=${color.alias}
|
||||
title=${color.alias}
|
||||
value=${color.alias}
|
||||
style="--uui-swatch-color: var(${color.varName})">
|
||||
</uui-color-swatch>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</uui-color-swatches>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
renderIcons() {
|
||||
return this._iconsFiltered
|
||||
? repeat(
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
|
||||
export interface UmbIconPickerModalData {
|
||||
placeholder?: string;
|
||||
showEmptyOption?: boolean;
|
||||
hideColors?: boolean;
|
||||
}
|
||||
|
||||
export interface UmbIconPickerModalValue {
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
|
||||
}
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.selection.length >= this.max) return nothing;
|
||||
if (this.selection.length > 0 && this.max === 1) return nothing;
|
||||
if (this.readonly && this.selection.length > 0) {
|
||||
return nothing;
|
||||
} else {
|
||||
|
||||
+3
@@ -407,6 +407,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.readonly) return nothing;
|
||||
if (this.max === 1 && this._cards.length > 0) return nothing;
|
||||
return html`
|
||||
<uui-button
|
||||
id="btn-add"
|
||||
@@ -468,6 +469,8 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
display: grid;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getMimeTypeFromExtension } from '../../components/index.js';
|
||||
import { getMimeTypeFromExtension } from './utils.js';
|
||||
import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js';
|
||||
import type { UmbFileUploadPreviewElement as UmbFileUploadPreviewElementInterface } from './file-upload-preview.interface.js';
|
||||
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
+3
@@ -16,9 +16,12 @@ export default class UmbInputUploadFieldSvgElement extends UmbLitElement impleme
|
||||
static override readonly styles = [
|
||||
css`
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
max-height: 400px;
|
||||
|
||||
width: fit-content;
|
||||
min-width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
+22
-4
@@ -30,6 +30,8 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
return this._progressItems;
|
||||
}
|
||||
|
||||
#dragCounter = 0;
|
||||
|
||||
protected override _manager = new UmbMediaDropzoneManager(this);
|
||||
public progressItems = () => this._manager.progressItems;
|
||||
public progress = () => this._manager.progress;
|
||||
@@ -39,6 +41,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
|
||||
document.addEventListener('dragenter', this.#handleDragEnter.bind(this));
|
||||
document.addEventListener('dragleave', this.#handleDragLeave.bind(this));
|
||||
document.addEventListener('dragover', this.#handleDragOver.bind(this));
|
||||
document.addEventListener('drop', this.#handleDrop.bind(this));
|
||||
|
||||
// TODO: Revisit this. I am not sure why it is needed to call these methods here when they are already called in the constructor of the parent class.
|
||||
@@ -65,6 +68,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('dragenter', this.#handleDragEnter.bind(this));
|
||||
document.removeEventListener('dragleave', this.#handleDragLeave.bind(this));
|
||||
document.removeEventListener('dragover', this.#handleDragOver.bind(this));
|
||||
document.removeEventListener('drop', this.#handleDrop.bind(this));
|
||||
}
|
||||
|
||||
@@ -78,21 +82,35 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
|
||||
#handleDragEnter(e: DragEvent) {
|
||||
if (this.disabled) return;
|
||||
// Avoid collision with UmbSorterController
|
||||
const types = e.dataTransfer?.types;
|
||||
if (!types?.length || !types?.includes('Files')) return;
|
||||
|
||||
// Normalize types for Safari
|
||||
const types = Array.from(e.dataTransfer?.types || []).map((t) => t.toLowerCase());
|
||||
if (!types.includes('files')) return;
|
||||
|
||||
this.#dragCounter++;
|
||||
this.toggleAttribute('dragging', true);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
#handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
#handleDragLeave() {
|
||||
if (this.disabled) return;
|
||||
this.toggleAttribute('dragging', false);
|
||||
|
||||
this.#dragCounter--;
|
||||
if (this.#dragCounter <= 0) {
|
||||
this.toggleAttribute('dragging', false);
|
||||
this.#dragCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (this.disabled) return;
|
||||
|
||||
this.#dragCounter = 0;
|
||||
this.toggleAttribute('dragging', false);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ import { UMB_MODAL_MANAGER_CONTEXT, UmbModalBaseElement } from '@umbraco-cms/bac
|
||||
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
|
||||
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
|
||||
|
||||
import '../../components/input-upload-field/file-upload-preview.element.js';
|
||||
|
||||
@customElement('umb-image-cropper-editor-modal')
|
||||
export class UmbImageCropperEditorModalElement extends UmbModalBaseElement<
|
||||
UmbImageCropperEditorModalData<any>,
|
||||
|
||||
+79
-19
@@ -1,8 +1,9 @@
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { css, html, customElement, state, query, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { css, html, customElement, state, query, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
import { ProfilingService } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
|
||||
|
||||
@customElement('umb-dashboard-performance-profiling')
|
||||
export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
|
||||
@@ -13,55 +14,114 @@ export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
|
||||
@state()
|
||||
private _isDebugMode = true;
|
||||
|
||||
@state()
|
||||
private _isLoading = true;
|
||||
|
||||
@query('#toggle')
|
||||
private _toggle!: HTMLInputElement;
|
||||
|
||||
private _notificationContext: typeof UMB_NOTIFICATION_CONTEXT.TYPE | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (notificationContext) => {
|
||||
this._notificationContext = notificationContext;
|
||||
});
|
||||
}
|
||||
|
||||
#setToggle(value: boolean) {
|
||||
this._toggle.checked = value;
|
||||
this._profilingStatus = value;
|
||||
this._isLoading = false;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._getProfilingStatus();
|
||||
override async firstUpdated() {
|
||||
const status = await this.#getProfilingStatus();
|
||||
this.#setToggle(status);
|
||||
}
|
||||
|
||||
private async _getProfilingStatus() {
|
||||
async #getProfilingStatus() {
|
||||
const { data } = await tryExecute(this, ProfilingService.getProfilingStatus());
|
||||
|
||||
if (!data) return;
|
||||
this._profilingStatus = data.enabled ?? false;
|
||||
return data?.enabled ?? false;
|
||||
}
|
||||
|
||||
private async _changeProfilingStatus() {
|
||||
const { error } = await tryExecute(
|
||||
this,
|
||||
ProfilingService.putProfilingStatus({ body: { enabled: !this._profilingStatus } }),
|
||||
);
|
||||
async #disableProfilingStatus() {
|
||||
this._isLoading = true;
|
||||
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: false } }));
|
||||
|
||||
if (error) {
|
||||
this.#setToggle(this._profilingStatus);
|
||||
} else {
|
||||
this.#setToggle(!this._profilingStatus);
|
||||
this.#setToggle(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test that it was actually disabled
|
||||
const status = await this.#getProfilingStatus();
|
||||
|
||||
if (status) {
|
||||
this.#setToggle(true);
|
||||
this._notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.localize.term('profiling_errorDisablingProfilerTitle'),
|
||||
message: this.localize.term('profiling_errorDisablingProfilerDescription'),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#setToggle(false);
|
||||
}
|
||||
|
||||
async #enableProfilingStatus() {
|
||||
this._isLoading = true;
|
||||
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: true } }));
|
||||
|
||||
if (error) {
|
||||
this.#setToggle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test that it was actually enabled
|
||||
const status = await this.#getProfilingStatus();
|
||||
|
||||
if (!status) {
|
||||
this.#setToggle(false);
|
||||
this._notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.localize.term('profiling_errorEnablingProfilerTitle'),
|
||||
message: this.localize.term('profiling_errorEnablingProfilerDescription'),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#setToggle(true);
|
||||
}
|
||||
|
||||
#renderProfilingStatus() {
|
||||
return this._isDebugMode
|
||||
? html`
|
||||
${unsafeHTML(this.localize.term('profiling_performanceProfilingDescription'))}
|
||||
<umb-localize key="profiling_performanceProfilingDescription"></umb-localize>
|
||||
|
||||
<uui-toggle
|
||||
id="toggle"
|
||||
label=${this.localize.term('profiling_activateByDefault')}
|
||||
label-position="left"
|
||||
?checked="${this._profilingStatus}"
|
||||
@change="${this._changeProfilingStatus}"></uui-toggle>
|
||||
?disabled="${this._isLoading}"
|
||||
@change="${() =>
|
||||
this._profilingStatus ? this.#disableProfilingStatus() : this.#enableProfilingStatus()}"></uui-toggle>
|
||||
|
||||
<h4>${this.localize.term('profiling_reminder')}</h4>
|
||||
${when(this._isLoading, () => html`<uui-loader-circle></uui-loader-circle>`)}
|
||||
|
||||
${unsafeHTML(this.localize.term('profiling_reminderDescription'))}
|
||||
<h4>
|
||||
<umb-localize key="profiling_reminder"></umb-localize>
|
||||
</h4>
|
||||
|
||||
<umb-localize key="profiling_reminderDescription"></umb-localize>
|
||||
`
|
||||
: html` ${unsafeHTML(this.localize.term('profiling_profilerEnabledDescription'))} `;
|
||||
: html`<umb-localize key="profiling_profilerEnabledDescription"></umb-localize>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
|
||||
@@ -16,6 +16,12 @@ export const manifests: Array<UmbExtensionManifest> = [
|
||||
description: 'Icon name to show when no icon is selected',
|
||||
propertyEditorUiAlias: 'Umb.PropertyEditorUi.IconPicker',
|
||||
},
|
||||
{
|
||||
alias: 'hideColors',
|
||||
label: 'Hide colors',
|
||||
description: 'Hide color swatches from modal',
|
||||
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+5
-1
@@ -58,10 +58,14 @@ export class UmbPropertyEditorUIIconPickerElement
|
||||
@state()
|
||||
private _placeholderIcon = '';
|
||||
|
||||
@state()
|
||||
private _hideColors = false;
|
||||
|
||||
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
|
||||
if (!config) return;
|
||||
const placeholder = config.getValueByAlias('placeholder');
|
||||
this._placeholderIcon = typeof placeholder === 'string' ? placeholder : '';
|
||||
this._hideColors = config.getValueByAlias('hideColors') as boolean;
|
||||
}
|
||||
|
||||
private async _openModal() {
|
||||
@@ -70,7 +74,7 @@ export class UmbPropertyEditorUIIconPickerElement
|
||||
icon: this._icon,
|
||||
color: this._color,
|
||||
},
|
||||
data: { placeholder: this._placeholderIcon, showEmptyOption: !this.mandatory },
|
||||
data: { placeholder: this._placeholderIcon, showEmptyOption: !this.mandatory, hideColors: this._hideColors },
|
||||
}).catch(() => undefined);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { UmbMarked } from './ufm.context.js';
|
||||
|
||||
describe('UmbMarked sanitization', () => {
|
||||
describe('XSS prevention on custom elements', () => {
|
||||
it('should strip onclick from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button onclick="alert(1)">Click</uui-button>');
|
||||
expect(markup).to.not.include('onclick');
|
||||
expect(markup).to.include('<uui-button>');
|
||||
});
|
||||
|
||||
it('should strip onload from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test onload="alert(1)">Test</umb-test>');
|
||||
expect(markup).to.not.include('onload');
|
||||
expect(markup).to.include('<umb-test>');
|
||||
});
|
||||
|
||||
it('should strip onmouseover from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-box onmouseover="alert(1)">Hover</uui-box>');
|
||||
expect(markup).to.not.include('onmouseover');
|
||||
expect(markup).to.include('<uui-box>');
|
||||
});
|
||||
|
||||
it('should strip onfocus from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<ufm-label-value onfocus="alert(1)" alias="test"></ufm-label-value>');
|
||||
expect(markup).to.not.include('onfocus');
|
||||
expect(markup).to.include('alias="test"');
|
||||
});
|
||||
|
||||
it('should strip onerror from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test onerror="alert(1)">Test</umb-test>');
|
||||
expect(markup).to.not.include('onerror');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe attributes on custom elements', () => {
|
||||
it('should preserve class attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button class="primary">Click</uui-button>');
|
||||
expect(markup).to.include('class="primary"');
|
||||
});
|
||||
|
||||
it('should preserve alias attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<ufm-label-value alias="prop1"></ufm-label-value>');
|
||||
expect(markup).to.include('alias="prop1"');
|
||||
});
|
||||
|
||||
it('should preserve look attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button look="primary">Click</uui-button>');
|
||||
expect(markup).to.include('look="primary"');
|
||||
});
|
||||
|
||||
it('should preserve slot attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test slot="header">Test</umb-test>');
|
||||
expect(markup).to.include('slot="header"');
|
||||
});
|
||||
|
||||
it('should preserve label attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button label="Submit">Click</uui-button>');
|
||||
expect(markup).to.include('label="Submit"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard HTML sanitization', () => {
|
||||
it('should strip script tags', async () => {
|
||||
const markup = await UmbMarked.parseInline('<script>alert(1)</script>');
|
||||
expect(markup).to.not.include('<script');
|
||||
});
|
||||
|
||||
it('should allow standard markdown bold', async () => {
|
||||
const markup = await UmbMarked.parseInline('**bold text**');
|
||||
expect(markup).to.include('<strong>bold text</strong>');
|
||||
});
|
||||
|
||||
it('should allow standard markdown links', async () => {
|
||||
const markup = await UmbMarked.parseInline('[link](https://example.com)');
|
||||
expect(markup).to.include('<a');
|
||||
expect(markup).to.include('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('should strip non-allowed custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<custom-evil onclick="alert(1)">Test</custom-evil>');
|
||||
expect(markup).to.not.include('onclick');
|
||||
expect(markup).to.not.include('<custom-evil');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const UmbDomPurifyConfig: DOMPurifyConfig = {
|
||||
USE_PROFILES: { html: true },
|
||||
CUSTOM_ELEMENT_HANDLING: {
|
||||
tagNameCheck: /^(?:ufm|umb|uui)-.*$/,
|
||||
attributeNameCheck: /.+/,
|
||||
attributeNameCheck: /^(?!on)/,
|
||||
allowCustomizedBuiltInElements: false,
|
||||
},
|
||||
};
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "^2.0.40",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.55",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.60",
|
||||
"camelize": "^1.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"node-fetch": "^2.6.7"
|
||||
@@ -67,9 +67,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@umbraco/playwright-testhelpers": {
|
||||
"version": "16.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.55.tgz",
|
||||
"integrity": "sha512-715l112FHB7snWq0sY7e0fUD2ppWSSysBKHFhcQkGGw+3Gbo68Z6iXfeAketzKohWji19un4KC3mvZU0IICr9g==",
|
||||
"version": "16.0.60",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.60.tgz",
|
||||
"integrity": "sha512-/6CS4YtsNN3vtahaG35xj5BNYlJMcIPKdiUBrmilWCYAFzxIT9u6EegWBtua46bQxcdeqQgUvIAghsNvDPEk1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "2.0.40",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "^2.0.40",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.55",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.60",
|
||||
"camelize": "^1.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"node-fetch": "^2.6.7"
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
|
||||
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
|
||||
});
|
||||
|
||||
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
await umbracoApi.document.ensureNameNotExists(secondContentName);
|
||||
await umbracoApi.document.createDefaultDocumentWithABlockGridEditorAndBlockWithValue(contentName, documentTypeName, blockGridDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, richTextDataTypeUiAlias);
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
|
||||
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
|
||||
});
|
||||
|
||||
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
await umbracoApi.document.ensureNameNotExists(secondContentName);
|
||||
await umbracoApi.document.createDefaultDocumentWithABlockListEditorAndBlockWithValue(contentName, documentTypeName, blockListDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, elementDataTypeUiAlias, groupName);
|
||||
|
||||
@@ -234,8 +234,8 @@ test('can duplicate a content node to other parent', async ({umbracoApi, umbraco
|
||||
await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.duplicated);
|
||||
await umbracoUi.content.isContentInTreeVisible(contentName);
|
||||
await umbracoUi.content.isContentInTreeVisible(parentContentName);
|
||||
await umbracoUi.content.openContentCaretButtonForName(parentContentName);
|
||||
await umbracoUi.content.isChildContentInTreeVisible(parentContentName, contentName);
|
||||
await umbracoUi.content.goToContentWithName(parentContentName);
|
||||
await umbracoUi.content.isContentWithNameVisibleInList(contentName);
|
||||
|
||||
// Clean
|
||||
await umbracoApi.document.ensureNameNotExists(parentContentName);
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ test('can publish content with the true/false data type', async ({umbracoApi, um
|
||||
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([]);
|
||||
expect(contentData.values[0].value).toEqual(false);
|
||||
});
|
||||
|
||||
test('can toggle the true/false value in the content', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
|
||||
|
||||
+7
-7
@@ -51,7 +51,7 @@ test('can create content using an invariant document blueprint', async ({umbraco
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -75,7 +75,7 @@ test('can create content using a variant document blueprint', async ({umbracoApi
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
await umbracoUi.content.clickSaveButton();
|
||||
|
||||
@@ -104,7 +104,7 @@ test('can create content with different name using an invariant document bluepri
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.enterContentName(contentName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
@@ -130,7 +130,7 @@ test('can create content with different name using a variant document blueprint'
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.enterContentName(contentName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
await umbracoUi.content.clickSaveButton();
|
||||
@@ -161,7 +161,7 @@ test('can create content using a document blueprint with block list', async ({um
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -187,7 +187,7 @@ test('can create content using a document blueprint with block grid', async ({um
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -197,4 +197,4 @@ test('can create content using a document blueprint with block grid', async ({um
|
||||
expect(contentData.values[0].value.contentData[0].values[0].value.markup).toEqual(textContent);
|
||||
const blockListValue = contentData.values.find(item => item.editorAlias === "Umbraco.BlockGrid")?.value;
|
||||
expect(blockListValue).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -275,7 +275,7 @@ test('max can not be less than min in a block grid editor', async ({umbracoApi,
|
||||
|
||||
// Assert
|
||||
await umbracoUi.dataType.isFailedStateButtonVisible();
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not be exceed the high value');
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not exceed the high value.');
|
||||
const dataTypeData = await umbracoApi.dataType.getByName(blockGridEditorName);
|
||||
expect(dataTypeData.values[0].value.min).toBe(minAmount);
|
||||
// The max value should not be updated
|
||||
|
||||
+1
-1
@@ -173,7 +173,7 @@ test('max can not be less than min', async ({umbracoApi, umbracoUi}) => {
|
||||
// Assert
|
||||
await umbracoUi.dataType.isFailedStateButtonVisible();
|
||||
const dataTypeData = await umbracoApi.dataType.getByName(blockListEditorName);
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not be exceed the high value');
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not exceed the high value.');
|
||||
expect(dataTypeData.values[0].value.min).toBe(minAmount);
|
||||
// The max value should not be updated
|
||||
expect(dataTypeData.values[0].value.max).toBe(oldMaxAmount);
|
||||
|
||||
@@ -73,6 +73,7 @@ for (const mediaFileType of mediaFileTypes) {
|
||||
|
||||
// Assert
|
||||
await umbracoUi.media.waitForMediaItemToBeCreated();
|
||||
await umbracoUi.media.goToSection(ConstantHelper.sections.media);
|
||||
const mediaData = await umbracoApi.media.getByName(mediaFileType.fileName);
|
||||
const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaData.id);
|
||||
await umbracoUi.media.doesMediaHaveThumbnail(mediaData.id, mediaFileType.thumbnail, mediaUrl);
|
||||
|
||||
@@ -14,7 +14,7 @@ test.afterEach(async ({umbracoApi}) => {
|
||||
await umbracoApi.webhook.ensureNameNotExists(webhookName);
|
||||
});
|
||||
|
||||
test('can create a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
|
||||
test('can create a webhook', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
const event = 'Content Deleted';
|
||||
const webhookSiteUrl = umbracoApi.webhook.webhookSiteUrl + webhookSiteToken;
|
||||
@@ -122,7 +122,7 @@ test('can disable a webhook', async ({umbracoApi, umbracoUi}) => {
|
||||
await umbracoApi.webhook.isWebhookEnabled(webhookName, false);
|
||||
});
|
||||
|
||||
test('cannot remove all events from a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
|
||||
test('cannot remove all events from a webhook', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
const event = 'Content Deleted';
|
||||
await umbracoApi.webhook.createDefaultWebhook(webhookName, webhookSiteToken, event);
|
||||
@@ -173,7 +173,7 @@ test('can remove a header from a webhook', async ({umbracoApi, umbracoUi}) => {
|
||||
expect(await umbracoApi.webhook.doesWebhookHaveHeader(webhookName, headerName, headerValue)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('cannot add both content event and media event for a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
|
||||
test('cannot add both content event and media event for a webhook', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
const event = 'Content Published';
|
||||
await umbracoApi.webhook.createDefaultWebhook(webhookName, webhookSiteToken, event);
|
||||
@@ -185,4 +185,4 @@ test('cannot add both content event and media event for a webhook', {tag: '@rele
|
||||
// Assert
|
||||
await umbracoUi.webhook.isModalMenuItemWithNameDisabled('Media Saved');
|
||||
await umbracoUi.webhook.isModalMenuItemWithNameDisabled('Media Deleted');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Media.EmbedProviders;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
|
||||
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.None)]
|
||||
internal sealed class OEmbedServiceTests : UmbracoIntegrationTest
|
||||
{
|
||||
private IOEmbedService OEmbedService => GetRequiredService<IOEmbedService>();
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
base.CustomTestSetup(builder);
|
||||
|
||||
// Clear all providers and add only the X provider
|
||||
builder.EmbedProviders().Clear().Append<X>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies resolution to https://github.com/umbraco/Umbraco-CMS/issues/21052.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests marked as [Explicit] as we don't want a random external service call to X to fail during regular test runs.
|
||||
/// </remarks>
|
||||
[Explicit]
|
||||
[TestCase("https://x.com/THR/status/1995620384344080849?s=20")]
|
||||
[TestCase("https://x.com/SquareEnix/status/1995780120888705216?s=20")]
|
||||
[TestCase("https://x.com/sem_sep/status/1991750339427700739?s=20")]
|
||||
public async Task GetMarkupAsync_WithXUrls_ReturnsSuccessAndMarkup(string url)
|
||||
{
|
||||
// Arrange
|
||||
var uri = new Uri(url);
|
||||
|
||||
// Act
|
||||
var result = await OEmbedService.GetMarkupAsync(uri, width: null, height: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(result.Status, Is.EqualTo(OEmbedOperationStatus.Success));
|
||||
Assert.That(result.Result, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(result.Result, Does.Contain("blockquote"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -415,6 +415,7 @@ internal sealed class LocksTests : UmbracoIntegrationTest
|
||||
}
|
||||
|
||||
[Test]
|
||||
[LongRunning]
|
||||
public void Throws_When_Lock_Timeout_Is_Exceeded_Read()
|
||||
{
|
||||
if (BaseTestDatabase.IsSqlite())
|
||||
@@ -423,38 +424,57 @@ internal sealed class LocksTests : UmbracoIntegrationTest
|
||||
Assert.Ignore("Doesn't apply to SQLite with journal_mode=wal");
|
||||
}
|
||||
|
||||
var counter = 0;
|
||||
var gate = new ManualResetEventSlim(false);
|
||||
var logger = GetRequiredService<ILogger<LocksTests>>();
|
||||
|
||||
using (ExecutionContext.SuppressFlow())
|
||||
{
|
||||
var t1 = Task.Run(() =>
|
||||
{
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
Console.WriteLine("Write lock A");
|
||||
// This will acquire right away
|
||||
scope.EagerWriteLock(TimeSpan.FromMilliseconds(2000), Constants.Locks.ContentTree);
|
||||
Thread.Sleep(6000); // Wait longer than the Read Lock B timeout
|
||||
scope.Complete();
|
||||
Console.WriteLine("Finished Write lock A");
|
||||
}
|
||||
});
|
||||
using var scope = ScopeProvider.CreateScope();
|
||||
|
||||
Thread.Sleep(500); // 100% sure task 1 starts first
|
||||
_ = scope.Database; // Begin transaction
|
||||
Interlocked.Increment(ref counter);
|
||||
gate.Wait();
|
||||
|
||||
logger.LogInformation("t1 - Attempting to acquire write lock");
|
||||
// This will acquire right away
|
||||
scope.EagerWriteLock(TimeSpan.FromMilliseconds(2000), Constants.Locks.ContentTree);
|
||||
|
||||
logger.LogInformation("t1 - Acquired write lock, sleeping");
|
||||
Thread.Sleep(6000); // Wait longer than the Read Lock B timeout
|
||||
|
||||
scope.Complete();
|
||||
logger.LogInformation("t1 - Complete transaction");
|
||||
});
|
||||
|
||||
var t2 = Task.Run(() =>
|
||||
{
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
Console.WriteLine("Read lock B");
|
||||
using var scope = ScopeProvider.CreateScope();
|
||||
|
||||
// This will wait for the write lock to release but it isn't going to wait long
|
||||
// enough so an exception will be thrown.
|
||||
Assert.Throws<DistributedReadLockTimeoutException>(() =>
|
||||
scope.EagerReadLock(TimeSpan.FromMilliseconds(3000), Constants.Locks.ContentTree));
|
||||
scope.Complete();
|
||||
Console.WriteLine("Finished Read lock B");
|
||||
}
|
||||
_ = scope.Database; // Begin transaction
|
||||
Interlocked.Increment(ref counter);
|
||||
gate.Wait();
|
||||
Thread.Sleep(100); // Let other transaction obtain write lock first.
|
||||
|
||||
logger.LogInformation("t2 - Attempting to acquire read lock");
|
||||
|
||||
// This will wait for the write lock to release but it isn't going to wait long
|
||||
// enough so an exception will be thrown.
|
||||
Assert.Throws<DistributedReadLockTimeoutException>(() =>
|
||||
scope.EagerReadLock(TimeSpan.FromMilliseconds(3000), Constants.Locks.ContentTree));
|
||||
|
||||
scope.Complete();
|
||||
logger.LogInformation("t2 - Finished read lock attempt");
|
||||
});
|
||||
|
||||
while (counter < 2)
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
gate.Set();
|
||||
Task.WaitAll(t1, t2);
|
||||
}
|
||||
}
|
||||
|
||||
+396
@@ -14,6 +14,7 @@ using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Tests.Common.Attributes;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Attributes;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping;
|
||||
|
||||
@@ -44,6 +45,9 @@ internal sealed class ContentTypeServiceVariantsTests : UmbracoIntegrationTest
|
||||
});
|
||||
}
|
||||
|
||||
public static void ConfigureAllowEditInvariantFromNonDefaultTrue(IUmbracoBuilder builder)
|
||||
=> builder.Services.Configure<ContentSettings>(config => config.AllowEditInvariantFromNonDefault = true);
|
||||
|
||||
private void AssertJsonStartsWith(int id, string expected)
|
||||
{
|
||||
var json = GetJson(id).Replace('"', '\'');
|
||||
@@ -1310,4 +1314,396 @@ internal sealed class ContentTypeServiceVariantsTests : UmbracoIntegrationTest
|
||||
|
||||
return propertyCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from invariant to variant does not throw an exception
|
||||
/// when content exists only in a non-default language.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[TestCase(ContentVariation.Nothing, ContentVariation.Culture)]
|
||||
[TestCase(ContentVariation.Nothing, ContentVariation.CultureAndSegment)]
|
||||
[TestCase(ContentVariation.Segment, ContentVariation.Culture)]
|
||||
[TestCase(ContentVariation.Segment, ContentVariation.CultureAndSegment)]
|
||||
public async Task Change_Property_Type_From_Invariant_To_Variant_When_Content_Only_Exists_In_Non_Default_Language(
|
||||
ContentVariation invariant, ContentVariation variant)
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with an invariant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture | ContentVariation.Segment);
|
||||
var properties = CreatePropertyCollection(("title", invariant));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
// This is the key scenario - no content exists in the default language (English)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "hello world"); // invariant property
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en")); // No English version exists
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act - Change the property type from invariant to variant
|
||||
// This should NOT throw a PanicException even though content only exists in non-default language
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = variant;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
// The invariant value should be migrated to the default language
|
||||
Assert.AreEqual("hello world", document.GetValue("title", "en"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from variant to invariant does not throw an exception
|
||||
/// when content exists only in a non-default language.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[TestCase(ContentVariation.Culture, ContentVariation.Nothing)]
|
||||
[TestCase(ContentVariation.Culture, ContentVariation.Segment)]
|
||||
[TestCase(ContentVariation.CultureAndSegment, ContentVariation.Nothing)]
|
||||
[TestCase(ContentVariation.CultureAndSegment, ContentVariation.Segment)]
|
||||
public async Task Change_Property_Type_From_Variant_To_Invariant_When_Content_Only_Exists_In_Non_Default_Language(
|
||||
ContentVariation variant, ContentVariation invariant)
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with a variant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture | ContentVariation.Segment);
|
||||
var properties = CreatePropertyCollection(("title", variant));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "bonjour monde", "fr"); // variant property value in French only
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en")); // No English version exists
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("bonjour monde", document.GetValue("title", "fr"));
|
||||
Assert.IsNull(document.GetValue("title", "en")); // No English value
|
||||
|
||||
// Act - Change the property type from variant to invariant
|
||||
// This should NOT throw a PanicException even though content only exists in non-default language
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = invariant;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
// The variant value from the default language should be used (which was null/empty),
|
||||
// or the French value depending on implementation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from invariant to variant and back does not throw an exception
|
||||
/// when content exists only in a non-default language.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Change_Property_Type_Invariant_To_Variant_And_Back_When_Content_Only_Exists_In_Non_Default_Language()
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with an invariant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture);
|
||||
var properties = CreatePropertyCollection(("title", ContentVariation.Nothing));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "hello world"); // invariant property
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en"));
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act 1 - Change property from invariant to variant
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = ContentVariation.Culture;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("hello world", document.GetValue("title", "en")); // Migrated to default language
|
||||
|
||||
// Act 2 - Change property back from variant to invariant
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = ContentVariation.Nothing;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a content type from invariant to variant does not throw an exception
|
||||
/// when content exists only in a non-default language.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Change_Content_Type_From_Invariant_To_Variant_When_Content_Only_Exists_In_Non_Default_Language()
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create an invariant content type first
|
||||
var contentType = CreateContentType(ContentVariation.Nothing);
|
||||
var properties = CreatePropertyCollection(("title", ContentVariation.Nothing));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create invariant content
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.Name = "doc1";
|
||||
document.SetValue("title", "hello world");
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, Array.Empty<string>());
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.AreEqual("doc1", document.Name);
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act - Change content type from invariant to variant
|
||||
// This changes the content to be culture-variant, with the invariant data
|
||||
// migrated to the default language
|
||||
contentType.Variations = ContentVariation.Culture;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should be accessible in the default language
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1", document.GetCultureName("en"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from invariant to variant does not throw an exception
|
||||
/// when content exists only in a non-default language with AllowEditInvariantFromNonDefault enabled.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[TestCase(ContentVariation.Nothing, ContentVariation.Culture)]
|
||||
[TestCase(ContentVariation.Nothing, ContentVariation.CultureAndSegment)]
|
||||
[TestCase(ContentVariation.Segment, ContentVariation.Culture)]
|
||||
[TestCase(ContentVariation.Segment, ContentVariation.CultureAndSegment)]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
|
||||
public async Task Change_Property_Type_From_Invariant_To_Variant_When_Content_Only_Exists_In_Non_Default_Language_With_AllowEditInvariantFromNonDefault(
|
||||
ContentVariation invariant, ContentVariation variant)
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with an invariant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture | ContentVariation.Segment);
|
||||
var properties = CreatePropertyCollection(("title", invariant));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
// This is the key scenario - no content exists in the default language (English)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "hello world"); // invariant property
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en")); // No English version exists
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act - Change the property type from invariant to variant
|
||||
// This should NOT throw a PanicException even though content only exists in non-default language
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = variant;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
// The invariant value should be migrated to the default language
|
||||
Assert.AreEqual("hello world", document.GetValue("title", "en"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from variant to invariant does not throw an exception
|
||||
/// when content exists only in a non-default language with AllowEditInvariantFromNonDefault enabled.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[TestCase(ContentVariation.Culture, ContentVariation.Nothing)]
|
||||
[TestCase(ContentVariation.Culture, ContentVariation.Segment)]
|
||||
[TestCase(ContentVariation.CultureAndSegment, ContentVariation.Nothing)]
|
||||
[TestCase(ContentVariation.CultureAndSegment, ContentVariation.Segment)]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
|
||||
public async Task Change_Property_Type_From_Variant_To_Invariant_When_Content_Only_Exists_In_Non_Default_Language_With_AllowEditInvariantFromNonDefault(
|
||||
ContentVariation variant, ContentVariation invariant)
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with a variant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture | ContentVariation.Segment);
|
||||
var properties = CreatePropertyCollection(("title", variant));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "bonjour monde", "fr"); // variant property value in French only
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en")); // No English version exists
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("bonjour monde", document.GetValue("title", "fr"));
|
||||
Assert.IsNull(document.GetValue("title", "en")); // No English value
|
||||
|
||||
// Act - Change the property type from variant to invariant
|
||||
// This should NOT throw a PanicException even though content only exists in non-default language
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = invariant;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
// The variant value from the default language should be used (which was null/empty),
|
||||
// or the French value depending on implementation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a property from invariant to variant and back does not throw an exception
|
||||
/// when content exists only in a non-default language with AllowEditInvariantFromNonDefault enabled.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[Test]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
|
||||
public async Task Change_Property_Type_Invariant_To_Variant_And_Back_When_Content_Only_Exists_In_Non_Default_Language_With_AllowEditInvariantFromNonDefault()
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create a content type that varies by culture with an invariant property
|
||||
var contentType = CreateContentType(ContentVariation.Culture);
|
||||
var properties = CreatePropertyCollection(("title", ContentVariation.Nothing));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create content ONLY in the non-default language (French)
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.SetCultureName("doc1fr", "fr");
|
||||
document.SetValue("title", "hello world"); // invariant property
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, new[] { "fr" }); // Only publish in French
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNull(document.GetCultureName("en"));
|
||||
Assert.AreEqual("doc1fr", document.GetCultureName("fr"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act 1 - Change property from invariant to variant
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = ContentVariation.Culture;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("hello world", document.GetValue("title", "en")); // Migrated to default language
|
||||
|
||||
// Act 2 - Change property back from variant to invariant
|
||||
contentType.PropertyTypes.First(x => x.Alias == "title").Variations = ContentVariation.Nothing;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should still be accessible
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that changing a content type from invariant to variant does not throw an exception
|
||||
/// when content exists only in a non-default language with AllowEditInvariantFromNonDefault enabled.
|
||||
/// This is a regression test for https://github.com/umbraco/Umbraco-CMS/issues/11771
|
||||
/// </summary>
|
||||
[Test]
|
||||
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
|
||||
public async Task Change_Content_Type_From_Invariant_To_Variant_When_Content_Only_Exists_In_Non_Default_Language_With_AllowEditInvariantFromNonDefault()
|
||||
{
|
||||
// Arrange - Create languages with English as default and French as non-default
|
||||
var languageEn = new Language("en", "English") { IsDefault = true };
|
||||
await LanguageService.CreateAsync(languageEn, Constants.Security.SuperUserKey);
|
||||
var languageFr = new Language("fr", "French");
|
||||
await LanguageService.CreateAsync(languageFr, Constants.Security.SuperUserKey);
|
||||
|
||||
// Create an invariant content type first
|
||||
var contentType = CreateContentType(ContentVariation.Nothing);
|
||||
var properties = CreatePropertyCollection(("title", ContentVariation.Nothing));
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(properties) { Alias = "content", Name = "Content" });
|
||||
ContentTypeService.Save(contentType);
|
||||
|
||||
// Create invariant content
|
||||
IContent document = new Content("document", -1, contentType);
|
||||
document.Name = "doc1";
|
||||
document.SetValue("title", "hello world");
|
||||
ContentService.Save(document);
|
||||
ContentService.Publish(document, Array.Empty<string>());
|
||||
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.AreEqual("doc1", document.Name);
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
|
||||
// Act - Change content type from invariant to variant
|
||||
// This changes the content to be culture-variant, with the invariant data
|
||||
// migrated to the default language
|
||||
contentType.Variations = ContentVariation.Culture;
|
||||
Assert.DoesNotThrow(() => ContentTypeService.Save(contentType));
|
||||
|
||||
// Assert - Content should be accessible in the default language
|
||||
document = ContentService.GetById(document.Id);
|
||||
Assert.IsNotNull(document);
|
||||
Assert.AreEqual("doc1", document.GetCultureName("en"));
|
||||
Assert.AreEqual("hello world", document.GetValue("title"));
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Security;
|
||||
|
||||
[TestFixture]
|
||||
public class UserGroupAssignmentAuthorizationTests
|
||||
{
|
||||
[Test]
|
||||
public void Returns_Empty_When_No_Groups_Are_Being_Added()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: ["editor"],
|
||||
existingGroupAliases: ["editor"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Added_Groups_Belong_To_Performer()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor", "writer"],
|
||||
requestedGroupAliases: ["editor", "writer"],
|
||||
existingGroupAliases: ["editor"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Unauthorized_Groups_Not_Belonging_To_Performer()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: ["editor", "admin"],
|
||||
existingGroupAliases: ["editor"]);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("admin", result[0]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Existing_Groups_Are_Not_Flagged_Even_If_Performer_Does_Not_Belong()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: ["writer", "editor"],
|
||||
existingGroupAliases: ["writer"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_All_Unauthorized_When_Multiple_New_Groups_Fail()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: ["editor", "admin", "sensitiveData"],
|
||||
existingGroupAliases: []);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
CollectionAssert.AreEquivalent(new[] { "admin", "sensitiveData" }, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_All_Requested_Groups_Are_Removals()
|
||||
{
|
||||
// Requesting fewer groups than existing = only removals, no additions
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: ["editor"],
|
||||
existingGroupAliases: ["editor", "writer", "admin"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Comparison_Is_Case_Insensitive()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["Editor"],
|
||||
requestedGroupAliases: ["editor", "Writer"],
|
||||
existingGroupAliases: ["WRITER"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_No_Groups_Requested()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: ["editor"],
|
||||
requestedGroupAliases: [],
|
||||
existingGroupAliases: ["editor", "writer"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Performer_Has_No_Groups_But_Nothing_Added()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: [],
|
||||
requestedGroupAliases: ["editor"],
|
||||
existingGroupAliases: ["editor"]);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Unauthorized_When_Performer_Has_No_Groups_And_Groups_Added()
|
||||
{
|
||||
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
|
||||
performingUserGroupAliases: [],
|
||||
requestedGroupAliases: ["editor"],
|
||||
existingGroupAliases: []);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("editor", result[0]);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,276 @@ public class UserGroupServiceTests
|
||||
Assert.AreEqual(status, updateAttempt.Status);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_Admin_Can_Assign_Any_Groups()
|
||||
{
|
||||
// Arrange - admin performing user assigning a group they don't belong to (sensitive)
|
||||
// This verifies the admin bypass: without it, assigning "sensitive" would be unauthorized.
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var sensitiveGroupKey = Constants.Security.SensitiveDataGroupKey;
|
||||
var adminGroupKey = Constants.Security.AdminGroupKey;
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: [Constants.Security.AdminGroupAlias],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["editor"],
|
||||
requestedGroups: [(adminGroupKey, Constants.Security.AdminGroupAlias), (sensitiveGroupKey, "sensitive")]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { adminGroupKey, sensitiveGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Add_Groups_They_Belong_To()
|
||||
{
|
||||
// Arrange - Editor performing user adding Editor group to a Writer user
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var writerGroupKey = Guid.NewGuid();
|
||||
var editorGroupKey = Guid.NewGuid();
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: ["editor"],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["writer"],
|
||||
requestedGroups: [(writerGroupKey, "writer"), (editorGroupKey, "editor")]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { writerGroupKey, editorGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_NonAdmin_Cannot_Add_Groups_They_Do_Not_Belong_To()
|
||||
{
|
||||
// Arrange - Editor performing user trying to add Admin group to an Editor user
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var editorGroupKey = Guid.NewGuid();
|
||||
var adminGroupKey = Constants.Security.AdminGroupKey;
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: ["editor"],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["editor"],
|
||||
requestedGroups: [(editorGroupKey, "editor"), (adminGroupKey, Constants.Security.AdminGroupAlias)]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { editorGroupKey, adminGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Unauthorized, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Keep_Existing_Groups_They_Do_Not_Belong_To()
|
||||
{
|
||||
// Arrange - Editor user keeping Writer+Translator (existing) and adding Editor (own group)
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var writerGroupKey = Guid.NewGuid();
|
||||
var translatorGroupKey = Guid.NewGuid();
|
||||
var editorGroupKey = Guid.NewGuid();
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: ["editor"],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["writer", "translator"],
|
||||
requestedGroups: [(writerGroupKey, "writer"), (translatorGroupKey, "translator"), (editorGroupKey, "editor")]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { writerGroupKey, translatorGroupKey, editorGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Remove_Groups()
|
||||
{
|
||||
// Arrange - Editor user removing Writer group from a user that has both Writer and Editor
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var editorGroupKey = Guid.NewGuid();
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: ["editor"],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["writer", "editor"],
|
||||
requestedGroups: [(editorGroupKey, "editor")]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { editorGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_NonAdmin_Cannot_Escalate_Via_Replacement()
|
||||
{
|
||||
// Arrange - Editor user replacing their Editor group with Admin group
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var adminGroupKey = Constants.Security.AdminGroupKey;
|
||||
|
||||
var service = SetupUserGroupServiceForUpdateUserGroups(
|
||||
performingUserKey,
|
||||
performingUserGroupAliases: ["editor"],
|
||||
targetUserKey,
|
||||
targetUserCurrentGroupAliases: ["editor"],
|
||||
requestedGroups: [(adminGroupKey, Constants.Security.AdminGroupAlias)]);
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { adminGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.Unauthorized, result.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateUserGroupsOnUsers_Missing_Performing_User_Returns_MissingUser()
|
||||
{
|
||||
// Arrange - performing user key that doesn't resolve to a user
|
||||
var performingUserKey = Guid.NewGuid();
|
||||
var targetUserKey = Guid.NewGuid();
|
||||
var editorGroupKey = Guid.NewGuid();
|
||||
|
||||
var scope = new Mock<ICoreScope>();
|
||||
var provider = new Mock<ICoreScopeProvider>();
|
||||
provider.Setup(p => p.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher?>(),
|
||||
It.IsAny<IScopedNotificationPublisher?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()))
|
||||
.Returns(scope.Object);
|
||||
|
||||
var query = new Mock<IQuery<IUserGroup>>();
|
||||
query.Setup(q => q.Where(It.IsAny<Expression<Func<IUserGroup, bool>>>())).Returns(query.Object);
|
||||
provider.Setup(p => p.CreateQuery<IUserGroup>()).Returns(query.Object);
|
||||
|
||||
var targetUser = SetupUserWithGroupAccess(targetUserKey, ["editor"]);
|
||||
var userService = new Mock<IUserService>();
|
||||
// Performing user not found
|
||||
userService.Setup(s => s.GetAsync(performingUserKey)).Returns(Task.FromResult<IUser?>(null));
|
||||
userService.Setup(s => s.GetAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||
.Returns(Task.FromResult<IEnumerable<IUser>>(new[] { targetUser.Object }));
|
||||
|
||||
var userGroupRepository = new Mock<IUserGroupRepository>();
|
||||
userGroupRepository
|
||||
.Setup(r => r.Get(It.IsAny<IQuery<IUserGroup>>()))
|
||||
.Returns(new[] { new UserGroup(Mock.Of<IShortStringHelper>(), 0, "editor", "Editor", null) { Key = editorGroupKey } });
|
||||
|
||||
var service = new UserGroupService(
|
||||
provider.Object,
|
||||
Mock.Of<ILoggerFactory>(),
|
||||
Mock.Of<IEventMessagesFactory>(),
|
||||
userGroupRepository.Object,
|
||||
Mock.Of<IUserGroupPermissionService>(),
|
||||
Mock.Of<IEntityService>(),
|
||||
userService.Object,
|
||||
Mock.Of<ILogger<UserGroupService>>());
|
||||
|
||||
// Act
|
||||
var result = await service.UpdateUserGroupsOnUsersAsync(
|
||||
new HashSet<Guid> { editorGroupKey },
|
||||
new HashSet<Guid> { targetUserKey },
|
||||
performingUserKey);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.MissingUser, result.Result);
|
||||
}
|
||||
|
||||
private UserGroupService SetupUserGroupServiceForUpdateUserGroups(
|
||||
Guid performingUserKey,
|
||||
string[] performingUserGroupAliases,
|
||||
Guid targetUserKey,
|
||||
string[] targetUserCurrentGroupAliases,
|
||||
(Guid Key, string Alias)[] requestedGroups)
|
||||
{
|
||||
var scope = new Mock<ICoreScope>();
|
||||
var provider = new Mock<ICoreScopeProvider>();
|
||||
provider.Setup(p => p.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher?>(),
|
||||
It.IsAny<IScopedNotificationPublisher?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()))
|
||||
.Returns(scope.Object);
|
||||
|
||||
var query = new Mock<IQuery<IUserGroup>>();
|
||||
query.Setup(q => q.Where(It.IsAny<Expression<Func<IUserGroup, bool>>>())).Returns(query.Object);
|
||||
provider.Setup(p => p.CreateQuery<IUserGroup>()).Returns(query.Object);
|
||||
|
||||
var performingUser = SetupUserWithGroupAccess(performingUserKey, performingUserGroupAliases);
|
||||
var targetUser = SetupUserWithGroupAccess(targetUserKey, targetUserCurrentGroupAliases);
|
||||
|
||||
var userService = new Mock<IUserService>();
|
||||
userService.Setup(s => s.GetAsync(performingUserKey)).Returns(Task.FromResult<IUser?>(performingUser.Object));
|
||||
userService.Setup(s => s.GetAsync(It.IsAny<IEnumerable<Guid>>()))
|
||||
.Returns(Task.FromResult<IEnumerable<IUser>>(new[] { targetUser.Object }));
|
||||
|
||||
IUserGroup[] userGroups = requestedGroups
|
||||
.Select(g => new UserGroup(Mock.Of<IShortStringHelper>(), 0, g.Alias, g.Alias, null) { Key = g.Key })
|
||||
.ToArray<IUserGroup>();
|
||||
|
||||
var userGroupRepository = new Mock<IUserGroupRepository>();
|
||||
userGroupRepository
|
||||
.Setup(r => r.Get(It.IsAny<IQuery<IUserGroup>>()))
|
||||
.Returns(userGroups);
|
||||
|
||||
return new UserGroupService(
|
||||
provider.Object,
|
||||
Mock.Of<ILoggerFactory>(),
|
||||
Mock.Of<IEventMessagesFactory>(),
|
||||
userGroupRepository.Object,
|
||||
Mock.Of<IUserGroupPermissionService>(),
|
||||
Mock.Of<IEntityService>(),
|
||||
userService.Object,
|
||||
Mock.Of<ILogger<UserGroupService>>());
|
||||
}
|
||||
|
||||
private IEnumerable<IReadOnlyUserGroup> CreateGroups(params string[] aliases)
|
||||
=> aliases.Select(alias =>
|
||||
{
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Examine;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Examine;
|
||||
|
||||
[TestFixture]
|
||||
internal class UmbracoFieldDefinitionCollectionTests
|
||||
{
|
||||
[Test]
|
||||
public void Create_Contains_Expected_Fields()
|
||||
{
|
||||
var collection = new UmbracoFieldDefinitionCollection();
|
||||
AssertDefaultField(collection);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_New_Contains_Expected_Fields()
|
||||
{
|
||||
var collection = new UmbracoFieldDefinitionCollection();
|
||||
collection.AddOrUpdate(new FieldDefinition("customField", "string"));
|
||||
var collectionCount = collection.Count;
|
||||
|
||||
collection = new UmbracoFieldDefinitionCollection();
|
||||
Assert.AreEqual(collectionCount - 1, collection.Count);
|
||||
AssertDefaultField(collection);
|
||||
AssertCustomField(collection, expectExists: false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_With_Existing_Contains_Expected_Fields()
|
||||
{
|
||||
var collection = new UmbracoFieldDefinitionCollection();
|
||||
collection.AddOrUpdate(new FieldDefinition("customField", "string"));
|
||||
var collectionCount = collection.Count;
|
||||
|
||||
collection = new UmbracoFieldDefinitionCollection(collection);
|
||||
Assert.AreEqual(collectionCount, collection.Count);
|
||||
AssertDefaultField(collection);
|
||||
AssertCustomField(collection, expectExists: true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_With_Existing_Retains_Override_Of_DefaultField()
|
||||
{
|
||||
var collection = new UmbracoFieldDefinitionCollection();
|
||||
collection.AddOrUpdate(new FieldDefinition("parentID", "string"));
|
||||
|
||||
collection = new UmbracoFieldDefinitionCollection(collection);
|
||||
AssertDefaultField(collection, "string");
|
||||
}
|
||||
|
||||
private static void AssertDefaultField(UmbracoFieldDefinitionCollection collection, string expectedType = "int")
|
||||
{
|
||||
var field = collection.SingleOrDefault(x => x.Name == "parentID");
|
||||
Assert.IsNotNull(field);
|
||||
Assert.AreEqual("parentID", field.Name);
|
||||
Assert.AreEqual(expectedType, field.Type);
|
||||
}
|
||||
|
||||
private static void AssertCustomField(UmbracoFieldDefinitionCollection collection, bool expectExists)
|
||||
{
|
||||
var field = collection.SingleOrDefault(x => x.Name == "customField");
|
||||
if (expectExists is false)
|
||||
{
|
||||
Assert.IsNull(field.Name);
|
||||
Assert.IsNull(field.Type);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.IsNotNull(field);
|
||||
Assert.AreEqual("customField", field.Name);
|
||||
Assert.AreEqual("string", field.Type);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
|
||||
"version": "16.4.0",
|
||||
"version": "16.6.0-rc",
|
||||
"assemblyVersion": {
|
||||
"precision": "build"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user