Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using EPiServer;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SiteImprove.Optimizely.Plugin.Helper;
Expand Down Expand Up @@ -48,20 +47,12 @@ public ActionResult PageUrl(string contentId, string locale)
var contentRep = ServiceLocator.Current.GetInstance<IContentRepository>();
var content = contentRep.Get<IContent>(
new ContentReference(contentId),
new LanguageSelector(locale));
LanguageSelector.Fallback(locale, false));

if (content is PageData page)
{
//if (page.CheckPublishedStatus(PagePublishedStatus.Published))
//{
var externalUrl = _siteimproveHelper.GetExternalUrl(page);
return Json(new { url = externalUrl, isDomain = false });
//}
//else
//{
// var currentSiteUrl = SiteDefinition.Current.SiteUrl.ToString();
// return Json(new { url = currentSiteUrl, isDomain = true });
//}
var externalUrl = _siteimproveHelper.GetExternalUrl(page, locale);
return Json(new { url = externalUrl, isDomain = false });
}

return StatusCode((int)HttpStatusCode.BadRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public interface ISiteimproveHelper
string RequestToken();
void PassEvent(string type, string url, string token);
string GetExternalUrl(PageData page);
// Keep existing custom helpers compatible; they can override regional URL resolution.
string GetExternalUrl(PageData page, string language) => GetExternalUrl(page);
bool GetPrepublishCheckEnabled(string apiUser, string apiKey);
bool EnablePrepublishCheck(string apiUser, string apiKey);
}
}
}
12 changes: 11 additions & 1 deletion SiteImprove.Optimizely.Plugin/Helper/SiteimproveHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,20 @@ public string GetSiteimprovePluginVersion()
}

public string GetExternalUrl(PageData page)
{
return GetExternalUrl(page, null);
}

public string GetExternalUrl(PageData page, string language)
{
try
{
var internalUrl = ServiceLocator.Current.GetInstance<IUrlResolver>().GetUrl(page.ContentLink);
// Fallback content can have a different language from the editor's selected region.
var urlLanguage = string.IsNullOrEmpty(language) ? page.Language?.Name : language;
var internalUrl = ServiceLocator.Current.GetInstance<IUrlResolver>().GetUrl(
page.ContentLink.ToReferenceWithoutVersion(),
urlLanguage,
new UrlResolverArguments { ContextMode = ContextMode.Default });

if (internalUrl == null) //can be null for special pages like settings
{
Expand Down
2 changes: 2 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Functional tests

See [CST-6191 regional-language validation](docs/cst-6191-validation.md) for the regression evidence and merge-risk assessment.

These tests cover the plugin's configuration, URL handling, authorization,
publishing integration, and browser callbacks. No Siteimprove account, API key,
Optimizely database, or running CMS site is needed. All credentials and content
Expand Down
57 changes: 57 additions & 0 deletions docs/cst-6191-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# CST-6191: regional language URLs

## Problem and fix

When an editor selects a regional language such as `en-US`, the plugin previously loaded content with fallback disabled, then resolved its URL without passing the selected language. A page inheriting English content could therefore fail to load or be looked up under the master-language URL instead of its regional public URL.

The controller now honors configured fallback and replacement rules and passes the editor's selected locale to the URL helper. It does not enable unconditional master-language fallback. The helper requests a public URL using a content reference without the editor revision. Existing calls without an editor locale, including publish events, use the page's own language. The content object and its revision are not modified.

Optimizely's [language selector contract](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Core.LanguageSelector?version=12) supports configured fallback without unconditional master fallback. Its [URL resolver contract](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Web.Routing.IUrlResolver?version=12) accepts a language, and [URL arguments](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Web.Routing.UrlResolverArguments?version=12) let callers explicitly select public context instead of inheriting the request's context.

URL paths remain the CMS resolver's responsibility; the plugin does not manufacture a locale prefix or change its existing host mappings. A missing route still returns a null URL. The original helper method remains available, and a default implementation of the new overload delegates to it for existing custom implementations. Custom helpers must implement the new overload to gain locale-aware behavior.

## Validation on September 15, 2026

Base: local `origin/main`, commit `b23e48e7b12d8b3170d4595be28b61c16b39eeef`. Branch: `fix/cst-6191-regional-language-urls`.

Environment: ARM64 macOS, .NET SDK 8.0.425, .NET/ASP.NET Core 6.0.36 and 8.0.31. The plugin and tests restored their checked-in dependency graphs in locked mode. The obsolete feed URL was overridden for this restore with the Optimizely v3 feed; no dependency or package target changes were made. The existing .NET 6 end-of-support warning remains.

| Check | Result |
| --- | --- |
| Initial regional tests against unchanged production code | On each .NET target: 15 failed, 1 passed, none skipped |
| Same 16 cases after the fix | All passed on both targets |
| Additional missing-locale and custom-helper compatibility cases | All 3 passed on both targets |
| Full backend suite after the fix | 75 passed on .NET 6 and 75 passed on .NET 8; none failed or skipped |
| Existing Chromium/Firefox functional suite | 20 passed; none failed or skipped |
| Diff whitespace | Passed |

The failures were runtime assertions, not compilation errors: all 11 reported regional branches produced master-language URLs; configured fallback loading, translated content, public routing from a revision, and calls without an editor locale also failed. The missing-route control already passed before the fix.

The regional tests use the production controller and helper together, with test doubles for content loading, URL/site resolution and settings. They validate the language and routing contract, not Optimizely's actual fallback routing or Siteimprove crawl matching. Existing tests additionally cover host/scheme/port mapping, distinct sites, authorization, Block requests, settings, HTTP handling and publish events. One existing URL-mapping fixture was updated to expect the public reference instead of a draft revision; its output assertions are unchanged.

Local test evidence is in `test-results/regional-before/` and `test-results/backend-final/`. The browser report is in `playwright-report/`. Test reports are ignored build artifacts. To rerun the completed backend suite with suitable .NET runtimes:

```sh
dotnet restore tests/Plugin.Tests/Plugin.Tests.csproj --locked-mode --source https://api.nuget.org/v3/index.json --source https://nuget.optimizely.com/v3/index.json
dotnet test tests/Plugin.Tests/Plugin.Tests.csproj --configuration Release --no-restore -p:GeneratePackageOnBuild=false
```

Use `--filter FullyQualifiedName~RegionalLanguageUrlTests` for the 19 new cases. Browser setup and commands are in `TESTING.md`.

## Likelihood of success and merge risk

**High confidence in correcting the identified plugin defects; moderate confidence in resolving the entire customer report.** This is an engineering assessment, not a measured success probability. The failing/passing tests establish that selected locales reach the resolver and configured fallback reaches the loader. The ticket does not include a failing network request, its referenced screenshots, the customer's routing code or an exact comparison with the crawl inventory. A wrong canonical host, redirect, language-specific domain or missing crawl entry could still prevent a live report.

**Overall merge risk: medium until a representative CMS smoke test passes.** The implementation is small and the automated suite passes, but URL resolution is shared by the editor and publish events.

| Risk | Impact and mitigation |
| --- | --- |
| Site-specific fallback/replacement or custom routing | Explicit language and public context can change which URL a custom router returns. Check inherited and translated pages in the customer's staging configuration. |
| Editor revisions and unpublished pages | Public URL lookup now drops the work ID; draft content remains available to the existing preview callback. Confirm a draft of a published page and a never-published page, since actual CMS rendering/route availability is not tested here. |
| Existing custom helper implementations | The default overload preserves existing behavior, as verified with an implementation of only the original interface contract. Such helpers need their own locale-aware overload to benefit. Precompiled third-party implementations were not tested. |
| Crawl inventory and canonical URLs | Existing origin mappings are unchanged, including their limitations for language-specific hosts. Compare the returned URL with the exact crawled regional URL and verify the real live-page report. |
| Customer version coverage | Builds use locked baseline CMS 12 dependencies, not the customer's exact 12.29.1/12.34.3 stack. No real CMS or authenticated Siteimprove session was run for this fix. |

Before release, check an inherited `en-US` page, a locally translated `fr-CA` page and a numeric region such as `es-419` on representative CMS staging. Verify the plugin's returned URL, the corresponding live report, draft Prepublish capture, and the publish-event recheck URL. Also confirm that content without a configured public fallback does not acquire an invented regional route.

Merging this change does not migrate data or alter stored settings. Reverting it restores previous URL behavior. No package has been published or deployed.
3 changes: 2 additions & 1 deletion tests/Plugin.Tests/ControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public void Page_url_resolves_the_requested_revision_and_language()
{
var page = ContentFixture.Page(42, 7);
content.Setup(x => x.Get<IContent>(It.Is<ContentReference>(r => r.ID == 42 && r.WorkID == 7), It.Is<LoaderOptions>(l => l.Get<LanguageLoaderOption>().Language.Name == "da"))).Returns(page);
helper.Setup(x => x.GetExternalUrl(page)).Returns("https://public.example/da/news");
helper.Setup(x => x.GetExternalUrl(page, "da")).Returns("https://public.example/da/news");
var result = Assert.IsType<JsonResult>(new SiteimproveController(settings.Object, helper.Object).PageUrl("42_7", "da"));
var json = JObject.FromObject(result.Value);
Assert.Equal("https://public.example/da/news", (string)json["url"]);
Expand All @@ -48,6 +48,7 @@ public void Direct_block_request_returns_bad_request_without_resolving_a_page_ur
var result = Assert.IsType<StatusCodeResult>(new SiteimproveController(settings.Object, helper.Object).PageUrl("99", "en"));
Assert.Equal(400, result.StatusCode);
helper.Verify(x => x.GetExternalUrl(It.IsAny<PageData>()), Times.Never);
helper.Verify(x => x.GetExternalUrl(It.IsAny<PageData>(), It.IsAny<string>()), Times.Never);
}

private SiteimproveAdminController Admin() => new(settings.Object, helper.Object, Mock.Of<IModuleResourceResolver>());
Expand Down
168 changes: 168 additions & 0 deletions tests/Plugin.Tests/RegionalLanguageUrlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Globalization;
using EPiServer;
using EPiServer.Core;
using EPiServer.Web;
using EPiServer.Web.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Newtonsoft.Json.Linq;
using SiteImprove.Optimizely.Plugin.Controllers;
using SiteImprove.Optimizely.Plugin.Helper;
using SiteImprove.Optimizely.Plugin.Models;
using SiteImprove.Optimizely.Plugin.Repositories;
using Xunit;

namespace Plugin.Tests;

public class RegionalLanguageUrlTests : ServiceFixture
{
private readonly Mock<IContentRepository> content = new();
private readonly Mock<IUrlResolver> urls = new();
private readonly Mock<ISiteDefinitionResolver> sites = new();
private readonly Mock<ISettingsRepository> settings = new();
private readonly PageData master = ContentFixture.Page(42, 7);
private readonly SiteimproveHelper helper;

public RegionalLanguageUrlTests()
{
master.Property.Add("PageLanguageBranch", new PropertyString("en"));
content.Setup(x => x.Get<IContent>(It.IsAny<ContentReference>(), It.IsAny<LoaderOptions>())).Returns(master);
sites.Setup(x => x.GetByContent(It.IsAny<ContentReference>(), false))
.Returns(new SiteDefinition { SiteUrl = new Uri("https://cms.example") });
settings.Setup(x => x.GetSetting()).Returns(new Settings
{
UrlMap = new() { ["https://cms.example"] = "https://www.example" }
});
Services.AddSingleton(content.Object);
Services.AddSingleton(urls.Object);
Services.AddSingleton(sites.Object);
UseServices();
helper = new SiteimproveHelper(settings.Object);
}

// These are the regional language branches reported in CST-6191.
[Theory]
[InlineData("en-001")]
[InlineData("en-GB")]
[InlineData("en-US")]
[InlineData("en-150")]
[InlineData("en-CA")]
[InlineData("en-SG")]
[InlineData("fr-FR")]
[InlineData("fr-CA")]
[InlineData("de-DE")]
[InlineData("es-ES")]
[InlineData("es-419")]
public void Inherited_page_uses_the_selected_region_instead_of_the_master_language(string locale)
{
// The content is English; the editor and public route can still be regional.
Assert.Equal("en", master.Language.Name);
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns((ContentReference link, string language, UrlResolverArguments args) =>
$"/{(language ?? "en").ToLowerInvariant()}/investor-relations/");

Assert.Equal($"https://www.example/{locale.ToLowerInvariant()}/investor-relations/", PageUrl("42_7", locale));
content.Verify(x => x.Get<IContent>(
It.Is<ContentReference>(r => r.ID == 42 && r.WorkID == 7),
It.Is<LoaderOptions>(o => o.Get<LanguageLoaderOption>().Language.Name == locale)), Times.Once);
}

[Fact]
public void Page_without_a_regional_version_can_load_using_configured_fallback()
{
content.Setup(x => x.Get<IContent>(It.IsAny<ContentReference>(), It.IsAny<LoaderOptions>()))
.Returns((ContentReference link, LoaderOptions options) =>
options.Get<LanguageLoaderOption>().FallbackBehaviour == LanguageBehaviour.Fallback ? master : null);
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns("/en-gb/investor-relations/");

Assert.Equal("https://www.example/en-gb/investor-relations/", PageUrl("42", "en-GB"));
}

[Fact]
public void Live_page_url_uses_a_public_route_without_the_editor_revision()
{
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns((ContentReference link, string language, UrlResolverArguments args) =>
link.WorkID == 0 && args?.ContextMode == ContextMode.Default
? "/en-us/investor-relations/"
: "/EPiServer/CMS/Content/investor-relations,,42_7/?epieditmode=True");

Assert.Equal("https://www.example/en-us/investor-relations/", PageUrl("42_7", "en-US"));
Assert.Equal(7, master.ContentLink.WorkID);
}

[Fact]
public void Locally_translated_page_keeps_its_regional_url()
{
master.Language = CultureInfo.GetCultureInfo("fr-CA");
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns((ContentReference link, string language, UrlResolverArguments args) =>
language == "fr-CA" ? "/fr-ca/relations-investisseurs/" : "/en/investor-relations/");

Assert.Equal("https://www.example/fr-ca/relations-investisseurs/", PageUrl("42_7", "fr-CA"));
}

[Fact]
public void Calls_without_an_editor_locale_use_the_pages_own_language()
{
master.Language = CultureInfo.GetCultureInfo("es-419");
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns((ContentReference link, string language, UrlResolverArguments args) =>
language == "es-419" ? "/es-419/inversores/" : "/en/investors/");

Assert.Equal("https://www.example/es-419/inversores/", helper.GetExternalUrl(master));
}

[Fact]
public void Missing_public_route_does_not_invent_a_regional_url()
{
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), It.IsAny<string>(), It.IsAny<UrlResolverArguments>()))
.Returns((string)null);

Assert.Null(PageUrl("42_7", "en-US"));
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Missing_editor_locale_keeps_the_pages_language(string locale)
{
master.Language = CultureInfo.GetCultureInfo("fr-CA");
urls.Setup(x => x.GetUrl(It.IsAny<ContentReference>(), "fr-CA", It.IsAny<UrlResolverArguments>()))
.Returns("/fr-ca/relations-investisseurs/");

Assert.Equal("https://www.example/fr-ca/relations-investisseurs/", helper.GetExternalUrl(master, locale));
}

[Fact]
public void Custom_helper_implementing_only_the_original_contract_still_works()
{
ISiteimproveHelper customHelper = new ExistingCustomHelper();
var controller = new SiteimproveController(settings.Object, customHelper);
var response = Assert.IsType<JsonResult>(controller.PageUrl("42_7", "en-US"));

Assert.Equal("https://custom.example/page/42", (string)JObject.FromObject(response.Value)["url"]);
}

private sealed class ExistingCustomHelper : ISiteimproveHelper
{
public string GetExternalUrl(PageData page) => $"https://custom.example/page/{page.ContentLink.ID}";
public string GetOptimizelyVersion() => throw new NotSupportedException();
public string GetSiteimprovePluginVersion() => throw new NotSupportedException();
public string RequestToken() => throw new NotSupportedException();
public void PassEvent(string type, string url, string token) => throw new NotSupportedException();
public bool GetPrepublishCheckEnabled(string apiUser, string apiKey) => throw new NotSupportedException();
public bool EnablePrepublishCheck(string apiUser, string apiKey) => throw new NotSupportedException();
}

private string PageUrl(string contentId, string locale)
{
var controller = new SiteimproveController(settings.Object, helper);
var response = Assert.IsType<JsonResult>(controller.PageUrl(contentId, locale));
var json = JObject.FromObject(response.Value);
Assert.False((bool)json["isDomain"]);
return (string)json["url"];
}
}
Loading
Loading