Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/EagerActorStartTests.cs
T
Joseph Doherty 6112a9f447 fix(secrets): Akka replication actor starts with the host (scadaproj#33)
The replication actor was created only when something resolved ISecretStore
(store -> replicator -> SecretReplicationActorProvider.ActorRef). A node that
expanded its ${secret:} references pre-host and never touched the store at
runtime had no actor: no topic subscription, no announces, no pull replies.

AddZbSecretsAkkaReplication now registers SecretReplicationActorStarter, which
creates the actor on IHostApplicationLifetime.ApplicationStarted (after every
hosted service, incl. an Akka.Hosting AkkaHostedService registered later), or
in StartAsync when no lifetime is registered.

- tests: EagerActorStartTests (generic host in both registration orders with a
  no-actor-before-start control; bare container) - all fail without the fix
- docs: README, CHANGELOG
- code-reviews: Secrets-010 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00

110 lines
4.1 KiB
C#

using Akka.Actor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
/// <summary>
/// scadaproj#33 (Secrets-010): the replication actor must exist once the host has started, without
/// anything resolving <see cref="ISecretStore"/>. Before the fix the actor was created only through
/// the store → replicator chain, so a node that expanded its references pre-host and never touched
/// the store at runtime had no actor and silently missed every rotation.
/// </summary>
/// <remarks>
/// Nothing in these tests may resolve <see cref="ISecretStore"/>, <see cref="ISecretReplicator"/> or
/// <see cref="ISecretResolver"/> before the assertion — that would create the actor the old way and
/// make the test vacuous. <see cref="HostedProcessResolutionTests"/> could not catch the defect for
/// exactly that reason.
/// </remarks>
public sealed class EagerActorStartTests(SingleNodeClusterFixture cluster)
: IClassFixture<SingleNodeClusterFixture>
{
public enum Order
{
CoreFirst,
ExtensionFirst,
}
private static Dictionary<string, string?> Settings(string actorName) => new()
{
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"zb-eager-{Guid.NewGuid():N}.db"),
["Secrets:RunMigrationsOnStartup"] = "true",
["Secrets:Replication:AnnounceInterval"] = "00:00:30",
["Secrets:Replication:ActorName"] = actorName,
};
private async Task<bool> ActorExistsAsync(string actorName)
{
try
{
await cluster.System.ActorSelection($"/user/{actorName}").ResolveOne(TimeSpan.FromSeconds(3));
return true;
}
catch (ActorNotFoundException)
{
return false;
}
}
[Theory]
[InlineData(Order.CoreFirst)]
[InlineData(Order.ExtensionFirst)]
public async Task A_started_generic_host_creates_the_actor_without_resolving_the_store(Order order)
{
string actorName = $"zb-eager-host-{order}";
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
Args = [],
DisableDefaults = true,
});
builder.Configuration.AddInMemoryCollection(Settings(actorName));
builder.Services.AddSingleton(cluster.System);
if (order == Order.CoreFirst)
{
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddZbSecretsAkkaReplication(builder.Configuration);
}
else
{
builder.Services.AddZbSecretsAkkaReplication(builder.Configuration);
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
}
using IHost host = builder.Build();
Assert.False(await ActorExistsAsync(actorName), "positive control: no actor before the host starts");
await host.StartAsync();
try
{
Assert.True(await ActorExistsAsync(actorName), "the replication actor was not created at startup");
}
finally
{
await host.StopAsync();
}
}
[Fact]
public async Task Without_a_host_lifetime_the_starter_creates_the_actor_in_StartAsync()
{
string actorName = "zb-eager-bare";
IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(Settings(actorName)).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(cluster.System);
services.AddZbSecretsAkkaReplication(config);
await using ServiceProvider provider = services.BuildServiceProvider();
foreach (IHostedService hosted in provider.GetServices<IHostedService>())
{
await hosted.StartAsync(CancellationToken.None);
}
Assert.True(await ActorExistsAsync(actorName), "the replication actor was not created at startup");
}
}