diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bdfced8..e1138fa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,12 +10,8 @@ env:
jobs:
test:
- runs-on: ${{ matrix.os }}
+ runs-on: windows-latest
name: Build & Test
- strategy:
- fail-fast: false
- matrix:
- os: [ ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@v4
- name: Setup .NET Core
@@ -29,10 +25,5 @@ jobs:
- name: Install Playwright browsers & dependencies
run: pwsh test/OrchardCoreContrib.Testing.UI.Tests/bin/Release/net8.0/playwright.ps1 install --with-deps
- name: Test
- run: |
- if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
- xvfb-run dotnet test test/OrchardCoreContrib.Testing.UI.Tests -c Release --no-restore --verbosity normal
- else
+ run:
dotnet test test/OrchardCoreContrib.Testing.UI.Tests -c Release --no-restore --verbosity normal
- fi
- shell: bash
diff --git a/.gitignore b/.gitignore
index 8a30d25..3b81dcb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -396,3 +396,4 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+src/OrchardCoreContrib.Testing.Web/Localization
diff --git a/OrchardCoreContrib.Testing.sln b/OrchardCoreContrib.Testing.sln
index 5d6aa80..c2560c1 100644
--- a/OrchardCoreContrib.Testing.sln
+++ b/OrchardCoreContrib.Testing.sln
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.9.34701.34
+# Visual Studio Version 18
+VisualStudioVersion = 18.1.11312.151 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{20F306B8-D63F-4A61-AF6E-64B4E0918E30}"
EndProject
@@ -11,6 +11,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCoreContrib.Testing.
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCoreContrib.Testing.UI.Tests", "test\OrchardCoreContrib.Testing.UI.Tests\OrchardCoreContrib.Testing.UI.Tests.csproj", "{985F5AD6-8C18-4E63-A35C-A5673F237A4D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.Testing", "src\OrchardCoreContrib.Testing\OrchardCoreContrib.Testing.csproj", "{60B9E226-55FF-4B5C-BFFE-57A14DE6CC58}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.Testing.Web", "src\OrchardCoreContrib.Testing.Web\OrchardCoreContrib.Testing.Web.csproj", "{A0FE9046-5B92-4C73-AB51-33CE15F14917}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.Testing.Tests", "test\OrchardCoreContrib.Testing.Tests\OrchardCoreContrib.Testing.Tests.csproj", "{F0C2E52D-4412-4A18-B0F7-FD99F85F192C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -25,6 +31,18 @@ Global
{985F5AD6-8C18-4E63-A35C-A5673F237A4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{985F5AD6-8C18-4E63-A35C-A5673F237A4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{985F5AD6-8C18-4E63-A35C-A5673F237A4D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {60B9E226-55FF-4B5C-BFFE-57A14DE6CC58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {60B9E226-55FF-4B5C-BFFE-57A14DE6CC58}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {60B9E226-55FF-4B5C-BFFE-57A14DE6CC58}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {60B9E226-55FF-4B5C-BFFE-57A14DE6CC58}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A0FE9046-5B92-4C73-AB51-33CE15F14917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A0FE9046-5B92-4C73-AB51-33CE15F14917}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A0FE9046-5B92-4C73-AB51-33CE15F14917}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A0FE9046-5B92-4C73-AB51-33CE15F14917}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F0C2E52D-4412-4A18-B0F7-FD99F85F192C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F0C2E52D-4412-4A18-B0F7-FD99F85F192C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F0C2E52D-4412-4A18-B0F7-FD99F85F192C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F0C2E52D-4412-4A18-B0F7-FD99F85F192C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -32,6 +50,9 @@ Global
GlobalSection(NestedProjects) = preSolution
{A9E5A40B-78F8-4F02-9E73-C74F395B5BBD} = {20F306B8-D63F-4A61-AF6E-64B4E0918E30}
{985F5AD6-8C18-4E63-A35C-A5673F237A4D} = {27507B03-7D7C-492D-92A4-FF5812B9D7DB}
+ {60B9E226-55FF-4B5C-BFFE-57A14DE6CC58} = {20F306B8-D63F-4A61-AF6E-64B4E0918E30}
+ {A0FE9046-5B92-4C73-AB51-33CE15F14917} = {20F306B8-D63F-4A61-AF6E-64B4E0918E30}
+ {F0C2E52D-4412-4A18-B0F7-FD99F85F192C} = {27507B03-7D7C-492D-92A4-FF5812B9D7DB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD153137-BF4D-4977-A4D7-4C448D23479A}
diff --git a/src/OrchardCoreContrib.Testing.Web/NLog.config b/src/OrchardCoreContrib.Testing.Web/NLog.config
new file mode 100644
index 0000000..410f7bf
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/NLog.config
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrchardCoreContrib.Testing.Web/OrchardCoreContrib.Testing.Web.csproj b/src/OrchardCoreContrib.Testing.Web/OrchardCoreContrib.Testing.Web.csproj
new file mode 100644
index 0000000..31bf943
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/OrchardCoreContrib.Testing.Web.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ InProcess
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrchardCoreContrib.Testing.Web/Program.cs b/src/OrchardCoreContrib.Testing.Web/Program.cs
new file mode 100644
index 0000000..6de8170
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/Program.cs
@@ -0,0 +1,35 @@
+using OrchardCore.Logging;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Host.UseNLogHost();
+
+builder.Services
+ .AddOrchardCms()
+// // Orchard Specific Pipeline
+// .ConfigureServices( services => {
+// })
+// .Configure( (app, routes, services) => {
+// })
+;
+
+var app = builder.Build();
+
+if (!app.Environment.IsDevelopment())
+{
+ app.UseExceptionHandler("/Error");
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseStaticFiles();
+
+app.UseOrchardCore();
+
+app.Run();
+
+namespace OrchardCoreContrib.Testing.Web
+{
+ public partial class Program;
+}
diff --git a/src/OrchardCoreContrib.Testing.Web/Properties/launchSettings.json b/src/OrchardCoreContrib.Testing.Web/Properties/launchSettings.json
new file mode 100644
index 0000000..87329d2
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/Properties/launchSettings.json
@@ -0,0 +1,27 @@
+{
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:8080",
+ "sslPort": 44300
+ }
+ },
+ "profiles": {
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "OrchardCoreContrib.Testing.Web": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:5001;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/OrchardCoreContrib.Testing.Web/appsettings.json b/src/OrchardCoreContrib.Testing.Web/appsettings.json
new file mode 100644
index 0000000..6d27889
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "IncludeScopes": false,
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+}
diff --git a/src/OrchardCoreContrib.Testing.Web/wwwroot/.placeholder b/src/OrchardCoreContrib.Testing.Web/wwwroot/.placeholder
new file mode 100644
index 0000000..46b134b
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing.Web/wwwroot/.placeholder
@@ -0,0 +1 @@
+ÿþ
\ No newline at end of file
diff --git a/src/OrchardCoreContrib.Testing/ISiteContext.cs b/src/OrchardCoreContrib.Testing/ISiteContext.cs
new file mode 100644
index 0000000..5aba3c2
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/ISiteContext.cs
@@ -0,0 +1,20 @@
+using OrchardCore.Environment.Shell;
+
+namespace OrchardCoreContrib.Testing;
+
+public interface ISiteContext : IDisposable
+{
+ static IShellHost ShellHost { get; }
+
+ static IShellSettingsManager ShellSettingsManager { get; }
+
+ static HttpClient DefaultTenantClient { get; }
+
+ SiteContextOptions Options { init; get; }
+
+ HttpClient Client { get; }
+
+ string TenantName { get; }
+
+ Task InitializeAsync();
+}
diff --git a/src/OrchardCoreContrib.Testing/ISiteContextOfT.cs b/src/OrchardCoreContrib.Testing/ISiteContextOfT.cs
new file mode 100644
index 0000000..fe30b25
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/ISiteContextOfT.cs
@@ -0,0 +1,6 @@
+namespace OrchardCoreContrib.Testing;
+
+public interface ISiteContext : ISiteContext where TSiteStartup : class
+{
+ static OrchardCoreWebApplicationFactory Site { get; }
+}
diff --git a/src/OrchardCoreContrib.Testing/ModuleNamesProvider.cs b/src/OrchardCoreContrib.Testing/ModuleNamesProvider.cs
new file mode 100644
index 0000000..c3183ad
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/ModuleNamesProvider.cs
@@ -0,0 +1,21 @@
+using OrchardCore.Modules;
+using OrchardCore.Modules.Manifest;
+using System.Reflection;
+
+namespace OrchardCoreContrib.Testing;
+
+public sealed class ModuleNamesProvider : IModuleNamesProvider
+{
+ private readonly IEnumerable _moduleNames;
+
+ public ModuleNamesProvider(Assembly assembly)
+ {
+ ArgumentNullException.ThrowIfNull(assembly);
+
+ _moduleNames = Assembly.Load(new AssemblyName(assembly.GetName().Name))
+ .GetCustomAttributes()
+ .Select(m => m.Name);
+ }
+
+ public IEnumerable GetModuleNames() => _moduleNames;
+}
diff --git a/src/OrchardCoreContrib.Testing/OrchardCoreContrib.Testing.csproj b/src/OrchardCoreContrib.Testing/OrchardCoreContrib.Testing.csproj
new file mode 100644
index 0000000..4fb5f7f
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/OrchardCoreContrib.Testing.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrchardCoreContrib.Testing/OrchardCoreWebApplicationFactory.cs b/src/OrchardCoreContrib.Testing/OrchardCoreWebApplicationFactory.cs
new file mode 100644
index 0000000..0bfe02f
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/OrchardCoreWebApplicationFactory.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Hosting;
+
+namespace OrchardCoreContrib.Testing;
+
+public class OrchardCoreWebApplicationFactory : WebApplicationFactory where TEntryPoint : class
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ var shellsApplicationDataPath = Path.Combine(Directory.GetCurrentDirectory(), "App_Data");
+
+ if (Directory.Exists(shellsApplicationDataPath))
+ {
+ Directory.Delete(shellsApplicationDataPath, true);
+ }
+
+ builder.UseContentRoot(Directory.GetCurrentDirectory());
+ }
+
+ protected override IWebHostBuilder CreateWebHostBuilder()
+ => WebHostBuilderFactory.CreateFromAssemblyEntryPoint(typeof(TEntryPoint).Assembly, []);
+
+ protected override IHostBuilder CreateHostBuilder()
+ => Host.CreateDefaultBuilder().ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup());
+}
diff --git a/src/OrchardCoreContrib.Testing/Security/PermissionContextAuthorizationHandler.cs b/src/OrchardCoreContrib.Testing/Security/PermissionContextAuthorizationHandler.cs
new file mode 100644
index 0000000..63cb97f
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/Security/PermissionContextAuthorizationHandler.cs
@@ -0,0 +1,83 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using OrchardCore.Security;
+using OrchardCore.Security.Permissions;
+
+namespace OrchardCoreContrib.Testing.Security;
+
+public sealed class PermissionContextAuthorizationHandler : AuthorizationHandler
+{
+ private readonly PermissionsContext _permissionsContext;
+
+ public PermissionContextAuthorizationHandler(IHttpContextAccessor httpContextAccessor, IDictionary permissionsContexts)
+ {
+ _permissionsContext = new PermissionsContext();
+
+ if (httpContextAccessor.HttpContext is null)
+ {
+ return;
+ }
+
+ var request = httpContextAccessor.HttpContext.Request;
+
+ if (request?.Headers.ContainsKey(nameof(PermissionsContext)) == true &&
+ permissionsContexts.TryGetValue(request.Headers[nameof(PermissionsContext)], out var permissionsContext))
+ {
+ _permissionsContext = permissionsContext;
+ }
+ }
+
+ public PermissionContextAuthorizationHandler(PermissionsContext permissionsContext)
+ {
+ _permissionsContext = permissionsContext;
+ }
+
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
+ {
+ var permissions = (_permissionsContext.AuthorizedPermissions ?? []).ToList();
+
+ if (!_permissionsContext.UsePermissionsContext)
+ {
+ context.Succeed(requirement);
+ }
+ else if (permissions.Contains(requirement.Permission))
+ {
+ context.Succeed(requirement);
+ }
+ else
+ {
+ var grantingNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ GetGrantingNamesInternal(requirement.Permission, grantingNames);
+
+ if (permissions.Any(p => grantingNames.Contains(p.Name)))
+ {
+ context.Succeed(requirement);
+ }
+ else
+ {
+ context.Fail();
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static void GetGrantingNamesInternal(Permission permission, HashSet stack)
+ {
+ stack.Add(permission.Name);
+
+ if (permission.ImpliedBy != null && permission.ImpliedBy.Any())
+ {
+ foreach (var impliedBy in permission.ImpliedBy)
+ {
+ if (impliedBy == null || stack.Contains(impliedBy.Name))
+ {
+ continue;
+ }
+
+ GetGrantingNamesInternal(impliedBy, stack);
+ }
+ }
+ }
+}
diff --git a/src/OrchardCoreContrib.Testing/Security/PermissionsContext.cs b/src/OrchardCoreContrib.Testing/Security/PermissionsContext.cs
new file mode 100644
index 0000000..d58ddbc
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/Security/PermissionsContext.cs
@@ -0,0 +1,15 @@
+using OrchardCore.Security.Permissions;
+
+namespace OrchardCoreContrib.Testing.Security;
+
+public class PermissionsContext
+{
+ public PermissionsContext()
+ {
+ AuthorizedPermissions = [];
+ UsePermissionsContext = false;
+ }
+ public IEnumerable AuthorizedPermissions { get; set; }
+
+ public bool UsePermissionsContext { get; set; }
+}
diff --git a/src/OrchardCoreContrib.Testing/SiteContextBase.cs b/src/OrchardCoreContrib.Testing/SiteContextBase.cs
new file mode 100644
index 0000000..456bb37
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/SiteContextBase.cs
@@ -0,0 +1,125 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.BackgroundTasks;
+using OrchardCore.Environment.Shell;
+using OrchardCore.Environment.Shell.Scope;
+using OrchardCore.Tenants.ViewModels;
+using OrchardCoreContrib.Testing.Security;
+using System.Net.Http.Json;
+
+namespace OrchardCoreContrib.Testing;
+
+public abstract class SiteContextBase : ISiteContext where TEntryPoint : class
+{
+ static SiteContextBase()
+ {
+ Site = new OrchardCoreWebApplicationFactory();
+ ShellHost = Site.Services.GetRequiredService();
+ ShellSettingsManager = Site.Services.GetRequiredService();
+ HttpContextAccessor = Site.Services.GetRequiredService();
+ DefaultTenantClient = Site.CreateDefaultClient();
+ }
+
+ public SiteContextBase()
+ {
+ Options = new SiteContextOptions();
+ }
+
+ public static OrchardCoreWebApplicationFactory Site { get; }
+
+ public static IShellHost ShellHost { get; private set; }
+
+ public static IShellSettingsManager ShellSettingsManager { get; private set; }
+
+ public static IHttpContextAccessor HttpContextAccessor { get; }
+
+ public static HttpClient DefaultTenantClient { get; }
+
+ public SiteContextOptions Options { init; get; }
+
+ public HttpClient Client { get; private set; }
+
+ public string TenantName { get; private set; }
+
+ public virtual async Task InitializeAsync()
+ {
+ var tenantName = Guid.NewGuid().ToString("n");
+
+ var response = await CreateSiteAsync(tenantName);
+
+ var content = await response.Content.ReadAsStringAsync();
+
+ await SetupSiteAsync(tenantName);
+
+ lock (Site)
+ {
+ var url = new Uri(content.Trim('"'));
+ url = new Uri(url.Scheme + "://" + url.Authority + url.LocalPath + "/");
+
+ Client = Site.CreateDefaultClient(url);
+
+ TenantName = tenantName;
+ }
+
+ if (Options.PermissionsContext is not null)
+ {
+ var permissionContextKey = Guid.NewGuid().ToString();
+
+ SiteContextOptions.PermissionsContexts.TryAdd(permissionContextKey, Options.PermissionsContext);
+
+ Client.DefaultRequestHeaders.Add(nameof(PermissionsContext), permissionContextKey);
+ }
+ }
+
+ public void Dispose() => Client?.Dispose();
+
+ private async Task CreateSiteAsync(string tenantName)
+ {
+ var model = new CreateApiViewModel
+ {
+ DatabaseProvider = Options.DatabaseProvider,
+ TablePrefix = Options.TablePrefix,
+ ConnectionString = Options.ConnectionString,
+ RecipeName = Options.RecipeName,
+ Name = tenantName,
+ RequestUrlPrefix = tenantName
+ };
+
+ var result = await DefaultTenantClient.PostAsJsonAsync("api/tenants/create", model);
+
+ result.EnsureSuccessStatusCode();
+
+ return result;
+ }
+
+ private async Task SetupSiteAsync(string tenantName)
+ {
+ var model = new SetupApiViewModel
+ {
+ SiteName = Options.SiteName,
+ DatabaseProvider = Options.DatabaseProvider,
+ TablePrefix = Options.TablePrefix,
+ ConnectionString = Options.ConnectionString,
+ RecipeName = Options.RecipeName,
+ UserName = Options.Username,
+ Password = Options.Password,
+ Name = tenantName,
+ Email = Options.Email
+ };
+
+ var result = await DefaultTenantClient.PostAsJsonAsync("api/tenants/setup", model);
+
+ result.EnsureSuccessStatusCode();
+ }
+
+ public async Task UsingTenantScopeAsync(Func execute, bool activateShell = true)
+ {
+ var shellScope = await ShellHost.GetScopeAsync(TenantName);
+
+ HttpContextAccessor.HttpContext = shellScope.ShellContext.CreateHttpContext();
+
+ await shellScope.UsingAsync(execute, activateShell);
+
+ HttpContextAccessor.HttpContext = null;
+ }
+}
diff --git a/src/OrchardCoreContrib.Testing/SiteContextOptionDefaults.cs b/src/OrchardCoreContrib.Testing/SiteContextOptionDefaults.cs
new file mode 100644
index 0000000..4d73bbb
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/SiteContextOptionDefaults.cs
@@ -0,0 +1,16 @@
+namespace OrchardCoreContrib.Testing;
+
+internal sealed class SiteContextOptionDefaults
+{
+ public const string RecipeName = "Blog";
+
+ public const string DatabaseProvider = "Sqlite";
+
+ public const string SiteName = "Orchard Core Contrib";
+
+ public const string Username = "admin";
+
+ public const string Password = "P@ssw0rd";
+
+ public const string Email = "admin@orchardcorecontrib.com";
+}
diff --git a/src/OrchardCoreContrib.Testing/SiteContextOptions.cs b/src/OrchardCoreContrib.Testing/SiteContextOptions.cs
new file mode 100644
index 0000000..8c3b133
--- /dev/null
+++ b/src/OrchardCoreContrib.Testing/SiteContextOptions.cs
@@ -0,0 +1,32 @@
+using OrchardCoreContrib.Testing.Security;
+using System.Collections.Concurrent;
+
+namespace OrchardCoreContrib.Testing;
+
+public class SiteContextOptions
+{
+ static SiteContextOptions()
+ {
+ PermissionsContexts = new();
+ }
+
+ public static ConcurrentDictionary PermissionsContexts { get; set; }
+
+ public string SiteName { get; set; } = SiteContextOptionDefaults.SiteName;
+
+ public string Username { get; set; } = SiteContextOptionDefaults.Username;
+
+ public string Password { get; set; } = SiteContextOptionDefaults.Password;
+
+ public string Email { get; set; } = SiteContextOptionDefaults.Email;
+
+ public string RecipeName { get; set; } = SiteContextOptionDefaults.RecipeName;
+
+ public string DatabaseProvider { get; set; } = SiteContextOptionDefaults.DatabaseProvider;
+
+ public string ConnectionString { get; set; }
+
+ public string TablePrefix { get; set; }
+
+ public PermissionsContext PermissionsContext { get; set; }
+}
diff --git a/test/OrchardCoreContrib.Testing.Tests/BlogSiteContext.cs b/test/OrchardCoreContrib.Testing.Tests/BlogSiteContext.cs
new file mode 100644
index 0000000..e62aba4
--- /dev/null
+++ b/test/OrchardCoreContrib.Testing.Tests/BlogSiteContext.cs
@@ -0,0 +1,6 @@
+namespace OrchardCoreContrib.Testing.Tests;
+
+public class BlogSiteContext : SiteContextBase
+{
+ public BlogSiteContext() => Options.RecipeName = "Blog";
+}
diff --git a/test/OrchardCoreContrib.Testing.Tests/OrchardCoreApplicationTests.cs b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreApplicationTests.cs
new file mode 100644
index 0000000..cafba5a
--- /dev/null
+++ b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreApplicationTests.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.ContentManagement;
+using System.Net;
+
+namespace OrchardCoreContrib.Testing.Tests;
+
+public class OrchardCoreApplicationTests
+{
+ [Fact]
+ public async Task IndexPage_ShouldContainsBlogInItsContent()
+ {
+ // Arrange
+ var context = new BlogSiteContext();
+
+ await context.InitializeAsync();
+
+ // Act
+ var response = await context.Client.GetAsync("/");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains("Blog", content);
+ }
+
+ [Fact]
+ public async Task Tenant_ShouldAccessServiceFromDIContainer()
+ {
+ // Arrange
+ var context = new BlogSiteContext();
+
+ await context.InitializeAsync();
+
+ // Act & Assert
+ await context.UsingTenantScopeAsync(async scope =>
+ {
+ var siteService = scope.ServiceProvider.GetRequiredService();
+
+ Assert.NotNull(siteService);
+ });
+ }
+}
\ No newline at end of file
diff --git a/test/OrchardCoreContrib.Testing.Tests/OrchardCoreContrib.Testing.Tests.csproj b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreContrib.Testing.Tests.csproj
new file mode 100644
index 0000000..0315bc7
--- /dev/null
+++ b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreContrib.Testing.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/OrchardCoreContrib.Testing.Tests/OrchardCoreStartup.cs b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreStartup.cs
new file mode 100644
index 0000000..86b560b
--- /dev/null
+++ b/test/OrchardCoreContrib.Testing.Tests/OrchardCoreStartup.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.Modules;
+using OrchardCoreContrib.Testing.Security;
+
+namespace OrchardCoreContrib.Testing.Tests;
+
+public class OrchardCoreStartup
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddOrchardCms(builder => builder
+ .AddSetupFeatures("OrchardCore.Tenants")
+ .ConfigureServices(serviceCollection =>
+ {
+ serviceCollection.AddScoped(sp =>
+ new PermissionContextAuthorizationHandler(sp.GetRequiredService(), SiteContextOptions.PermissionsContexts));
+ })
+ .Configure(appBuilder => appBuilder.UseAuthorization()));
+
+ services.AddSingleton(new ModuleNamesProvider(typeof(Web.Program).Assembly));
+ }
+
+ public void Configure(IApplicationBuilder app) => app.UseOrchardCore();
+}
\ No newline at end of file
diff --git a/test/OrchardCoreContrib.Testing.Tests/SiteContextTests.cs b/test/OrchardCoreContrib.Testing.Tests/SiteContextTests.cs
new file mode 100644
index 0000000..7f566ba
--- /dev/null
+++ b/test/OrchardCoreContrib.Testing.Tests/SiteContextTests.cs
@@ -0,0 +1,122 @@
+namespace OrchardCoreContrib.Testing.Tests;
+
+public class SiteContextTests
+{
+ [Fact]
+ public async Task Site_ShouldBeSameForAllSites()
+ {
+ // Arrange & Act
+ var _ = new BlogSiteContext();
+ var site1 = BlogSiteContext.Site;
+
+ var __ = new BlogSiteContext();
+ var site2 = BlogSiteContext.Site;
+
+ // Assert
+ Assert.Same(site1, site2);
+ }
+
+ [Fact]
+ public async Task ShellHost_ShouldBeSameForAllSites()
+ {
+ // Arrange & Act
+ var _ = new BlogSiteContext();
+ var shellHost1 = BlogSiteContext.ShellHost;
+
+ var __ = new BlogSiteContext();
+ var shellHost2 = BlogSiteContext.ShellHost;
+
+ // Assert
+ Assert.Same(shellHost1, shellHost2);
+ }
+
+ [Fact]
+ public async Task ShellSettingsManager_ShouldBeSameForAllSites()
+ {
+ // Arrange & Act
+ var _ = new BlogSiteContext();
+ var shellSettingsManager1 = BlogSiteContext.ShellSettingsManager;
+
+ var __ = new BlogSiteContext();
+ var shellSettingsManager2 = BlogSiteContext.ShellSettingsManager;
+
+ // Assert
+ Assert.Same(shellSettingsManager1, shellSettingsManager2);
+ }
+
+ [Fact]
+ public async Task HttpContextAccessor_ShouldBeSameForAllSites()
+ {
+ // Arrange & Act
+ var _ = new BlogSiteContext();
+ var httpContextAccessor1 = BlogSiteContext.HttpContextAccessor;
+
+ var __ = new BlogSiteContext();
+ var httpContextAccessor2 = BlogSiteContext.HttpContextAccessor;
+
+ // Assert
+ Assert.Same(httpContextAccessor1, httpContextAccessor2);
+ }
+
+ [Fact]
+ public async Task Options_ShouldBeSetOnConstructor()
+ {
+ // Arrange
+ BlogSiteContext siteContext;
+
+ // Act
+ siteContext = new BlogSiteContext();
+
+ // Assert
+ Assert.NotNull(siteContext.Options);
+ }
+
+ [Fact]
+ public async Task Client_ShouldBeSetAfterCallingInitializeAsync()
+ {
+ // Arrange
+ var siteContext = new BlogSiteContext();
+
+ Assert.Null(siteContext.Client);
+
+ // Act
+ await siteContext.InitializeAsync();
+
+ // Assert
+ Assert.NotNull(siteContext.Client);
+ }
+
+ [Fact]
+ public async Task TenantName_ShouldBeSetAfterCallingInitializeAsync()
+ {
+ // Arrange
+ var siteContext = new BlogSiteContext();
+
+ Assert.Null(siteContext.TenantName);
+
+ // Act
+ await siteContext.InitializeAsync();
+
+ // Assert
+ Assert.NotNull(siteContext.TenantName);
+ }
+
+ [Fact]
+ public async Task CallingInitializeAsyncMultipleTimes_ShouldChangeTenantName()
+ {
+ // Arrange
+ var siteContext = new BlogSiteContext();
+
+ // Act
+ await siteContext.InitializeAsync();
+
+ var tenant1 = siteContext.TenantName;
+
+ await siteContext.InitializeAsync();
+
+ var tenant2 = siteContext.TenantName;
+
+ // Assert
+ Assert.NotEqual(tenant1, tenant2);
+ }
+}