f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
210 lines
9.7 KiB
C#
210 lines
9.7 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.GalaxyRepository;
|
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
|
|
|
|
/// <summary>
|
|
/// End-to-end wiring tests for the host-side browse-scope authorisation chain:
|
|
/// API-key constraints → <see cref="GatewayRequestIdentityAccessor.Push"/> →
|
|
/// <see cref="GatewayBrowseScopeProvider.ResolveBrowseSubtrees"/> → lib
|
|
/// <see cref="GalaxyRepositoryGrpcService"/> → filtered <c>BrowseChildren</c> reply.
|
|
///
|
|
/// These tests are NOT replaceable by the isolated
|
|
/// <c>GatewayBrowseScopeProviderTests</c> (which tests the provider in isolation) or
|
|
/// the lib's own test suite (which uses a fake <c>IGalaxyBrowseScopeProvider</c>).
|
|
/// They verify that the gateway's concrete <see cref="GatewayBrowseScopeProvider"/> is
|
|
/// correctly wired to the lib's <see cref="GalaxyRepositoryGrpcService"/> and that the
|
|
/// filter actually propagates end-to-end into the returned child list.
|
|
/// </summary>
|
|
public sealed class GalaxyRepositoryHostWiringTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies the full production authz-filtering chain end-to-end.
|
|
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
|
|
/// returns both children (unconstrained). That, not an absent identity, is production's
|
|
/// unscoped case — with <c>Authentication:Mode=ApiKey</c> an absent identity
|
|
/// now denies, which the isolated provider test pins.
|
|
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
|
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
|
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
|
/// service → <c>BrowseChildren</c> returns empty children.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren()
|
|
{
|
|
// Arrange: construct the REAL host seam — not stubs.
|
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
|
GatewayBrowseScopeProvider scopeProvider = new(
|
|
identityAccessor,
|
|
Options.Create(new GatewayOptions
|
|
{
|
|
Authentication = new Server.Configuration.AuthenticationOptions
|
|
{
|
|
Mode = AuthenticationMode.ApiKey,
|
|
},
|
|
}),
|
|
NullLogger<GatewayBrowseScopeProvider>.Instance);
|
|
|
|
GalaxyObject[] objects = CreateFilterObjects();
|
|
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
|
{
|
|
Status = GalaxyCacheStatus.Healthy,
|
|
Sequence = 7,
|
|
LastSuccessAt = DateTimeOffset.UtcNow,
|
|
Objects = objects,
|
|
Index = GalaxyHierarchyIndex.Build(objects),
|
|
ObjectCount = objects.Length,
|
|
};
|
|
|
|
// Inject the REAL GatewayBrowseScopeProvider as the IGalaxyBrowseScopeProvider.
|
|
GalaxyRepositoryGrpcService service = new(
|
|
new StubGalaxyRepository(),
|
|
new StubGalaxyHierarchyCache(entry),
|
|
new GalaxyDeployNotifier(),
|
|
scopeProvider);
|
|
|
|
// Phase 1: an authenticated identity with no BrowseSubtrees constraint → browse is
|
|
// unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
|
|
ApiKeyIdentity unscopedIdentity = new(
|
|
KeyId: "test-key",
|
|
KeyPrefix: "mxgw_test",
|
|
DisplayName: "unscoped",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
|
|
Constraints: ApiKeyConstraints.Empty);
|
|
|
|
using (identityAccessor.Push(unscopedIdentity))
|
|
{
|
|
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
|
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
|
new TestServerCallContext());
|
|
|
|
Assert.Equal(2, unconstrained.Children.Count);
|
|
}
|
|
|
|
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
|
|
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],
|
|
// the lib service must honour it, and the child list must be empty.
|
|
ApiKeyIdentity identity = new(
|
|
KeyId: "test-key",
|
|
KeyPrefix: "mxgw_test",
|
|
DisplayName: "constraint-test",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
|
|
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = new[] { "NonExistent" } });
|
|
|
|
using (identityAccessor.Push(identity))
|
|
{
|
|
BrowseChildrenReply constrained = await service.BrowseChildren(
|
|
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
|
new TestServerCallContext());
|
|
|
|
Assert.Empty(constrained.Children);
|
|
Assert.Equal(0, constrained.TotalChildCount);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a minimal hierarchy: Area1 at root, Line3 under Area1,
|
|
/// Pump_001 and Valve_001 under Line3.
|
|
/// </summary>
|
|
private static GalaxyObject[] CreateFilterObjects() =>
|
|
[
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 1,
|
|
TagName = "Area1",
|
|
ContainedName = "Area1",
|
|
BrowseName = "Area1",
|
|
IsArea = true,
|
|
CategoryId = 13,
|
|
},
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 2,
|
|
TagName = "Line3",
|
|
ContainedName = "Line3",
|
|
BrowseName = "Line3",
|
|
ParentGobjectId = 1,
|
|
CategoryId = 10,
|
|
},
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 3,
|
|
TagName = "Pump_001",
|
|
ContainedName = "Pump",
|
|
BrowseName = "Pump_001",
|
|
ParentGobjectId = 2,
|
|
CategoryId = 10,
|
|
},
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 4,
|
|
TagName = "Valve_001",
|
|
ContainedName = "Valve",
|
|
BrowseName = "Valve_001",
|
|
ParentGobjectId = 2,
|
|
CategoryId = 11,
|
|
},
|
|
];
|
|
|
|
/// <summary>Immediately-ready stub: Current returns the seeded entry, loads are instant.</summary>
|
|
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
|
{
|
|
/// <summary>Gets the seeded cache entry passed to the stub's constructor.</summary>
|
|
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
|
|
|
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stub repository that throws on every method: <c>BrowseChildren</c> uses only
|
|
/// the hierarchy cache, so the repository is never called during these tests.
|
|
/// </summary>
|
|
private sealed class StubGalaxyRepository : IGalaxyRepository
|
|
{
|
|
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Never returns; always throws.</returns>
|
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
|
|
throw new NotSupportedException("Not called during BrowseChildren.");
|
|
|
|
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Never returns; always throws.</returns>
|
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
|
throw new NotSupportedException("Not called during BrowseChildren.");
|
|
|
|
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Never returns; always throws.</returns>
|
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
|
throw new NotSupportedException("Not called during BrowseChildren.");
|
|
|
|
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Never returns; always throws.</returns>
|
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
|
throw new NotSupportedException("Not called during BrowseChildren.");
|
|
|
|
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Never returns; always throws.</returns>
|
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
|
|
throw new NotSupportedException("Not called during BrowseChildren.");
|
|
}
|
|
}
|