Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48a8c1f3e | ||
|
|
28b849f2d3 | ||
|
|
5a20452e7a | ||
|
|
adeddeb148 | ||
|
|
a14b908574 | ||
|
|
9f633416b1 | ||
|
|
e2cf205d34 | ||
|
|
fe3318ef79 | ||
|
|
7433641348 | ||
|
|
764d4eb1d7 | ||
|
|
d0fc7dc8a0 | ||
|
|
89baa9482b |
@@ -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,
|
||||
};
|
||||
|
||||
+13
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+495
-495
File diff suppressed because it is too large
Load Diff
@@ -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;">© 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;">© 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;">© 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;">© 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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -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;
|
||||
});
|
||||
});
|
||||
+26
-2
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
+4
-1
@@ -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
@@ -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',
|
||||
|
||||
+30
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
+12
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+30
@@ -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});
|
||||
});
|
||||
+100
@@ -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));
|
||||
}
|
||||
+90
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
-7
@@ -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());
|
||||
}
|
||||
|
||||
+199
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user