96bea1d478
Restyles the Blazor dashboard onto a portable token-based theme so it reads like an instrument panel: warm-paper background, hairline-ruled panels, IBM Plex type, monospace tabular numerics, and status carried by colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css as a thin token-driven view layer, and swaps the Bootstrap navbar and status badges for the design-system app bar and chips. Also includes pending API-key management, Galaxy hierarchy projection, and constraint-enforcement work with their tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
8.3 KiB
C#
238 lines
8.3 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Options;
|
|
using MxGateway.Server.Configuration;
|
|
using MxGateway.Server.Dashboard;
|
|
using MxGateway.Server.Security.Authentication;
|
|
using MxGateway.Server.Security.Authorization;
|
|
|
|
namespace 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");
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|
|
}
|