Compare commits

...
32 Commits
Author SHA1 Message Date
Nhu DinhandGitHub 9505011d71 Build: Updated nightly E2E test pipeline schedule in v16 (#22805)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:39 +07:00
Nhu DinhandGitHub 2579aaf2db Build: Cherry pick #22164 for V16 (#22171)
Serialize E2E stages and stagger branch schedules to reduce agent usage
2026-03-19 21:19:45 +07:00
Andy Butland 2b7784a226 Merge branch 'release/16.5.1' into v16/dev 2026-03-10 06:36:50 +01:00
8555a97b39 Merge commit from fork
* Add authorization checks for domain operations.

* Remove duplicate 403 ProducesResponseType attributes.

BackOfficeSecurityRequirementsOperationFilterBase already adds 403
responses for endpoints whose controllers inject IAuthorizationService.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-03-10 05:11:16 +01:00
Andy ButlandandGitHub c2dd685a4b Merge commit from fork 2026-03-10 05:10:31 +01:00
Andy ButlandandGitHub 66fc819379 Merge commit from fork
* Protect endpoint that sets user groups for a user collection to prevent elevation of permissions for users.

* Update tests from code review feedback.
2026-03-10 05:07:41 +01:00
Andy Butland 4f1f7e15c4 Bump version to 16.5.1. 2026-02-23 16:38:23 +01:00
Andy Butland a826c52e2e Merge branch 'release/16.5' into v16/dev and bumped version to 16.6.0-rc 2026-01-22 06:45:06 +01:00
Andy Butland 8b2c22aaf1 Merge branch 'release/16.5' of https://github.com/umbraco/Umbraco-CMS into release/16.5 2026-01-21 17:40:17 +01:00
Andy Butland aecfee4469 Bump version to 16.5.0. 2026-01-21 17:39:59 +01:00
Niels LyngsøandGitHub 9c785a9c5b Varying Compositions in Invariant Document Types, Cherrypick of #21267 (#21472)
* cherry picked a5a6d0645f

* correct to getVariesBySegment
2026-01-21 15:27:26 +00:00
Nhu DinhandGitHub 2fe10387ee E2E: V16 QA Update acceptance tests to use refactored UI helpers (#21271)
* Bumped version of test helper

* Bumped version

* Bumped version
2026-01-05 17:25:23 +07:00
8642b9e615 Content Types: Fix property variation change when content exists only in non-default language (#21182)
* Content Types: Fix property variation change when content exists only in non-default language (closes #11771)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add tests for AllowEditInvariantFromNonDefault enabled scenario

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:21:17 +01:00
Andreas ZerbstandGitHub 2a604c8719 E2E: QA Replaced unreliable Thread.Sleep(500) with a counter/gate pattern that ensures both transactions are initialized before releasing them to compete for locks (#21165)
Fix flaky test
2025-12-16 12:02:07 +00:00
9c0a0a1086 V16/drag event media (#20893)
* updates the drag event to convert types to lowercase

* clearing up

* clearing up

* Fixed style problem in safari and an unused parameter.

* Fixed lint error.

---------

Co-authored-by: engjlr <enl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2025-12-15 14:16:57 +00:00
Andy Butland 1a4256f997 Add option to hide colors from icon picker (#20650)
* Add option to hide colors from icon picker

* Hide colors

* Hide colors from config
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts
2025-12-11 06:57:29 +01:00
Andy Butland 80ae0380a2 oEmbed Providers: Updated the X oEmbed provider to use the x.com domain (closes #21052) (#21053)
* Updated the X oEmbed provider to use the x.com domain.

* Fixed issues raised in code review.
2025-12-05 10:55:09 +01:00
Andy Butland fd01282798 Merge branch 'release/16.4.1' into v16/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-12-05 06:44:51 +01:00
f7ba2eaa62 Property Editors: Hide "add button" when maximum configuration is 1 (fixes #20407) (#20738)
Hide add button when max 1

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2025-12-04 16:34:10 +01:00
9485a95c0e Image cropper modal import missing component (#20651)
* Import missing component

* Handle nullable type

* Vertically center image

* Add minimum width for SVG without dimensions

* 100% height until max height

* 100% height minus top/bottom padding

* Revert "100% height minus top/bottom padding"

This reverts commit 67ada4c70f4b75dfcfa2b54ce139ec7465a17ce1.

* Revert "Handle nullable type"

This reverts commit 3130e11a4be83a18b5a7d8c1c24ee23c94d8765d.

* Removed flexbox style

* Fixed circular dependency

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2025-12-04 16:32:10 +01:00
f1ab605bb9 Debug mode: Marks UMB-DEBUG cookie as HttpOnly and Secure (#21032)
* fix: sets profiling cookie to httpOnly and strict in order to run non-secure

* fix: adds extra message to explain when you can set a cookie

* fix: simplify cookie explanation comment in WebProfilerRepository

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: checks that the profiler is actually enabled and/or disabled and warns the user if that is not the case

* Update src/Umbraco.Web.UI.Client/src/assets/lang/en.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 10:24:12 +01:00
Andy Butland 3472ff9ba3 Bump version to 16.4.1. 2025-12-03 15:38:10 +01:00
577dc06d55 Delivery API: Only add default strategy if delivery API is not registered. (#20982)
* Only add if not already present

* Update src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2025-12-03 15:37:03 +01:00
f4771d1495 Delivery API: Only add default strategy if delivery API is not registered. (#20982)
* Only add if not already present

* Update src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2025-12-03 12:32:30 +01:00
Andy Butland 4b3ce53acf Merge branch 'release/16.4' into v16/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-11-26 07:18:09 +01:00
Jacob Overgaard 0543163817 bumps version to 16.5.0-rc 2025-11-24 16:29:11 +01:00
Jacob Overgaard 72f43a5821 Merge branch 'release/16.4' into v16/dev 2025-11-24 16:28:24 +01:00
Callum WhyteandZeegaan d7231c5435 Preserve existing Examine FieldDefinitionCollection if it already exists (#20931)
* Preserve existing Examine FieldDefinitionCollection if it already exists (#20267)

* Fix missing bracket

* Minor tidy/addition of comments; addition of unit tests.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit 908974c6ac)
2025-11-24 12:47:08 +09:00
Jacob Overgaard aed7505e4b Merge remote-tracking branch 'origin/release/16.4' into v16/dev 2025-11-17 16:39:38 +01:00
Jacob Overgaard 15c6ca7628 Merge remote-tracking branch 'origin/release/16.4' into v16/dev 2025-11-13 14:15:24 +01:00
Jacob Overgaard 4e74dbf218 Merge branch 'release/16.4' into v16/dev 2025-11-04 14:07:30 +01:00
Andreas ZerbstandGitHub 76fed82e91 E2E: QA cherry picked acceptance tests updates from 17 (#20714)
* Updated tests

* Bumped version

* Added v16 to nightly e2e run
2025-11-04 08:23:04 +01:00
55 changed files with 1566 additions and 171 deletions
+8 -7
View File
@@ -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);
@@ -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();
+1 -1
View File
@@ -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);
}
+18 -1
View File
@@ -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);
}
+11 -3
View File
@@ -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);
+51 -1
View File
@@ -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>
@@ -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
View File
@@ -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 -1
View File
@@ -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',
@@ -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()}
@@ -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) {
@@ -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;
}),
@@ -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"
@@ -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(
@@ -3,6 +3,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export interface UmbIconPickerModalData {
placeholder?: string;
showEmptyOption?: boolean;
hideColors?: boolean;
}
export interface UmbIconPickerModalValue {
@@ -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 {
@@ -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,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';
@@ -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%;
}
@@ -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);
}
@@ -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>,
@@ -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',
},
],
},
},
@@ -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
View File
@@ -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"
@@ -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);
@@ -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);
@@ -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}) => {
@@ -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();
});
});
@@ -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
@@ -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);
}
}
@@ -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"));
}
}
@@ -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 =>
{
@@ -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
View File
@@ -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"
},