IServiceProvider now flows through the actor chain (DeploymentManagerActor → InstanceActor → ScriptActor → ScriptExecutionActor) so scripts can resolve IExternalSystemClient, IDatabaseGateway, and INotificationDeliveryService from DI. ScriptGlobals exposes ExternalSystem, Database, Notify, and Scripts as top-level properties so scripts can use them without the Instance. prefix.
430 lines
16 KiB
C#
430 lines
16 KiB
C#
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.ClusterInfrastructure;
|
|
using ScadaLink.Communication;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.DataConnectionLayer;
|
|
using ScadaLink.DeploymentManager;
|
|
using ScadaLink.ExternalSystemGateway;
|
|
using ScadaLink.HealthMonitoring;
|
|
using ScadaLink.Host;
|
|
using ScadaLink.Host.Actors;
|
|
using ScadaLink.InboundAPI;
|
|
using ScadaLink.ManagementService;
|
|
using ScadaLink.NotificationService;
|
|
using ScadaLink.Security;
|
|
using ScadaLink.SiteEventLogging;
|
|
using ScadaLink.SiteRuntime;
|
|
using ScadaLink.SiteRuntime.Persistence;
|
|
using ScadaLink.SiteRuntime.Repositories;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
using ScadaLink.StoreAndForward;
|
|
using ScadaLink.TemplateEngine;
|
|
using ScadaLink.TemplateEngine.Flattening;
|
|
using ScadaLink.TemplateEngine.Services;
|
|
using ScadaLink.TemplateEngine.Validation;
|
|
|
|
namespace ScadaLink.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Removes AkkaHostedService from running as a hosted service while keeping it
|
|
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
|
|
/// </summary>
|
|
internal static class AkkaHostedServiceRemover
|
|
{
|
|
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
|
|
{
|
|
// Pattern used in Program.cs:
|
|
// services.AddSingleton<AkkaHostedService>(); // index N
|
|
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
|
|
// We keep the singleton so DI resolution works, but remove the IHostedService
|
|
// factory so StartAsync is never called.
|
|
int akkaIndex = -1;
|
|
for (int i = 0; i < services.Count; i++)
|
|
{
|
|
if (services[i].ServiceType == typeof(AkkaHostedService))
|
|
{
|
|
akkaIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (akkaIndex < 0) return;
|
|
|
|
// The IHostedService factory is the next registration after the singleton
|
|
for (int i = akkaIndex + 1; i < services.Count; i++)
|
|
{
|
|
if (services[i].ServiceType == typeof(IHostedService)
|
|
&& services[i].ImplementationFactory != null)
|
|
{
|
|
services.RemoveAt(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies every expected DI service resolves from the Central composition root.
|
|
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
|
|
/// </summary>
|
|
public class CentralCompositionRootTests : IDisposable
|
|
{
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
private readonly string? _previousEnv;
|
|
|
|
public CentralCompositionRootTests()
|
|
{
|
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
|
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureAppConfiguration((_, config) =>
|
|
{
|
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaLink:Node:NodeHostname"] = "localhost",
|
|
["ScadaLink:Node:RemotingPort"] = "0",
|
|
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
|
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
|
["ScadaLink:Database:SkipMigrations"] = "true",
|
|
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
|
["ScadaLink:Security:LdapServer"] = "localhost",
|
|
["ScadaLink:Security:LdapPort"] = "3893",
|
|
["ScadaLink:Security:LdapUseTls"] = "false",
|
|
["ScadaLink:Security:AllowInsecureLdap"] = "true",
|
|
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
|
|
});
|
|
});
|
|
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
|
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// Replace SQL Server with in-memory database
|
|
var descriptorsToRemove = services
|
|
.Where(d =>
|
|
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
|
d.ServiceType == typeof(DbContextOptions) ||
|
|
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
|
.ToList();
|
|
foreach (var d in descriptorsToRemove)
|
|
services.Remove(d);
|
|
|
|
services.AddDbContext<ScadaLinkDbContext>(options =>
|
|
options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));
|
|
|
|
// Keep AkkaHostedService in DI (other services depend on it)
|
|
// but prevent it from starting by removing only its IHostedService registration.
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
|
});
|
|
});
|
|
|
|
// Trigger host build
|
|
_ = _factory.Server;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_factory.Dispose();
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
|
}
|
|
|
|
// --- Singletons ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CentralSingletonServices))]
|
|
public void Central_ResolveSingleton(Type serviceType)
|
|
{
|
|
var service = _factory.Services.GetService(serviceType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> CentralSingletonServices => new[]
|
|
{
|
|
new object[] { typeof(CommunicationService) },
|
|
new object[] { typeof(ISiteHealthCollector) },
|
|
new object[] { typeof(CentralHealthAggregator) },
|
|
new object[] { typeof(ICentralHealthAggregator) },
|
|
new object[] { typeof(OperationLockManager) },
|
|
new object[] { typeof(OAuth2TokenService) },
|
|
new object[] { typeof(InboundScriptExecutor) },
|
|
};
|
|
|
|
// --- Scoped services ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CentralScopedServices))]
|
|
public void Central_ResolveScoped(Type serviceType)
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var service = scope.ServiceProvider.GetService(serviceType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> CentralScopedServices => new[]
|
|
{
|
|
// TemplateEngine
|
|
new object[] { typeof(TemplateService) },
|
|
new object[] { typeof(SharedScriptService) },
|
|
new object[] { typeof(InstanceService) },
|
|
new object[] { typeof(SiteService) },
|
|
new object[] { typeof(AreaService) },
|
|
new object[] { typeof(TemplateDeletionService) },
|
|
// DeploymentManager
|
|
new object[] { typeof(IFlatteningPipeline) },
|
|
new object[] { typeof(DeploymentService) },
|
|
new object[] { typeof(ArtifactDeploymentService) },
|
|
// Security
|
|
new object[] { typeof(LdapAuthService) },
|
|
new object[] { typeof(JwtTokenService) },
|
|
new object[] { typeof(RoleMapper) },
|
|
// InboundAPI
|
|
new object[] { typeof(ApiKeyValidator) },
|
|
new object[] { typeof(RouteHelper) },
|
|
// ExternalSystemGateway
|
|
new object[] { typeof(ExternalSystemClient) },
|
|
new object[] { typeof(IExternalSystemClient) },
|
|
new object[] { typeof(DatabaseGateway) },
|
|
new object[] { typeof(IDatabaseGateway) },
|
|
// NotificationService
|
|
new object[] { typeof(NotificationDeliveryService) },
|
|
new object[] { typeof(INotificationDeliveryService) },
|
|
// ConfigurationDatabase repositories
|
|
new object[] { typeof(ScadaLinkDbContext) },
|
|
new object[] { typeof(ISecurityRepository) },
|
|
new object[] { typeof(ICentralUiRepository) },
|
|
new object[] { typeof(ITemplateEngineRepository) },
|
|
new object[] { typeof(IDeploymentManagerRepository) },
|
|
new object[] { typeof(ISiteRepository) },
|
|
new object[] { typeof(IExternalSystemRepository) },
|
|
new object[] { typeof(INotificationRepository) },
|
|
new object[] { typeof(IInboundApiRepository) },
|
|
new object[] { typeof(IAuditService) },
|
|
new object[] { typeof(IInstanceLocator) },
|
|
// CentralUI
|
|
new object[] { typeof(AuthenticationStateProvider) },
|
|
};
|
|
|
|
// --- Transient services ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CentralTransientServices))]
|
|
public void Central_ResolveTransient(Type serviceType)
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var service = scope.ServiceProvider.GetService(serviceType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> CentralTransientServices => new[]
|
|
{
|
|
new object[] { typeof(FlatteningService) },
|
|
new object[] { typeof(DiffService) },
|
|
new object[] { typeof(RevisionHashService) },
|
|
new object[] { typeof(ScriptCompiler) },
|
|
new object[] { typeof(SemanticValidator) },
|
|
new object[] { typeof(ValidationService) },
|
|
};
|
|
|
|
// --- Options ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CentralOptions))]
|
|
public void Central_ResolveOptions(Type optionsType)
|
|
{
|
|
var service = _factory.Services.GetService(optionsType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> CentralOptions => new[]
|
|
{
|
|
new object[] { typeof(IOptions<NodeOptions>) },
|
|
new object[] { typeof(IOptions<ClusterOptions>) },
|
|
new object[] { typeof(IOptions<DatabaseOptions>) },
|
|
new object[] { typeof(IOptions<CommunicationOptions>) },
|
|
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
|
new object[] { typeof(IOptions<NotificationOptions>) },
|
|
new object[] { typeof(IOptions<LoggingOptions>) },
|
|
new object[] { typeof(IOptions<SecurityOptions>) },
|
|
new object[] { typeof(IOptions<InboundApiOptions>) },
|
|
new object[] { typeof(IOptions<ManagementServiceOptions>) },
|
|
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
|
|
};
|
|
|
|
// --- Hosted services ---
|
|
|
|
[Fact]
|
|
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
|
|
{
|
|
var hostedServices = _factory.Services.GetServices<IHostedService>();
|
|
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies every expected DI service resolves from the Site composition root.
|
|
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
|
|
/// matches the real Program.cs registration.
|
|
/// </summary>
|
|
public class SiteCompositionRootTests : IDisposable
|
|
{
|
|
private readonly IHost _host;
|
|
private readonly string _tempDbPath;
|
|
|
|
public SiteCompositionRootTests()
|
|
{
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
|
|
|
|
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
|
builder.ConfigureAppConfiguration(config =>
|
|
{
|
|
config.Sources.Clear();
|
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaLink:Node:Role"] = "Site",
|
|
["ScadaLink:Node:NodeHostname"] = "test-site",
|
|
["ScadaLink:Node:SiteId"] = "TestSite",
|
|
["ScadaLink:Node:RemotingPort"] = "0",
|
|
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
|
});
|
|
});
|
|
builder.ConfigureServices((context, services) =>
|
|
{
|
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
|
|
|
// Keep AkkaHostedService in DI (other services depend on it)
|
|
// but prevent it from starting by removing only its IHostedService registration.
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
|
});
|
|
|
|
_host = builder.Build();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_host.Dispose();
|
|
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
|
}
|
|
|
|
// --- Singletons ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SiteSingletonServices))]
|
|
public void Site_ResolveSingleton(Type serviceType)
|
|
{
|
|
var service = _host.Services.GetService(serviceType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> SiteSingletonServices => new[]
|
|
{
|
|
new object[] { typeof(CommunicationService) },
|
|
new object[] { typeof(ISiteHealthCollector) },
|
|
new object[] { typeof(SiteStorageService) },
|
|
new object[] { typeof(ScriptCompilationService) },
|
|
new object[] { typeof(SharedScriptLibrary) },
|
|
new object[] { typeof(IDataConnectionFactory) },
|
|
new object[] { typeof(StoreAndForwardStorage) },
|
|
new object[] { typeof(StoreAndForwardService) },
|
|
new object[] { typeof(ReplicationService) },
|
|
new object[] { typeof(ISiteEventLogger) },
|
|
new object[] { typeof(IEventLogQueryService) },
|
|
new object[] { typeof(OAuth2TokenService) },
|
|
new object[] { typeof(ISiteIdentityProvider) },
|
|
new object[] { typeof(IHealthReportTransport) },
|
|
};
|
|
|
|
// --- Scoped services ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SiteScopedServices))]
|
|
public void Site_ResolveScoped(Type serviceType)
|
|
{
|
|
using var scope = _host.Services.CreateScope();
|
|
var service = scope.ServiceProvider.GetService(serviceType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> SiteScopedServices => new[]
|
|
{
|
|
new object[] { typeof(IExternalSystemRepository) },
|
|
new object[] { typeof(INotificationRepository) },
|
|
new object[] { typeof(ExternalSystemClient) },
|
|
new object[] { typeof(IExternalSystemClient) },
|
|
new object[] { typeof(DatabaseGateway) },
|
|
new object[] { typeof(IDatabaseGateway) },
|
|
new object[] { typeof(NotificationDeliveryService) },
|
|
new object[] { typeof(INotificationDeliveryService) },
|
|
};
|
|
|
|
// --- Implementation type assertions ---
|
|
|
|
[Fact]
|
|
public void Site_ExternalSystemRepository_IsSiteImplementation()
|
|
{
|
|
using var scope = _host.Services.CreateScope();
|
|
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
|
|
Assert.IsType<SiteExternalSystemRepository>(repo);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_NotificationRepository_IsSiteImplementation()
|
|
{
|
|
using var scope = _host.Services.CreateScope();
|
|
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
|
Assert.IsType<SiteNotificationRepository>(repo);
|
|
}
|
|
|
|
// --- Options ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SiteOptions))]
|
|
public void Site_ResolveOptions(Type optionsType)
|
|
{
|
|
var service = _host.Services.GetService(optionsType);
|
|
Assert.NotNull(service);
|
|
}
|
|
|
|
public static IEnumerable<object[]> SiteOptions => new[]
|
|
{
|
|
new object[] { typeof(IOptions<NodeOptions>) },
|
|
new object[] { typeof(IOptions<ClusterOptions>) },
|
|
new object[] { typeof(IOptions<DatabaseOptions>) },
|
|
new object[] { typeof(IOptions<CommunicationOptions>) },
|
|
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
|
new object[] { typeof(IOptions<NotificationOptions>) },
|
|
new object[] { typeof(IOptions<LoggingOptions>) },
|
|
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
|
|
new object[] { typeof(IOptions<DataConnectionOptions>) },
|
|
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
|
|
new object[] { typeof(IOptions<SiteEventLogOptions>) },
|
|
};
|
|
|
|
// --- Hosted services ---
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SiteHostedServices))]
|
|
public void Site_HostedServiceRegistered(Type expectedType)
|
|
{
|
|
var hostedServices = _host.Services.GetServices<IHostedService>();
|
|
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
|
|
}
|
|
|
|
public static IEnumerable<object[]> SiteHostedServices => new[]
|
|
{
|
|
new object[] { typeof(HealthReportSender) },
|
|
new object[] { typeof(SiteStorageInitializer) },
|
|
new object[] { typeof(EventLogPurgeService) },
|
|
};
|
|
}
|