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