Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs
T
Joseph Doherty 7fd5cb2b56 feat(comm): Phase 4 — delete Akka ClusterClient site↔central transport, gRPC-only
ClusterClient→gRPC migration Phase 4 (docs/plans/2026-07-22-clusterclient-to-grpc-plan.md).
Phases 2/3 proved both directions on gRPC; this removes the Akka transport underneath.

Deleted:
- AkkaCentralTransport, AkkaSiteTransport (+ their dedicated tests)
- ISiteClientFactory + DefaultSiteClientFactory; CentralCommunicationActor legacy
  ctor + SelectTransport (Host now builds GrpcSiteTransport and injects it)
- ClusterClient creation + both ClusterClientReceptionist.RegisterService calls in
  AkkaHostedService; the RegisterCentralClient message + receive block
- CommunicationOptions.CentralContactPoints; the CentralTransport/SiteTransport
  coexistence flags; the CentralTransportMode/SiteTransportKind enums

gRPC is now the only site↔central transport (site→central CentralControlService via
GrpcCentralTransport; central→site SiteCommandService via GrpcSiteTransport), both
built unconditionally by the Host. NoOpCentralTransport is the fail-loud null-default
so TestKit command-dispatch suites still construct the site actor without a wired
transport; production always injects GrpcCentralTransport.

Config: CentralGrpcEndpoints is now unconditional — CommunicationOptionsValidator
rejects blank entries (role-agnostic), and StartupValidator requires a Site node to
list >=1 endpoint (fail-fast, mirrors GrpcPsk). Rig configs moved
CentralContactPoints -> CentralGrpcEndpoints (docker x6, docker-env2 x2, Host default,
deploy/wonder-app-vd03). Kept Akka.Cluster.Tools (ClusterSingleton still used).

Tests: build 0/0; Communication.Tests 640, Host.Tests 421 green. Removed the
ClusterClient.Send per-site-routing tests (covered by the transport suites), swapped
the ISiteClientFactory-based ctors to a substitute ISiteCommandTransport, converted
the audit-push integration relay to an in-process bridge transport.

Docs: Component-Communication/Host/StoreAndForward, components/Communication,
topology-guide, grpc_streams (SUPERSEDED note), the frame-size known-issue (retired
amendment), and CLAUDE.md transport decisions.

Not included: the dead IntegrationCallRequest path (#32) is a separate user-owned
behavioral decision — SiteEnvelope routing is transport-agnostic so it still compiles.
2026-07-23 12:54:32 -04:00

254 lines
11 KiB
C#

using Akka.Actor;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Verifies that all expected Central-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection(HostBootCollection.Name)]
public class CentralActorPathTests : IAsyncLifetime
{
private WebApplicationFactory<Program>? _factory;
private ActorSystem? _actorSystem;
private string? _previousEnv;
private CentralDbTestEnvironment? _dbEnv;
public Task InitializeAsync()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
// Supply the pepper, MachineDataDb and the G-4 secret-backed keys
// (ConfigurationDb / LDAP service-account password / JWT signing key) as
// whole-key env overrides so the pre-host config builder — which calls
// AddEnvironmentVariables() before the ${secret:...} expander runs — sees
// concrete values and the Central-role StartupValidator preflight passes.
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["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",
});
});
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($"ActorPathTests_{Guid.NewGuid()}"));
});
});
// CreateClient triggers host startup including AkkaHostedService
_ = _factory.CreateClient();
var akkaService = _factory.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
// try/finally: a throwing host teardown must not strand the process-wide
// environment variables. Without it, one failed _factory.Dispose() leaks
// DOTNET_ENVIRONMENT=Central and the CentralDbTestEnvironment vars into
// every later test in the run.
try
{
_factory?.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
_dbEnv?.Dispose();
}
await Task.CompletedTask;
}
[Fact]
public async Task CentralActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task CentralActors_CentralCommunication_Exists()
=> await AssertActorExists("/user/central-communication");
[Fact]
public async Task CentralActors_Management_Exists()
=> await AssertActorExists("/user/management");
[Fact]
public async Task CentralActors_NotificationOutboxSingleton_Exists()
=> await AssertActorExists("/user/notification-outbox-singleton");
[Fact]
public async Task CentralActors_NotificationOutboxProxy_Exists()
=> await AssertActorExists("/user/notification-outbox-proxy");
[Fact]
public async Task CentralActors_AuditLogPurgeSingleton_Exists()
=> await AssertActorExists("/user/audit-log-purge-singleton");
[Fact]
public async Task CentralActors_AuditLogPurgeProxy_Exists()
=> await AssertActorExists("/user/audit-log-purge-proxy");
[Fact]
public async Task CentralActors_SiteAuditReconciliationSingleton_Exists()
=> await AssertActorExists("/user/site-audit-reconciliation-singleton");
[Fact]
public async Task CentralActors_SiteAuditReconciliationProxy_Exists()
=> await AssertActorExists("/user/site-audit-reconciliation-proxy");
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}
/// <summary>
/// Verifies that all expected Site-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection(HostBootCollection.Name)]
public class SiteActorPathTests : IAsyncLifetime
{
private IHost? _host;
private ActorSystem? _actorSystem;
private string _tempDbPath = null!;
private string _tempLocalDbPath = null!;
public async Task InitializeAsync()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_localdb_{Guid.NewGuid()}.db");
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Required: LocalDbOptions.Path is validated with ValidateOnStart, and this
// fixture actually starts the host — a site node with no configured
// consolidated-database path fails fast rather than booting half-working.
["LocalDb:Path"] = _tempLocalDbPath,
// A central gRPC endpoint so the Site branch builds its GrpcCentralTransport
// (the only site→central transport since Phase 4). Never dialled by this test.
["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://localhost:8083",
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
});
_host = builder.Build();
await _host.StartAsync();
var akkaService = _host.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
}
public async Task DisposeAsync()
{
if (_host != null)
{
await _host.StopAsync();
_host.Dispose();
}
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
[Fact]
public async Task SiteActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task SiteActors_DclManager_Exists()
=> await AssertActorExists("/user/dcl-manager");
[Fact]
public async Task SiteActors_DeploymentManagerSingleton_Exists()
=> await AssertActorExists("/user/deployment-manager-singleton");
[Fact]
public async Task SiteActors_DeploymentManagerProxy_Exists()
=> await AssertActorExists("/user/deployment-manager-proxy");
[Fact]
public async Task SiteActors_SiteCommunication_Exists()
=> await AssertActorExists("/user/site-communication");
// SiteActors_CentralClusterClient_Exists was removed with the site→central Akka ClusterClient
// (the `central-cluster-client` actor) in the ClusterClient→gRPC migration's Phase 4. The site
// now reaches central over gRPC (GrpcCentralTransport dialling CentralControlService), which is
// not an actor in the local system, so there is no actor path to assert here.
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}