rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("GwAdmin");
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local");
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AnonymousUser_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = new(new ClaimsIdentity());
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ReadOnly");
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
private static DashboardApiKeyAuthorization CreateAuthorization()
|
||||
{
|
||||
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string group)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyManagementServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
CreateRequest(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(0, adminStore.CreateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new();
|
||||
FakeApiKeyAuditStore auditStore = new();
|
||||
FakeApiKeySecretHasher hasher = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
||||
CreateAuthorizedUser(),
|
||||
CreateRequest(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.ApiKey);
|
||||
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
||||
string secret = result.ApiKey["mxgw_operator01_".Length..];
|
||||
Assert.Equal(secret, hasher.LastSecret);
|
||||
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
|
||||
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
|
||||
Assert.Equal("operator01", stored.KeyId);
|
||||
Assert.Equal("Operator", stored.DisplayName);
|
||||
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
|
||||
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
|
||||
Assert.Contains(auditStore.Entries, entry =>
|
||||
entry.EventType == "dashboard-create-key"
|
||||
&& entry.KeyId == "operator01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
"operator01",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(0, adminStore.RevokeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
|
||||
FakeApiKeyAuditStore auditStore = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
||||
CreateAuthorizedUser(),
|
||||
"operator01",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
|
||||
Assert.Contains(auditStore.Entries, entry =>
|
||||
entry.EventType == "dashboard-revoke-key"
|
||||
&& entry.KeyId == "operator01"
|
||||
&& entry.Details == "revoked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
|
||||
FakeApiKeyAuditStore auditStore = new();
|
||||
FakeApiKeySecretHasher hasher = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.RotateAsync(
|
||||
CreateAuthorizedUser(),
|
||||
"operator01",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.ApiKey);
|
||||
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
||||
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
|
||||
Assert.Contains(auditStore.Entries, entry =>
|
||||
entry.EventType == "dashboard-rotate-key"
|
||||
&& entry.KeyId == "operator01"
|
||||
&& entry.Details == "rotated");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-004 regression: the dashboard create path must reject a request
|
||||
/// carrying a non-canonical scope string rather than persisting a key whose
|
||||
/// scope the authorization resolver never matches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAsync_UnknownScope_DoesNotCallStore()
|
||||
{
|
||||
FakeApiKeyAdminStore adminStore = new();
|
||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
||||
|
||||
DashboardApiKeyManagementRequest request = CreateRequest() with
|
||||
{
|
||||
Scopes = new HashSet<string>(
|
||||
[GatewayScopes.SessionOpen, "invoke", "metadata"],
|
||||
StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
||||
CreateAuthorizedUser(),
|
||||
request,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(0, adminStore.CreateCount);
|
||||
}
|
||||
|
||||
private static DashboardApiKeyManagementService CreateService(
|
||||
FakeApiKeyAdminStore? adminStore = null,
|
||||
FakeApiKeyAuditStore? auditStore = null,
|
||||
FakeApiKeySecretHasher? hasher = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
};
|
||||
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||
|
||||
return new DashboardApiKeyManagementService(
|
||||
new DashboardApiKeyAuthorization(Options.Create(options)),
|
||||
adminStore ?? new FakeApiKeyAdminStore(),
|
||||
auditStore ?? new FakeApiKeyAuditStore(),
|
||||
hasher ?? new FakeApiKeySecretHasher(),
|
||||
new HttpContextAccessor { HttpContext = httpContext });
|
||||
}
|
||||
|
||||
private static DashboardApiKeyManagementRequest CreateRequest()
|
||||
{
|
||||
return new DashboardApiKeyManagementRequest(
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.SessionOpen], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty with
|
||||
{
|
||||
BrowseSubtrees = ["Area1/*"],
|
||||
});
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateAuthorizedUser()
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||
{
|
||||
public int CreateCount { get; private set; }
|
||||
|
||||
public int RevokeCount { get; private set; }
|
||||
|
||||
public bool RevokeResult { get; init; }
|
||||
|
||||
public bool RotateResult { get; init; }
|
||||
|
||||
public string? LastRevokedKeyId { get; private set; }
|
||||
|
||||
public byte[]? LastRotatedSecretHash { get; private set; }
|
||||
|
||||
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
|
||||
|
||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
CreateCount++;
|
||||
CreatedRequests.Add(request);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeAsync(
|
||||
string keyId,
|
||||
DateTimeOffset revokedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RevokeCount++;
|
||||
LastRevokedKeyId = keyId;
|
||||
return Task.FromResult(RevokeResult);
|
||||
}
|
||||
|
||||
public Task<bool> RotateAsync(
|
||||
string keyId,
|
||||
byte[] secretHash,
|
||||
DateTimeOffset rotatedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastRotatedSecretHash = secretHash;
|
||||
return Task.FromResult(RotateResult);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
|
||||
{
|
||||
public List<ApiKeyAuditEntry> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
|
||||
int count,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
|
||||
{
|
||||
public string? LastSecret { get; private set; }
|
||||
|
||||
public byte[] HashSecret(string secret)
|
||||
{
|
||||
LastSecret = secret;
|
||||
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthenticatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EscapeLdapFilter_EscapesSpecialCharacters()
|
||||
{
|
||||
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
|
||||
|
||||
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", true)]
|
||||
[InlineData("gwadmin", true)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
|
||||
[InlineData("OtherGroup", false)]
|
||||
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
|
||||
string requiredGroup,
|
||||
bool expected)
|
||||
{
|
||||
string[] groups =
|
||||
[
|
||||
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
|
||||
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
|
||||
];
|
||||
|
||||
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
|
||||
{
|
||||
string result = DashboardAuthenticator.ExtractFirstRdnValue(
|
||||
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
|
||||
|
||||
Assert.Equal("Gateway Admins", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
},
|
||||
});
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
|
||||
{
|
||||
return new DashboardAuthenticator(
|
||||
Options.Create(options),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthorizationHandlerTests
|
||||
{
|
||||
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: true);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the anonymous-localhost bypass is denied when <c>AllowAnonymousLocalhost</c>
|
||||
/// is off, even on a loopback connection — the misconfiguration must not expose the dashboard.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the anonymous-localhost bypass stays scoped to loopback: an anonymous
|
||||
/// request from a non-loopback address is denied even when <c>AllowAnonymousLocalhost</c> is on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: true);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.EventsRead),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.Admin),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||
ClaimsPrincipal principal,
|
||||
IPAddress remoteAddress,
|
||||
bool allowAnonymousLocalhost)
|
||||
{
|
||||
DashboardAuthorizationRequirement requirement = new();
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||
DashboardAuthorizationHandler handler = new(
|
||||
new HttpContextAccessor { HttpContext = httpContext },
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||
RequireAdminScope = true
|
||||
}
|
||||
}));
|
||||
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the pure projection/formatting helpers behind the
|
||||
/// dashboard Browse and Alarms tabs.
|
||||
/// </summary>
|
||||
public sealed class DashboardBrowseAndAlarmModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
|
||||
{
|
||||
GalaxyObject area = new() { GobjectId = 1, BrowseName = "AreaA", IsArea = true, ParentGobjectId = 0 };
|
||||
GalaxyObject child = new() { GobjectId = 2, BrowseName = "Pump01", ParentGobjectId = 1 };
|
||||
GalaxyObject orphan = new() { GobjectId = 3, BrowseName = "Lost", ParentGobjectId = 99 };
|
||||
|
||||
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([area, child, orphan]);
|
||||
|
||||
// The area and the orphan (its parent id is absent) are both roots.
|
||||
Assert.Equal(2, roots.Count);
|
||||
DashboardBrowseNode areaNode = Assert.Single(roots, node => node.Object.GobjectId == 1);
|
||||
Assert.Single(areaNode.Children);
|
||||
Assert.Equal(2, areaNode.Children[0].Object.GobjectId);
|
||||
Assert.Contains(roots, node => node.Object.GobjectId == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTree_SortsAreasBeforeObjects()
|
||||
{
|
||||
GalaxyObject instance = new() { GobjectId = 1, BrowseName = "Zeta", IsArea = false };
|
||||
GalaxyObject areaB = new() { GobjectId = 2, BrowseName = "Beta", IsArea = true };
|
||||
|
||||
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([instance, areaB]);
|
||||
|
||||
Assert.Equal(2, roots.Count);
|
||||
Assert.True(roots[0].IsArea);
|
||||
Assert.Equal("Beta", roots[0].DisplayName);
|
||||
Assert.False(roots[1].IsArea);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "true")]
|
||||
[InlineData(false, "false")]
|
||||
public void FormatValue_FormatsBooleans(bool input, string expected)
|
||||
{
|
||||
MxValue value = new() { DataType = MxDataType.Boolean, BoolValue = input };
|
||||
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_FormatsNumbersAndStrings()
|
||||
{
|
||||
Assert.Equal("42", DashboardMxValueFormatter.FormatValue(new MxValue { Int32Value = 42 }));
|
||||
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_HandlesNullPayloadAndNullReference()
|
||||
{
|
||||
Assert.Equal("-", DashboardMxValueFormatter.FormatValue(null));
|
||||
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
|
||||
{
|
||||
BulkReadResult result = new()
|
||||
{
|
||||
TagAddress = "Galaxy!Area.Tag",
|
||||
WasSuccessful = true,
|
||||
Quality = 192,
|
||||
Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 1.5 },
|
||||
};
|
||||
|
||||
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
|
||||
|
||||
Assert.True(value.Ok);
|
||||
Assert.True(value.QualityGood);
|
||||
Assert.Equal("1.5", value.ValueText);
|
||||
Assert.Null(value.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagValue_FromFailedReadResult_CarriesError()
|
||||
{
|
||||
BulkReadResult result = new()
|
||||
{
|
||||
TagAddress = "Galaxy!Area.Bad",
|
||||
WasSuccessful = false,
|
||||
Quality = 0,
|
||||
ErrorMessage = "invalid handle",
|
||||
};
|
||||
|
||||
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
|
||||
|
||||
Assert.False(value.Ok);
|
||||
Assert.False(value.QualityGood);
|
||||
Assert.Equal("invalid handle", value.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
|
||||
{
|
||||
ActiveAlarmSnapshot unacked = new()
|
||||
{
|
||||
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
Category = "TestArea",
|
||||
CurrentState = AlarmConditionState.Active,
|
||||
Severity = 500,
|
||||
};
|
||||
ActiveAlarmSnapshot acked = new()
|
||||
{
|
||||
AlarmFullReference = "Galaxy!TestArea.TestMachine_002.TestAlarm001",
|
||||
CurrentState = AlarmConditionState.ActiveAcked,
|
||||
};
|
||||
|
||||
DashboardActiveAlarm unackedRow = DashboardActiveAlarm.FromSnapshot(unacked);
|
||||
DashboardActiveAlarm ackedRow = DashboardActiveAlarm.FromSnapshot(acked);
|
||||
|
||||
Assert.Equal("Galaxy", unackedRow.Provider);
|
||||
Assert.Equal("TestArea", unackedRow.Area);
|
||||
Assert.Equal(500, unackedRow.Severity);
|
||||
Assert.True(unackedRow.IsUnacknowledged);
|
||||
Assert.False(ackedRow.IsUnacknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
|
||||
{
|
||||
MxArray array = new() { ElementDataType = MxDataType.Double };
|
||||
array.Dimensions.Add(3u);
|
||||
array.DoubleValues = new DoubleArray();
|
||||
array.DoubleValues.Values.Add(new[] { 1.5, 2.25, 3.0 });
|
||||
MxValue value = new() { ArrayValue = array };
|
||||
|
||||
Assert.Equal("[1.5, 2.25, 3]", DashboardMxValueFormatter.FormatValue(value));
|
||||
Assert.Equal("Double[3]", DashboardMxValueFormatter.FormatDataType(value));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardConnectionStringDisplayTests
|
||||
{
|
||||
[Fact]
|
||||
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
|
||||
{
|
||||
string display = DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(
|
||||
"Server=localhost;Database=ZB;User ID=mxuser;Password=secret;Encrypt=True;Trust Server Certificate=False;");
|
||||
|
||||
Assert.Contains("Data Source=localhost", display, StringComparison.Ordinal);
|
||||
Assert.Contains("Initial Catalog=ZB", display, StringComparison.Ordinal);
|
||||
Assert.Contains("Encrypt=True", display, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("User", display, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Password", display, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("secret", display, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("mxuser", display, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardCookieOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
|
||||
[Fact]
|
||||
public async Task Build_ConfiguresSecureDashboardCookie()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||
|
||||
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
|
||||
Assert.True(options.Cookie.HttpOnly);
|
||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||
Assert.Equal("/", options.Cookie.Path);
|
||||
Assert.Equal("/dashboard/login", options.LoginPath);
|
||||
Assert.Equal("/dashboard/logout", options.LogoutPath);
|
||||
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardSnapshotServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies snapshot returns empty collections and healthy status when registry is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Empty(snapshot.Sessions);
|
||||
Assert.Empty(snapshot.Workers);
|
||||
Assert.Empty(snapshot.Faults);
|
||||
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0);
|
||||
Assert.Equal("Healthy", snapshot.GatewayStatus);
|
||||
Assert.NotNull(snapshot.Configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
|
||||
{
|
||||
SessionRegistry registry = new();
|
||||
GatewaySession activeSession = CreateSession(
|
||||
"session-active",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
|
||||
activeSession.MarkReady();
|
||||
GatewaySession faultedSession = CreateSession(
|
||||
"session-faulted",
|
||||
"client-two",
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
|
||||
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
|
||||
faultedSession.MarkFaulted("worker pipe disconnected");
|
||||
GatewaySession closedSession = CreateSession(
|
||||
"session-closed",
|
||||
"client-three",
|
||||
DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture));
|
||||
closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed));
|
||||
closedSession.TransitionTo(SessionState.Closed);
|
||||
registry.TryAdd(activeSession);
|
||||
registry.TryAdd(faultedSession);
|
||||
registry.TryAdd(closedSession);
|
||||
using GatewayMetrics metrics = new();
|
||||
metrics.SessionOpened();
|
||||
metrics.SessionOpened();
|
||||
metrics.CommandStarted("Register");
|
||||
metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7));
|
||||
metrics.EventReceived("session-active", "OnDataChange");
|
||||
metrics.Fault("WorkerFaulted");
|
||||
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Equal(3, snapshot.Sessions.Count);
|
||||
Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId);
|
||||
Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State);
|
||||
DashboardSessionSummary activeSummary = Assert.Single(
|
||||
snapshot.Sessions,
|
||||
session => session.SessionId == "session-active");
|
||||
Assert.Equal(1, activeSummary.EventsReceived);
|
||||
Assert.Equal(2, snapshot.Workers.Count);
|
||||
Assert.DoesNotContain(snapshot.Workers, worker => worker.SessionId == "session-closed");
|
||||
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1);
|
||||
Assert.Contains(
|
||||
snapshot.Metrics,
|
||||
metric => metric.Name == "mxgateway.events.received"
|
||||
&& metric.Dimension == "OnDataChange"
|
||||
&& metric.Value == 1);
|
||||
DashboardFaultSummary fault = Assert.Single(snapshot.Faults);
|
||||
Assert.Equal("Worker", fault.Source);
|
||||
Assert.Equal("session-faulted", fault.SessionId);
|
||||
Assert.Equal("worker pipe disconnected", fault.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
|
||||
{
|
||||
SessionRegistry registry = new();
|
||||
GatewaySession session = CreateSession(
|
||||
"session-redacted",
|
||||
"Bearer mxgw_admin_super-secret",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture),
|
||||
clientSessionName: "password=hunter2",
|
||||
clientCorrelationId: "token=abc123");
|
||||
session.MarkFaulted("secret=credential-value");
|
||||
registry.TryAdd(session);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
DashboardSessionSummary summary = Assert.Single(snapshot.Sessions);
|
||||
Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity);
|
||||
Assert.Equal("[redacted]", summary.ClientSessionName);
|
||||
Assert.Equal("[redacted]", summary.ClientCorrelationId);
|
||||
Assert.Equal("[redacted]", summary.LastFault);
|
||||
Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message);
|
||||
Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot generation does not mutate session or worker client state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
|
||||
{
|
||||
SessionRegistry registry = new();
|
||||
GatewaySession session = CreateSession(
|
||||
"session-active",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||
|
||||
service.GetSnapshot();
|
||||
service.GetSnapshot();
|
||||
|
||||
Assert.Equal(1, registry.ActiveCount);
|
||||
Assert.Equal(SessionState.Ready, session.State);
|
||||
Assert.Equal(WorkerClientState.Ready, workerClient.State);
|
||||
Assert.Equal(0, workerClient.StartCount);
|
||||
Assert.Equal(0, workerClient.ShutdownCount);
|
||||
Assert.Equal(0, workerClient.KillCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot respects configured limits for recent sessions and faults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
|
||||
{
|
||||
SessionRegistry registry = new();
|
||||
GatewaySession olderSession = CreateSession(
|
||||
"session-older",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
GatewaySession newerSession = CreateSession(
|
||||
"session-newer",
|
||||
"client-two",
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
|
||||
olderSession.MarkFaulted("older fault");
|
||||
newerSession.MarkFaulted("newer fault");
|
||||
registry.TryAdd(olderSession);
|
||||
registry.TryAdd(newerSession);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(
|
||||
registry,
|
||||
metrics,
|
||||
new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
RecentSessionLimit = 1,
|
||||
RecentFaultLimit = 1,
|
||||
},
|
||||
});
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId);
|
||||
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 7,
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
|
||||
DashboardSummary = new DashboardGalaxySummary(
|
||||
DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastError: null,
|
||||
ObjectCount: 3,
|
||||
AreaCount: 1,
|
||||
AttributeCount: 2,
|
||||
HistorizedAttributeCount: 1,
|
||||
AlarmAttributeCount: 1,
|
||||
TopTemplates:
|
||||
[
|
||||
new DashboardGalaxyTemplateUsage("$Pump", 2),
|
||||
new DashboardGalaxyTemplateUsage("$Area", 1),
|
||||
],
|
||||
ObjectCategories:
|
||||
[
|
||||
new DashboardGalaxyCategoryCount(10, "UserDefined", 2),
|
||||
new DashboardGalaxyCategoryCount(13, "Area", 1),
|
||||
]),
|
||||
ObjectCount = 3,
|
||||
AreaCount = 1,
|
||||
AttributeCount = 2,
|
||||
HistorizedAttributeCount = 1,
|
||||
AlarmAttributeCount = 1,
|
||||
};
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry));
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status);
|
||||
Assert.Equal(3, snapshot.Galaxy.ObjectCount);
|
||||
Assert.Equal(1, snapshot.Galaxy.AreaCount);
|
||||
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
|
||||
Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
|
||||
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
|
||||
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
|
||||
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
CountingApiKeyAdminStore apiKeyAdminStore = new();
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Empty(snapshot.ApiKeys);
|
||||
Assert.Equal(0, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
||||
new ApiKeyRecord(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
SecretHash: [1, 2, 3],
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty with
|
||||
{
|
||||
BrowseSubtrees = ["Area1/*"],
|
||||
},
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot snapshot = enumerator.Current;
|
||||
|
||||
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
|
||||
Assert.Equal("operator01", key.KeyId);
|
||||
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
|
||||
Assert.Equal(1, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
SequencedApiKeyAdminStore apiKeyAdminStore = new(
|
||||
new ApiKeyRecord(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
SecretHash: [1, 2, 3],
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty,
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
},
|
||||
},
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot first = enumerator.Current;
|
||||
apiKeyAdminStore.FailNext = true;
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot second = enumerator.Current;
|
||||
|
||||
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
|
||||
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
|
||||
Assert.Equal(2, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
},
|
||||
});
|
||||
using CancellationTokenSource cancellation = new();
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||
await cancellation.CancelAsync();
|
||||
bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.False(hasNext);
|
||||
}
|
||||
|
||||
private static DashboardSnapshotService CreateService(
|
||||
SessionRegistry registry,
|
||||
GatewayMetrics metrics,
|
||||
GatewayOptions? options = null,
|
||||
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
|
||||
IApiKeyAdminStore? apiKeyAdminStore = null)
|
||||
{
|
||||
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
},
|
||||
};
|
||||
GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions));
|
||||
|
||||
return new DashboardSnapshotService(
|
||||
registry,
|
||||
metrics,
|
||||
configurationProvider,
|
||||
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
|
||||
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
|
||||
Options.Create(resolvedOptions));
|
||||
}
|
||||
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current Galaxy hierarchy cache entry.
|
||||
/// </summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the cache asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the first cache load asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||
{
|
||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeAsync(
|
||||
string keyId,
|
||||
DateTimeOffset revokedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> RotateAsync(
|
||||
string keyId,
|
||||
byte[] secretHash,
|
||||
DateTimeOffset rotatedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
||||
{
|
||||
public int ListCount { get; protected set; }
|
||||
|
||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ListCount++;
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
|
||||
{
|
||||
public bool FailNext { get; set; }
|
||||
|
||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (FailNext)
|
||||
{
|
||||
FailNext = false;
|
||||
ListCount++;
|
||||
throw new InvalidOperationException("Simulated SQLite failure.");
|
||||
}
|
||||
|
||||
return base.ListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(
|
||||
string sessionId,
|
||||
string? clientIdentity,
|
||||
DateTimeOffset openedAt,
|
||||
string? clientSessionName = "test-session",
|
||||
string? clientCorrelationId = "client-correlation")
|
||||
{
|
||||
return new GatewaySession(
|
||||
sessionId,
|
||||
"mxaccess",
|
||||
$"mxaccess-gateway-1-{sessionId}",
|
||||
"nonce",
|
||||
clientIdentity,
|
||||
clientSessionName,
|
||||
clientCorrelationId,
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(5),
|
||||
openedAt);
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient(
|
||||
string sessionId,
|
||||
int? processId,
|
||||
WorkerClientState state) : IWorkerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the session identifier.
|
||||
/// </summary>
|
||||
public string SessionId { get; } = sessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process identifier.
|
||||
/// </summary>
|
||||
public int? ProcessId { get; } = processId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current worker client state.
|
||||
/// </summary>
|
||||
public WorkerClientState State { get; private set; } = state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last heartbeat.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of start invocations.
|
||||
/// </summary>
|
||||
public int StartCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of shutdown invocations.
|
||||
/// </summary>
|
||||
public int ShutdownCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of kill invocations.
|
||||
/// </summary>
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the worker client asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a worker command asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to invoke.</param>
|
||||
/// <param name="timeout">Command timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Command reply.</returns>
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new WorkerCommandReply());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events from the worker asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of worker events.</returns>
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuts down the worker client asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Shutdown timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task ShutdownAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ShutdownCount++;
|
||||
State = WorkerClientState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates the worker client.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for termination.</param>
|
||||
public void Kill(string reason)
|
||||
{
|
||||
KillCount++;
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources used by this worker client.
|
||||
/// </summary>
|
||||
/// <returns>Completed value task.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user