Skip to content

Commit

Permalink
Merge pull request #84 from Lombiq/issue/OSOE-638
Browse files Browse the repository at this point in the history
OSOE-638: Feature to edit shell settings from the admin in Lombiq.Hosting.Tenants
  • Loading branch information
Piedone authored Oct 16, 2023
2 parents d3734e1 + 83f9e95 commit 819285d
Show file tree
Hide file tree
Showing 22 changed files with 579 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.0.2" />
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.0.2" />
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.0.2" />
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.0.2" />
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.0.2" />
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Lombiq.Tests.UI.Extensions;
using Lombiq.Tests.UI.Services;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using Shouldly;
using System.Threading.Tasks;

namespace Lombiq.Hosting.Tenants.Management.Tests.UI.Extensions;

public static class TestCaseUITestContextExtensions
{
public static async Task TestShellSettingsEditorFeatureAsync(this UITestContext context)
{
await context.SignInDirectlyAsync();
await context.GoToAdminRelativeUrlAsync("/Tenants/Edit/Default");

// Expected JSON string.
#pragma warning disable JSON002 // Probable JSON string detected
await context.FillInEditorThenCheckValueAsync(
"{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"FirstTestKey\": \"FirstTestValue\",\"SecondTestKey\": \"SecondTestValue\"}}}}",
"FirstTestKey",
"FirstTestValue");

await context.FillInEditorThenCheckValueAsync(
"{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"NewKey\": \"NewValue\",\"SecondTestKey\": \"SecondTestValue\"}}}}",
"NewKey",
"NewValue");

await context.FillInEditorThenCheckValueAsync(
"{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"SecondTestKey\": \"SecondTestValue\"}}}}",
"NewKey",
string.Empty);

CheckEditorValue(context, "SecondTestKey", "SecondTestValue");

await context.FillInEditorThenCheckValueAsync(
"{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{}}}}",
"SecondTestKey",
string.Empty);
#pragma warning restore JSON002 // Probable JSON string detected

await context.FillInEditorThenCheckValueAsync(
string.Empty,
"SecondTestKey",
string.Empty);
}

private static async Task FillInEditorThenCheckValueAsync(this UITestContext context, string text, string keyToCheck, string expectedValue)
{
context.FillInMonacoEditor("Json_editor", text);
await context.ClickReliablyOnAsync(By.XPath("//button[contains(.,'Save settings')]"));
CheckEditorValue(context, keyToCheck, expectedValue);
}

private static void CheckEditorValue(this UITestContext context, string keyToCheck, string expectedValue)
{
var editorText = context.GetMonacoEditorText("Json_editor");
var editorJson = string.IsNullOrEmpty(editorText) ? "{}" : editorText;

var editorValue = JObject.Parse(editorJson);
editorValue.SelectToken($"TestKey.TestSubKey.TestSubOptions.{keyToCheck}")?.ToString().ShouldBeAsString(expectedValue);
}
}
13 changes: 13 additions & 0 deletions Lombiq.Hosting.Tenants.Management.Tests.UI/License.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright © 2021, [Lombiq Technologies Ltd.](https://lombiq.com)

All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

Check failure on line 1 in Lombiq.Hosting.Tenants.Management.Tests.UI/Lombiq.Hosting.Tenants.Management.Tests.UI.csproj

View workflow job for this annotation

GitHub Actions / publish-nuget / publish-nuget

dotnet pack failed for the project Lombiq.Hosting.Tenants.Management.Tests.UI.csproj.

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<PropertyGroup>
<Title>Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions</Title>
<Authors>Lombiq Technologies</Authors>
<Copyright>Copyright © 2021, Lombiq Technologies Ltd.</Copyright>
<Description>Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions: Extension methods that test tenants management for Orchard Core.</Description>
<PackageIcon>NuGetIcon.png</PackageIcon>
<PackageTags>OrchardCore;Lombiq;AspNetCore;Multitenancy;SaaS</PackageTags>
<RepositoryUrl>https://github.com/Lombiq/Hosting-Tenants</RepositoryUrl>
<PackageProjectUrl>https://github.com/Lombiq/Hosting-Tenants/blob/dev/Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md</PackageProjectUrl>
<PackageLicenseFile>License.md</PackageLicenseFile>
</PropertyGroup>

<ItemGroup Condition="'$(NuGetBuild)' != 'true'">
<ProjectReference Include="..\..\..\..\test\Lombiq.UITestingToolbox\Lombiq.Tests.UI\Lombiq.Tests.UI.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(NuGetBuild)' == 'true'">
<PackageReference Include="Lombiq.Tests.UI" Version="8.1.0-alpha.1.osoe-638" />
</ItemGroup>

<ItemGroup>
<None Include="License.md" Pack="true" PackagePath="" />
<None Include="NuGetIcon.png" Pack="true" PackagePath="" />
<None Include="Readme.md" />
</ItemGroup>

</Project>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions Lombiq.Hosting.Tenants.Management.Tests.UI/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Lombiq Hosting - Tenants Management for Orchard Core - UI Test Extensions

## About

Extension methods that test tenants management for Orchard Core, with the help of [Lombiq UI Testing Toolbox for Orchard Core](https://github.com/Lombiq/UI-Testing-Toolbox).

Call these from a UI test project to verify the module's basic features; as seen in [Open-Source Orchard Core Extensions](https://github.com/Lombiq/Open-Source-Orchard-Core-Extensions).
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public static class FeatureNames

public const string ForbiddenTenantNames = Module + "." + nameof(ForbiddenTenantNames);
public const string HideRecipesFromSetup = Module + "." + nameof(HideRecipesFromSetup);
public const string ShellSettingsEditor = Module + "." + nameof(ShellSettingsEditor);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using Lombiq.Hosting.Tenants.Management.Constants;
using Lombiq.Hosting.Tenants.Management.Models;
using Lombiq.Hosting.Tenants.Management.Service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Configuration;
using OrchardCore.DisplayManagement.Notify;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Configuration;
using OrchardCore.Locking.Distributed;
using OrchardCore.Modules;
using OrchardCore.Mvc.Core.Utilities;
using OrchardCore.Tenants.Controllers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using static OrchardCore.Tenants.Permissions;

namespace Lombiq.Hosting.Tenants.Management.Controllers;

[Feature(FeatureNames.ShellSettingsEditor)]
public class ShellSettingsEditorController : Controller
{
private readonly IAuthorizationService _authorizationService;
private readonly IShellHost _shellHost;
private readonly IShellConfigurationSources _shellConfigurationSources;
private readonly IDistributedLock _distributedLock;
private readonly INotifier _notifier;
private readonly IHtmlLocalizer<ShellSettingsEditorController> H;

public ShellSettingsEditorController(
IAuthorizationService authorizationService,
IShellHost shellHost,
IShellConfigurationSources shellConfigurationSources,
IDistributedLock distributedLock,
INotifier notifier,
IHtmlLocalizer<ShellSettingsEditorController> htmlLocalizer)
{
_authorizationService = authorizationService;
_shellHost = shellHost;
_shellConfigurationSources = shellConfigurationSources;
_distributedLock = distributedLock;
_notifier = notifier;
H = htmlLocalizer;
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(ShellSettingsEditorViewModel model)
{
if (!await _authorizationService.AuthorizeAsync(User, ManageTenants) ||
!_shellHost.TryGetSettings(model.TenantId, out var shellSettings))
{
return NotFound();
}

model.Json ??= "{}";
if (!IsValidJson(model.Json))
{
await _notifier.ErrorAsync(H["Please provide valid JSON input for shell settings."]);
TempData["ValidationErrorJson"] = model.Json;

return RedirectToAction(
nameof(AdminController.Edit),
typeof(AdminController).ControllerName(),
new
{
area = "OrchardCore.Tenants",
id = model.TenantId,
});
}

var tenantConfiguration = new JsonConfigurationParser().ParseConfiguration(model.Json);
var newTenantConfiguration = new Dictionary<string, string>();

var tenantSettingsPrefix = $"{model.TenantId}Prefix:";
var currentSettings = shellSettings.ShellConfiguration.AsEnumerable()
.Where(item => item.Value != null &&
item.Key.Contains(tenantSettingsPrefix))
.ToDictionary(key => key.Key.Replace(tenantSettingsPrefix, string.Empty), value => value.Value);

foreach (var key in tenantConfiguration.Keys)
{
var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}";
if (shellSettings[key] != tenantConfiguration[key])
{
newTenantConfiguration[tenantSettingsPrefixWithKey] = tenantConfiguration[key];
newTenantConfiguration[key] = tenantConfiguration[key];
}
}

var deletableKeys = currentSettings
.Where(item => !tenantConfiguration.ContainsKey(item.Key))
.Select(item => item.Key);

foreach (var key in deletableKeys)
{
var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}";
newTenantConfiguration[key] = null;
newTenantConfiguration[tenantSettingsPrefixWithKey] = null;
}

var (locker, locked) =
await _distributedLock.TryAcquireLockAsync(
"LOMBIQ_HOSTING_TENANTS_MANAGEMENT_SHELL_SETTINGS_EDITOR_LOCK",
TimeSpan.FromSeconds(10));

if (!locked)
{
throw new TimeoutException($"Failed to acquire a lock before saving settings to the tenant: {model.TenantId}.");
}

await using var acquiredLock = locker;

// We are using the shell configuration sources directly because using IShellHost.UpdateShellSettingsAsync would
// not save settings that has a key with multiple sections, see
// https://github.com/OrchardCMS/OrchardCore/issues/14481. Once this is fixed, we can get rid of the locking and
// retrieve and save the shell settings settings with IShellHost.
await _shellConfigurationSources.SaveAsync(shellSettings.Name, newTenantConfiguration);
await _shellHost.UpdateShellSettingsAsync(shellSettings);

return RedirectToAction(
nameof(AdminController.Edit),
typeof(AdminController).ControllerName(),
new
{
area = "OrchardCore.Tenants",
id = model.TenantId,
});
}

private static bool IsValidJson(string json)
{
try
{
JsonDocument.Parse(json);
return true;
}
catch (JsonException)
{
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Lombiq.Hosting.Tenants.Management.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Layout;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Configuration;
using OrchardCore.Mvc.Core.Utilities;
using OrchardCore.Tenants.Controllers;
using System.Threading.Tasks;

namespace Lombiq.Hosting.Tenants.Management.Filters;

public class ShellSettingsEditorFilter : IAsyncResultFilter
{
private readonly ILayoutAccessor _layoutAccessor;
private readonly IShapeFactory _shapeFactory;
private readonly IShellHost _shellHost;

public ShellSettingsEditorFilter(
ILayoutAccessor layoutAccessor,
IShapeFactory shapeFactory,
IShellHost shellHost)
{
_layoutAccessor = layoutAccessor;
_shapeFactory = shapeFactory;
_shellHost = shellHost;
}

public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
var actionRouteController = context.ActionDescriptor.RouteValues["Controller"];
var actionRouteArea = context.ActionDescriptor.RouteValues["Area"];
var actionRouteValue = context.ActionDescriptor.RouteValues["Action"];

if (actionRouteController == typeof(AdminController).ControllerName() &&
actionRouteArea == $"{nameof(OrchardCore)}.{nameof(OrchardCore.Tenants)}" &&
actionRouteValue is nameof(AdminController.Edit) &&
context.Result is ViewResult)
{
var tenantName = context.RouteData.Values["Id"].ToString();
if (!_shellHost.TryGetSettings(tenantName, out var shellSettings))
{
await next();
return;
}

var layout = await _layoutAccessor.GetLayoutAsync();
var contentZone = layout.Zones["Content"];

(context.Controller as Controller)
!.TempData
.TryGetValue(
"ValidationErrorJson",
out var validationErrorJson);

var editableItems = shellSettings.ShellConfiguration.AsJsonNode();
var editorJson = string.IsNullOrEmpty(validationErrorJson?.ToString())
? editableItems[$"{tenantName}Prefix"]?.ToJsonString()
: validationErrorJson.ToString();

await contentZone.AddAsync(
await _shapeFactory.CreateAsync<ShellSettingsEditorViewModel>(
"ShellSettingsEditor",
viewModel =>
{
viewModel.Json = editorJson;
viewModel.TenantId = tenantName;
}),
"10");
}

await next();
}
}
Loading

0 comments on commit 819285d

Please sign in to comment.