0702b31029
v2-ci / unit-tests (push) Waiting to run
Item 3 of #765. A keyed (API-key) session whose key had no Galaxy ack identity got Good and then a revert, because whether the key has an identity lives in the Secrets store, which must not be read under the SDK lock. GalaxyAckIdentityDirectory (Driver.Galaxy) keeps a node-wide set of the key ids that have a galaxy-ack-user secret. It lists this node's Secrets store (metadata only, nothing decrypted) and is read lock-free. GalaxyDriver.GetAcknowledgeRefusal (IAlarmAcknowledgePreflight, published through NativeAlarmAckPreflightView as in #760) now refuses a keyed session whose key the directory reports Absent, when the driver has no fallback ack account. A refusal also asks for a refresh. Fails open: before the first refresh, once the snapshot is older than StaleAfter, and from a signalled identity-secret change until the next refresh, the lookup answers Unknown and the ack takes the old Good-then-revert path. Refresh: GalaxyAckIdentityRefreshService runs on driver nodes, every Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds (default 30; StaleAfterSeconds 0 = three intervals; validated at start). It also refreshes early on secret changes. The Secrets library has no change event, so SecretsRegistration decorates ISecretCacheInvalidator, which is called by AdminUI /api-keys, /admin/secrets and the replication appliers. API-key changes need no event: a revoked key is already denied before the pre-check. Tests: Galaxy.Tests GalaxyAckIdentityDirectoryTests (16) and Host.Tests GalaxyAckIdentityCacheWiringTests (9). Seven separate mutations each turn at least one test red: pre-check ignores the directory, factory drops it, no invalidator decoration, no staleness check, no change-pending check, no refresh on refusal, loop never wakes early. Docs: Galaxy.md, AlarmTracking.md, Configuration.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
209 lines
9.3 KiB
C#
209 lines
9.3 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
|
using ZB.MOM.WW.Secrets;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#765 item 3 — the host wiring of the cached API-key → Galaxy ack identity set: the driver registry
|
|
/// hands every Galaxy driver the node's directory (so the synchronous pre-check can refuse a keyed session
|
|
/// without an identity), a refresh loop runs on driver nodes, secret writes signal it through the decorated
|
|
/// invalidator, and the refresh interval is configurable.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class GalaxyAckIdentityCacheWiringTests
|
|
{
|
|
private const string WithIdentity = "0a1b2c3d4e5f6071";
|
|
private const string WithoutIdentity = "ffeeddccbbaa0099";
|
|
|
|
private const string GalaxyConfig = """
|
|
{
|
|
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "dev:key" },
|
|
"MxAccess": { "ClientName": "OtOpcUa-A" }
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public async Task A_registry_built_Galaxy_driver_refuses_a_keyed_session_without_an_identity()
|
|
{
|
|
var store = new ListOnlySecretStore(GalaxyAckIdentitySecrets.UserSecretName(WithIdentity));
|
|
using var sp = DriverServices(store).BuildServiceProvider();
|
|
var directory = sp.GetRequiredService<GalaxyAckIdentityDirectory>();
|
|
(await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
|
|
|
|
var driver = sp.GetRequiredService<DriverFactoryRegistry>()
|
|
.TryGet(GalaxyDriverFactoryExtensions.DriverTypeName)!.Invoke("galaxy-1", GalaxyConfig);
|
|
var preflight = driver.ShouldBeAssignableTo<IAlarmAcknowledgePreflight>()!;
|
|
|
|
preflight.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull();
|
|
preflight.GetAcknowledgeRefusal(WithIdentity).ShouldBeNull();
|
|
(driver as IDisposable)?.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void Driver_nodes_run_the_refresh_loop_and_share_one_directory()
|
|
{
|
|
var services = DriverServices(new ListOnlySecretStore());
|
|
services.AddOtOpcUaDriverFactories(); // a fused admin,driver node may reach it twice
|
|
// By descriptor: resolving every IHostedService would need the resilience publisher's whole graph.
|
|
services.Count(d => d.ServiceType == typeof(IHostedService)
|
|
&& d.ImplementationType == typeof(GalaxyAckIdentityRefreshService)).ShouldBe(1);
|
|
using var sp = services.BuildServiceProvider();
|
|
sp.GetRequiredService<IGalaxyAckIdentityDirectory>()
|
|
.ShouldBeSameAs(sp.GetRequiredService<GalaxyAckIdentityDirectory>());
|
|
}
|
|
|
|
[Fact]
|
|
public void The_refresh_interval_and_staleness_are_configurable()
|
|
{
|
|
var services = DriverServices(new ListOnlySecretStore());
|
|
services.AddGalaxyAckIdentityCache(Config(new()
|
|
{
|
|
["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = "7",
|
|
["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = "50",
|
|
}));
|
|
using var sp = services.BuildServiceProvider();
|
|
|
|
var directory = sp.GetRequiredService<GalaxyAckIdentityDirectory>();
|
|
directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(7));
|
|
directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(50));
|
|
}
|
|
|
|
[Fact]
|
|
public void Defaults_are_30s_and_three_intervals()
|
|
{
|
|
using var sp = DriverServices(new ListOnlySecretStore()).BuildServiceProvider();
|
|
var directory = sp.GetRequiredService<GalaxyAckIdentityDirectory>();
|
|
directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(30));
|
|
directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(90));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("0", "0")]
|
|
[InlineData("3601", "0")]
|
|
[InlineData("30", "30")]
|
|
public void Bad_cache_settings_fail_validation(string interval, string staleAfter)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddGalaxyAckIdentityCache(Config(new()
|
|
{
|
|
["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = interval,
|
|
["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = staleAfter,
|
|
}));
|
|
using var sp = services.BuildServiceProvider();
|
|
|
|
Should.Throw<OptionsValidationException>(() => sp.GetRequiredService<IOptions<GalaxyAckIdentityCacheOptions>>().Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddOtOpcUaSecrets_decorates_the_invalidator_so_an_identity_write_untrusts_the_directory()
|
|
{
|
|
// Registered before AddOtOpcUaSecrets, so the library's TryAdd keeps this store for the directory's listing.
|
|
var services = DriverServices(new ListOnlySecretStore());
|
|
services.AddLogging();
|
|
services.AddOtOpcUaSecrets(Config(new()
|
|
{
|
|
// Resolving the invalidator never opens the store; the path only has to pass validation.
|
|
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"),
|
|
["Secrets:MasterKey:Source"] = "Environment",
|
|
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
|
|
["Secrets:RunMigrationsOnStartup"] = "false",
|
|
}));
|
|
using var sp = services.BuildServiceProvider();
|
|
|
|
var invalidator = sp.GetRequiredService<ISecretCacheInvalidator>();
|
|
var decorated = invalidator.ShouldBeOfType<SecretChangeSignallingInvalidator>();
|
|
decorated.Inner.ShouldBeOfType<DefaultSecretResolver>();
|
|
decorated.Inner.ShouldBeSameAs(sp.GetRequiredService<DefaultSecretResolver>(), "the resolver cache is still invalidated");
|
|
|
|
var directory = sp.GetRequiredService<GalaxyAckIdentityDirectory>();
|
|
(await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
|
|
directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
|
|
|
|
// What ApiKeyGalaxyAckIdentityService.SetAsync does after writing the user secret.
|
|
invalidator.Invalidate(new SecretName(GalaxyAckIdentitySecrets.UserSecretName(WithoutIdentity)));
|
|
|
|
directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_a_directory_the_decorated_invalidator_is_a_pass_through()
|
|
{
|
|
var inner = new RecordingInvalidator();
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<ISecretCacheInvalidator>(_ => inner);
|
|
SecretsRegistration.DecorateCacheInvalidator(services);
|
|
using var sp = services.BuildServiceProvider();
|
|
|
|
var name = new SecretName("otopcua/apikeys/abc/galaxy-ack-user");
|
|
sp.GetRequiredService<ISecretCacheInvalidator>().Invalidate(name);
|
|
|
|
inner.Names.ShouldBe([name]);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ helpers
|
|
|
|
private static IServiceCollection DriverServices(ISecretStore store)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<ISecretResolver>(new NullResolver());
|
|
services.AddSingleton(store);
|
|
services.AddOtOpcUaDriverFactories();
|
|
return services;
|
|
}
|
|
|
|
private static IConfiguration Config(Dictionary<string, string?> values) =>
|
|
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
|
|
private sealed class NullResolver : ISecretResolver
|
|
{
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
|
}
|
|
|
|
private sealed class RecordingInvalidator : ISecretCacheInvalidator
|
|
{
|
|
public List<SecretName> Names { get; } = [];
|
|
|
|
public void Invalidate(SecretName name) => Names.Add(name);
|
|
}
|
|
|
|
private sealed class ListOnlySecretStore(params string[] names) : ISecretStore
|
|
{
|
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<SecretMetadata>>(names.Select(n => new SecretMetadata
|
|
{
|
|
Name = new SecretName(n),
|
|
ContentType = SecretContentType.Text,
|
|
KekId = "k",
|
|
Revision = 1,
|
|
CreatedUtc = DateTimeOffset.UnixEpoch,
|
|
UpdatedUtc = DateTimeOffset.UnixEpoch,
|
|
}).ToList());
|
|
|
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|