using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
///
/// Tests the gateway dashboard API-key management surface over the shared
/// ZB.MOM.WW.Auth.ApiKeys admin commands and stores (the gateway is the donor). The service
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
/// rule are all proven end-to-end.
///
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
{
private readonly List _tempDirectories = [];
/// Verifies that unauthorized users cannot create API keys.
/// A task that represents the asynchronous operation.
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
CreateRequest(),
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Empty(await ListAsync(services));
}
/// Verifies that authorized users create a verifiable, constrained key and audit it.
/// A task that represents the asynchronous operation.
[Fact]
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
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);
// The freshly minted token authenticates against the same store and surfaces its scopes.
ApiKeyVerification verification = await services
.GetRequiredService()
.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
// Constraints round-trip through the opaque JSON blob.
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
IReadOnlyList audit = await ListAuditAsync(services);
// Phase 3: Actor = operator username ("alice"), Target = managed keyId ("operator01").
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-create-key"
&& entry.KeyId == "alice");
}
/// Verifies that creating a key whose id already exists is rejected.
/// A task that represents the asynchronous operation.
[Fact]
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
CreateAuthorizedUser(),
CreateRequest(),
CancellationToken.None);
Assert.False(duplicate.Succeeded);
Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
}
/// Verifies that authorized users can revoke keys with audit trail.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.True(result.Succeeded);
ApiKeyListItem key = Assert.Single(await ListAsync(services));
Assert.NotNull(key.RevokedUtc);
IReadOnlyList audit = await ListAuditAsync(services);
// Phase 3: Actor = operator username; the dashboard-revoke-key event surfaces KeyId = "alice"
// (the operator) and Details = "revoked".
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-revoke-key"
&& entry.KeyId == "alice"
&& entry.Details == "revoked");
}
/// Verifies that authorized users can rotate a key's secret with audit trail.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult created = await service.CreateAsync(
CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
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.NotEqual(created.ApiKey, result.ApiKey);
// Old token no longer authenticates; new one does.
IApiKeyVerifier verifier = services.GetRequiredService();
Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
IReadOnlyList audit = await ListAuditAsync(services);
// Phase 3: Actor = operator username ("alice").
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-rotate-key"
&& entry.KeyId == "alice"
&& entry.Details == "rotated");
}
/// Verifies that authorized users can delete revoked keys with audit trail.
/// A task that represents the asynchronous operation.
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Empty(await ListAsync(services));
IReadOnlyList audit = await ListAuditAsync(services);
// Phase 3: Actor = operator username ("alice").
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-delete-key"
&& entry.KeyId == "alice"
&& entry.Details == "deleted");
}
///
/// When the key is still active (not revoked), the delete is refused but a
/// dashboard-delete-key audit entry with Details = "not-found-or-active" is still
/// written — audit completeness for refused deletes.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
IReadOnlyList audit = await ListAuditAsync(services);
ApiKeyAuditEntry deleteEntry = Assert.Single(
audit, entry => entry.EventType == "dashboard-delete-key");
// Phase 3: Actor = operator username ("alice"), not the managed keyId.
Assert.Equal("alice", deleteEntry.KeyId);
Assert.Equal("not-found-or-active", deleteEntry.Details);
}
/// A blank key id fails validation before any store or audit call runs.
/// A blank or whitespace key identifier.
/// A task that represents the asynchronous operation.
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
blankKeyId,
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Empty(await ListAuditAsync(services));
}
///
/// 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.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task CreateAsync_UnknownScope_DoesNotCreate()
{
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
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.Empty(await ListAsync(services));
}
///
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
/// the operator username as Actor and the managed keyId as Target.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
{
await using ServiceProvider services = BuildServices();
// Wire a recording writer so we can inspect the canonical AuditEvent directly (bypassing
// the CanonicalForwardingApiKeyAuditStore round-trip that ListAuditAsync uses).
RecordingAuditWriter recordingWriter = new();
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
DashboardApiKeyManagementService service = new(
new DashboardApiKeyAuthorization(),
services.GetRequiredService(),
services.GetRequiredService(),
recordingWriter,
new HttpContextAccessor { HttpContext = httpContext });
await service.CreateAsync(
CreateAuthorizedUser(),
CreateRequest(),
CancellationToken.None);
// The dashboard-create-key event emitted directly by the service (not the library's
// create-key event forwarded via the adapter) must have Actor = operator username and
// Target = managed keyId.
ZB.MOM.WW.Audit.AuditEvent dashboardEvent = Assert.Single(
recordingWriter.Events,
e => e.Action == "dashboard-create-key");
Assert.Equal("alice", dashboardEvent.Actor);
Assert.Equal("operator01", dashboardEvent.Target);
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, dashboardEvent.Outcome);
}
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
{
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(),
services.GetRequiredService(),
services.GetRequiredService(),
services.GetRequiredService(),
new HttpContextAccessor { HttpContext = httpContext });
}
private ServiceProvider BuildServices()
{
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
_tempDirectories.Add(directory);
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(
new Dictionary
{
["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
["MxGateway:ApiKeyPepper"] = "test-pepper"
})
.Build();
ServiceCollection services = new();
services.AddSingleton(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore(configuration);
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
// Production migrates the schema via the migration hosted service at startup; in these
// DI-only tests no host runs, so apply the (idempotent) migration up front.
provider.GetRequiredService()
.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return provider;
}
private static Task> ListAsync(ServiceProvider services)
{
return services.GetRequiredService().ListAsync(CancellationToken.None);
}
private static Task> ListAuditAsync(ServiceProvider services)
{
return services.GetRequiredService().ListRecentAsync(50, CancellationToken.None);
}
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()
{
// Phase 3: include ZbClaimTypes.Username so ResolveOperatorActor picks up the LDAP
// login name ("alice") as the audit Actor. The keyId ("operator01") is the Target.
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
new Claim(ZbClaimTypes.Username, "alice"),
],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
/// Clears SQLite pools and deletes every temporary directory created by this test.
public void Dispose()
{
foreach (TempDatabaseDirectory directory in _tempDirectories)
{
directory.Dispose();
}
_tempDirectories.Clear();
}
/// In-memory that records every event.
private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
{
/// Gets the recorded canonical audit events.
public List Events { get; } = [];
/// Records the audit event in memory instead of writing it to a real sink.
/// The audit event to record.
/// A token to observe for cancellation requests.
/// A task that represents the asynchronous operation.
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
Events.Add(auditEvent);
return Task.CompletedTask;
}
}
}