6ae605160c
Replace dc=scadabridge,dc=local with dc=zb,dc=local in all dev/test LDAP references — app config, docker test-cluster node configs (docker/ and docker-env2/), GLAuth fixture, dev tooling, Host.Tests fixtures, IntegrationTests factory, and operational test_infra docs. OU structure (ou=SCADA-Admins,ou=users,etc.) preserved throughout. Email domains (@scadabridge.local), hostnames, and container names are untouched. Historical plan docs (2026-05-24-second-environment.md, 2026-05-31-folder-repo-rename-scadabridge-design.md) excluded as point-in-time records. No synthetic dc=example,dc=com placeholders touched.
485 lines
20 KiB
C#
485 lines
20 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
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 ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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");
|
|
|
|
// Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5)
|
|
// passes. The pre-host config builder uses AddEnvironmentVariables(), which
|
|
// runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config.
|
|
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper",
|
|
CentralDbTestEnvironment.TestPepper);
|
|
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureAppConfiguration((_, config) =>
|
|
{
|
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
|
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
|
|
// ServiceAccountDn is now required by the library's LdapOptionsValidator
|
|
// (ValidateOnStart), so it must be present for the host to start.
|
|
["ScadaBridge:Security:Ldap:Server"] = "localhost",
|
|
["ScadaBridge:Security:Ldap:Port"] = "3893",
|
|
["ScadaBridge:Security:Ldap:Transport"] = "None",
|
|
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
|
|
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
|
|
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
|
|
// Auth re-arch (C5): inbound-API keys live in the shared
|
|
// ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
|
|
// this same config key as its pepper secret (PepperSecretName),
|
|
// and AddZbApiKeyAuth fails fast if it is missing/weak — so a
|
|
// configured pepper is still required for the host to start.
|
|
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
|
});
|
|
});
|
|
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// Replace SQL Server with in-memory database
|
|
var descriptorsToRemove = services
|
|
.Where(d =>
|
|
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
|
d.ServiceType == typeof(DbContextOptions) ||
|
|
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
|
.ToList();
|
|
foreach (var d in descriptorsToRemove)
|
|
services.Remove(d);
|
|
|
|
services.AddDbContext<ScadaBridgeDbContext>(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);
|
|
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null);
|
|
}
|
|
|
|
// --- 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) },
|
|
// Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless
|
|
// singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService.
|
|
new object[] { typeof(ILdapAuthService) },
|
|
};
|
|
|
|
// --- 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 (ILdapAuthService is now a singleton — see CentralSingletonServices)
|
|
new object[] { typeof(JwtTokenService) },
|
|
new object[] { typeof(RoleMapper) },
|
|
// InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
|
|
// inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
|
|
new object[] { typeof(RouteHelper) },
|
|
// ExternalSystemGateway
|
|
new object[] { typeof(ExternalSystemClient) },
|
|
new object[] { typeof(IExternalSystemClient) },
|
|
new object[] { typeof(DatabaseGateway) },
|
|
new object[] { typeof(IDatabaseGateway) },
|
|
// NotificationService — central-only SMTP primitives. The orphan
|
|
// NotificationDeliveryService + INotificationDeliveryService were removed
|
|
// (NS-019) when sites stopped delivering notifications; the central
|
|
// EmailNotificationDeliveryAdapter is now the only resolver of these
|
|
// primitives.
|
|
new object[] { typeof(Func<ISmtpClientWrapper>) },
|
|
new object[] { typeof(OAuth2TokenService) },
|
|
// ConfigurationDatabase repositories
|
|
new object[] { typeof(ScadaBridgeDbContext) },
|
|
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));
|
|
}
|
|
|
|
// --- InboundAPI-022 regression ---
|
|
|
|
/// <summary>
|
|
/// InboundAPI-022 regression: the Central composition root MUST register a
|
|
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
|
|
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
|
|
/// standby central node continues to serve the inbound API, racing the
|
|
/// active node and executing scripts against stale singleton state. The
|
|
/// design's "central cluster only (active node)" guarantee is enforced only
|
|
/// when the production gate is wired here.
|
|
///
|
|
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
|
|
/// — a registration the framework cannot resolve to a concrete instance is
|
|
/// indistinguishable from "missing" at runtime, which is the failure mode
|
|
/// the finding describes.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
|
|
{
|
|
var gate = _factory.Services.GetService<IActiveNodeGate>();
|
|
Assert.NotNull(gate);
|
|
Assert.IsType<ActiveNodeGate>(gate);
|
|
}
|
|
}
|
|
|
|
/// <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 (WebApplicationBuilder + gRPC).
|
|
/// </summary>
|
|
public class SiteCompositionRootTests : IDisposable
|
|
{
|
|
private readonly WebApplication _host;
|
|
private readonly string _tempDbPath;
|
|
|
|
public SiteCompositionRootTests()
|
|
{
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:Role"] = "Site",
|
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
|
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
|
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
});
|
|
|
|
// gRPC server registration (mirrors Program.cs site section)
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
|
|
// Keep AkkaHostedService in DI (other services depend on it)
|
|
// but prevent it from starting by removing only its IHostedService registration.
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
|
|
|
_host = builder.Build();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
(_host as IDisposable)?.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(SiteStreamManager) },
|
|
new object[] { typeof(ISiteStreamSubscriber) },
|
|
new object[] { typeof(SiteStreamGrpcServer) },
|
|
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(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) },
|
|
};
|
|
|
|
// --- 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) },
|
|
};
|
|
}
|