Merge branch 'main' into v17/dev

# Conflicts:
#	src/Umbraco.Core/Services/PropertyValidationService.cs
#	src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs
#	src/Umbraco.Infrastructure/PublishedContentQuery.cs
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts
#	templates/UmbracoProject/.template.config/template.json
#	version.json
This commit is contained in:
Andy Butland
2025-09-24 23:31:14 +02:00
265 changed files with 5961 additions and 1546 deletions

View File

@@ -7,8 +7,8 @@
"name": "acceptancetest",
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.38",
"@umbraco/playwright-testhelpers": "^16.0.42",
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.47",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -58,21 +58,21 @@
}
},
"node_modules/@umbraco/json-models-builders": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.38.tgz",
"integrity": "sha512-6nC1Y1xn+8zyqU3iqHubRo18L53TdZkhHIY4z68VSLcA6YoAzdxtjw+zx7yDIMV+epoQ4NCG2ooAa0gBhHqQgg==",
"version": "2.0.40",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.40.tgz",
"integrity": "sha512-Yqojp/0akRgXsnjg18+MjMdkRvFrmlUNbfITgZ3d1h/PIRbWXPNKY1YAfZmdUv+g1SRSHrbIRpPPtSy+gNOjHw==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.1"
}
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "16.0.42",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.42.tgz",
"integrity": "sha512-ePKl8gtELoIMEV57E3N4VumfKNkuOTFo/LYH7ePhseCcm5oUh1Cc/RVqvlXYsdfBTiMfZ7x7Nu4lOSv15D2Z3Q==",
"version": "16.0.47",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.47.tgz",
"integrity": "sha512-N88UCvjqCwJMRCu5wUmW2xxPVqEMR0sKGDlUsko9EejvyyJBFSE00PRGyWo6lPuYxAy4LkkONwIWBATWiry7xg==",
"license": "MIT",
"dependencies": {
"@umbraco/json-models-builders": "2.0.38",
"@umbraco/json-models-builders": "2.0.40",
"node-fetch": "^2.6.7"
}
},

View File

@@ -21,8 +21,8 @@
"typescript": "^4.8.3"
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.38",
"@umbraco/playwright-testhelpers": "^16.0.42",
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.47",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"

View File

@@ -44,14 +44,42 @@ export default defineConfig({
testMatch: '**/*.setup.ts',
},
{
name: 'chromium',
name: 'defaultConfig',
testMatch: 'DefaultConfig/**',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
// Use prepared auth state.
ignoreHTTPSErrors: true,
storageState: STORAGE_STATE,
storageState: STORAGE_STATE
}
},
{
name: 'deliveryApi',
testMatch: 'DeliveryApi/**',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
// Use prepared auth state.
ignoreHTTPSErrors: true,
storageState: STORAGE_STATE
},
},
{
name: 'externalLoginAzureADB2C',
testMatch: 'ExternalLogin/AzureADB2C/**',
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true,
}
},
// This project is used to test the install steps, for that we do not need to authenticate.
{
name: 'unattendedInstallConfig',
testMatch: 'UnattendedInstallConfig/**',
use: {
...devices['Desktop Chrome']
}
}
],
});

View File

@@ -1,4 +1,4 @@
import {ConstantHelper, test} from '@umbraco/playwright-testhelpers';
import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers';
import {expect} from "@playwright/test";
const contentName = 'TestContent';
@@ -85,3 +85,54 @@ test('can publish content with RTE Tiptap property editor', async ({umbracoApi,
expect(contentData.variants[0].state).toBe(expectedState);
expect(contentData.values[0].value.markup).toEqual('<p>' + inputText + '</p>');
});
// This is a test for the regression issue #19763
test('can save a variant content node after removing embedded block in RTE', async ({umbracoApi, umbracoUi}) => {
// Arrange
// Language
const danishIsoCode = 'da';
await umbracoApi.language.createDanishLanguage();
// Content Names
const englishContentName = 'English Content';
const danishContentName = 'Danish Content';
// Element Type
const elementTypeName = 'Default Element Type';
const elementTypeGroupName = 'Content';
const elementTypeDataTypeName = 'Textstring';
const elementTypeDataType = await umbracoApi.dataType.getByName(elementTypeDataTypeName);
// Rich Text Editor
const richTextEditorDataTypeName = 'Rich Text Editor with a block';
const textStringValue = 'Block Content';
const elementTypeId = await umbracoApi.documentType.createDefaultElementTypeWithVaryByCulture(elementTypeName, elementTypeGroupName, elementTypeDataTypeName, elementTypeDataType.id, true, false);
const richTextEditorId = await umbracoApi.dataType.createRichTextEditorWithABlock(richTextEditorDataTypeName, elementTypeId);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, richTextEditorDataTypeName, richTextEditorId, 'TestGroup', true, false);
const cultures = [{isoCode: 'en-US', name: englishContentName}, {isoCode: 'da', name: danishContentName}];
await umbracoApi.document.createDocumentWithMultipleVariantsWithSharedProperty(contentName, documentTypeId, AliasHelper.toAlias(richTextEditorDataTypeName), 'Umbraco.RichText', cultures, '');
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName(englishContentName);
// Act
await umbracoUi.content.clickInsertBlockButton();
await umbracoUi.content.clickLinkWithName(elementTypeName);
await umbracoUi.content.enterTextstring(textStringValue);
await umbracoUi.content.clickCreateModalButton();
await umbracoUi.content.clickSaveButtonForContent();
await umbracoUi.content.clickSaveButton();
const contentData = await umbracoApi.document.getByName(englishContentName);
expect(contentData.values[0].value.blocks.contentData[0].values[0].value).toBe(textStringValue);
await umbracoUi.content.clearTipTapEditor();
await umbracoUi.content.clickSaveButtonForContent();
await umbracoUi.content.clickSaveButton();
// Assert
await umbracoUi.content.isErrorNotificationVisible(false);
await umbracoUi.content.waitForContentToBeCreated();
expect(await umbracoApi.document.doesNameExist(englishContentName)).toBeTruthy();
// Clean
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
await umbracoApi.documentType.ensureNameNotExists(elementTypeName);
await umbracoApi.dataType.ensureNameNotExists(richTextEditorDataTypeName);
await umbracoApi.language.ensureIsoCodeNotExists(danishIsoCode);
});

View File

@@ -248,7 +248,7 @@ test('can add a thumbnail to a block', {tag: '@smoke'}, async ({umbracoApi, umbr
const textStringData = await umbracoApi.dataType.getByName(dataTypeName);
const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id);
await umbracoApi.dataType.createBlockGridWithABlock(blockGridEditorName, contentElementTypeId);
const mediaUrl = await umbracoApi.media.getMediaUrl(mediaId);
const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaId);
// Act
await umbracoUi.dataType.goToDataType(blockGridEditorName);

View File

@@ -422,7 +422,7 @@ test('can add a thumbnail to a block', {tag: '@release'}, async ({umbracoApi, um
const textStringData = await umbracoApi.dataType.getByName(dataTypeName);
const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id);
await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId);
const mediaUrl = await umbracoApi.media.getMediaUrl(mediaId);
const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaId);
// Act
await umbracoUi.dataType.goToDataType(blockListEditorName);

View File

@@ -6,16 +6,37 @@ const tipTapPropertyEditorName = 'Rich Text Editor [Tiptap] Property Editor UI';
const tipTapAlias = 'Umbraco.RichText';
const tipTapUiAlias = 'Umb.PropertyEditorUi.Tiptap';
const extensionsDefaultValue = [
"Umb.Tiptap.RichTextEssentials",
"Umb.Tiptap.Anchor",
"Umb.Tiptap.Blockquote",
"Umb.Tiptap.Bold",
"Umb.Tiptap.BulletList",
"Umb.Tiptap.CodeBlock",
"Umb.Tiptap.Embed",
"Umb.Tiptap.Link",
"Umb.Tiptap.Figure",
"Umb.Tiptap.Heading",
"Umb.Tiptap.HorizontalRule",
"Umb.Tiptap.HtmlAttributeClass",
"Umb.Tiptap.HtmlAttributeDataset",
"Umb.Tiptap.HtmlAttributeId",
"Umb.Tiptap.HtmlAttributeStyle",
"Umb.Tiptap.HtmlTagDiv",
"Umb.Tiptap.HtmlTagSpan",
"Umb.Tiptap.Image",
"Umb.Tiptap.Italic",
"Umb.Tiptap.Link",
"Umb.Tiptap.MediaUpload",
"Umb.Tiptap.OrderedList",
"Umb.Tiptap.Strike",
"Umb.Tiptap.Subscript",
"Umb.Tiptap.Superscript",
"Umb.Tiptap.Table",
"Umb.Tiptap.Underline",
"Umb.Tiptap.TextAlign",
"Umb.Tiptap.MediaUpload"
"Umb.Tiptap.TextDirection",
"Umb.Tiptap.TextIndent",
"Umb.Tiptap.TrailingNode",
"Umb.Tiptap.Underline",
"Umb.Tiptap.WordCount"
];
const toolbarDefaultValue = [

View File

@@ -73,7 +73,7 @@ for (const mediaFileType of mediaFileTypes) {
// Assert
await umbracoUi.media.waitForMediaItemToBeCreated();
const mediaData = await umbracoApi.media.getByName(mediaFileType.fileName);
const mediaUrl = await umbracoApi.media.getMediaUrl(mediaData.id);
const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaData.id);
await umbracoUi.media.doesMediaHaveThumbnail(mediaData.id, mediaFileType.thumbnail, mediaUrl);
await umbracoUi.media.isMediaTreeItemVisible(mediaFileType.fileName);
expect(await umbracoApi.media.doesNameExist(mediaFileType.fileName)).toBeTruthy();

View File

@@ -0,0 +1,27 @@
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.CreateUmbracoBuilder()
.AddBackOffice()
.AddWebsite()
.AddDeliveryApi()
.AddComposers()
.Build();
WebApplication app = builder.Build();
await app.BootUmbracoAsync();
app.UseUmbraco()
.WithMiddleware(u =>
{
u.UseBackOffice();
u.UseWebsite();
})
.WithEndpoints(u =>
{
u.UseBackOfficeEndpoints();
u.UseWebsiteEndpoints();
});
await app.RunAsync();

View File

@@ -0,0 +1,64 @@
{
"$schema": "appsettings-schema.json",
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"Configure": [
{
"Name": "Console"
}
]
}
}
]
},
"Umbraco": {
"CMS": {
"DeliveryApi": {
"Enabled": true,
"Media": {
"Enabled": true
}
},
"Unattended": {
"InstallUnattended": true,
"UnattendedUserName": "Playwright Test",
"UnattendedUserEmail": "playwright@umbraco.com",
"UnattendedUserPassword": "UmbracoAcceptance123!"
},
"Content": {
"ContentVersionCleanupPolicy": {
"EnableCleanup": false
}
},
"Global": {
"DisableElectionForSingleServer": true,
"InstallMissingDatabase": true,
"Id": "00000000-0000-0000-0000-000000000042",
"VersionCheckPeriod": 0,
"UseHttps": true
},
"HealthChecks": {
"Notification": {
"Enabled": false
}
},
"KeepAlive": {
"DisableKeepAliveTask": true
},
"WebRouting": {
"UmbracoApplicationUrl": "https://localhost:44331/"
}
}
}
}

View File

@@ -0,0 +1,42 @@
import {expect} from '@playwright/test';
import {AliasHelper, test} from '@umbraco/playwright-testhelpers';
test('can get content from delivery api', async ({umbracoApi}) => {
// Arrange
const documentTypeName = 'TestDocumentType';
const contentName = 'TestContent';
const dataTypeName = 'Textstring';
const textStringValue = 'This is a test text string value';
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
const dataType = await umbracoApi.dataType.getByName(dataTypeName);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, 'TestGroup');
const documentId = await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, textStringValue, dataTypeName);
const propertyValue = {
dataTypeName: AliasHelper.toAlias(dataTypeName),
dataTypeValue: textStringValue
}
// Act
await umbracoApi.document.publish(documentId);
// Assert
expect(await umbracoApi.contentDeliveryApi.doesContentItemWithIdContainValues(documentId, contentName, AliasHelper.toAlias(documentTypeName), [propertyValue])).toBeTruthy();
// Clean
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can get media image from delivery api', async ({umbracoApi}) => {
// Arrange
const mediaName = 'TestMedia';
const mediaTypeName = 'File';
await umbracoApi.media.ensureNameNotExists(mediaName);
const mediaId = await umbracoApi.media.createDefaultMediaFile(mediaName);
const mediaUrl = await umbracoApi.media.getMediaUrlWithoutBaseUrl(mediaId);
// Assert
expect(await umbracoApi.mediaDeliveryApi.doesMediaItemWithIdContainValues(mediaId, mediaName, mediaTypeName, mediaUrl)).toBeTruthy();
// Clean
await umbracoApi.media.ensureNameNotExists(mediaName);
});

View File

@@ -0,0 +1,64 @@
{
"$schema": "appsettings-schema.json",
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"Configure": [
{
"Name": "Console"
}
]
}
}
]
},
"Umbraco": {
"CMS": {
"DeliveryApi": {
"Enabled": true,
"Media": {
"Enabled": true
}
},
"Unattended": {
"InstallUnattended": true,
"UnattendedUserName": "Playwright Test",
"UnattendedUserEmail": "playwright@umbraco.com",
"UnattendedUserPassword": "UmbracoAcceptance123!"
},
"Content": {
"ContentVersionCleanupPolicy": {
"EnableCleanup": false
}
},
"Global": {
"DisableElectionForSingleServer": true,
"InstallMissingDatabase": true,
"Id": "00000000-0000-0000-0000-000000000042",
"VersionCheckPeriod": 0,
"UseHttps": true
},
"HealthChecks": {
"Notification": {
"Enabled": false
}
},
"KeepAlive": {
"DisableKeepAliveTask": true
},
"WebRouting": {
"UmbracoApplicationUrl": "https://localhost:44331/"
}
}
}
}

View File

@@ -0,0 +1,25 @@
{
"$schema": "../../umbraco-package-schema.json",
"name": "Azure B2C Login",
"allowPublicAccess": true,
"extensions": [
{
"type": "authProvider",
"alias": "Test.AzureB2C",
"name": "Azure AD B2C",
"forProviderName": "Umbraco.AzureB2C",
"meta": {
"label": "Sign in with Azure AD B2C",
"defaultView": {
"icon": "icon-cloud"
},
"behavior": {
"autoRedirect": false
},
"linking": {
"allowManualLinking": true
}
}
}
]
}

View File

@@ -0,0 +1,73 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C
{
public static class AzureB2CAuthenticationExtensions
{
public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder,
IConfiguration configuration)
{
var b2cSettings = new AzureB2CSettings();
builder.AddBackOfficeExternalLogins(logins =>
{
const string schemeName = AzureB2COptions.SchemeName;
var backOfficeScheme = Constants.Security.BackOfficeExternalAuthenticationTypePrefix + schemeName;
logins.AddBackOfficeLogin(backOfficeAuth =>
{
backOfficeAuth.AddOpenIdConnect(backOfficeScheme, options =>
{
options.RequireHttpsMetadata = true;
options.SaveTokens = true;
options.ClientId = b2cSettings.ClientId;
options.ClientSecret = b2cSettings.ClientSecret;
options.CallbackPath = "/umbraco-b2c-users-signin";
options.MetadataAddress =
$"https://{b2cSettings.Domain}/{b2cSettings.Tenant}/{b2cSettings.Policy}/v2.0/.well-known/openid-configuration";
options.ResponseType = OpenIdConnectResponseType.Code;
options.TokenValidationParameters.SaveSigninToken = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
options.Events = new OpenIdConnectEvents
{
OnTokenResponseReceived = context =>
{
if (string.IsNullOrEmpty(context.TokenEndpointResponse.AccessToken))
{
context.TokenEndpointResponse.AccessToken = "empty_access_token";
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var identity = context.Principal!.Identities.First();
var email = identity.FindFirst("emails")?.Value
?? identity.FindFirst(ClaimTypes.Email)?.Value;
if (!string.IsNullOrWhiteSpace(email))
{
identity.AddClaim(new Claim(ClaimTypes.Email, email));
identity.AddClaim(new Claim("email", email));
}
return Task.CompletedTask;
}
};
});
});
});
return builder;
}
}
}

View File

@@ -0,0 +1,14 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C
{
public class AzureB2CComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.ConfigureOptions<AzureB2COptions>();
builder.ConfigureAuthentication(builder.Config);
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C
{
public class AzureB2COptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
public const string SchemeName = "AzureB2C";
public void Configure(string? name, BackOfficeExternalLoginProviderOptions options)
{
if (name != Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName)
return;
options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(
autoLinkExternalAccount: true,
defaultUserGroups: [Constants.Security.AdminGroupAlias],
defaultCulture: "en-US",
allowManualLinking: true
)
{
OnAutoLinking = (user, loginInfo) => { user.IsApproved = true; },
OnExternalLogin = (user, loginInfo) => { return true; }
};
}
public void Configure(BackOfficeExternalLoginProviderOptions options) =>
Configure(Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName, options);
}
}

View File

@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C
{
public class AzureB2CSettings
{
public string Domain { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CDOMAIN") ?? string.Empty;
public string Tenant { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CTENANT") ?? string.Empty;
public string Policy { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CPOLICY") ?? string.Empty;
public string ClientId { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CCLIENTID") ?? string.Empty;
public string ClientSecret { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CCLIENTSECRET") ?? string.Empty;
}
}

View File

@@ -0,0 +1,58 @@
{
"$schema": "appsettings-schema.json",
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"Configure": [
{
"Name": "Console"
}
]
}
}
]
},
"Umbraco": {
"CMS": {
"Unattended": {
"InstallUnattended": true,
"UnattendedUserName": "Playwright Test",
"UnattendedUserEmail": "playwright@umbraco.com",
"UnattendedUserPassword": "UmbracoAcceptance123!"
},
"Content": {
"ContentVersionCleanupPolicy": {
"EnableCleanup": false
}
},
"Global": {
"DisableElectionForSingleServer": true,
"InstallMissingDatabase": true,
"Id": "00000000-0000-0000-0000-000000000042",
"VersionCheckPeriod": 0,
"UseHttps": true
},
"HealthChecks": {
"Notification": {
"Enabled": false
}
},
"KeepAlive": {
"DisableKeepAliveTask": true
},
"WebRouting": {
"UmbracoApplicationUrl": "https://localhost:44331/"
}
}
}
}

View File

@@ -0,0 +1,20 @@
import {ConstantHelper, test} from '@umbraco/playwright-testhelpers';
const azureEmail = process.env.AZUREADB2CTESTUSEREMAIL;
const azurePassword = process.env.AZUREADB2CTESTUSERPASSWORD;
// Really simple test to check if we can log in using Azure AD B2C
test('Log in to Umbraco using Azure AD B2C', async ({umbracoUi}) => {
test.slow();
// Arrange
await umbracoUi.goToBackOffice();
// Act
await umbracoUi.externalLogin.clickSignInWithAzureADB2CButton();
await umbracoUi.externalLogin.enterAzureADB2CEmail(azureEmail);
await umbracoUi.externalLogin.enterAzureADB2CPassword(azurePassword);
await umbracoUi.externalLogin.clickSignInButton();
// Assert
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
});

View File

@@ -0,0 +1,54 @@
{
"$schema": "appsettings-schema.json",
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"Configure": [
{
"Name": "Console"
}
]
}
}
]
},
"Umbraco": {
"CMS": {
"Unattended": {
"InstallUnattended": false
},
"Content": {
"ContentVersionCleanupPolicy": {
"EnableCleanup": false
}
},
"Global": {
"DisableElectionForSingleServer": true,
"Id": "00000000-0000-0000-0000-000000000042",
"VersionCheckPeriod": 0,
"UseHttps": true
},
"HealthChecks": {
"Notification": {
"Enabled": false
}
},
"KeepAlive": {
"DisableKeepAliveTask": true
},
"WebRouting": {
"UmbracoApplicationUrl": "https://localhost:44331/"
}
}
}
}

View File

@@ -0,0 +1,28 @@
// To be able to test different databases, we need to set an additional UnattendedInstallConfig up because we would have to start from scratch, otherwise we would be using the same database.
import {ConstantHelper, test} from '@umbraco/playwright-testhelpers';
const name = 'TestName';
const email = process.env.UMBRACO_USER_LOGIN;
const password = process.env.UMBRACO_USER_PASSWORD;
test('Install Umbraco using SQLServer Express', async ({umbracoUi}) => {
test.slow();
// Arrange
await umbracoUi.install.goToInstallPage();
// Act
await umbracoUi.install.enterName(name);
await umbracoUi.install.enterEmail(email);
await umbracoUi.install.enterPassword(password);
await umbracoUi.install.clickNextButton();
await umbracoUi.install.clickNextButton();
await umbracoUi.install.setDatabaseType('SQL Server Express LocalDB');
await umbracoUi.install.doesDatabaseHaveType('SQL Server Express LocalDB');
await umbracoUi.install.clickInstallButton();
// Assert
await umbracoUi.login.enterEmail(email);
await umbracoUi.login.enterPassword(password);
await umbracoUi.login.clickLoginButton();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
});

View File

@@ -0,0 +1,27 @@
// To be able to test different databases, we need to set an additional UnattendedInstallConfig up because we would have to start from scratch, otherwise we would be using the same database.
import {ConstantHelper, test} from '@umbraco/playwright-testhelpers';
const name = 'TestName';
const email = process.env.UMBRACO_USER_LOGIN;
const password = process.env.UMBRACO_USER_PASSWORD;
test('Install Umbraco using SQLite', async ({umbracoUi}) => {
test.slow();
// Arrange
await umbracoUi.install.goToInstallPage();
// Act
await umbracoUi.install.enterName(name);
await umbracoUi.install.enterEmail(email);
await umbracoUi.install.enterPassword(password);
await umbracoUi.install.clickNextButton();
await umbracoUi.install.clickNextButton();
await umbracoUi.install.doesDatabaseHaveType('SQLite');
await umbracoUi.install.clickInstallButton();
// Assert
await umbracoUi.login.enterEmail(email);
await umbracoUi.login.enterPassword(password);
await umbracoUi.login.clickLoginButton();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
});

View File

@@ -0,0 +1,83 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Tests.Common.TestHelpers;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees;
public abstract class FileSystemTreeServiceTestsBase : UmbracoIntegrationTest
{
protected FileSystems FileSystems { get; private set; }
protected IFileSystem TestFileSystem { get; private set; }
protected abstract string FileSystemPath { get; }
protected IHostingEnvironment HostingEnvironment => GetRequiredService<IHostingEnvironment>();
[SetUp]
public void SetUpFileSystem()
{
TestFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, LoggerFactory.CreateLogger<PhysicalFileSystem>(), HostingEnvironment.MapPathWebRoot(FileSystemPath), HostingEnvironment.ToAbsolute(FileSystemPath));
FileSystems = FileSystemsCreator.CreateTestFileSystems(
LoggerFactory,
IOHelper,
GetRequiredService<IOptions<GlobalSettings>>(),
HostingEnvironment,
GetPartialViewsFileSystem(),
GetStylesheetsFileSystem(),
GetScriptsFileSystem(),
null);
for (int i = 0; i < 10; i++)
{
using var stream = CreateStream(Path.Join("tests"));
TestFileSystem.AddFile($"file{i}", stream);
}
}
private static Stream CreateStream(string contents = null)
{
if (string.IsNullOrEmpty(contents))
{
contents = "/* test */";
}
var bytes = Encoding.UTF8.GetBytes(contents);
return new MemoryStream(bytes);
}
protected virtual IFileSystem? GetPartialViewsFileSystem() => null;
protected virtual IFileSystem? GetStylesheetsFileSystem() => null;
protected virtual IFileSystem? GetScriptsFileSystem() => null;
[TearDown]
public void TearDownFileSystem()
{
Purge(TestFileSystem, string.Empty);
FileSystems = null;
}
private static void Purge(IFileSystem fs, string path)
{
var files = fs.GetFiles(path, "*");
foreach (var file in files)
{
fs.DeleteFile(file);
}
var dirs = fs.GetDirectories(path);
foreach (var dir in dirs)
{
Purge(fs, dir);
fs.DeleteDirectory(dir);
}
}
}

View File

@@ -0,0 +1,54 @@
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees;
public class PartialViewTreeServiceTests : FileSystemTreeServiceTestsBase
{
protected override string FileSystemPath => Constants.SystemDirectories.PartialViews;
protected override IFileSystem? GetPartialViewsFileSystem() => TestFileSystem;
[Test]
public void Can_Get_Siblings_From_PartialView_Tree_Service()
{
var service = new PartialViewTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after);
int index = Array.FindIndex(treeModel, item => item.Name == "file5");
Assert.AreEqual(treeModel[index].Name, "file5");
Assert.AreEqual(treeModel[index - 1].Name, "file4");
Assert.AreEqual(treeModel[index + 1].Name, "file6");
Assert.That(treeModel.Length == 3);
Assert.AreEqual(after, 3);
Assert.AreEqual(before, 4);
}
[Test]
public void Can_Get_Ancestors_From_StyleSheet_Tree_Service()
{
var service = new PartialViewTreeService(FileSystems);
var path = Path.Join("tests", "file5");
FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true);
Assert.IsNotEmpty(treeModel);
Assert.AreEqual(treeModel.Length, 2);
Assert.AreEqual(treeModel[0].Name, "tests");
}
[Test]
public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service()
{
var service = new PartialViewTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems);
Assert.IsNotEmpty(treeModels);
Assert.AreEqual(treeModels.Length, totalItems);
}
}

View File

@@ -0,0 +1,53 @@
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees;
public class ScriptTreeServiceTests : FileSystemTreeServiceTestsBase
{
protected override string FileSystemPath => GlobalSettings.UmbracoScriptsPath;
protected override IFileSystem? GetScriptsFileSystem() => TestFileSystem;
[Test]
public void Can_Get_Siblings_From_Script_Tree_Service()
{
var service = new ScriptTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after);
int index = Array.FindIndex(treeModel, item => item.Name == "file5");
Assert.AreEqual(treeModel[index].Name, "file5");
Assert.AreEqual(treeModel[index - 1].Name, "file4");
Assert.AreEqual(treeModel[index + 1].Name, "file6");
Assert.That(treeModel.Length == 3);
Assert.AreEqual(after, 3);
Assert.AreEqual(before, 4);
}
[Test]
public void Can_Get_Ancestors_From_StyleSheet_Tree_Service()
{
var service = new ScriptTreeService(FileSystems);
var path = Path.Join("tests", "file5");
FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true);
Assert.IsNotEmpty(treeModel);
Assert.AreEqual(treeModel.Length, 2);
Assert.AreEqual(treeModel[0].Name, "tests");
}
[Test]
public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service()
{
var service = new ScriptTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems);
Assert.IsNotEmpty(treeModels);
Assert.AreEqual(treeModels.Length, totalItems);
}
}

View File

@@ -0,0 +1,53 @@
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees;
public class StyleSheetTreeServiceTests : FileSystemTreeServiceTestsBase
{
protected override string FileSystemPath => GlobalSettings.UmbracoCssPath;
protected override IFileSystem? GetStylesheetsFileSystem() => TestFileSystem;
[Test]
public void Can_Get_Siblings_From_StyleSheet_Tree_Service()
{
var service = new StyleSheetTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after);
int index = Array.FindIndex(treeModel, item => item.Name == "file5");
Assert.AreEqual(treeModel[index].Name, "file5");
Assert.AreEqual(treeModel[index - 1].Name, "file4");
Assert.AreEqual(treeModel[index + 1].Name, "file6");
Assert.That(treeModel.Length == 3);
Assert.AreEqual(after, 3);
Assert.AreEqual(before, 4);
}
[Test]
public void Can_Get_Ancestors_From_StyleSheet_Tree_Service()
{
var service = new StyleSheetTreeService(FileSystems);
var path = Path.Join("tests", "file5");
FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true);
Assert.IsNotEmpty(treeModel);
Assert.AreEqual(treeModel.Length, 2);
Assert.AreEqual(treeModel[0].Name, "tests");
}
[Test]
public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service()
{
var service = new StyleSheetTreeService(FileSystems);
FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems);
Assert.IsNotEmpty(treeModels);
Assert.AreEqual(treeModels.Length, totalItems);
}
}

View File

@@ -0,0 +1,60 @@
using System.Linq.Expressions;
using System.Net.Http.Json;
using NUnit.Framework;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Controllers.DocumentType.Tree;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Tests.Common.Builders;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Trees;
[TestFixture]
internal sealed class DocumentTypeSiblingControllerTests : ManagementApiTest<SiblingsDocumentTypeTreeController>
{
private IContentTypeContainerService ContentTypeContainerService => GetRequiredService<IContentTypeContainerService>();
private ContentTypeService ContentTypeService => (ContentTypeService)GetRequiredService<IContentTypeService>();
protected override Expression<Func<SiblingsDocumentTypeTreeController, object>> MethodSelector =>
x => x.Siblings(CancellationToken.None, Guid.Empty, 0, 0, false);
[Test]
public async Task Document_Type_Siblings_Under_Folder_Have_Correct_Parent()
{
// create folder
Attempt<EntityContainer, EntityContainerOperationStatus> folderResult =
await ContentTypeContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey);
// create contentTypeOne
IContentType contentTypeOne = ContentTypeBuilder.CreateBasicContentType();
contentTypeOne.Alias = "contentTypeOne";
contentTypeOne.ParentId = folderResult.Result.Id;
contentTypeOne.Variations = ContentVariation.Nothing;
ContentTypeService.Save(contentTypeOne);
// create contentTypeTwo
IContentType contentTypeTwo = ContentTypeBuilder.CreateBasicContentType();
contentTypeTwo.Alias = "contentTypeTwo";
contentTypeTwo.ParentId = folderResult.Result.Id;
contentTypeTwo.Variations = ContentVariation.Nothing;
ContentTypeService.Save(contentTypeTwo);
// get siblings of doctype one
await AuthenticateClientAsync(Client, "test@test.test", "test@test.test", true);
var siblingsResponse = await GetManagementApiResponseAsync(contentTypeOne.Key);
var responseModel = await siblingsResponse.Content.ReadFromJsonAsync<SubsetViewModel<DocumentTreeItemResponseModel>>();
Assert.IsTrue(responseModel.Items.All(i => i.Parent!.Id == folderResult.Result.Key));
}
private async Task<HttpResponseMessage> GetManagementApiResponseAsync(Guid target)
{
var url = GetManagementApiUrl<SiblingsDocumentTypeTreeController>(x =>
x.Siblings(CancellationToken.None, target, 10, 10, false));
return await Client.GetAsync(url);
}
}

View File

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Delivery.Controllers.Content;
@@ -132,7 +133,9 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest
}
methodParams["version"] = method?.GetCustomAttribute<MapToApiVersionAttribute>()?.Versions?.First().MajorVersion.ToString();
methodParams["version"] =
method?.GetCustomAttribute<MapToApiVersionAttribute>()?.Versions?.First().MajorVersion.ToString() // get it from the attribute
?? Factory.Services.GetRequiredService<IOptions<ApiVersioningOptions>>()?.Value.DefaultApiVersion.MajorVersion.ToString(); // or use the default version from options
if (method == null)
{
throw new MissingMethodException(

View File

@@ -398,4 +398,35 @@ internal sealed partial class UserServiceCrudTests
Assert.IsNotNull(updatedUser.StartMediaIds);
Assert.IsEmpty(updatedUser.StartMediaIds);
}
[TestCase(false, false)]
[TestCase(true, true)]
public async Task Cannot_Remove_Admin_Group_From_Only_Admin_User(bool createAdditionalAdminUser, bool expectSuccess)
{
var userService = CreateUserService(securitySettings: new SecuritySettings { UsernameIsEmail = false });
if (createAdditionalAdminUser)
{
var (updateModel, _) = await CreateUserForUpdate(userService);
updateModel.UserGroupKeys = new HashSet<Guid> { Constants.Security.AdminGroupKey };
var updateResult = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel);
Assert.IsTrue(updateResult.Success);
}
var adminUser = await userService.GetAsync(Constants.Security.SuperUserKey);
var adminUserUpdateModel = await MapUserToUpdateModel(adminUser);
adminUserUpdateModel.Email = "admin@test.com";
adminUserUpdateModel.UserGroupKeys = new HashSet<Guid> { Constants.Security.EditorGroupKey };
var adminUserUpdateResult = await userService.UpdateAsync(Constants.Security.SuperUserKey, adminUserUpdateModel);
if (expectSuccess)
{
Assert.IsTrue(adminUserUpdateResult.Success);
}
else
{
Assert.IsFalse(adminUserUpdateResult.Success);
Assert.AreEqual(UserOperationStatus.AdminUserGroupMustNotBeEmpty, adminUserUpdateResult.Status);
}
}
}

View File

@@ -972,6 +972,27 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
Assert.IsTrue(result[2].Key == children[3].Key);
}
[Test]
public void EntityService_Siblings_Returns_Trashed_Siblings()
{
ContentService.EmptyRecycleBin();
var children = CreateDocumentSiblingsTestData();
for (int i = 0; i <= 3; i++)
{
ContentService.MoveToRecycleBin(children[i]);
}
var result = EntityService.GetTrashedSiblings(children[1].Key, [UmbracoObjectTypes.Document], 1, 1, out long totalBefore, out long totalAfter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(1, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
Assert.IsTrue(result[2].Key == children[2].Key);
Assert.IsFalse(result.Any(x => x.Key == children[3].Key));
}
[Test]
public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithSet()
{

View File

@@ -1019,6 +1019,42 @@ internal sealed class UserServiceTests : UmbracoIntegrationTest
}
}
[Test]
public async Task Can_Assign_And_Get_Groups_For_User()
{
// Arrange
var (user, userGroup1) = await CreateTestUserAndGroup();
var userGroup2 = await CreateTestUserGroup("testGroup2", "Test Group 2");
// Act & Assert
user = UserService.GetByUsername(user.Username);
Assert.IsNotNull(user);
Assert.AreEqual(1, user.Groups.Count());
Assert.AreEqual(userGroup1.Alias, user.Groups.First().Alias);
// - add second group
user.AddGroup(userGroup2);
UserService.Save(user);
user = UserService.GetByUsername(user.Username);
Assert.AreEqual(2, user.Groups.Count());
// - remove first group
user.RemoveGroup(userGroup1.Alias);
UserService.Save(user);
user = UserService.GetByUsername(user.Username);
Assert.AreEqual(1, user.Groups.Count());
Assert.AreEqual(userGroup2.Alias, user.Groups.First().Alias);
// - remove second group and add first
user.RemoveGroup(userGroup2.Alias);
user.AddGroup(userGroup1.ToReadOnlyGroup());
UserService.Save(user);
user = UserService.GetByUsername(user.Username);
Assert.AreEqual(1, user.Groups.Count());
Assert.AreEqual(userGroup1.Alias, user.Groups.First().Alias);
}
[TestCase(UserKind.Default, UserClientCredentialsOperationStatus.InvalidUser)]
[TestCase(UserKind.Api, UserClientCredentialsOperationStatus.Success)]
public async Task Can_Assign_ClientId_To_Api_User(UserKind userKind, UserClientCredentialsOperationStatus expectedResult)

View File

@@ -26,8 +26,10 @@ public class DecimalPropertyValueEditorTests
{ 123, 123m },
{ -123, -123m },
{ 123.45d, 123.45m },
{ 123.45f, 123.45m },
{ "123.45", 123.45m },
{ "1234.56", 1234.56m },
{ "1,234.56", 1234.56m },
{ "123,45", 12345m },
{ "1.234,56", null },
{ "123 45", null },
@@ -49,6 +51,18 @@ public class DecimalPropertyValueEditorTests
}
}
[SetCulture("it-IT")]
[SetUICulture("it-IT")]
[TestCase("123,45", 123.45)]
[TestCase("1.234,56", 1234.56)]
[TestCase("123.45", 12345)]
[TestCase("1,234.56", null)]
public void Can_Parse_Values_From_Editor_Using_Culture_With_Non_EnUs_Decimal_Separator(object value, decimal? expected)
{
var fromEditor = FromEditor(value);
Assert.AreEqual(expected, fromEditor);
}
[Test]
public void Can_Parse_Values_To_Editor()
{

View File

@@ -128,6 +128,7 @@ public class MultiValuePropertyEditorTests
Assert.AreEqual("Item 3", result.Items[2]);
}
[TestCase("", true, "")]
[TestCase("Red", true, "")]
[TestCase("Yellow", false, "notOneOfOptions")]
[TestCase("Red,Green", true, "")]

View File

@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Tests.Common;
using Umbraco.Cms.Tests.UnitTests.TestHelpers.Objects;
@@ -216,18 +217,204 @@ public class HtmlLocalLinkParserTests
var umbracoContextAccessor = new TestUmbracoContextAccessor();
var umbracoContextFactory = TestUmbracoContextFactory.Create(
umbracoContextAccessor: umbracoContextAccessor);
using (var reference = umbracoContextFactory.EnsureUmbracoContext())
{
var contentCache = Mock.Get(reference.UmbracoContext.Content);
contentCache.Setup(x => x.GetById(It.IsAny<int>())).Returns(publishedContent.Object);
contentCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(publishedContent.Object);
var mediaCache = Mock.Get(reference.UmbracoContext.Media);
mediaCache.Setup(x => x.GetById(It.IsAny<int>())).Returns(media.Object);
mediaCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(media.Object);
var publishedUrlProvider = CreatePublishedUrlProvider(
contentUrlProvider,
mediaUrlProvider,
umbracoContextAccessor);
var linkParser = new HtmlLocalLinkParser(publishedUrlProvider);
var output = linkParser.EnsureInternalLinks(input);
Assert.AreEqual(result, output);
}
}
[Test]
public void ParseLocalLinks_WithUrlMode_RespectsUrlMode()
{
// Arrange
var input = "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world";
// Setup content URL provider that returns different URLs based on UrlMode
var contentUrlProvider = new Mock<IUrlProvider>();
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Relative,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/relative-url"));
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Absolute,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("http://example.com/absolute-url"));
var contentType = new PublishedContentType(
Guid.NewGuid(),
666,
"alias",
PublishedItemType.Content,
Enumerable.Empty<string>(),
Enumerable.Empty<PublishedPropertyType>(),
ContentVariation.Nothing);
var publishedContent = new Mock<IPublishedContent>();
publishedContent.Setup(x => x.Id).Returns(1234);
publishedContent.Setup(x => x.ContentType).Returns(contentType);
var umbracoContextAccessor = new TestUmbracoContextAccessor();
var umbracoContextFactory = TestUmbracoContextFactory.Create(
umbracoContextAccessor: umbracoContextAccessor);
var webRoutingSettings = new WebRoutingSettings();
var navigationQueryService = new Mock<IDocumentNavigationQueryService>();
// Guid? parentKey = null;
// navigationQueryService.Setup(x => x.TryGetParentKey(It.IsAny<Guid>(), out parentKey)).Returns(true);
IEnumerable<Guid> ancestorKeys = [];
navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny<Guid>(), out ancestorKeys)).Returns(true);
var publishedUrlProvider = CreatePublishedUrlProvider(
contentUrlProvider,
new Mock<IMediaUrlProvider>(),
umbracoContextAccessor);
var publishedContentStatusFilteringService = new Mock<IPublishedContentStatusFilteringService>();
using (var reference = umbracoContextFactory.EnsureUmbracoContext())
{
var contentCache = Mock.Get(reference.UmbracoContext.Content);
contentCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(publishedContent.Object);
var linkParser = new HtmlLocalLinkParser(publishedUrlProvider);
// Act
var relativeOutput = linkParser.EnsureInternalLinks(input, UrlMode.Relative);
var absoluteOutput = linkParser.EnsureInternalLinks(input, UrlMode.Absolute);
// Assert
Assert.AreEqual("hello href=\"/relative-url\" world", relativeOutput);
Assert.AreEqual("hello href=\"http://example.com/absolute-url\" world", absoluteOutput);
}
}
[TestCase(UrlMode.Default, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Relative, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:1234}\" world ", "hello href=\"https://example.com/absolute-url\" world ")]
[TestCase(UrlMode.Auto, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Default, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/absolute-url\" world ")]
[TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
[TestCase(UrlMode.Default, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
[TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/media/absolute/image.jpg\" world ")]
[TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
public void ParseLocalLinks_WithVariousUrlModes_ReturnsCorrectUrls(UrlMode urlMode, string input, string expectedResult)
{
// Setup content URL provider that returns different URLs based on UrlMode
var contentUrlProvider = new Mock<IUrlProvider>();
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Default,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/relative-url"));
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Relative,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/relative-url"));
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Absolute,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("https://example.com/absolute-url"));
contentUrlProvider
.Setup(x => x.GetUrl(
It.IsAny<IPublishedContent>(),
UrlMode.Auto,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/relative-url"));
var contentType = new PublishedContentType(
Guid.NewGuid(),
666,
"alias",
PublishedItemType.Content,
Enumerable.Empty<string>(),
Enumerable.Empty<PublishedPropertyType>(),
ContentVariation.Nothing);
var publishedContent = new Mock<IPublishedContent>();
publishedContent.Setup(x => x.Id).Returns(1234);
publishedContent.Setup(x => x.ContentType).Returns(contentType);
// Setup media URL provider that returns different URLs based on UrlMode
var mediaUrlProvider = new Mock<IMediaUrlProvider>();
mediaUrlProvider.Setup(x => x.GetMediaUrl(
It.IsAny<IPublishedContent>(),
It.IsAny<string>(),
UrlMode.Default,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
mediaUrlProvider.Setup(x => x.GetMediaUrl(
It.IsAny<IPublishedContent>(),
It.IsAny<string>(),
UrlMode.Relative,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
mediaUrlProvider.Setup(x => x.GetMediaUrl(
It.IsAny<IPublishedContent>(),
It.IsAny<string>(),
UrlMode.Absolute,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("https://example.com/media/absolute/image.jpg"));
mediaUrlProvider.Setup(x => x.GetMediaUrl(
It.IsAny<IPublishedContent>(),
It.IsAny<string>(),
UrlMode.Auto,
It.IsAny<string>(),
It.IsAny<Uri>()))
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
var mediaType = new PublishedContentType(
Guid.NewGuid(),
777,
"image",
PublishedItemType.Media,
Enumerable.Empty<string>(),
Enumerable.Empty<PublishedPropertyType>(),
ContentVariation.Nothing);
var media = new Mock<IPublishedContent>();
media.Setup(x => x.ContentType).Returns(mediaType);
var umbracoContextAccessor = new TestUmbracoContextAccessor();
var umbracoContextFactory = TestUmbracoContextFactory.Create(
umbracoContextAccessor: umbracoContextAccessor);
var webRoutingSettings = new WebRoutingSettings();
var publishedUrlProvider = CreatePublishedUrlProvider(
contentUrlProvider,
mediaUrlProvider,
umbracoContextAccessor);
using (var reference = umbracoContextFactory.EnsureUmbracoContext())
{
@@ -239,25 +426,35 @@ public class HtmlLocalLinkParserTests
mediaCache.Setup(x => x.GetById(It.IsAny<int>())).Returns(media.Object);
mediaCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(media.Object);
var publishStatusQueryService = new Mock<IPublishStatusQueryService>();
publishStatusQueryService
.Setup(x => x.IsDocumentPublished(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns(true);
var publishedUrlProvider = new UrlProvider(
umbracoContextAccessor,
Options.Create(webRoutingSettings),
new UrlProviderCollection(() => new[] { contentUrlProvider.Object }),
new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }),
Mock.Of<IVariationContextAccessor>(),
navigationQueryService.Object,
publishedContentStatusFilteringService.Object);
var linkParser = new HtmlLocalLinkParser(publishedUrlProvider);
var output = linkParser.EnsureInternalLinks(input);
var output = linkParser.EnsureInternalLinks(input, urlMode);
Assert.AreEqual(result, output);
Assert.AreEqual(expectedResult, output);
}
}
private static UrlProvider CreatePublishedUrlProvider(
Mock<IUrlProvider> contentUrlProvider,
Mock<IMediaUrlProvider> mediaUrlProvider,
TestUmbracoContextAccessor umbracoContextAccessor)
{
var navigationQueryService = new Mock<IDocumentNavigationQueryService>();
IEnumerable<Guid> ancestorKeys = [];
navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny<Guid>(), out ancestorKeys)).Returns(true);
var publishStatusQueryService = new Mock<IPublishStatusQueryService>();
publishStatusQueryService
.Setup(x => x.IsDocumentPublished(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns(true);
return new UrlProvider(
umbracoContextAccessor,
Options.Create(new WebRoutingSettings()),
new UrlProviderCollection(() => new[] { contentUrlProvider.Object }),
new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }),
Mock.Of<IVariationContextAccessor>(),
navigationQueryService.Object,
new Mock<IPublishedContentStatusFilteringService>().Object);
}
}

View File

@@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Dictionary;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.Validators;
using Umbraco.Cms.Core.Serialization;
@@ -27,16 +28,14 @@ public class PropertyValidationServiceTests
private void MockObjects(out PropertyValidationService validationService, out IDataType dt)
{
var dataTypeService = new Mock<IDataTypeService>();
var dataType = Mock.Of<IDataType>(
x => x.ConfigurationObject == string.Empty // irrelevant but needs a value
&& x.DatabaseType == ValueStorageType.Nvarchar
&& x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox);
var dataType = Mock.Of<IDataType>(x => x.ConfigurationObject == string.Empty // irrelevant but needs a value
&& x.DatabaseType == ValueStorageType.Nvarchar
&& x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox);
dataTypeService.Setup(x => x.GetDataType(It.IsAny<int>())).Returns(() => dataType);
dt = dataType;
// new data editor that returns a TextOnlyValueEditor which will do the validation for the properties
var dataEditor = Mock.Of<IDataEditor>(
x => x.Alias == Constants.PropertyEditors.Aliases.TextBox);
var dataEditor = Mock.Of<IDataEditor>(x => x.Alias == Constants.PropertyEditors.Aliases.TextBox);
Mock.Get(dataEditor).Setup(x => x.GetValueEditor(It.IsAny<object>()))
.Returns(new CustomTextOnlyValueEditor(
new DataEditorAttribute(Constants.PropertyEditors.Aliases.TextBox),
@@ -44,7 +43,15 @@ public class PropertyValidationServiceTests
new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()),
Mock.Of<IIOHelper>()));
var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => new[] { dataEditor }));
var languageService = new Mock<ILanguageService>();
languageService
.Setup(s => s.GetDefaultIsoCodeAsync())
.ReturnsAsync(() => "en-US");
var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => [dataEditor]));
var contentSettings = new Mock<IOptions<ContentSettings>>();
contentSettings.Setup(x => x.Value).Returns(new ContentSettings());
validationService = new PropertyValidationService(
propEditors,
@@ -52,8 +59,8 @@ public class PropertyValidationServiceTests
Mock.Of<ILocalizedTextService>(),
new ValueEditorCache(),
Mock.Of<ICultureDictionary>(),
Mock.Of<ILanguageService>(),
Mock.Of<IOptions<ContentSettings>>());
languageService.Object,
contentSettings.Object);
}
[Test]
@@ -279,6 +286,23 @@ public class PropertyValidationServiceTests
Assert.AreEqual(4, invalid.Length);
}
[TestCase(null)]
[TestCase(24)]
[TestCase("test")]
[TestCase("{\"test\": true}")]
public void ValidatePropertyValue_Always_Returns_No_Validation_Errors_For_Missing_Editor(object? value)
{
MockObjects(out var validationService, out _);
var p1 = new PropertyType(ShortStringHelper, "Missing.Alias", ValueStorageType.Ntext)
{
Variations = ContentVariation.Nothing,
};
var result = validationService.ValidatePropertyValue(p1, value, PropertyValidationContext.Empty());
Assert.AreEqual(0, result.Count());
}
// used so we can inject a mock - we should fix the base class DataValueEditor to be able to have the ILocalizedTextField passed
// in to create the Requried and Regex validators so we aren't using singletons
private class CustomTextOnlyValueEditor : TextOnlyValueEditor