134 lines
6.0 KiB
C#
134 lines
6.0 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Guards the role-conditional data-plane authentication wiring (arch-review 03/S18). A decorator
|
|
/// registered in one branch of a role conditional is the "built but never wired" shape, so the branch
|
|
/// each node role lands in is asserted rather than assumed.
|
|
/// </summary>
|
|
public sealed class ApiKeyStoreRegistrationTests
|
|
{
|
|
/// <summary>
|
|
/// The authorizer's clock and the registered clock must be the SAME instance.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="DataPlaneAuthorizer"/> already documents that a divergence between two clocks denies
|
|
/// every request with a verdict indistinguishable from "no grant matched" — found once by a test
|
|
/// clock two hours ahead. One registered singleton threaded into every consumer makes the
|
|
/// divergence unrepresentable instead of merely unlikely, so the registration is what is pinned.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void The_authorizer_and_the_key_store_share_one_TimeProvider()
|
|
{
|
|
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
|
|
|
|
var registered = sp.GetRequiredService<TimeProvider>();
|
|
var authorizer = sp.GetRequiredService<DataPlaneAuthorizer>();
|
|
|
|
registered.ShouldBeSameAs(Probe.Instance);
|
|
authorizer.Clock.ShouldBeSameAs(registered);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An admin (or fused admin+driver) node's key store is the <see cref="BoundedApiKeyStore"/>
|
|
/// decorator, wrapping the ConfigDb store.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The decorator lives in ONE branch of a role conditional, which is the "built but never wired"
|
|
/// shape OVERALL theme 3 names. Unwired, every unit test in <c>BoundedApiKeyStoreTests</c> still
|
|
/// passes and the deadline protects nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void An_admin_node_wraps_the_ConfigDb_store_in_the_bound()
|
|
{
|
|
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
|
|
|
|
var store = sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<BoundedApiKeyStore>();
|
|
store.Inner.ShouldBeOfType<ConfigDbApiKeyStore>();
|
|
store.Clock.ShouldBeSameAs(sp.GetRequiredService<TimeProvider>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// A driver-only node's key store is the bare artifact store — deliberately NOT wrapped.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// It is two dictionary probes on an in-memory snapshot, so it can neither hang nor be worth
|
|
/// caching; wrapping it would add a deadline to a call that cannot miss one and a second copy of
|
|
/// data the snapshot already holds.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_driver_only_node_gets_the_bare_artifact_store()
|
|
{
|
|
using var sp = BuildNode(hasAdmin: false, hasDriver: true);
|
|
|
|
sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<ArtifactApiKeyStore>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node carrying neither role registers the deny-all authenticator <b>explicitly</b>, and no key
|
|
/// store at all.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Explicitly, not by absence: <c>OtOpcUaServerHostedService</c> takes
|
|
/// <see cref="IOpcUaUserAuthenticator"/> as a required constructor parameter, and the built-in
|
|
/// container does not fall back to a default for an unregistered service — it throws, and the node
|
|
/// does not start. The assertion is on the singleton INSTANCE, so a future registration that
|
|
/// happened to resolve some other deny-all type still surfaces here.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_node_with_neither_role_registers_the_deny_all_authenticator()
|
|
{
|
|
using var sp = BuildNode(hasAdmin: false, hasDriver: false);
|
|
|
|
sp.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeSameAs(NullOpcUaUserAuthenticator.Instance);
|
|
sp.GetService<IApiKeyStore>().ShouldBeNull("a node that serves no data plane has no keys to read");
|
|
}
|
|
|
|
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
|
|
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
|
|
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>
|
|
/// <returns>The built provider; the caller disposes it.</returns>
|
|
internal static ServiceProvider BuildNode(bool hasAdmin, bool hasDriver)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
|
|
services.AddSingleton<TimeProvider>(Probe.Instance);
|
|
services.Configure<ApiKeyOptions>(_ => { });
|
|
services.Configure<AclOptions>(_ => { });
|
|
services.Configure<AkkaClusterOptions>(o => o.Roles = ["admin", "driver", "cluster-MAIN"]);
|
|
services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
|
|
services.AddSingleton<PermissionTrieCache>();
|
|
services.AddSingleton<DataPlaneAuthorityState>();
|
|
|
|
if (hasAdmin)
|
|
{
|
|
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
|
|
o => o.UseInMemoryDatabase($"apikey-reg-{Guid.NewGuid():N}"));
|
|
}
|
|
|
|
services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
/// <summary>A clock distinguishable from <see cref="TimeProvider.System"/> by reference.</summary>
|
|
private sealed class Probe : TimeProvider
|
|
{
|
|
public static readonly Probe Instance = new();
|
|
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
|
|
}
|
|
}
|