Compare commits

...
Author SHA1 Message Date
Zeegaan d48a8c1f3e Bump version to 17.5.0. 2026-06-25 09:33:10 +09:00
Jacob Overgaard 28b849f2d3 build(deps): bumps @umbraco-ui/uui to 1.18.1 2026-06-23 11:54:32 +02:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
Jacob OvergaardandClaude Opus 4.8 a14b908574 test(backoffice): fix host type in extension-initializer-base test
The `loaded`-signal test (added in #23167) built its host with
`UmbControllerHostElementMixin(HTMLElement)`, mirroring the older
`UmbBaseExtensionInitializer` tests. But `UmbExtensionInitializerBase`
requires a full `UmbElement` host, so the test failed `tsc` (TS2345)
under the root tsconfig. The product build excludes `*.test.ts`, so it
slipped through CI but breaks `npm run compile`/the editor.

Use `UmbElementMixin(HTMLElement)`, matching what production callers pass
(app/backoffice/preview elements are all UmbElements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:56 +02:00
9f633416b1 External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:28 +01:00
Jesper MadsenandJacob Overgaard e2cf205d34 Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:57 +02:00
Andy Butland fe3318ef79 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:19:24 +02:00
Andy Butland 7433641348 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:35:25 +02:00
Zeegaan 764d4eb1d7 bump version 2026-06-11 14:02:32 +09:00
Andy Butland d0fc7dc8a0 Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-06-05 15:56:23 +02:00
Andy Butland 89baa9482b Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:40:00 +02:00
33 changed files with 1336 additions and 589 deletions
+1 -1
View File
@@ -53,7 +53,7 @@
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -84,8 +84,19 @@ public class TouchServerJob : RecurringBackgroundJobBase
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
if (string.IsNullOrWhiteSpace(serverAddress))
{
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
return Task.CompletedTask;
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
// comes from the server identity, not this address). If a URL is later detected from a request, the next
// touch overwrites the placeholder.
serverAddress = Environment.MachineName;
_logger.LogDebug(
"No application URL available; registering server with placeholder address {ServerAddress}.",
serverAddress);
}
else
{
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
try
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
/// </summary>
/// <remarks>
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
/// </remarks>
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
private bool _initialized = false;
private readonly Lock _initializationLock = new();
// Both fields are written under _initializationLock but read on the hot path (request routing) without
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
// the fully populated dictionary and the completed-initialization flag together, never a stale or
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
// ordinary reads already have acquire semantics, but we cannot rely on that.
private volatile ConcurrentDictionary<int, Domain> _domains = new();
private volatile bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
/// </summary>
/// <param name="domainService">The service used to load domains from the database.</param>
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
InitializeIfMissing();
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <summary>
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
/// </summary>
private void InitializeIfMissing()
{
// Lazy, on-demand initialization triggered by the first request to reach the cache.
// The flag must only be set to true *after* the domains have been loaded and published.
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
// The double-checked lock ensures a single load while concurrent readers block until it completes.
if (_initialized)
{
return;
}
_initialized = true;
LoadDomains();
lock (_initializationLock)
{
if (_initialized)
{
return;
}
LoadDomains();
_initialized = true;
}
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
InitializeIfMissing();
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
}
/// <inheritdoc />
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
break;
}
}
}
/// <summary>
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
/// as the current cache.
/// </summary>
private void LoadDomains()
{
// Build the replacement set in a local dictionary and publish it with a single write to the
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
// rebuild, and the published set contains exactly the current domains (any removed since the last
// load are absent).
var newDomains = new ConcurrentDictionary<int, Domain>();
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
newDomains[domain.Id] = domain;
}
scope.Complete();
}
_domains = newDomains;
}
}
+44 -36
View File
@@ -5,6 +5,7 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
## Documentation Structure
### Architecture & Design
- **[Architecture](./docs/architecture.md)** - Technology stack, design philosophy, developer roles, package system, import map pipeline, design patterns
- **[Manifests & Aliases](./docs/manifests.md)** - Manifest shape, alias conventions, alias constants, how aliases connect extensions, registration, registry operations, kind merging
- **[Entities](./docs/entities.md)** - Entity types, entity context, how entityType connects workspaces/trees/actions/routing
@@ -18,9 +19,11 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Value Summary](./docs/value-summary.md)** - `valueSummary` extension type; rendering compact values in collection views, batch resolver pattern, coordinator
### Development
- **[Commands](./docs/commands.md)** - Build, test, and development commands
### Code Quality
- **[Style Guide](./docs/style-guide.md)** - Naming and formatting conventions
- **[Design Choices](./docs/design-choices.md)** - Visual restraint: icons, colours, buttons, and UX copy
- **[Clean Code](./docs/clean-code.md)** - Best practices and SOLID principles
@@ -28,10 +31,12 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Testing](./docs/testing.md)** - Testing strategy, priority by code area, MSW mocking, test patterns
### Troubleshooting
- **[Error Handling](./docs/error-handling.md)** - Error patterns and debugging
- **[Edge Cases](./docs/edge-cases.md)** - Common pitfalls and gotchas
### Security & AI
- **[Security](./docs/security.md)** - XSS prevention, authentication, input validation
- **[Agentic Workflow](./docs/agentic-workflow.md)** - Three-phase AI development process
@@ -41,17 +46,17 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
**Before performing any of these actions, you MUST read the linked doc first:**
| Before you... | Read |
|----------------|------|
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
| Before you... | Read |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
This is not optional. Skipping these leads to convention violations that are caught in review.
@@ -79,24 +84,24 @@ cd src/Umbraco.Web.UI.Client && npm install && npm run dev
See **[Commands](./docs/commands.md)** for all available commands.
| Task | Command |
|------|---------|
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Task | Command |
| ----------------------- | --------------------------------------------------------- |
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Testing (specific file) | `npm test -- --files "src/packages/path/to/file.test.ts"` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
---
## Quick Reference
| Item | Details |
|------|---------|
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
| Item | Details |
| ----------------------- | ----------------------------------------------------------------- |
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
---
@@ -117,6 +122,7 @@ The `npm pack` process (prepack hook) runs `devops/publish/cleanse-pkg.js` which
Uses the `semver` package (npm's own semver library) for robust parsing:
**Pre-release packages (0.x.y)**
```
Input: ^0.85.0 or 0.85.0
Output: >=0.85.0 <1.0.0
@@ -126,6 +132,7 @@ Why: Pre-release caret (^0.85.0) only allows patch updates (0.85.x).
```
**Stable packages with caret (major ≥ 1)**
```
Input: ^3.3.1
Output: ^3.3.1 (kept as-is)
@@ -134,6 +141,7 @@ Why: Caret already implements the correct range: >=3.3.1 <4.0.0
```
**Stable exact versions (major ≥ 1)**
```
Input: 3.16.0 (from @tiptap/*)
Output: ^3.16.0
@@ -145,14 +153,14 @@ Why: Normalizes to conventional semver format
```json
{
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^1.17.0-rc.5",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^1.18.1",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
}
```
@@ -168,9 +176,9 @@ When using `@umbraco-cms/backoffice`:
### Key Files
| File | Purpose |
|------|---------|
| `package.json` | Root package with exports and workspace references |
| File | Purpose |
| ------------------------------- | ---------------------------------------------------------------- |
| `package.json` | Root package with exports and workspace references |
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "17.5.0-rc",
"version": "17.5.0",
"type": "module",
"exports": {
".": null,
@@ -260,7 +260,7 @@ export class UmbAppElement extends UmbLitElement {
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Try to initialise the auth flow and get the runtime status
try {
@@ -276,6 +276,12 @@ export class UmbAppElement extends UmbLitElement {
await this.#setAuthStatus();
}
// The login screen decides which auth provider to use from the registered
// `authProvider` extensions. App-entry-points may register or unregister those during
// their async onInit, so wait for them to settle before routing — otherwise on a slow
// connection the decision races and falls back to the local login.
await this.observe(entryPointInitializer.loaded).asPromise();
// Initialise the router
this.#redirect();
} catch (error) {
@@ -1056,7 +1056,7 @@ export default {
greeting5: 'Willkommen',
greeting6: 'Willkommen',
instruction: 'Hier anmelden:',
signInWith: 'Anmelden mit',
signInWith: 'Anmelden mit {0}',
timeout: 'Sitzung abgelaufen',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.org</a></p> ',
@@ -923,7 +923,7 @@ export default {
greeting5: 'Bienvenue',
greeting6: 'Bienvenue',
instruction: 'Connectez-vous ci-dessous',
signInWith: 'Identifiez-vous avec',
signInWith: 'Identifiez-vous avec {0}',
timeout: 'La session a expiré',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">Umbraco.com</a></p> ',
@@ -712,7 +712,7 @@ export default {
greeting5: 'Velkommen',
greeting6: 'Velkommen',
instruction: 'Logg på nedenfor',
signInWith: 'Logg på med',
signInWith: 'Logg på med {0}',
timeout: 'Din sesjon er utløpt',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p> ',
@@ -943,7 +943,7 @@ export default {
greeting5: 'Welkom',
greeting6: 'Welkom',
instruction: 'log hieronder in',
signInWith: 'Inloggen met',
signInWith: 'Inloggen met {0}',
timeout: 'Sessie is verlopen',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p>',
@@ -696,7 +696,7 @@ export default {
greeting5: 'Välkommen',
greeting6: 'Välkommen',
instruction: 'Logga in nedan',
signInWith: 'Logga in med',
signInWith: 'Logga in med {0}',
timeout: 'Sessionen har nått sin maxgräns',
},
main: {
+2 -2
View File
@@ -6,7 +6,7 @@
"build": "vite build"
},
"dependencies": {
"@umbraco-ui/uui": "^1.18.0",
"@umbraco-ui/uui-css": "^1.18.0"
"@umbraco-ui/uui": "^1.18.1",
"@umbraco-ui/uui-css": "^1.18.1"
}
}
@@ -0,0 +1,134 @@
import type { ManifestBase } from '../types/index.js';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { loadManifestPlainJs } from '../functions/load-manifest-plain-js.function.js';
import { UmbExtensionInitializerBase } from './extension-initializer-base.js';
import { UmbObserver } from '../../observable-api/observer.js';
import { expect, fixture } from '@open-wc/testing';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import type { UmbElement } from '@umbraco-cms/backoffice/element-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-initializer-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestInitializerBaseHostElement extends UmbElementMixin(HTMLElement) {}
async function wait(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
// Factory for a concrete initializer over the 'test' manifest type. The base constructor's
// `observe` callback fires synchronously during `super()` — before any subclass field would
// initialise — so the record of instantiated aliases is a closed-over array created up front
// rather than instance state.
function createTestInitializer(host: UmbElement, registry: UmbExtensionRegistry<ManifestBase>) {
const instantiated: string[] = [];
class UmbTestInitializer extends UmbExtensionInitializerBase<'test'> {
constructor() {
super(host, registry as never, 'test');
}
async instantiateExtension(manifest: ManifestBase & { js?: unknown }): Promise<void> {
if (manifest.js) {
await loadManifestPlainJs(manifest.js as never);
}
instantiated.push(manifest.alias);
}
unloadExtension(manifest: ManifestBase): void {
const index = instantiated.indexOf(manifest.alias);
if (index !== -1) instantiated.splice(index, 1);
}
}
return { initializer: new UmbTestInitializer(), instantiated };
}
describe('UmbExtensionInitializerBase — loaded signal', () => {
let hostElement: UmbElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-initializer-base-host></umb-test-initializer-base-host>`);
});
// Regression for the v17.4+ external-login race (introduced in #22522).
//
// A default Umbraco install registers ZERO app-entry-point extensions. The boot sequence
// awaits the app-entry-point initializer's `loaded` before deciding which login provider
// to use. If `loaded` never resolves when there are no matching extensions, that await
// hangs forever — which is precisely why the await was removed, leaving externally
// registered auth providers un-awaited and the login flow racing on slow connections.
//
// So: an initializer for a type with zero matching extensions MUST still resolve `loaded`.
it('resolves `loaded` even when no extensions of the type are registered', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
const { initializer } = createTestInitializer(hostElement, extensionRegistry);
const outcome = await Promise.race([
new UmbObserver(initializer.loaded).asPromise().then(() => 'resolved'),
wait(1000).then(() => 'timeout'),
]);
expect(outcome, '`loaded` must resolve for an initializer with zero matching extensions').to.equal('resolved');
});
// Regression for the late-loading race that the external-login bug is built on.
//
// This simulates an extension that registers AFTER the initial load and whose
// instantiation is slow (the app-entry-point case: its onInit registers an auth provider
// after an async module load). A consumer that awaits `loaded` must not be told "loaded"
// until that late, slow extension has actually finished instantiating — otherwise it makes
// its decision (e.g. which login provider to redirect to) against a stale registry.
it('does not report `loaded` until a late-registered, slow extension has finished instantiating', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
// Initial, fast extension — load settles to `true`.
extensionRegistry.register({ type: 'test', name: 'a', alias: 'Umb.Test.A' } as never);
const { initializer, instantiated } = createTestInitializer(hostElement, extensionRegistry);
await new UmbObserver(initializer.loaded).asPromise();
expect(instantiated, 'initial extension instantiated').to.eql(['Umb.Test.A']);
// A late, slow extension registers (mirrors an app-entry-point's onInit registering an
// auth provider after an async delay).
extensionRegistry.register({
type: 'test',
name: 'b-late',
alias: 'Umb.Test.B.Late',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
// Awaiting `loaded` now must wait for the late extension to finish instantiating.
const lateExtInstantiatedWhenLoaded = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.B.Late'));
expect(
lateExtInstantiatedWhenLoaded,
'`loaded` resolved before the late, slow extension finished instantiating',
).to.be.true;
});
// Permission-timing guard (re: the #22522 "user permissions resolved too late" concern).
//
// The backoffice route is gated by `#loadedGuard`, which awaits `bundleInitializer.loaded`
// via `.asPromise()`; the private extensions and user-permission data that load behind that
// gate must not be raced. So the gate must NOT open until the extensions registered before it
// was awaited have actually finished instantiating. This guards against a naive "resolve
// unconditionally" that sets `loaded` before instantiation completes.
//
// Note: user-permission *condition* resolution itself lives in UmbBaseExtensionInitializer
// (see base-extension-initializer.race.test.ts) — a different class this change does not touch.
it('does not open the `loaded` gate until the initially-registered extensions have instantiated', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
extensionRegistry.register({
type: 'test',
name: 'slow-boot',
alias: 'Umb.Test.SlowBoot',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
const {initializer, instantiated} = createTestInitializer(hostElement, extensionRegistry);
const instantiatedWhenGateOpened = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.SlowBoot'));
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.be.true;
});
});
@@ -20,11 +20,23 @@ export abstract class UmbExtensionInitializerBase<
#loaded = new UmbBooleanState(undefined);
loaded = this.#loaded.asObservable();
// Identifies the current processing pass. The observer callback is async, so passes can
// overlap; only the latest pass is allowed to settle `loaded`, so a slower earlier pass
// cannot unblock waiters before the newest set of extensions has finished instantiating.
#loadPass = 0;
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
super(host);
this.host = host;
this.extensionRegistry = extensionRegistry;
this.observe(extensionRegistry.byType<Key, T>(manifestType), async (extensions) => {
const pass = ++this.#loadPass;
// Re-arm while this pass is in flight so a consumer awaiting `loaded` waits for it to
// finish instead of resolving on a stale `true` from a previous pass. `undefined`
// rather than `false` because `asPromise()` resolves on the first non-undefined value.
this.#loaded.setValue(undefined);
this.#extensionMap.forEach((existingExt) => {
if (!extensions.find((b) => b.alias === existingExt.alias)) {
this.unloadExtension(existingExt);
@@ -32,7 +44,10 @@ export abstract class UmbExtensionInitializerBase<
}
});
await Promise.all(
// `allSettled` so a throwing/rejecting `instantiateExtension` cannot leave `loaded`
// stuck at `undefined` and hang a waiter (e.g. the app boot gate). Failures are
// surfaced rather than swallowed.
const results = await Promise.allSettled(
extensions.map((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
this.#extensionMap.set(extension.alias, extension);
@@ -40,7 +55,16 @@ export abstract class UmbExtensionInitializerBase<
}),
);
if (extensions.length > 0) {
for (const result of results) {
if (result.status === 'rejected') {
console.error('[UmbExtensionInitializer] Failed to instantiate extension', result.reason);
}
}
// Only the latest pass settles `loaded`. Resolving unconditionally — including for
// zero extensions — so a consumer awaiting `loaded` (the app-entry-point boot gate,
// the bundle guard) never hangs on a default install that registers none of this type.
if (pass === this.#loadPass) {
this.#loaded.setValue(true);
}
});
@@ -228,7 +228,10 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
onContentError: ({ error }) => {
console.error('contentError', [error.message, error.cause]);
},
onUpdate: ({ editor }) => {
onUpdate: ({ editor, transaction }) => {
// Tiptap also fires `update` for no-op transactions (e.g. setEditable),
// which would otherwise dirty the workspace with a phantom change.
if (!transaction.docChanged) return;
this.#value = editor.getHTML();
this._runValidators();
this.dispatchEvent(new UmbChangeEvent());
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@umbraco/acceptance-test-helpers",
"version": "17.5.0-rc",
"version": "17.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco/acceptance-test-helpers",
"version": "17.5.0-rc",
"version": "17.5.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1,6 +1,6 @@
{
"name": "@umbraco-cms/acceptance-test-helpers",
"version": "17.5.0-rc",
"version": "17.5.0",
"description": "Test helpers and builders for making Playwright tests for Umbraco solutions",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -95,6 +95,15 @@ export default defineConfig({
ignoreHTTPSErrors: true,
}
},
// Unauthenticated: this exercises the login screen itself (a late-registered auth provider).
{
name: 'authProviderLateRegistration',
testMatch: 'AuthProviderLateRegistration/**/*.spec.ts',
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',
@@ -0,0 +1,30 @@
// Test fixture: an appEntryPoint that registers an external auth provider LATE — i.e. after
// an async delay inside onInit — mirroring a real provider (e.g. Umbraco ID) whose onInit
// registers its authProvider after fetching/initialising on a slow connection.
//
// The backoffice boot must wait for app-entry-points to settle before deciding which login
// provider to use. If it doesn't, the login screen renders before this provider is registered
// and the late provider never appears (the v17.4+ regression). The delay makes that race
// deterministic.
const LATE_REGISTRATION_DELAY_MS = 1500;
export const onInit = async (_host, extensionRegistry) => {
await new Promise((resolve) => setTimeout(resolve, LATE_REGISTRATION_DELAY_MS));
extensionRegistry.register({
type: 'authProvider',
alias: 'Test.LateAuthProvider',
name: 'Late External Login',
forProviderName: 'Umbraco.LateTest',
meta: {
label: 'Late External Login',
defaultView: {
icon: 'icon-cloud',
},
behavior: {
autoRedirect: false,
},
},
});
};
@@ -0,0 +1,12 @@
{
"name": "Late Auth Provider (test)",
"allowPublicAccess": true,
"extensions": [
{
"type": "appEntryPoint",
"alias": "Test.LateAuthProvider.EntryPoint",
"name": "Late Auth Provider Entry Point",
"js": "/App_Plugins/LateAuthProvider/entry-point.js"
}
]
}
@@ -0,0 +1,30 @@
import {test} from '@umbraco/acceptance-test-helpers';
import {expect} from '@playwright/test';
// Regression guard for the v17.4+ external-login race (introduced in #22522).
//
// AdditionalSetup/App_Plugins/LateAuthProvider deploys an appEntryPoint whose onInit, after a
// 1.5s delay, registers an external auth provider ("Late External Login"). This mirrors a real
// provider (e.g. Umbraco ID) that registers its authProvider during an async onInit on a slow
// connection.
//
// The backoffice boot must wait for app-entry-points to settle before rendering the login
// screen. If it does not, the login decision is made before the provider is registered, the
// late provider never appears, and the user is dropped on the local login instead. So: the
// late-registered provider MUST be offered on the login screen.
//
// This test is intentionally brittle (it depends on boot timing) but must remain working — it
// is the only end-to-end guard for the boot-gate behaviour.
test('a late-registered external auth provider is offered on the login screen', async ({umbracoUi}) => {
test.slow();
// Act - navigate to the backoffice unauthenticated (the login screen).
await umbracoUi.goToBackOffice();
// Assert - the provider registered late by the appEntryPoint is still offered. On the
// buggy boot the login screen renders before this provider exists, so it never appears.
const lateProviderButton = umbracoUi.page
.locator('umb-auth-provider-default')
.getByText('Sign in with Late External Login');
await expect(lateProviderButton).toBeVisible({timeout: 15000});
});
@@ -0,0 +1,100 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.BackgroundJobs;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
internal sealed class TouchServerJobTests : UmbracoIntegrationTest
{
private const string ApplicationUrl = "https://example.com/";
private IServerRegistrationService ServerRegistrationService => GetRequiredService<IServerRegistrationService>();
[Test]
public async Task RunJobAsync_With_No_Application_Url_Registers_Server_And_Resolves_Role_To_Single()
{
// No application URL is available, so the job has to fall back to a placeholder address.
TouchServerJob job = CreateJob(ApplicationMainUrl(null));
// Before the job runs nothing has been registered, so the role cannot be determined.
Assert.AreEqual(ServerRole.Unknown, ServerRegistrationService.GetCurrentServerRole());
await job.RunJobAsync(CancellationToken.None);
// The server is registered despite having no application URL, so election resolves the role to Single.
Assert.AreEqual(ServerRole.Single, ServerRegistrationService.GetCurrentServerRole());
IServerRegistration[] activeServers = ServerRegistrationService.GetActiveServers(refresh: true).ToArray();
Assert.That(activeServers, Has.Length.EqualTo(1));
Assert.AreEqual(Environment.MachineName, activeServers[0].ServerAddress);
}
[Test]
public async Task RunJobAsync_With_Application_Url_Registers_Server_With_That_Url()
{
TouchServerJob job = CreateJob(ApplicationMainUrl(new Uri(ApplicationUrl)));
await job.RunJobAsync(CancellationToken.None);
Assert.AreEqual(ServerRole.Single, ServerRegistrationService.GetCurrentServerRole());
IServerRegistration[] activeServers = ServerRegistrationService.GetActiveServers(refresh: true).ToArray();
Assert.That(activeServers, Has.Length.EqualTo(1));
Assert.AreEqual(ApplicationUrl, activeServers[0].ServerAddress);
}
[Test]
public async Task RunJobAsync_Replaces_Placeholder_With_Real_Url_Once_It_Becomes_Available()
{
// The application URL is not known on the first touch (e.g. detection is on but no request has arrived yet),
// but becomes available later (e.g. detected from a request).
Uri? applicationMainUrl = null;
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.SetupGet(x => x.ApplicationMainUrl).Returns(() => applicationMainUrl);
TouchServerJob job = CreateJob(hostingEnvironment.Object);
await job.RunJobAsync(CancellationToken.None);
// First touch: registered with the placeholder.
Assert.AreEqual(
Environment.MachineName,
ServerRegistrationService.GetActiveServers(refresh: true).Single().ServerAddress);
applicationMainUrl = new Uri(ApplicationUrl);
await job.RunJobAsync(CancellationToken.None);
// Second touch: the same (single) registration now holds the real URL, and the role is still Single.
IServerRegistration[] activeServers = ServerRegistrationService.GetActiveServers(refresh: true).ToArray();
Assert.That(activeServers, Has.Length.EqualTo(1));
Assert.AreEqual(ApplicationUrl, activeServers[0].ServerAddress);
Assert.AreEqual(ServerRole.Single, ServerRegistrationService.GetCurrentServerRole());
}
private static IHostingEnvironment ApplicationMainUrl(Uri? url)
{
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.SetupGet(x => x.ApplicationMainUrl).Returns(url);
return hostingEnvironment.Object;
}
private TouchServerJob CreateJob(IHostingEnvironment hostingEnvironment) => new(
ServerRegistrationService,
hostingEnvironment,
GetRequiredService<ILogger<TouchServerJob>>(),
GetRequiredService<IOptionsMonitor<GlobalSettings>>(),
new ElectedServerRoleAccessor(ServerRegistrationService));
}
@@ -0,0 +1,90 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Globalization;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.HealthChecks;
using Umbraco.Cms.Core.HealthChecks.Checks.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.HealthChecks;
[TestFixture]
public class UmbracoApplicationUrlCheckTests
{
// ILocalizedTextService.Localize is called via the extension Localize(area, alias) which delegates to
// Localize(area, alias, CultureInfo, IDictionary). We return the alias so tests can assert on which key was used.
private static ILocalizedTextService MockTextService()
{
var mock = new Mock<ILocalizedTextService>();
mock.Setup(x => x.Localize(
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<CultureInfo?>(),
It.IsAny<IDictionary<string, string?>>()))
.Returns((string? _, string? alias, CultureInfo? _, IDictionary<string, string?> _) => alias ?? string.Empty);
return mock.Object;
}
private static UmbracoApplicationUrlCheck CreateCheck(string? url, ApplicationUrlDetection detection)
{
var settings = new WebRoutingSettings
{
UmbracoApplicationUrl = url!,
ApplicationUrlDetection = detection,
};
IOptionsMonitor<WebRoutingSettings> monitor = new TestOptionsMonitor<WebRoutingSettings>(settings);
return new UmbracoApplicationUrlCheck(MockTextService(), monitor);
}
[Test]
public async Task When_Url_Is_Configured_Returns_Success()
{
UmbracoApplicationUrlCheck check = CreateCheck("https://mysite.com/", ApplicationUrlDetection.None);
HealthCheckStatus status = (await check.GetStatusAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(status.ResultType, Is.EqualTo(StatusResultType.Success));
Assert.That(status.Message, Is.EqualTo("umbracoApplicationUrlCheckResultTrue"));
Assert.That(status.ReadMoreLink, Is.Null.Or.Empty);
});
}
[TestCase(ApplicationUrlDetection.FirstRequest)]
[TestCase(ApplicationUrlDetection.EveryRequest)]
public async Task When_Url_Is_Not_Configured_But_Detection_Is_Enabled_Returns_Warning(ApplicationUrlDetection detection)
{
UmbracoApplicationUrlCheck check = CreateCheck(null, detection);
HealthCheckStatus status = (await check.GetStatusAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(status.ResultType, Is.EqualTo(StatusResultType.Warning));
Assert.That(status.Message, Is.EqualTo("umbracoApplicationUrlCheckResultFalse"));
Assert.That(status.ReadMoreLink, Is.Not.Null.And.Not.Empty);
});
}
[Test]
public async Task When_Url_Is_Not_Configured_And_Detection_Is_None_Returns_Error()
{
UmbracoApplicationUrlCheck check = CreateCheck(null, ApplicationUrlDetection.None);
HealthCheckStatus status = (await check.GetStatusAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(status.ResultType, Is.EqualTo(StatusResultType.Error));
Assert.That(status.Message, Is.EqualTo("umbracoApplicationUrlCheckResultError"));
Assert.That(status.ReadMoreLink, Is.Not.Null.And.Not.Empty);
});
}
}
@@ -24,18 +24,18 @@ public class TouchServerJobTests
[Test]
public async Task Does_Not_Execute_When_Application_Url_Is_Not_Available()
public async Task Touches_Server_With_Placeholder_When_Application_Url_Is_Not_Available()
{
var sut = CreateTouchServerTask(applicationUrl: string.Empty);
await sut.RunJobAsync();
VerifyServerNotTouched();
await sut.RunJobAsync(CancellationToken.None);
VerifyServerTouchedWithPlaceholder();
}
[Test]
public async Task Executes_And_Touches_Server()
{
var sut = CreateTouchServerTask();
await sut.RunJobAsync();
await sut.RunJobAsync(CancellationToken.None);
VerifyServerTouched();
}
@@ -43,14 +43,45 @@ public class TouchServerJobTests
public async Task Does_Not_Execute_When_Role_Accessor_Is_Not_Elected()
{
var sut = CreateTouchServerTask(useElection: false);
await sut.RunJobAsync();
await sut.RunJobAsync(CancellationToken.None);
VerifyServerNotTouched();
}
[Test]
public void Does_Not_Throw_When_Touching_The_Server_Fails()
{
var sut = CreateTouchServerTask();
_mockServerRegistrationService
.Setup(x => x.TouchServer(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Throws(new InvalidOperationException("database unavailable"));
// A failure to touch the server must not bubble out and kill the recurring job loop.
Assert.DoesNotThrowAsync(() => sut.RunJobAsync(CancellationToken.None));
}
[Test]
public void Runs_On_All_Server_Roles()
{
var sut = CreateTouchServerTask();
// The touch job must run on every role (including Unknown/Subscriber); otherwise a server whose role
// has not yet been elected would never register itself and the role could never be determined.
CollectionAssert.AreEquivalent(Enum.GetValues<ServerRole>(), sut.ServerRoles);
}
[Test]
public void Period_Is_Configured_From_WaitTimeBetweenCalls()
{
var waitTimeBetweenCalls = TimeSpan.FromSeconds(42);
var sut = CreateTouchServerTask(waitTimeBetweenCalls: waitTimeBetweenCalls);
Assert.AreEqual(waitTimeBetweenCalls, sut.Period);
}
private TouchServerJob CreateTouchServerTask(
RuntimeLevel runtimeLevel = RuntimeLevel.Run,
string applicationUrl = ApplicationUrl,
bool useElection = true)
bool useElection = true,
TimeSpan? waitTimeBetweenCalls = null)
{
var mockRequestAccessor = new Mock<IHostingEnvironment>();
mockRequestAccessor.SetupGet(x => x.ApplicationMainUrl)
@@ -65,7 +96,11 @@ public class TouchServerJobTests
var settings = new GlobalSettings
{
DatabaseServerRegistrar = new DatabaseServerRegistrarSettings { StaleServerTimeout = _staleServerTimeout },
DatabaseServerRegistrar = new DatabaseServerRegistrarSettings
{
StaleServerTimeout = _staleServerTimeout,
WaitTimeBetweenCalls = waitTimeBetweenCalls ?? TimeSpan.FromMinutes(1),
},
};
IServerRoleAccessor roleAccessor = useElection
@@ -90,4 +125,11 @@ public class TouchServerJobTests
It.Is<string>(y => y == ApplicationUrl),
It.Is<TimeSpan>(y => y == _staleServerTimeout)),
times);
private void VerifyServerTouchedWithPlaceholder() => _mockServerRegistrationService
.Verify(
x => x.TouchServer(
It.Is<string>(y => y == Environment.MachineName),
It.Is<TimeSpan>(y => y == _staleServerTimeout)),
Times.Once());
}
@@ -0,0 +1,199 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache;
[TestFixture]
internal sealed class DomainCacheServiceTests
{
[Test]
public void Can_Get_Configured_Domains_Excluding_Wildcards()
{
IDomain assigned = CreateDomain(1, "https://site-one.example/", 1001, "en-US", isWildcard: false);
IDomain wildcard = CreateDomain(2, "*.site-one.example", 1001, "en-US", isWildcard: true);
var domainService = new Mock<IDomainService>();
domainService
#pragma warning disable CS0618 // Type or member is obsolete. This test is for the DomainCacheService, which still calls the obsolete GetAll(bool) method.
.Setup(x => x.GetAll(true))
#pragma warning restore CS0618 // Type or member is obsolete
.Returns([assigned, wildcard]);
var sut = new DomainCacheService(domainService.Object, CreateScopeProvider());
Domain[] withoutWildcards = sut.GetAll(false).ToArray();
Domain[] withWildcards = sut.GetAll(true).ToArray();
Assert.Multiple(() =>
{
Assert.AreEqual(1, withoutWildcards.Length);
Assert.AreEqual(assigned.Id, withoutWildcards[0].Id);
Assert.AreEqual(2, withWildcards.Length);
});
}
[Test]
public async Task Cannot_Observe_Empty_Domain_Cache_During_Concurrent_First_Access()
{
// Arrange - a single configured domain that every caller must be able to see once the cache
// has initialized. The IDomainService load is gated so we can force a second caller to race in
// while the very first load is still in progress.
IDomain configuredDomain = CreateDomain(1, "https://site-one.example/", 1001, "en-US", isWildcard: false);
using var loadStarted = new ManualResetEventSlim(false);
using var secondCallerReady = new ManualResetEventSlim(false);
using var releaseLoad = new ManualResetEventSlim(false);
var domainService = new Mock<IDomainService>();
domainService
#pragma warning disable CS0618 // Type or member is obsolete. This test is for the DomainCacheService, which still calls the obsolete GetAll(bool) method.
.Setup(x => x.GetAll(true))
#pragma warning restore CS0618 // Type or member is obsolete
.Returns(() =>
{
loadStarted.Set();
// Fail loudly if the test never releases the load: returning anyway would let the test
// pass while the synchronization it depends on silently broke (and hide any hang).
Assert.IsTrue(
releaseLoad.Wait(TimeSpan.FromSeconds(10)),
"Timed out waiting for the gated domain load to be released.");
return [configuredDomain];
});
var sut = new DomainCacheService(domainService.Object, CreateScopeProvider());
// Act
// The first caller triggers initialization and blocks inside the gated load.
Task<Domain[]> firstCaller = Task.Run(() => sut.GetAll(false).ToArray());
Assert.IsTrue(loadStarted.Wait(TimeSpan.FromSeconds(10)), "Initialization did not start.");
// A second caller arrives while initialization is still in progress.
Task<Domain[]> secondCaller = Task.Run(() =>
{
// Confirm the caller has reached the cache before we assert on its blocking behaviour.
secondCallerReady.Set();
return sut.GetAll(false).ToArray();
});
Assert.IsTrue(secondCallerReady.Wait(TimeSpan.FromSeconds(10)), "Second caller did not start.");
// A correctly initialized cache makes the second caller block until the gated load completes, so it
// cannot finish while the load is still held. Observing an empty cache would instead let it return
// immediately. Completing here - before we release the load - therefore signals the empty-cache
// regression (and the result would be empty). Because this asserts a blocking invariant, it holds
// regardless of scheduler timing: a blocked caller never completes within any timeout.
var completedWhileLoadGated = secondCaller.Wait(TimeSpan.FromSeconds(1));
releaseLoad.Set();
Domain[] firstResult = await firstCaller;
Domain[] secondResult = await secondCaller;
// Assert
Assert.Multiple(() =>
{
Assert.IsFalse(completedWhileLoadGated, "Second caller returned before initialization completed - it observed an empty domain cache.");
Assert.AreEqual(1, firstResult.Length, "The caller that initialized the cache should see the configured domain.");
Assert.AreEqual(1, secondResult.Length, "A caller racing with initialization must not observe an empty domain cache.");
});
}
[Test]
public void Can_Refresh_To_Add_And_Update_A_Domain()
{
var domainService = new Mock<IDomainService>();
domainService
#pragma warning disable CS0618 // Type or member is obsolete. This test is for the DomainCacheService, which still calls the obsolete GetAll(bool) method.
.Setup(x => x.GetAll(true))
#pragma warning restore CS0618 // Type or member is obsolete
.Returns([]);
var sut = new DomainCacheService(domainService.Object, CreateScopeProvider());
// Trigger the initial (empty) load.
Assert.IsEmpty(sut.GetAll(true));
// A Refresh payload for a previously unknown domain adds it to the cache.
domainService.Setup(x => x.GetById(5)).Returns(CreateDomain(5, "https://site.example/", 2002, "en-US", isWildcard: false));
sut.Refresh([new DomainCacheRefresher.JsonPayload(5, DomainChangeTypes.Refresh)]);
Domain[] afterAdd = sut.GetAll(false).ToArray();
// A second Refresh for the same id with changed data must update in place, not duplicate the entry.
domainService.Setup(x => x.GetById(5)).Returns(CreateDomain(5, "https://renamed.example/", 3003, "en-US", isWildcard: false));
sut.Refresh([new DomainCacheRefresher.JsonPayload(5, DomainChangeTypes.Refresh)]);
Domain[] afterUpdate = sut.GetAll(false).ToArray();
Assert.Multiple(() =>
{
Assert.AreEqual(1, afterAdd.Length, "Refreshing an unknown domain should add it to the cache.");
Assert.AreEqual("https://site.example/", afterAdd[0].Name);
Assert.AreEqual(2002, afterAdd[0].ContentId);
Assert.AreEqual(1, afterUpdate.Length, "Refreshing an existing domain must update in place, not add a duplicate.");
Assert.AreEqual("https://renamed.example/", afterUpdate[0].Name);
Assert.AreEqual(3003, afterUpdate[0].ContentId);
});
}
[Test]
public void Can_Replace_Entire_Cache_On_RefreshAll()
{
IDomain first = CreateDomain(1, "https://site-one.example/", 1001, "en-US", isWildcard: false);
IDomain second = CreateDomain(2, "https://site-two.example/", 1002, "da-DK", isWildcard: false);
var current = new List<IDomain> { first, second };
var domainService = new Mock<IDomainService>();
domainService
#pragma warning disable CS0618 // Type or member is obsolete. This test is for the DomainCacheService, which still calls the obsolete GetAll(bool) method.
.Setup(x => x.GetAll(true))
#pragma warning restore CS0618 // Type or member is obsolete
.Returns(() => current.ToArray());
var sut = new DomainCacheService(domainService.Object, CreateScopeProvider());
// The initial lazy load sees both domains.
Assert.AreEqual(2, sut.GetAll(false).Count());
// A domain is removed at the source, then a RefreshAll rebuilds the whole cache from scratch.
current.Remove(second);
sut.Refresh([new DomainCacheRefresher.JsonPayload(0, DomainChangeTypes.RefreshAll)]);
Domain[] afterRefreshAll = sut.GetAll(false).ToArray();
Assert.Multiple(() =>
{
Assert.AreEqual(1, afterRefreshAll.Length, "RefreshAll should fully replace the cache, dropping removed domains.");
Assert.AreEqual(first.Id, afterRefreshAll[0].Id);
});
}
private static IDomain CreateDomain(int id, string name, int rootContentId, string culture, bool isWildcard)
{
var domain = new Mock<IDomain>();
domain.SetupGet(x => x.Id).Returns(id);
domain.SetupGet(x => x.DomainName).Returns(name);
domain.SetupGet(x => x.RootContentId).Returns(rootContentId);
domain.SetupGet(x => x.LanguageIsoCode).Returns(culture);
domain.SetupGet(x => x.IsWildcard).Returns(isWildcard);
domain.SetupGet(x => x.SortOrder).Returns(0);
return domain.Object;
}
private static ICoreScopeProvider CreateScopeProvider()
{
var scope = new Mock<ICoreScope>();
var scopeProvider = new Mock<ICoreScopeProvider>();
scopeProvider.Setup(x => x.CreateCoreScope()).Returns(scope.Object);
return scopeProvider.Object;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "17.5.0-rc",
"version": "17.5.0",
"assemblyVersion": {
"precision": "build"
},