b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask, SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass TestContext.Current.CancellationToken. Test counts unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
240 lines
11 KiB
C#
240 lines
11 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
|
|
/// admin+driver one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
|
|
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
|
|
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
|
|
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
|
|
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
|
|
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
|
|
/// central node.
|
|
/// </para>
|
|
/// <para>
|
|
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
|
|
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
|
|
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
|
|
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
|
|
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ConfigDbApiKeyStoreTests
|
|
{
|
|
private const string ThisCluster = "SITE-A";
|
|
private const string OtherCluster = "SITE-B";
|
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>
|
|
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
|
|
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
|
|
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [Key("abc", "scadabridge-dcl")],
|
|
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
|
|
|
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, TestContext.Current.CancellationToken);
|
|
|
|
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
|
|
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
|
|
found.ShouldNotBeNull();
|
|
found!.Grants.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The positive control for the test above: without it, a "fix" that returned no grants at all
|
|
/// would pass.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [Key("abc", "scadabridge-dcl")],
|
|
acls:
|
|
[
|
|
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
|
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
|
]);
|
|
|
|
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, TestContext.Current.CancellationToken);
|
|
|
|
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
|
|
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
|
|
/// Failing closed here would silently strip every grant on an existing deployment.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [Key("abc", "p")],
|
|
acls:
|
|
[
|
|
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
|
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
|
]);
|
|
|
|
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, TestContext.Current.CancellationToken);
|
|
|
|
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
|
|
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
|
|
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
|
|
/// authored independently. A case-sensitive match would therefore strip every grant on a node
|
|
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
|
|
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_ClusterId_matches_case_insensitively()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [Key("abc", "p")],
|
|
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
|
|
|
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, TestContext.Current.CancellationToken);
|
|
|
|
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
|
|
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [],
|
|
acls:
|
|
[
|
|
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
|
|
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
|
]);
|
|
|
|
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
|
|
.FindGrantsAsync(OpcUaPrincipals.Anonymous, TestContext.Current.CancellationToken);
|
|
|
|
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Narrower-than-Cluster scopes ARE returned (#520 stage 5 widened this) — the cluster filter is
|
|
/// now the only narrowing this store performs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The scope-kind filter that used to sit alongside it was redundant: <c>ApiKeyInterimRoleMapper</c>
|
|
/// applies exactly that filter itself, so removing it changes no role — pinned below — while a
|
|
/// truncated <c>Grants</c> collection would have quietly misled anything that read it once scope
|
|
/// started being enforced.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_narrower_scope_in_this_cluster_is_returned()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
Seed(factory,
|
|
keys: [Key("abc", "p")],
|
|
acls:
|
|
[
|
|
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
|
|
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
|
]);
|
|
|
|
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, TestContext.Current.CancellationToken);
|
|
|
|
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
|
|
|
// Both grants now reach the mapper, and it still takes flags from the CLUSTER-scoped one only:
|
|
// AlarmAck is conferred, WriteOperate is not. The two grants carry different flags precisely so
|
|
// this cannot pass by accident — which is why widening the store is safe with enforcement off.
|
|
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBe([OpcUaDataPlaneRoles.AlarmAck]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
|
|
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
|
|
{
|
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
|
|
.ShouldBe("SITE-A");
|
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
|
|
.ShouldBeNull();
|
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
private static void Seed(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
|
IReadOnlyList<ApiKey> keys,
|
|
IReadOnlyList<NodeAcl> acls)
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
db.ApiKeys.AddRange(keys);
|
|
db.NodeAcls.AddRange(acls);
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static ApiKey Key(string id, string principal) => new()
|
|
{
|
|
ApiKeyRowId = Guid.NewGuid(),
|
|
ApiKeyId = id,
|
|
Name = id,
|
|
SecretHash = "hash",
|
|
Principal = principal,
|
|
Enabled = true,
|
|
CreatedUtc = Now.AddDays(-1),
|
|
};
|
|
|
|
private static NodeAcl Acl(
|
|
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
|
|
{
|
|
NodeAclRowId = Guid.NewGuid(),
|
|
NodeAclId = id,
|
|
ClusterId = clusterId,
|
|
Principal = principal,
|
|
ScopeKind = scope,
|
|
PermissionFlags = flags,
|
|
};
|
|
|
|
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
|
|
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
|
|
|
|
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
/// <summary>Creates a context over this test's own in-memory database.</summary>
|
|
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
|
|
public OtOpcUaConfigDbContext CreateDbContext() =>
|
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
|
|
}
|
|
}
|