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}");
}
}
}