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"); } /// /// 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. /// [Fact] public async Task CreateAsync_UnknownScope_DoesNotCallStore() { FakeApiKeyAdminStore adminStore = new(); DashboardApiKeyManagementService service = CreateService(adminStore); DashboardApiKeyManagementRequest request = CreateRequest() with { Scopes = new HashSet( [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) { DefaultHttpContext httpContext = new(); httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; return new DashboardApiKeyManagementService( new DashboardApiKeyAuthorization(), 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([GatewayScopes.SessionOpen], StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = ["Area1/*"], }); } private static ClaimsPrincipal CreateAuthorizedUser() { ClaimsIdentity identity = new( [new Claim(ClaimTypes.Role, DashboardRoles.Admin)], DashboardAuthenticationDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role); 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 CreatedRequests { get; } = []; public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { CreateCount++; CreatedRequests.Add(request); return Task.CompletedTask; } public Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>([]); } public Task RevokeAsync( string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken) { RevokeCount++; LastRevokedKeyId = keyId; return Task.FromResult(RevokeResult); } public Task RotateAsync( string keyId, byte[] secretHash, DateTimeOffset rotatedUtc, CancellationToken cancellationToken) { LastRotatedSecretHash = secretHash; return Task.FromResult(RotateResult); } } private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore { public List Entries { get; } = []; public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { Entries.Add(entry); return Task.CompletedTask; } public Task> ListRecentAsync( int count, CancellationToken cancellationToken) { return Task.FromResult>([]); } } 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}"); } } }